feat: connect Twungeon to live Twitch
This commit is contained in:
@@ -1,10 +1,74 @@
|
||||
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??''}
|
||||
export interface Config {
|
||||
port: number
|
||||
publicBaseUrl: string
|
||||
resurrectionRewardId: string
|
||||
twitchEnabled: boolean
|
||||
broadcasterId: string
|
||||
channelLogin: string
|
||||
twitchClientId: string
|
||||
twitchClientSecret: string
|
||||
twitchRedirectUri: string
|
||||
twitchTokenFile: string
|
||||
extensionSecret: string
|
||||
oauthConfigured: boolean
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const port = Number(env.PORT ?? 3000)
|
||||
const publicBaseUrl = env.PUBLIC_BASE_URL ?? `http://localhost:${port}`
|
||||
const twitchEnabled = env.TWITCH_ENABLED === 'true'
|
||||
const resurrectionRewardId = env.CHANNEL_POINTS_RESURRECTION_REWARD_ID ?? 'local-resurrection'
|
||||
const twitchClientId = env.TWITCH_CLIENT_ID ?? ''
|
||||
const twitchClientSecret = env.TWITCH_CLIENT_SECRET ?? ''
|
||||
const channelLogin = env.TWITCH_CHANNEL_LOGIN ?? 'local-channel'
|
||||
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
|
||||
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
|
||||
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
|
||||
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')
|
||||
}
|
||||
try {
|
||||
new URL(publicBaseUrl)
|
||||
new URL(twitchRedirectUri)
|
||||
} catch {
|
||||
throw new Error('PUBLIC_BASE_URL and TWITCH_OAUTH_REDIRECT_URI must be valid absolute URLs')
|
||||
}
|
||||
if (Boolean(twitchClientId) !== Boolean(twitchClientSecret)) {
|
||||
throw new Error('TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET must be configured together')
|
||||
}
|
||||
if (twitchEnabled) {
|
||||
for (const key of [
|
||||
'TWITCH_CLIENT_ID',
|
||||
'TWITCH_CLIENT_SECRET',
|
||||
'TWITCH_BROADCASTER_ID',
|
||||
'TWITCH_CHANNEL_LOGIN',
|
||||
'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,
|
||||
resurrectionRewardId,
|
||||
twitchEnabled,
|
||||
broadcasterId: env.TWITCH_BROADCASTER_ID ?? 'local-broadcaster',
|
||||
channelLogin,
|
||||
twitchClientId,
|
||||
twitchClientSecret,
|
||||
twitchRedirectUri,
|
||||
twitchTokenFile,
|
||||
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
|
||||
oauthConfigured
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ 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'
|
||||
import { TwitchOAuthService } from './twitchOAuth.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 oauth=config.oauthConfigured?new TwitchOAuthService({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,redirectUri:config.twitchRedirectUri,expectedLogin:config.channelLogin,tokenFile:config.twitchTokenFile}):null
|
||||
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,tokenFile:config.twitchTokenFile,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)}
|
||||
function html(res:ServerResponse,status:number,title:string,message:string){const escape=(value:string)=>value.replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!));res.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff'});res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title><body style="font:16px system-ui;max-width:48rem;margin:4rem auto;padding:0 1rem;background:#111;color:#eee"><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>`)}
|
||||
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}
|
||||
@@ -25,7 +28,27 @@ function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(cons
|
||||
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==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:config.oauthConfigured})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/status')return json(res,200,oauth?await oauth.status():{configured:false,authorized:false,redirectUri:config.twitchRedirectUri})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/login'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure the Twitch Client ID, Client Secret, and channel login on the server first.')
|
||||
try{res.writeHead(302,{location:oauth.startAuthorization(),'cache-control':'no-store'});return res.end()}
|
||||
catch(error){return html(res,429,'Twitch OAuth is temporarily unavailable',error instanceof Error?error.message:'Try again later.')}
|
||||
}
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/callback'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure Twitch OAuth on the server and try again.')
|
||||
const oauthError=url.searchParams.get('error_description')??url.searchParams.get('error')
|
||||
if(oauthError)return html(res,400,'Twitch authorization was declined',oauthError)
|
||||
try{
|
||||
const result=await oauth.completeAuthorization(url.searchParams.get('code')??'',url.searchParams.get('state')??'')
|
||||
console.log(JSON.stringify({level:'info',component:'twitch-oauth',message:'Broadcaster authorization stored',userId:result.userId,login:result.login,scopes:result.scopes}))
|
||||
return html(res,200,'Twungeon is authorized',`Authorized Twitch broadcaster ${result.login} (${result.userId}). You may close this page and continue setup.`)
|
||||
}catch(error){
|
||||
const message=error instanceof Error?error.message:'Twitch authorization failed.'
|
||||
console.error(JSON.stringify({level:'error',component:'twitch-oauth',message}))
|
||||
return html(res,400,'Twitch authorization failed',message)
|
||||
}
|
||||
}
|
||||
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??''))
|
||||
@@ -33,6 +56,11 @@ export const server=createServer(async(req,res)=>{
|
||||
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/extension/spawn'){
|
||||
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
|
||||
const result=game.spawn(message);if(result.accepted)broadcast();return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
|
||||
}
|
||||
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}))})
|
||||
@@ -40,9 +68,10 @@ 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'});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)}
|
||||
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==='/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)}
|
||||
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'})}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REQUIRED_TWITCH_SCOPES,
|
||||
storedTwitchTokenExists,
|
||||
writeStoredTwitchToken
|
||||
} from '../../../packages/twitch-adapter/src/tokenStore.js'
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative(),
|
||||
scope: z.array(z.string()),
|
||||
token_type: z.string()
|
||||
})
|
||||
|
||||
const ValidationResponseSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
login: z.string().min(1),
|
||||
scopes: z.array(z.string()),
|
||||
user_id: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export interface TwitchOAuthConfig {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
expectedLogin: string
|
||||
tokenFile: string
|
||||
}
|
||||
|
||||
export interface TwitchOAuthResult {
|
||||
login: string
|
||||
userId: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
export interface TwitchOAuthStatus {
|
||||
configured: boolean
|
||||
authorized: boolean
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export class TwitchOAuthService {
|
||||
private readonly states = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly config: TwitchOAuthConfig,
|
||||
private readonly fetchImplementation: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
startAuthorization(): string {
|
||||
this.removeExpiredStates()
|
||||
if (this.states.size >= 100) {
|
||||
throw new Error('Too many OAuth attempts are pending. Wait ten minutes and try again.')
|
||||
}
|
||||
const state = randomBytes(32).toString('base64url')
|
||||
this.states.set(state, this.now() + 10 * 60_000)
|
||||
const url = new URL('https://id.twitch.tv/oauth2/authorize')
|
||||
url.searchParams.set('client_id', this.config.clientId)
|
||||
url.searchParams.set('redirect_uri', this.config.redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', REQUIRED_TWITCH_SCOPES.join(' '))
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async completeAuthorization(code: string, state: string): Promise<TwitchOAuthResult> {
|
||||
this.removeExpiredStates()
|
||||
const expiresAt = this.states.get(state)
|
||||
this.states.delete(state)
|
||||
if (!expiresAt || expiresAt <= this.now()) {
|
||||
throw new Error('The OAuth state is missing or expired. Start authorization again.')
|
||||
}
|
||||
if (!code) throw new Error('Twitch did not return an authorization code.')
|
||||
|
||||
const tokenResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: this.config.redirectUri
|
||||
})
|
||||
})
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Twitch token exchange failed with HTTP ${tokenResponse.status}.`)
|
||||
}
|
||||
const token = TokenResponseSchema.parse(await tokenResponse.json())
|
||||
|
||||
const validationResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/validate', {
|
||||
headers: { authorization: `OAuth ${token.access_token}` }
|
||||
})
|
||||
if (!validationResponse.ok) {
|
||||
throw new Error(`Twitch token validation failed with HTTP ${validationResponse.status}.`)
|
||||
}
|
||||
const validation = ValidationResponseSchema.parse(await validationResponse.json())
|
||||
if (validation.client_id !== this.config.clientId) {
|
||||
throw new Error('Twitch returned a token for a different application.')
|
||||
}
|
||||
if (validation.login.toLowerCase() !== this.config.expectedLogin.toLowerCase()) {
|
||||
throw new Error(`Authorize with the configured broadcaster account: ${this.config.expectedLogin}.`)
|
||||
}
|
||||
const missingScopes = REQUIRED_TWITCH_SCOPES.filter(scope => !validation.scopes.includes(scope))
|
||||
if (missingScopes.length) {
|
||||
throw new Error(`The Twitch token is missing required scopes: ${missingScopes.join(', ')}.`)
|
||||
}
|
||||
|
||||
await writeStoredTwitchToken(this.config.tokenFile, {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
scope: validation.scopes,
|
||||
expiresIn: token.expires_in,
|
||||
obtainmentTimestamp: this.now(),
|
||||
userId: validation.user_id,
|
||||
login: validation.login,
|
||||
clientId: validation.client_id
|
||||
})
|
||||
|
||||
return { login: validation.login, userId: validation.user_id, scopes: validation.scopes }
|
||||
}
|
||||
|
||||
async status(): Promise<TwitchOAuthStatus> {
|
||||
return {
|
||||
configured: true,
|
||||
authorized: await storedTwitchTokenExists(this.config.tokenFile),
|
||||
redirectUri: this.config.redirectUri
|
||||
}
|
||||
}
|
||||
|
||||
private removeExpiredStates(): void {
|
||||
const now = this.now()
|
||||
for (const [state, expiresAt] of this.states) {
|
||||
if (expiresAt <= now) this.states.delete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null
|
||||
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 $=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 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 ''}
|
||||
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))
|
||||
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'
|
||||
}
|
||||
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 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()}
|
||||
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 spawnFromExtension(){const result=await api('/api/extension/spawn',{method:'POST',body:'{}'});state=result.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))
|
||||
$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$('#spawnExtension').onclick=()=>spawnFromExtension().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command));$('#shareIdentity').onclick=()=>window.Twitch?.ext?.actions.requestIdShare()
|
||||
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))
|
||||
if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})
|
||||
|
||||
@@ -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"></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"><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>
|
||||
<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>
|
||||
<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>
|
||||
<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"></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-spawn"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Twungeon Privacy Notice</title>
|
||||
<style>
|
||||
:root{color-scheme:dark}body{max-width:760px;margin:0 auto;padding:32px 20px;background:#0d0f10;color:#f5ecd8;font:16px/1.6 system-ui,sans-serif}h1,h2{line-height:1.2}h2{margin-top:28px;color:#e8b04b}a{color:#e8b04b}small{color:#b8ad98}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Twungeon Privacy Notice</h1>
|
||||
<small>Effective August 17, 2026</small>
|
||||
|
||||
<p>Twungeon is an interactive Twitch Extension operated for the Labyricorn channel. This notice explains how Twungeon handles information when viewers participate in the shared dungeon.</p>
|
||||
|
||||
<h2>Information Twungeon processes</h2>
|
||||
<p>If you choose to share your Twitch identity, Twungeon receives your numeric Twitch user ID, display name, the channel where the Extension is running, and the authorization claims Twitch supplies. During play, Twungeon also processes chat commands, follower-verification results, game actions, and relevant Channel Points redemption identifiers.</p>
|
||||
|
||||
<h2>How the information is used</h2>
|
||||
<p>The information is used only to connect your chat character to your Extension controls, enforce game ownership and eligibility rules, prevent duplicate actions or redemptions, display the shared game state, and protect the service from unauthorized requests.</p>
|
||||
|
||||
<h2>Storage and retention</h2>
|
||||
<p>Viewer identities, game state, actions, and redemption deduplication records are held in server memory for the active Twungeon run and are cleared when the service restarts. Twungeon does not place tracking cookies in the Extension and does not use viewer information for advertising or profiling. The broadcaster's OAuth credentials are stored separately and are not viewer data.</p>
|
||||
|
||||
<h2>Sharing</h2>
|
||||
<p>Twungeon does not sell viewer information. Information is not disclosed to third parties except as necessary to operate the Extension through Twitch and its hosting or network providers, to protect the service, or when required by law. Twitch independently processes information under its own privacy notice.</p>
|
||||
|
||||
<h2>Your choices</h2>
|
||||
<p>Identity sharing is optional. Without it, Twungeon cannot safely bind Extension controls to a chat character. You can decline Twitch's identity prompt or manage your Extension permissions through Twitch. To ask about this notice or request removal from the current active run, contact the operator through the <a href="https://www.twitch.tv/labyricorn" rel="noopener">Labyricorn Twitch channel</a>.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>This notice may be updated when Twungeon's data practices change. The effective date above identifies the current version.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user