Implement Channel Points Twungeon MVP
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export interface Config { port:number; publicBaseUrl:string; resurrectionRewardId:string; twitchEnabled:boolean; broadcasterId:string; channelLogin:string; twitchClientId:string; twitchAccessToken:string; extensionSecret:string }
|
||||
export function loadConfig(env:NodeJS.ProcessEnv=process.env):Config{
|
||||
const port=Number(env.PORT??3000),twitchEnabled=env.TWITCH_ENABLED==='true',resurrectionRewardId=env.CHANNEL_POINTS_RESURRECTION_REWARD_ID??'local-resurrection'
|
||||
const missing:string[]=[]
|
||||
if(!Number.isInteger(port)||port<1||port>65535)throw new Error('PORT must be an integer from 1 to 65535')
|
||||
if(!resurrectionRewardId.trim())throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
|
||||
if(twitchEnabled)for(const key of ['TWITCH_CLIENT_ID','TWITCH_CLIENT_SECRET','TWITCH_BROADCASTER_ID','TWITCH_CHANNEL_LOGIN','TWITCH_BOT_ACCESS_TOKEN','TWITCH_EXTENSION_SECRET','CHANNEL_POINTS_RESURRECTION_REWARD_ID'])if(!env[key])missing.push(key)
|
||||
if(missing.length)throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
|
||||
return {port,publicBaseUrl:env.PUBLIC_BASE_URL??`http://localhost:${port}`,resurrectionRewardId,twitchEnabled,broadcasterId:env.TWITCH_BROADCASTER_ID??'local-broadcaster',channelLogin:env.TWITCH_CHANNEL_LOGIN??'local-channel',twitchClientId:env.TWITCH_CLIENT_ID??'',twitchAccessToken:env.TWITCH_BOT_ACCESS_TOKEN??'',extensionSecret:env.TWITCH_EXTENSION_SECRET??''}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { CommandEnvelopeSchema } from '../../../packages/contracts/src/index.js'
|
||||
import { Game } from '../../../packages/domain/src/index.js'
|
||||
import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
|
||||
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
|
||||
import { loadConfig } from './config.js'
|
||||
|
||||
const config=loadConfig(), startedAt=Date.now();let id=0
|
||||
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
|
||||
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,accessToken:config.twitchAccessToken,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
|
||||
const sessions=new Map<string,{userId:string;expiresAt:number}>()
|
||||
const clients=new Map<WebSocket,string|null>()
|
||||
const publicDir=join(dirname(fileURLToPath(import.meta.url)),'../../stream-view/public')
|
||||
|
||||
function json(res:ServerResponse,status:number,body:unknown){const data=JSON.stringify(body);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(data)}
|
||||
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
|
||||
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
|
||||
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
|
||||
function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))}
|
||||
|
||||
export const server=createServer(async(req,res)=>{
|
||||
try{
|
||||
const url=new URL(req.url??'/',config.publicBaseUrl)
|
||||
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic'})
|
||||
if(req.method==='GET'&&url.pathname==='/api/state')return json(res,200,game.snapshot())
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
|
||||
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
|
||||
if(!identity)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
|
||||
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/commands'){
|
||||
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
|
||||
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
|
||||
const result=game.command(userId,Boolean(userId),parsed.data);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)
|
||||
}
|
||||
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'});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'});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'});return res.end(css)}
|
||||
json(res,404,{error:'NOT_FOUND'})
|
||||
}catch(error){console.error(JSON.stringify({level:'error',message:error instanceof Error?error.message:'Unexpected error'}));json(res,500,{error:'INTERNAL_ERROR'})}
|
||||
})
|
||||
const wss=new WebSocketServer({noServer:true})
|
||||
server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))})
|
||||
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null);clients.set(ws,userId);if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>clients.delete(ws))})
|
||||
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
|
||||
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
|
||||
if(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
|
||||
@@ -0,0 +1,21 @@
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null
|
||||
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
||||
function disabledReason(){if(!userId)return 'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return '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 ''}
|
||||
function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorNumber}`;$('#phase').textContent=state.phase.kind;$('#banner').hidden=state.phase.kind!=='dormant'
|
||||
const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—'
|
||||
$('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
|
||||
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))
|
||||
}
|
||||
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);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()}
|
||||
async function spawn(){const id=$('#userId').value.trim(),name=$('#displayName').value.trim();await api('/api/dev/spawn',{method:'POST',body:JSON.stringify({command:'!spawn',externalEventId:crypto.randomUUID(),twitchUserId:id,displayName:name,followerVerified:true})}).catch(e=>{if(!String(e.message).includes('already'))throw e});const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token:`dev:${id}:${name}`})});session=auth.token;userId=id;state=auth.state;render()}
|
||||
async function command(kind){if(!state||state.phase.kind!=='player')return;const commands={up:{type:'move',direction:'up'},down:{type:'move',direction:'down'},left:{type:'move',direction:'left'},right:{type:'move',direction:'right'},attack:{type:'attack',targetId:'goblin'},heal:{type:'heal-self'},pass:{type:'pass'}};try{await api('/api/commands',{method:'POST',body:JSON.stringify({requestId:crypto.randomUUID(),runId:state.runId,floorId:state.floor.floorId,phaseId:state.phase.phaseId,command:commands[kind]})})}catch(e){$('#disabled').textContent=e.message}}
|
||||
$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command))
|
||||
function connect(){const ws=activeSocket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`);ws.onopen=()=>{if(session)ws.send(JSON.stringify({type:'authenticate',token:session}));$('#connection').textContent='Live'};ws.onclose=()=>{$('#connection').textContent='Reconnecting…';setTimeout(connect,1000)};ws.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.type!=='snapshot')return;if(lastSequence&&msg.sequence>lastSequence+1){fetch('/api/state').then(r=>r.json()).then(s=>{state=s;lastSequence=s.nextEventSequence-1;render()});return}lastSequence=msg.sequence;state=msg.state;render()}}
|
||||
connect()
|
||||
setInterval(()=>{if(state?.phase.kind==='player')render()},250)
|
||||
if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message))
|
||||
@@ -0,0 +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"></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><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><p id="disabled"></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></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"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
: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%}}
|
||||
Reference in New Issue
Block a user