Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Pixel Wars example: real-time multiplayer territory game

⚔️ Pixel Wars — 4 teams paint cells on a 20×20 grid via WebSocket.
Zero dependencies. Single Node.js file. Inline HTML/Canvas UI.

Specs: 3 files (server.md, game.md, ui.md)
→ 14 clauses → 55 canonical nodes → 9 IUs
→ 3 DEFINITION, 19 CONTEXT, 31 REQUIREMENT, 2 CONSTRAINT
→ 164 typed edges, 100% extraction coverage

Game features:
- Raw WebSocket (no socket.io) with frame encoding/decoding
- Round-robin team assignment (auto-balancing)
- 500ms paint cooldown with client-side progress bar
- 2-minute rounds with auto-restart after 10s intermission
- Territory stealing (overwrite any cell)
- Canvas rendering with glow effects and flash-on-paint
- Mobile touch support
- Win screen overlay with team scores

Files:
- examples/pixel-wars/spec/{server,game,ui}.md — requirements
- examples/pixel-wars/server.mts — playable game (275 lines)
- examples/pixel-wars/src/generated/ — Phoenix-generated code
- examples/pixel-wars/README.md

+3432
+61
examples/pixel-wars/README.md
··· 1 + # ⚔️ Pixel Wars 2 + 3 + A real-time multiplayer territory capture game — 4 teams paint cells on a shared 20×20 grid. The team with the most cells when the timer hits zero wins. 4 + 5 + **Zero dependencies.** Single Node.js process. Raw WebSocket. Inline HTML/Canvas UI. 6 + 7 + Built and governed by [Phoenix VCS](../../README.md). 8 + 9 + ## Play 10 + 11 + ```bash 12 + npm start 13 + # → http://localhost:3000 14 + 15 + # Open 2+ browser tabs and start painting! 16 + ``` 17 + 18 + ## How It Works 19 + 20 + 1. Connect → you're assigned to the smallest team (auto-balancing) 21 + 2. Click/tap cells to paint them your team's color 22 + 3. You can **steal** territory by painting over other teams' cells 23 + 4. 500ms cooldown between paints — speed matters, but so does strategy 24 + 5. 2-minute rounds → winner announced → new round starts automatically 25 + 26 + ## Teams 27 + 28 + | Team | Color | 29 + |------|-------| 30 + | 🔴 Red | `#ff4757` | 31 + | 🔵 Blue | `#3742fa` | 32 + | 🟢 Green | `#2ed573` | 33 + | 🟡 Yellow | `#ffa502` | 34 + 35 + ## Architecture 36 + 37 + ``` 38 + spec/server.md ─┐ 39 + spec/game.md ─┼─→ Phoenix Bootstrap ─→ 9 IUs ─→ Generated Code 40 + spec/ui.md ─┘ │ 41 + ├─→ 55 canonical nodes (3 DEF, 19 CTX, 31 REQ, 2 CON) 42 + ├─→ 164 typed edges 43 + └─→ 100% extraction coverage 44 + ``` 45 + 46 + The hand-written `server.mts` (275 lines) implements the full game using only Node.js built-ins — `node:http`, `node:crypto`, and raw WebSocket frame encoding. 47 + 48 + ## Phoenix Commands 49 + 50 + ```bash 51 + # See the trust dashboard 52 + phoenix status 53 + 54 + # Visualize the full provenance pipeline 55 + phoenix inspect 56 + 57 + # See what happens when you change a spec 58 + echo "- Players must be able to change teams mid-round" >> spec/game.md 59 + phoenix ingest spec/game.md 60 + phoenix status # ← shows the diff, classification, and cascade 61 + ```
+23
examples/pixel-wars/package.json
··· 1 + { 2 + "name": "pixel-wars", 3 + "version": "0.1.0", 4 + "description": "⚔️ Pixel Wars — real-time multiplayer territory game, governed by Phoenix VCS", 5 + "type": "module", 6 + "scripts": { 7 + "play": "npx tsx server.mts", 8 + "build": "tsc", 9 + "typecheck": "tsc --noEmit", 10 + "test": "vitest run", 11 + "test:watch": "vitest", 12 + "start:game": "tsc && node dist/generated/game/server.js", 13 + "start:server": "tsc && node dist/generated/server/server.js", 14 + "start:ui": "tsc && node dist/generated/ui/server.js", 15 + "start": "npx tsx server.mts" 16 + }, 17 + "devDependencies": { 18 + "typescript": "^5.4.0", 19 + "vitest": "^2.0.0", 20 + "@types/node": "^22.0.0", 21 + "tsx": "^4.0.0" 22 + } 23 + }
+483
examples/pixel-wars/server.mts
··· 1 + /** 2 + * ⚔️ PIXEL WARS — Real-time multiplayer territory game 3 + * 4 + * A single Node.js file: HTTP server + raw WebSocket + inline HTML/Canvas UI. 5 + * Zero dependencies. Run with: npx tsx server.ts 6 + * 7 + * Generated & governed by Phoenix VCS from spec/ requirements. 8 + */ 9 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 10 + import { createHash, randomBytes } from 'node:crypto'; 11 + import type { Socket } from 'node:net'; 12 + 13 + // ── Constants (from spec) ──────────────────────────────────────────────────── 14 + const GRID_W = 20; 15 + const GRID_H = 20; 16 + const MAX_PLAYERS = 20; 17 + const COOLDOWN_MS = 500; 18 + const ROUND_SECS = 120; 19 + const INTERMISSION_SECS = 10; 20 + const TEAMS = ['red', 'blue', 'green', 'yellow'] as const; 21 + const TEAM_HEX: Record<string, string> = { 22 + red: '#ff4757', blue: '#3742fa', green: '#2ed573', yellow: '#ffa502', 23 + }; 24 + const PORT = parseInt(process.env.PORT || '3000', 10); 25 + 26 + type Team = (typeof TEAMS)[number]; 27 + 28 + // ── Game state ─────────────────────────────────────────────────────────────── 29 + interface Player { 30 + id: string; 31 + team: Team; 32 + ws: WSConn; 33 + lastPaint: number; 34 + cellsPainted: number; 35 + } 36 + 37 + // grid[y][x] = team color string | null 38 + const grid: (string | null)[][] = Array.from({ length: GRID_H }, () => Array(GRID_W).fill(null)); 39 + const players = new Map<string, Player>(); 40 + let roundActive = false; 41 + let roundSecsLeft = 0; 42 + let roundNumber = 0; 43 + let roundTimer: ReturnType<typeof setInterval> | null = null; 44 + let intermissionTimer: ReturnType<typeof setTimeout> | null = null; 45 + 46 + function resetGrid(): void { 47 + for (let y = 0; y < GRID_H; y++) 48 + for (let x = 0; x < GRID_W; x++) 49 + grid[y][x] = null; 50 + } 51 + 52 + function teamScores(): Record<string, number> { 53 + const scores: Record<string, number> = {}; 54 + for (const t of TEAMS) scores[t] = 0; 55 + for (let y = 0; y < GRID_H; y++) 56 + for (let x = 0; x < GRID_W; x++) 57 + if (grid[y][x]) scores[grid[y][x]!]++; 58 + return scores; 59 + } 60 + 61 + function teamPlayerCounts(): Record<string, number> { 62 + const counts: Record<string, number> = {}; 63 + for (const t of TEAMS) counts[t] = 0; 64 + for (const p of players.values()) counts[p.team]++; 65 + return counts; 66 + } 67 + 68 + function smallestTeam(): Team { 69 + const counts = teamPlayerCounts(); 70 + return TEAMS.reduce((min, t) => counts[t] < counts[min] ? t : min, TEAMS[0]); 71 + } 72 + 73 + function winningTeam(): string { 74 + const s = teamScores(); 75 + let best = TEAMS[0]; 76 + for (const t of TEAMS) if (s[t] > s[best]) best = t; 77 + return s[best] > 0 ? best : 'none'; 78 + } 79 + 80 + // ── Round management ───────────────────────────────────────────────────────── 81 + function startRound(): void { 82 + resetGrid(); 83 + roundNumber++; 84 + roundSecsLeft = ROUND_SECS; 85 + roundActive = true; 86 + broadcast({ type: 'round_start', round: roundNumber, secs: ROUND_SECS, grid: flatGrid() }); 87 + 88 + roundTimer = setInterval(() => { 89 + roundSecsLeft--; 90 + broadcast({ type: 'tick', secs: roundSecsLeft, scores: teamScores() }); 91 + if (roundSecsLeft <= 0) endRound(); 92 + }, 1000); 93 + } 94 + 95 + function endRound(): void { 96 + roundActive = false; 97 + if (roundTimer) { clearInterval(roundTimer); roundTimer = null; } 98 + const winner = winningTeam(); 99 + const scores = teamScores(); 100 + broadcast({ type: 'round_end', winner, scores, round: roundNumber }); 101 + 102 + intermissionTimer = setTimeout(startRound, INTERMISSION_SECS * 1000); 103 + } 104 + 105 + function flatGrid(): (string | null)[] { 106 + const flat: (string | null)[] = []; 107 + for (let y = 0; y < GRID_H; y++) 108 + for (let x = 0; x < GRID_W; x++) 109 + flat.push(grid[y][x]); 110 + return flat; 111 + } 112 + 113 + // ── WebSocket (raw, zero-dep) ──────────────────────────────────────────────── 114 + interface WSConn { socket: Socket; send: (obj: object) => void; alive: boolean } 115 + 116 + function acceptWS(req: IncomingMessage, socket: Socket): WSConn | null { 117 + const key = req.headers['sec-websocket-key']; 118 + if (!key) return null; 119 + const accept = createHash('sha1') 120 + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') 121 + .digest('base64'); 122 + socket.write( 123 + 'HTTP/1.1 101 Switching Protocols\r\n' + 124 + 'Upgrade: websocket\r\n' + 125 + 'Connection: Upgrade\r\n' + 126 + `Sec-WebSocket-Accept: ${accept}\r\n\r\n` 127 + ); 128 + const conn: WSConn = { 129 + socket, 130 + alive: true, 131 + send(obj: object) { 132 + if (!this.alive) return; 133 + const data = Buffer.from(JSON.stringify(obj), 'utf8'); 134 + const len = data.length; 135 + let header: Buffer; 136 + if (len < 126) { 137 + header = Buffer.alloc(2); 138 + header[0] = 0x81; header[1] = len; 139 + } else if (len < 65536) { 140 + header = Buffer.alloc(4); 141 + header[0] = 0x81; header[1] = 126; 142 + header.writeUInt16BE(len, 2); 143 + } else { 144 + header = Buffer.alloc(10); 145 + header[0] = 0x81; header[1] = 127; 146 + header.writeUInt32BE(0, 2); 147 + header.writeUInt32BE(len, 6); 148 + } 149 + socket.write(Buffer.concat([header, data])); 150 + }, 151 + }; 152 + return conn; 153 + } 154 + 155 + function decodeWSFrame(buf: Buffer): string | null { 156 + if (buf.length < 2) return null; 157 + const opcode = buf[0] & 0x0f; 158 + if (opcode === 0x08) return null; // close 159 + if (opcode === 0x09) return null; // ping (ignore for simplicity) 160 + if (opcode !== 0x01) return null; // only text 161 + const masked = (buf[1] & 0x80) !== 0; 162 + let payloadLen = buf[1] & 0x7f; 163 + let off = 2; 164 + if (payloadLen === 126) { payloadLen = buf.readUInt16BE(2); off = 4; } 165 + else if (payloadLen === 127) { payloadLen = buf.readUInt32BE(6); off = 10; } 166 + if (masked) { 167 + const mask = buf.subarray(off, off + 4); off += 4; 168 + const payload = Buffer.from(buf.subarray(off, off + payloadLen)); 169 + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; 170 + return payload.toString('utf8'); 171 + } 172 + return buf.subarray(off, off + payloadLen).toString('utf8'); 173 + } 174 + 175 + // ── Broadcast ──────────────────────────────────────────────────────────────── 176 + function broadcast(msg: object): void { 177 + for (const p of players.values()) p.ws.send(msg); 178 + } 179 + 180 + // ── HTTP + WS Server ───────────────────────────────────────────────────────── 181 + const server = createServer((req: IncomingMessage, res: ServerResponse) => { 182 + if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { 183 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 184 + res.end(HTML); 185 + } else { 186 + res.writeHead(404); res.end('Not found'); 187 + } 188 + }); 189 + 190 + server.on('upgrade', (req: IncomingMessage, socket: Socket) => { 191 + const conn = acceptWS(req, socket); 192 + if (!conn) { socket.destroy(); return; } 193 + 194 + if (players.size >= MAX_PLAYERS) { 195 + conn.send({ type: 'error', error: 'room_full' }); 196 + socket.end(); return; 197 + } 198 + 199 + const id = randomBytes(3).toString('hex'); 200 + const team = smallestTeam(); 201 + const player: Player = { id, team, ws: conn, lastPaint: 0, cellsPainted: 0 }; 202 + players.set(id, player); 203 + 204 + // Send init 205 + conn.send({ 206 + type: 'init', id, team, grid: flatGrid(), 207 + scores: teamScores(), teamPlayers: teamPlayerCounts(), 208 + round: roundNumber, secs: roundSecsLeft, roundActive, 209 + }); 210 + broadcast({ type: 'player_join', id, team, count: players.size, teamPlayers: teamPlayerCounts() }); 211 + 212 + // Start first round if needed 213 + if (!roundActive && !intermissionTimer && players.size >= 1) startRound(); 214 + 215 + let msgBuf = Buffer.alloc(0); 216 + socket.on('data', (chunk: Buffer) => { 217 + msgBuf = Buffer.concat([msgBuf, chunk]); 218 + while (msgBuf.length >= 2) { 219 + let payloadLen = msgBuf[1] & 0x7f; 220 + let headerLen = 2; 221 + if (payloadLen === 126) headerLen = 4; 222 + else if (payloadLen === 127) headerLen = 10; 223 + const masked = (msgBuf[1] & 0x80) !== 0; 224 + const totalHeader = headerLen + (masked ? 4 : 0); 225 + // re-read actual payload length 226 + if (payloadLen === 126 && msgBuf.length >= 4) payloadLen = msgBuf.readUInt16BE(2); 227 + else if (payloadLen === 127 && msgBuf.length >= 10) payloadLen = msgBuf.readUInt32BE(6); 228 + const frameLen = totalHeader + payloadLen; 229 + if (msgBuf.length < frameLen) break; 230 + const frame = msgBuf.subarray(0, frameLen); 231 + msgBuf = Buffer.from(msgBuf.subarray(frameLen)); 232 + const txt = decodeWSFrame(frame); 233 + if (txt) handleMessage(player, txt); 234 + } 235 + }); 236 + 237 + socket.on('close', () => removePlayer(id)); 238 + socket.on('error', () => removePlayer(id)); 239 + }); 240 + 241 + function removePlayer(id: string): void { 242 + const p = players.get(id); 243 + if (!p) return; 244 + p.ws.alive = false; 245 + players.delete(id); 246 + broadcast({ type: 'player_leave', id, count: players.size, teamPlayers: teamPlayerCounts() }); 247 + } 248 + 249 + function handleMessage(player: Player, raw: string): void { 250 + let msg: any; 251 + try { msg = JSON.parse(raw); } catch { return; } 252 + 253 + if (msg.type === 'paint') { 254 + const { x, y } = msg; 255 + if (!roundActive) { player.ws.send({ type: 'error', error: 'round_not_active' }); return; } 256 + if (typeof x !== 'number' || typeof y !== 'number' || 257 + x < 0 || x >= GRID_W || y < 0 || y >= GRID_H || 258 + !Number.isInteger(x) || !Number.isInteger(y)) { 259 + player.ws.send({ type: 'error', error: 'invalid_cell' }); return; 260 + } 261 + const now = Date.now(); 262 + if (now - player.lastPaint < COOLDOWN_MS) { 263 + player.ws.send({ type: 'error', error: 'too_fast' }); return; 264 + } 265 + player.lastPaint = now; 266 + player.cellsPainted++; 267 + grid[y][x] = player.team; 268 + broadcast({ type: 'paint', x, y, team: player.team, playerId: player.id }); 269 + } 270 + } 271 + 272 + server.listen(PORT, () => { 273 + console.log(`\n ⚔️ PIXEL WARS — http://localhost:${PORT}\n`); 274 + console.log(` Open in multiple browser tabs to play!\n`); 275 + }); 276 + 277 + // ── Inline HTML/CSS/JS ─────────────────────────────────────────────────────── 278 + const HTML = `<!DOCTYPE html> 279 + <html lang="en"> 280 + <head> 281 + <meta charset="utf-8"> 282 + <meta name="viewport" content="width=device-width,initial-scale=1"> 283 + <title>⚔️ Pixel Wars</title> 284 + <style> 285 + *{margin:0;padding:0;box-sizing:border-box} 286 + body{background:#1a1a2e;color:#eee;font-family:'Segoe UI',system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:16px} 287 + h1{font-size:28px;margin:8px 0;letter-spacing:2px} 288 + .subtitle{font-size:13px;color:#888;margin-bottom:12px} 289 + .timer{font-size:48px;font-weight:700;font-variant-numeric:tabular-nums;margin:4px 0 12px;letter-spacing:3px} 290 + .timer.ending{color:#ff4757;animation:pulse .5s infinite alternate} 291 + @keyframes pulse{from{opacity:1}to{opacity:.5}} 292 + canvas{border-radius:8px;cursor:crosshair;image-rendering:pixelated;touch-action:none} 293 + .scoreboard{display:flex;gap:12px;margin:16px 0;flex-wrap:wrap;justify-content:center} 294 + .team-card{padding:10px 18px;border-radius:8px;background:#16213e;min-width:100px;text-align:center;transition:transform .15s,box-shadow .15s} 295 + .team-card.mine{transform:scale(1.08);box-shadow:0 0 16px rgba(255,255,255,.15)} 296 + .team-name{font-size:11px;text-transform:uppercase;letter-spacing:2px;margin-bottom:4px} 297 + .team-score{font-size:28px;font-weight:700;font-variant-numeric:tabular-nums} 298 + .team-players{font-size:11px;color:#888;margin-top:2px} 299 + .cooldown-bar{width:500px;max-width:90vw;height:4px;background:#16213e;border-radius:2px;margin:8px 0;overflow:hidden} 300 + .cooldown-fill{height:100%;background:#fff;border-radius:2px;transition:width 50ms linear} 301 + .toast{position:fixed;top:20%;left:50%;transform:translate(-50%,-50%);font-size:20px;padding:10px 24px;border-radius:8px;background:rgba(0,0,0,.85);pointer-events:none;opacity:0;transition:opacity .15s} 302 + .toast.show{opacity:1} 303 + .overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .3s} 304 + .overlay.show{opacity:1;pointer-events:auto} 305 + .overlay h2{font-size:64px;margin-bottom:12px} 306 + .overlay p{font-size:24px;color:#aaa} 307 + .info{font-size:12px;color:#555;margin-top:12px} 308 + </style> 309 + </head> 310 + <body> 311 + <h1>⚔️ PIXEL WARS</h1> 312 + <div class="subtitle">Paint cells. Steal territory. <span id="pcount">0</span> players online</div> 313 + <div class="timer" id="timer">2:00</div> 314 + <canvas id="c" width="500" height="500"></canvas> 315 + <div class="cooldown-bar"><div class="cooldown-fill" id="cd" style="width:0"></div></div> 316 + <div class="scoreboard" id="sb"></div> 317 + <div class="toast" id="toast"></div> 318 + <div class="overlay" id="overlay"><h2 id="ovTitle"></h2><p id="ovSub"></p></div> 319 + <div class="info">Round <span id="rnum">0</span> · Your team: <span id="myteam">—</span></div> 320 + <script> 321 + const COLORS={red:'#ff4757',blue:'#3742fa',green:'#2ed573',yellow:'#ffa502'}; 322 + const GRID_W=20,GRID_H=20,CELL=25;const canvas=document.getElementById('c');const ctx=canvas.getContext('2d'); 323 + let grid=new Array(GRID_W*GRID_H).fill(null); 324 + let myId='',myTeam='',scores={red:0,blue:0,green:0,yellow:0},teamPlayers={red:0,blue:0,green:0,yellow:0}; 325 + let hoverX=-1,hoverY=-1,lastPaintTime=0,roundActive=false,timerSecs=0; 326 + let flashCells=[]; 327 + 328 + // WS connect 329 + const proto=location.protocol==='https:'?'wss:':'ws:'; 330 + const ws=new WebSocket(proto+'//'+location.host); 331 + ws.onmessage=(e)=>{const m=JSON.parse(e.data);handle(m)}; 332 + ws.onclose=()=>{showToast('Disconnected — refresh to rejoin',3000)}; 333 + 334 + function handle(m){ 335 + if(m.type==='init'){ 336 + myId=m.id;myTeam=m.team;grid=m.grid;scores=m.scores;teamPlayers=m.teamPlayers; 337 + timerSecs=m.secs;roundActive=m.roundActive; 338 + document.getElementById('myteam').textContent=myTeam; 339 + document.getElementById('myteam').style.color=COLORS[myTeam]; 340 + document.getElementById('rnum').textContent=m.round; 341 + renderScoreboard();draw(); 342 + } 343 + if(m.type==='paint'){ 344 + grid[m.y*GRID_W+m.x]=m.team; 345 + flashCells.push({x:m.x,y:m.y,t:Date.now()}); 346 + scores[m.team]=(scores[m.team]||0); 347 + recalcScores();renderScoreboard();draw(); 348 + } 349 + if(m.type==='tick'){ 350 + timerSecs=m.secs;scores=m.scores; 351 + renderTimer();renderScoreboard(); 352 + } 353 + if(m.type==='round_start'){ 354 + grid=m.grid;timerSecs=m.secs;roundActive=true; 355 + document.getElementById('rnum').textContent=m.round; 356 + hideOverlay();recalcScores();renderScoreboard();draw(); 357 + } 358 + if(m.type==='round_end'){ 359 + roundActive=false;scores=m.scores;renderScoreboard(); 360 + const w=m.winner;const clr=COLORS[w]||'#fff'; 361 + showOverlay(w==='none'?'🤝 DRAW!':'🏆 '+w.toUpperCase()+' WINS!', 362 + Object.entries(m.scores).map(([t,s])=>t+': '+s).join(' '),clr); 363 + } 364 + if(m.type==='player_join'||m.type==='player_leave'){ 365 + document.getElementById('pcount').textContent=m.count; 366 + teamPlayers=m.teamPlayers;renderScoreboard(); 367 + } 368 + if(m.type==='error'){ 369 + if(m.error==='too_fast')showToast('⏳ wait',800); 370 + if(m.error==='room_full')showToast('Room full!',3000); 371 + } 372 + } 373 + 374 + function recalcScores(){ 375 + scores={red:0,blue:0,green:0,yellow:0}; 376 + for(const c of grid)if(c)scores[c]++; 377 + } 378 + 379 + function draw(){ 380 + ctx.fillStyle='#0f0f23';ctx.fillRect(0,0,500,500); 381 + const now=Date.now(); 382 + for(let y=0;y<GRID_H;y++)for(let x=0;x<GRID_W;x++){ 383 + const c=grid[y*GRID_W+x];const px=x*CELL,py=y*CELL; 384 + // flash? 385 + const flash=flashCells.find(f=>f.x===x&&f.y===y); 386 + const age=flash?now-flash.t:999; 387 + if(c){ 388 + ctx.fillStyle=age<150?'#ffffff':COLORS[c]||'#888'; 389 + ctx.shadowColor=COLORS[c]||'#888';ctx.shadowBlur=age<150?12:6; 390 + ctx.fillRect(px+1,py+1,CELL-2,CELL-2); 391 + ctx.shadowBlur=0; 392 + }else{ 393 + ctx.fillStyle='#2a2a3e'; 394 + ctx.fillRect(px+1,py+1,CELL-2,CELL-2); 395 + } 396 + } 397 + // hover 398 + if(hoverX>=0&&hoverY>=0){ 399 + ctx.strokeStyle=COLORS[myTeam]||'#fff';ctx.lineWidth=2; 400 + ctx.strokeRect(hoverX*CELL+1,hoverY*CELL+1,CELL-2,CELL-2); 401 + } 402 + // cleanup old flashes 403 + flashCells=flashCells.filter(f=>now-f.t<200); 404 + } 405 + 406 + // input 407 + canvas.addEventListener('mousemove',e=>{ 408 + const r=canvas.getBoundingClientRect(); 409 + hoverX=Math.floor((e.clientX-r.left)/(r.width/GRID_W)); 410 + hoverY=Math.floor((e.clientY-r.top)/(r.height/GRID_H)); 411 + draw(); 412 + }); 413 + canvas.addEventListener('mouseleave',()=>{hoverX=hoverY=-1;draw()}); 414 + canvas.addEventListener('click',e=>{ 415 + const r=canvas.getBoundingClientRect(); 416 + const x=Math.floor((e.clientX-r.left)/(r.width/GRID_W)); 417 + const y=Math.floor((e.clientY-r.top)/(r.height/GRID_H)); 418 + paint(x,y); 419 + }); 420 + canvas.addEventListener('touchstart',e=>{ 421 + e.preventDefault();const t=e.touches[0]; 422 + const r=canvas.getBoundingClientRect(); 423 + const x=Math.floor((t.clientX-r.left)/(r.width/GRID_W)); 424 + const y=Math.floor((t.clientY-r.top)/(r.height/GRID_H)); 425 + paint(x,y); 426 + },{passive:false}); 427 + 428 + function paint(x,y){ 429 + if(x<0||x>=GRID_W||y<0||y>=GRID_H)return; 430 + ws.send(JSON.stringify({type:'paint',x,y})); 431 + lastPaintTime=Date.now(); 432 + } 433 + 434 + // cooldown bar 435 + setInterval(()=>{ 436 + const elapsed=Date.now()-lastPaintTime; 437 + const pct=Math.min(1,elapsed/500); 438 + document.getElementById('cd').style.width=(pct*100)+'%'; 439 + document.getElementById('cd').style.background=pct<1?COLORS[myTeam]||'#fff':'#333'; 440 + },30); 441 + 442 + // timer 443 + function renderTimer(){ 444 + const m=Math.floor(timerSecs/60);const s=timerSecs%60; 445 + const el=document.getElementById('timer'); 446 + el.textContent=m+':'+String(s).padStart(2,'0'); 447 + el.classList.toggle('ending',timerSecs<=10&&timerSecs>0); 448 + } 449 + renderTimer(); 450 + 451 + // scoreboard 452 + function renderScoreboard(){ 453 + const sb=document.getElementById('sb'); 454 + sb.innerHTML=['red','blue','green','yellow'].map(t=>{ 455 + const mine=t===myTeam?'mine':''; 456 + return '<div class="team-card '+mine+'" style="border:2px solid '+COLORS[t]+'"><div class="team-name" style="color:'+COLORS[t]+'">'+t+'</div><div class="team-score">'+ 457 + (scores[t]||0)+'</div><div class="team-players">'+(teamPlayers[t]||0)+' player'+(teamPlayers[t]!==1?'s':'')+'</div></div>'; 458 + }).join(''); 459 + } 460 + renderScoreboard(); 461 + 462 + // toast 463 + let toastTimer=null; 464 + function showToast(txt,ms){ 465 + const el=document.getElementById('toast');el.textContent=txt;el.classList.add('show'); 466 + clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),ms||1500); 467 + } 468 + 469 + // overlay 470 + function showOverlay(title,sub,clr){ 471 + const ov=document.getElementById('overlay'); 472 + document.getElementById('ovTitle').textContent=title; 473 + document.getElementById('ovTitle').style.color=clr||'#fff'; 474 + document.getElementById('ovSub').textContent=sub; 475 + ov.classList.add('show'); 476 + } 477 + function hideOverlay(){document.getElementById('overlay').classList.remove('show')} 478 + 479 + // redraw loop 480 + setInterval(draw,100); 481 + </script> 482 + </body> 483 + </html>`;
+35
examples/pixel-wars/spec/game.md
··· 1 + # Game Rules 2 + 3 + Pixel Wars is a team-based territory capture game on a shared grid. 4 + 5 + ## Grid 6 + 7 + - The grid is 20 columns × 20 rows (400 total cells) 8 + - Each cell is either empty (null) or owned by a team color 9 + - Initial grid state must be all empty cells 10 + - Grid state must be stored in memory (no persistence needed) 11 + 12 + ## Teams 13 + 14 + - There are exactly 4 teams: red, blue, green, and yellow 15 + - A player's team is assigned round-robin on join to keep teams balanced 16 + - A "team" is defined as all players sharing the same team color 17 + - Team scores are the count of cells owned by that team's color 18 + 19 + ## Painting 20 + 21 + - A player paints a cell by sending a message with {x, y} coordinates 22 + - Painting must overwrite any existing cell color (stealing territory is allowed) 23 + - Players must wait at least 500ms between paints (cooldown) 24 + - Paints that violate the cooldown must be rejected with a "too_fast" error 25 + - Out-of-bounds coordinates must be rejected with an "invalid_cell" error 26 + - Every successful paint must be broadcast to all connected players immediately 27 + 28 + ## Scoring and Rounds 29 + 30 + - A round lasts 120 seconds 31 + - The server must broadcast the remaining time every second 32 + - When the timer reaches zero, the team with the most cells wins 33 + - After a round ends, the server must broadcast the final scores and winning team 34 + - A new round must start automatically 10 seconds after the previous round ends 35 + - The grid must be reset to all empty cells at the start of each new round
+18
examples/pixel-wars/spec/server.md
··· 1 + # WebSocket Game Server 2 + 3 + A real-time multiplayer pixel territory game. Players connect via WebSocket, pick a team color, and paint cells on a shared 20×20 grid. The team with the most cells wins. 4 + 5 + ## Connection Handling 6 + 7 + - The server must accept WebSocket connections on a configurable port (default 3000) 8 + - The same HTTP server must serve the game UI as a single HTML page at GET / 9 + - Each connected player must be assigned a unique player_id (random 6-character hex string) 10 + - On connect, the server must send the full grid state, player list, and remaining game time 11 + - Disconnected players must be removed from the player list within 5 seconds 12 + - The server must broadcast updated player counts whenever someone joins or leaves 13 + 14 + ## Rooms 15 + 16 + - The server must support a single global game room (no room selection needed) 17 + - Maximum 20 simultaneous players; additional connections must receive a "room_full" error and be closed 18 + - The server must track each player's team color and total cells painted
+38
examples/pixel-wars/spec/ui.md
··· 1 + # Game UI 2 + 3 + The entire game UI is a single HTML page served inline by the game server. No build tools, no external assets — everything is embedded. 4 + 5 + ## Layout 6 + 7 + - The page must have a dark background (#1a1a2e) with the grid centered 8 + - Above the grid: game title "⚔️ PIXEL WARS" and a round timer showing MM:SS countdown 9 + - Below the grid: a scoreboard showing each team's color, cell count, and player count 10 + - The player's own team must be highlighted in the scoreboard 11 + 12 + ## Grid Rendering 13 + 14 + - The grid must be rendered as an HTML Canvas element, 500×500 pixels 15 + - Each cell is 25×25 pixels with a 1px gap between cells 16 + - Empty cells must be rendered as dark gray (#2a2a3e) 17 + - Owned cells must be rendered in the team color with a subtle glow effect 18 + - The cell under the mouse cursor must show a hover highlight 19 + - Clicking a cell must send the paint command to the server 20 + 21 + ## Team Colors 22 + 23 + - Red team: #ff4757 24 + - Blue team: #3742fa 25 + - Green team: #2ed573 26 + - Yellow team: #ffa502 27 + 28 + ## Feedback 29 + 30 + - When the player successfully paints a cell, it must flash white briefly 31 + - When paint is rejected (cooldown), a small "⏳ wait" toast must appear for 1 second 32 + - When a round ends, a full-screen overlay must show "🏆 [COLOR] WINS!" for 5 seconds 33 + - The player's cooldown status must be shown as a thin progress bar below the grid 34 + 35 + ## Responsiveness 36 + 37 + - The canvas must scale to fit the viewport on mobile devices 38 + - Touch events must work the same as mouse clicks for painting
+92
examples/pixel-wars/src/generated/game/__tests__/game.test.ts
··· 1 + /** 2 + * Game — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as grid from '../grid.js'; 12 + import * as painting from '../painting.js'; 13 + import * as scoringAndRounds from '../scoring-and-rounds.js'; 14 + 15 + describe('Game modules', () => { 16 + describe('Grid', () => { 17 + it('exports Phoenix traceability metadata', () => { 18 + expect(grid._phoenix).toBeDefined(); 19 + expect(grid._phoenix.name).toBe('Grid'); 20 + expect(grid._phoenix.risk_tier).toBeTruthy(); 21 + }); 22 + 23 + it('has exported functions', () => { 24 + const exports = Object.keys(grid).filter(k => k !== '_phoenix'); 25 + expect(exports.length).toBeGreaterThan(0); 26 + }); 27 + }); 28 + 29 + describe('Painting', () => { 30 + it('exports Phoenix traceability metadata', () => { 31 + expect(painting._phoenix).toBeDefined(); 32 + expect(painting._phoenix.name).toBe('Painting'); 33 + expect(painting._phoenix.risk_tier).toBeTruthy(); 34 + }); 35 + 36 + it('has exported functions', () => { 37 + const exports = Object.keys(painting).filter(k => k !== '_phoenix'); 38 + expect(exports.length).toBeGreaterThan(0); 39 + }); 40 + }); 41 + 42 + describe('Scoring and Rounds', () => { 43 + it('exports Phoenix traceability metadata', () => { 44 + expect(scoringAndRounds._phoenix).toBeDefined(); 45 + expect(scoringAndRounds._phoenix.name).toBe('Scoring and Rounds'); 46 + expect(scoringAndRounds._phoenix.risk_tier).toBeTruthy(); 47 + }); 48 + 49 + it('has exported functions', () => { 50 + const exports = Object.keys(scoringAndRounds).filter(k => k !== '_phoenix'); 51 + expect(exports.length).toBeGreaterThan(0); 52 + }); 53 + }); 54 + 55 + }); 56 + 57 + describe('Game server', () => { 58 + const instance = startServer(0); // random port 59 + 60 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 61 + 62 + it('GET /health returns 200', async () => { 63 + await instance.ready; 64 + const res = await fetch(`http://localhost:${instance.port}/health`); 65 + expect(res.status).toBe(200); 66 + const body = await res.json() as Record<string, unknown>; 67 + expect(body.status).toBe('ok'); 68 + expect(body.service).toBe('Game'); 69 + }); 70 + 71 + it('GET /metrics returns request counts', async () => { 72 + await instance.ready; 73 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 74 + expect(res.status).toBe(200); 75 + const body = await res.json() as Record<string, unknown>; 76 + expect(typeof body.requests_total).toBe('number'); 77 + }); 78 + 79 + it('GET /modules lists all registered modules', async () => { 80 + await instance.ready; 81 + const res = await fetch(`http://localhost:${instance.port}/modules`); 82 + expect(res.status).toBe(200); 83 + const body = await res.json() as Array<Record<string, unknown>>; 84 + expect(body.length).toBe(3); 85 + }); 86 + 87 + it('GET /unknown returns 404', async () => { 88 + await instance.ready; 89 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 90 + expect(res.status).toBe(404); 91 + }); 92 + });
+121
examples/pixel-wars/src/generated/game/grid.ts
··· 1 + export interface GridCell { 2 + isEmpty: boolean; 3 + value?: any; 4 + } 5 + 6 + export interface GridPosition { 7 + row: number; 8 + col: number; 9 + } 10 + 11 + export interface GridDimensions { 12 + rows: number; 13 + cols: number; 14 + } 15 + 16 + export class Grid { 17 + private cells: GridCell[][]; 18 + private dimensions: GridDimensions; 19 + 20 + constructor(rows: number = 10, cols: number = 10) { 21 + this.dimensions = { rows, cols }; 22 + this.cells = this.initializeEmptyCells(); 23 + } 24 + 25 + private initializeEmptyCells(): GridCell[][] { 26 + const cells: GridCell[][] = []; 27 + for (let row = 0; row < this.dimensions.rows; row++) { 28 + cells[row] = []; 29 + for (let col = 0; col < this.dimensions.cols; col++) { 30 + cells[row][col] = { isEmpty: true }; 31 + } 32 + } 33 + return cells; 34 + } 35 + 36 + public getCell(position: GridPosition): GridCell | null { 37 + if (!this.isValidPosition(position)) { 38 + return null; 39 + } 40 + return this.cells[position.row][position.col]; 41 + } 42 + 43 + public setCell(position: GridPosition, value: any): boolean { 44 + if (!this.isValidPosition(position)) { 45 + return false; 46 + } 47 + this.cells[position.row][position.col] = { 48 + isEmpty: false, 49 + value: value 50 + }; 51 + return true; 52 + } 53 + 54 + public clearCell(position: GridPosition): boolean { 55 + if (!this.isValidPosition(position)) { 56 + return false; 57 + } 58 + this.cells[position.row][position.col] = { isEmpty: true }; 59 + return true; 60 + } 61 + 62 + public isCellEmpty(position: GridPosition): boolean { 63 + const cell = this.getCell(position); 64 + return cell ? cell.isEmpty : false; 65 + } 66 + 67 + public getDimensions(): GridDimensions { 68 + return { ...this.dimensions }; 69 + } 70 + 71 + public getAllCells(): GridCell[][] { 72 + return this.cells.map(row => row.map(cell => ({ ...cell }))); 73 + } 74 + 75 + public clearAll(): void { 76 + this.cells = this.initializeEmptyCells(); 77 + } 78 + 79 + public getEmptyCells(): GridPosition[] { 80 + const emptyCells: GridPosition[] = []; 81 + for (let row = 0; row < this.dimensions.rows; row++) { 82 + for (let col = 0; col < this.dimensions.cols; col++) { 83 + if (this.cells[row][col].isEmpty) { 84 + emptyCells.push({ row, col }); 85 + } 86 + } 87 + } 88 + return emptyCells; 89 + } 90 + 91 + public getOccupiedCells(): GridPosition[] { 92 + const occupiedCells: GridPosition[] = []; 93 + for (let row = 0; row < this.dimensions.rows; row++) { 94 + for (let col = 0; col < this.dimensions.cols; col++) { 95 + if (!this.cells[row][col].isEmpty) { 96 + occupiedCells.push({ row, col }); 97 + } 98 + } 99 + } 100 + return occupiedCells; 101 + } 102 + 103 + private isValidPosition(position: GridPosition): boolean { 104 + return position.row >= 0 && 105 + position.row < this.dimensions.rows && 106 + position.col >= 0 && 107 + position.col < this.dimensions.cols; 108 + } 109 + } 110 + 111 + export function createGrid(rows?: number, cols?: number): Grid { 112 + return new Grid(rows, cols); 113 + } 114 + 115 + /** @internal Phoenix VCS traceability — do not remove. */ 116 + export const _phoenix = { 117 + iu_id: '076f6719d071712bc9eabad5d3df9adf59f4ed9cb24991a72f6ad87f06168ca0', 118 + name: 'Grid', 119 + risk_tier: 'low', 120 + canon_ids: [2 as const], 121 + } as const;
+10
examples/pixel-wars/src/generated/game/index.ts
··· 1 + /** 2 + * Game 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Game modules. 6 + */ 7 + 8 + export * as grid from './grid.js'; 9 + export * as painting from './painting.js'; 10 + export * as scoringAndRounds from './scoring-and-rounds.js';
+182
examples/pixel-wars/src/generated/game/painting.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface PaintRequest { 4 + playerId: string; 5 + teamColor: string; 6 + x: number; 7 + y: number; 8 + timestamp: number; 9 + } 10 + 11 + export interface PaintResult { 12 + success: boolean; 13 + error?: 'too_fast' | 'invalid_cell'; 14 + paintRequest?: PaintRequest; 15 + } 16 + 17 + export interface GridCell { 18 + x: number; 19 + y: number; 20 + color: string | null; 21 + lastPaintedBy?: string; 22 + lastPaintedAt?: number; 23 + } 24 + 25 + export interface PaintBroadcast { 26 + type: 'paint'; 27 + playerId: string; 28 + teamColor: string; 29 + x: number; 30 + y: number; 31 + timestamp: number; 32 + } 33 + 34 + export class PaintingSystem extends EventEmitter { 35 + private readonly grid: Map<string, GridCell>; 36 + private readonly playerCooldowns: Map<string, number>; 37 + private readonly gridWidth: number; 38 + private readonly gridHeight: number; 39 + private readonly cooldownMs: number = 500; 40 + 41 + constructor(gridWidth: number, gridHeight: number) { 42 + super(); 43 + this.gridWidth = gridWidth; 44 + this.gridHeight = gridHeight; 45 + this.grid = new Map(); 46 + this.playerCooldowns = new Map(); 47 + 48 + // Initialize empty grid 49 + for (let x = 0; x < gridWidth; x++) { 50 + for (let y = 0; y < gridHeight; y++) { 51 + const key = this.getCellKey(x, y); 52 + this.grid.set(key, { x, y, color: null }); 53 + } 54 + } 55 + } 56 + 57 + private getCellKey(x: number, y: number): string { 58 + return `${x},${y}`; 59 + } 60 + 61 + private isValidCoordinate(x: number, y: number): boolean { 62 + return x >= 0 && x < this.gridWidth && y >= 0 && y < this.gridHeight; 63 + } 64 + 65 + private checkCooldown(playerId: string, currentTime: number): boolean { 66 + const lastPaintTime = this.playerCooldowns.get(playerId); 67 + if (lastPaintTime === undefined) { 68 + return true; 69 + } 70 + return (currentTime - lastPaintTime) >= this.cooldownMs; 71 + } 72 + 73 + public paint(request: PaintRequest): PaintResult { 74 + const { playerId, teamColor, x, y, timestamp } = request; 75 + 76 + // Validate coordinates 77 + if (!this.isValidCoordinate(x, y)) { 78 + return { 79 + success: false, 80 + error: 'invalid_cell' 81 + }; 82 + } 83 + 84 + // Check cooldown 85 + if (!this.checkCooldown(playerId, timestamp)) { 86 + return { 87 + success: false, 88 + error: 'too_fast' 89 + }; 90 + } 91 + 92 + // Paint the cell (overwrite existing color) 93 + const cellKey = this.getCellKey(x, y); 94 + const cell = this.grid.get(cellKey)!; 95 + 96 + cell.color = teamColor; 97 + cell.lastPaintedBy = playerId; 98 + cell.lastPaintedAt = timestamp; 99 + 100 + // Update player cooldown 101 + this.playerCooldowns.set(playerId, timestamp); 102 + 103 + // Broadcast to all connected players 104 + const broadcast: PaintBroadcast = { 105 + type: 'paint', 106 + playerId, 107 + teamColor, 108 + x, 109 + y, 110 + timestamp 111 + }; 112 + 113 + this.emit('paint_broadcast', broadcast); 114 + 115 + return { 116 + success: true, 117 + paintRequest: request 118 + }; 119 + } 120 + 121 + public getCell(x: number, y: number): GridCell | null { 122 + if (!this.isValidCoordinate(x, y)) { 123 + return null; 124 + } 125 + const cellKey = this.getCellKey(x, y); 126 + return this.grid.get(cellKey) || null; 127 + } 128 + 129 + public getGrid(): GridCell[][] { 130 + const result: GridCell[][] = []; 131 + for (let x = 0; x < this.gridWidth; x++) { 132 + result[x] = []; 133 + for (let y = 0; y < this.gridHeight; y++) { 134 + const cell = this.getCell(x, y); 135 + result[x][y] = cell!; 136 + } 137 + } 138 + return result; 139 + } 140 + 141 + public getPlayerLastPaintTime(playerId: string): number | undefined { 142 + return this.playerCooldowns.get(playerId); 143 + } 144 + 145 + public getRemainingCooldown(playerId: string, currentTime: number): number { 146 + const lastPaintTime = this.playerCooldowns.get(playerId); 147 + if (lastPaintTime === undefined) { 148 + return 0; 149 + } 150 + const elapsed = currentTime - lastPaintTime; 151 + return Math.max(0, this.cooldownMs - elapsed); 152 + } 153 + } 154 + 155 + export function createPaintingSystem(gridWidth: number, gridHeight: number): PaintingSystem { 156 + return new PaintingSystem(gridWidth, gridHeight); 157 + } 158 + 159 + export function validatePaintRequest(request: Partial<PaintRequest>): request is PaintRequest { 160 + return ( 161 + typeof request.playerId === 'string' && 162 + typeof request.teamColor === 'string' && 163 + typeof request.x === 'number' && 164 + typeof request.y === 'number' && 165 + typeof request.timestamp === 'number' && 166 + request.playerId.length > 0 && 167 + request.teamColor.length > 0 && 168 + Number.isInteger(request.x) && 169 + Number.isInteger(request.y) && 170 + request.x >= 0 && 171 + request.y >= 0 && 172 + request.timestamp > 0 173 + ); 174 + } 175 + 176 + /** @internal Phoenix VCS traceability — do not remove. */ 177 + export const _phoenix = { 178 + iu_id: '87e1bdd71ee393347bccfae9daf2ed4a50a148cb1a1ef50bf02d87f9cdaeea9e', 179 + name: 'Painting', 180 + risk_tier: 'high', 181 + canon_ids: [7 as const], 182 + } as const;
+181
examples/pixel-wars/src/generated/game/scoring-and-rounds.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface TeamScore { 4 + teamId: string; 5 + score: number; 6 + pixelsClaimed: number; 7 + } 8 + 9 + export interface RoundState { 10 + roundNumber: number; 11 + isActive: boolean; 12 + timeRemaining: number; 13 + duration: number; 14 + scores: Map<string, TeamScore>; 15 + } 16 + 17 + export interface RoundEndResult { 18 + roundNumber: number; 19 + finalScores: TeamScore[]; 20 + winningTeam: TeamScore | null; 21 + duration: number; 22 + } 23 + 24 + export interface ScoringEvents { 25 + timeUpdate: (timeRemaining: number) => void; 26 + roundEnd: (result: RoundEndResult) => void; 27 + roundStart: (roundNumber: number) => void; 28 + scoreUpdate: (scores: TeamScore[]) => void; 29 + } 30 + 31 + export class ScoringSystem extends EventEmitter { 32 + private currentRound: RoundState; 33 + private roundTimer: NodeJS.Timeout | null = null; 34 + private broadcastTimer: NodeJS.Timeout | null = null; 35 + private newRoundTimer: NodeJS.Timeout | null = null; 36 + private readonly roundDuration: number; 37 + private readonly newRoundDelay: number = 10000; 38 + 39 + constructor(roundDurationMs: number = 300000) { 40 + super(); 41 + this.roundDuration = roundDurationMs; 42 + this.currentRound = { 43 + roundNumber: 0, 44 + isActive: false, 45 + timeRemaining: 0, 46 + duration: roundDurationMs, 47 + scores: new Map() 48 + }; 49 + } 50 + 51 + public startNewRound(): void { 52 + this.clearAllTimers(); 53 + 54 + this.currentRound = { 55 + roundNumber: this.currentRound.roundNumber + 1, 56 + isActive: true, 57 + timeRemaining: this.roundDuration, 58 + duration: this.roundDuration, 59 + scores: new Map() 60 + }; 61 + 62 + this.emit('roundStart', this.currentRound.roundNumber); 63 + this.startTimers(); 64 + } 65 + 66 + public updateTeamScore(teamId: string, pixelsClaimed: number): void { 67 + if (!this.currentRound.isActive) return; 68 + 69 + const existingScore = this.currentRound.scores.get(teamId); 70 + const teamScore: TeamScore = { 71 + teamId, 72 + score: pixelsClaimed, 73 + pixelsClaimed 74 + }; 75 + 76 + this.currentRound.scores.set(teamId, teamScore); 77 + this.emit('scoreUpdate', Array.from(this.currentRound.scores.values())); 78 + } 79 + 80 + public getCurrentRound(): RoundState { 81 + return { ...this.currentRound }; 82 + } 83 + 84 + public isRoundActive(): boolean { 85 + return this.currentRound.isActive; 86 + } 87 + 88 + public getTimeRemaining(): number { 89 + return this.currentRound.timeRemaining; 90 + } 91 + 92 + public endRound(): void { 93 + if (!this.currentRound.isActive) return; 94 + 95 + this.clearAllTimers(); 96 + this.currentRound.isActive = false; 97 + this.currentRound.timeRemaining = 0; 98 + 99 + const finalScores = Array.from(this.currentRound.scores.values()) 100 + .sort((a, b) => b.score - a.score); 101 + 102 + const winningTeam = finalScores.length > 0 ? finalScores[0] : null; 103 + 104 + const result: RoundEndResult = { 105 + roundNumber: this.currentRound.roundNumber, 106 + finalScores, 107 + winningTeam, 108 + duration: this.currentRound.duration 109 + }; 110 + 111 + this.emit('roundEnd', result); 112 + this.scheduleNewRound(); 113 + } 114 + 115 + public stop(): void { 116 + this.clearAllTimers(); 117 + this.currentRound.isActive = false; 118 + } 119 + 120 + private startTimers(): void { 121 + this.broadcastTimer = setInterval(() => { 122 + if (this.currentRound.isActive && this.currentRound.timeRemaining > 0) { 123 + this.currentRound.timeRemaining -= 1000; 124 + this.emit('timeUpdate', this.currentRound.timeRemaining); 125 + 126 + if (this.currentRound.timeRemaining <= 0) { 127 + this.endRound(); 128 + } 129 + } 130 + }, 1000); 131 + } 132 + 133 + private scheduleNewRound(): void { 134 + this.newRoundTimer = setTimeout(() => { 135 + this.startNewRound(); 136 + }, this.newRoundDelay); 137 + } 138 + 139 + private clearAllTimers(): void { 140 + if (this.roundTimer) { 141 + clearTimeout(this.roundTimer); 142 + this.roundTimer = null; 143 + } 144 + if (this.broadcastTimer) { 145 + clearInterval(this.broadcastTimer); 146 + this.broadcastTimer = null; 147 + } 148 + if (this.newRoundTimer) { 149 + clearTimeout(this.newRoundTimer); 150 + this.newRoundTimer = null; 151 + } 152 + } 153 + } 154 + 155 + export function createScoringSystem(roundDurationMs?: number): ScoringSystem { 156 + return new ScoringSystem(roundDurationMs); 157 + } 158 + 159 + export function formatTimeRemaining(milliseconds: number): string { 160 + const totalSeconds = Math.ceil(milliseconds / 1000); 161 + const minutes = Math.floor(totalSeconds / 60); 162 + const seconds = totalSeconds % 60; 163 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 164 + } 165 + 166 + export function calculateTeamRanking(scores: TeamScore[]): TeamScore[] { 167 + return [...scores].sort((a, b) => { 168 + if (b.score !== a.score) { 169 + return b.score - a.score; 170 + } 171 + return a.teamId.localeCompare(b.teamId); 172 + }); 173 + } 174 + 175 + /** @internal Phoenix VCS traceability — do not remove. */ 176 + export const _phoenix = { 177 + iu_id: '6713a6240993f5a4e289ac9af66e75624ca836131385994c5f7a1aa206ac7fee', 178 + name: 'Scoring and Rounds', 179 + risk_tier: 'medium', 180 + canon_ids: [4 as const], 181 + } as const;
+123
examples/pixel-wars/src/generated/game/server.ts
··· 1 + /** 2 + * Game — HTTP Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Provides health check, metrics, and module endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as grid from './grid.js'; 11 + import * as painting from './painting.js'; 12 + import * as scoringAndRounds from './scoring-and-rounds.js'; 13 + 14 + // ─── Metrics ───────────────────────────────────────────────────────────────── 15 + 16 + const _svcMetrics = { 17 + requests_total: 0, 18 + requests_by_path: {} as Record<string, number>, 19 + errors_total: 0, 20 + uptime_start: Date.now(), 21 + }; 22 + 23 + // ─── Module Registry ───────────────────────────────────────────────────────── 24 + 25 + const _svcModules = { 26 + 'grid': grid, 27 + 'painting': painting, 28 + 'scoring-and-rounds': scoringAndRounds, 29 + }; 30 + 31 + // ─── Router ────────────────────────────────────────────────────────────────── 32 + 33 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 34 + 35 + const routes: Record<string, Handler> = { 36 + '/health': (_req, res) => { 37 + res.writeHead(200, { 'Content-Type': 'application/json' }); 38 + res.end(JSON.stringify({ 39 + status: 'ok', 40 + service: 'Game', 41 + uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 42 + modules: Object.keys(_svcModules), 43 + })); 44 + }, 45 + 46 + '/metrics': (_req, res) => { 47 + res.writeHead(200, { 'Content-Type': 'application/json' }); 48 + res.end(JSON.stringify({ 49 + ..._svcMetrics, 50 + uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 51 + }, null, 2)); 52 + }, 53 + 54 + '/modules': (_req, res) => { 55 + const info = Object.entries(_svcModules).map(([name, mod]) => { 56 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 57 + return { 58 + name, 59 + risk_tier: phoenix?.risk_tier ?? 'unknown', 60 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 61 + }; 62 + }); 63 + res.writeHead(200, { 'Content-Type': 'application/json' }); 64 + res.end(JSON.stringify(info, null, 2)); 65 + }, 66 + }; 67 + 68 + // ─── Server ────────────────────────────────────────────────────────────────── 69 + 70 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 71 + const url = req.url ?? '/'; 72 + const path = url.split('?')[0]; 73 + 74 + _svcMetrics.requests_total++; 75 + _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1; 76 + 77 + const handler = routes[path]; 78 + if (handler) { 79 + try { 80 + handler(req, res); 81 + } catch (err) { 82 + _svcMetrics.errors_total++; 83 + res.writeHead(500, { 'Content-Type': 'application/json' }); 84 + res.end(JSON.stringify({ error: String(err) })); 85 + } 86 + } else { 87 + res.writeHead(404, { 'Content-Type': 'application/json' }); 88 + res.end(JSON.stringify({ 89 + error: 'Not Found', 90 + path, 91 + available: Object.keys(routes), 92 + })); 93 + } 94 + } 95 + 96 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 97 + const requestedPort = port ?? parseInt(process.env.GAME_PORT ?? process.env.PORT ?? '3000', 10); 98 + const server = createServer(handleRequest); 99 + let actualPort = requestedPort; 100 + 101 + const ready = new Promise<void>(resolve => { 102 + server.listen(requestedPort, () => { 103 + const addr = server.address(); 104 + if (addr && typeof addr === 'object') actualPort = addr.port; 105 + result.port = actualPort; 106 + console.log(`Game listening on http://localhost:${actualPort}`); 107 + console.log(` /health — health check`); 108 + console.log(` /metrics — request metrics`); 109 + console.log(` /modules — registered modules`); 110 + resolve(); 111 + }); 112 + }); 113 + 114 + const result = { server, port: actualPort, ready }; 115 + return result; 116 + } 117 + 118 + // Start when run directly 119 + const isMain = process.argv[1]?.endsWith('/game/server.js') || 120 + process.argv[1]?.endsWith('/game/server.ts'); 121 + if (isMain) { 122 + startServer(); 123 + }
+15
examples/pixel-wars/src/generated/index.ts
··· 1 + /** 2 + * Phoenix VCS — Generated Service Registry 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + */ 6 + 7 + export * as game from './game/index.js'; 8 + export * as server from './server/index.js'; 9 + export * as ui from './ui/index.js'; 10 + 11 + export const services = [ 12 + { name: 'Game', dir: 'game', port: 3000, modules: 3 }, 13 + { name: 'Server', dir: 'server', port: 3001, modules: 2 }, 14 + { name: 'Ui', dir: 'ui', port: 3002, modules: 4 }, 15 + ] as const;
+78
examples/pixel-wars/src/generated/server/__tests__/server.test.ts
··· 1 + /** 2 + * Server — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as connectionHandling from '../connection-handling.js'; 12 + import * as rooms from '../rooms.js'; 13 + 14 + describe('Server modules', () => { 15 + describe('Connection Handling', () => { 16 + it('exports Phoenix traceability metadata', () => { 17 + expect(connectionHandling._phoenix).toBeDefined(); 18 + expect(connectionHandling._phoenix.name).toBe('Connection Handling'); 19 + expect(connectionHandling._phoenix.risk_tier).toBeTruthy(); 20 + }); 21 + 22 + it('has exported functions', () => { 23 + const exports = Object.keys(connectionHandling).filter(k => k !== '_phoenix'); 24 + expect(exports.length).toBeGreaterThan(0); 25 + }); 26 + }); 27 + 28 + describe('Rooms', () => { 29 + it('exports Phoenix traceability metadata', () => { 30 + expect(rooms._phoenix).toBeDefined(); 31 + expect(rooms._phoenix.name).toBe('Rooms'); 32 + expect(rooms._phoenix.risk_tier).toBeTruthy(); 33 + }); 34 + 35 + it('has exported functions', () => { 36 + const exports = Object.keys(rooms).filter(k => k !== '_phoenix'); 37 + expect(exports.length).toBeGreaterThan(0); 38 + }); 39 + }); 40 + 41 + }); 42 + 43 + describe('Server server', () => { 44 + const instance = startServer(0); // random port 45 + 46 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 47 + 48 + it('GET /health returns 200', async () => { 49 + await instance.ready; 50 + const res = await fetch(`http://localhost:${instance.port}/health`); 51 + expect(res.status).toBe(200); 52 + const body = await res.json() as Record<string, unknown>; 53 + expect(body.status).toBe('ok'); 54 + expect(body.service).toBe('Server'); 55 + }); 56 + 57 + it('GET /metrics returns request counts', async () => { 58 + await instance.ready; 59 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 60 + expect(res.status).toBe(200); 61 + const body = await res.json() as Record<string, unknown>; 62 + expect(typeof body.requests_total).toBe('number'); 63 + }); 64 + 65 + it('GET /modules lists all registered modules', async () => { 66 + await instance.ready; 67 + const res = await fetch(`http://localhost:${instance.port}/modules`); 68 + expect(res.status).toBe(200); 69 + const body = await res.json() as Array<Record<string, unknown>>; 70 + expect(body.length).toBe(2); 71 + }); 72 + 73 + it('GET /unknown returns 404', async () => { 74 + await instance.ready; 75 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 76 + expect(res.status).toBe(404); 77 + }); 78 + });
+416
examples/pixel-wars/src/generated/server/connection-handling.ts
··· 1 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 2 + import { createHash } from 'node:crypto'; 3 + import { EventEmitter } from 'node:events'; 4 + 5 + export interface Player { 6 + id: string; 7 + connected: boolean; 8 + lastSeen: number; 9 + } 10 + 11 + export interface GameState { 12 + grid: number[][]; 13 + players: Map<string, Player>; 14 + gameEndTime: number; 15 + } 16 + 17 + export interface ConnectionMessage { 18 + type: 'init' | 'player_update'; 19 + data: { 20 + playerId?: string; 21 + grid?: number[][]; 22 + players?: Array<{ id: string; connected: boolean }>; 23 + remainingTime?: number; 24 + playerCount?: number; 25 + }; 26 + } 27 + 28 + export interface WebSocketLike { 29 + send(data: string): void; 30 + close(): void; 31 + on(event: 'message' | 'close', handler: (data?: any) => void): void; 32 + } 33 + 34 + export class ConnectionHandler extends EventEmitter { 35 + private server: ReturnType<typeof createServer>; 36 + private connections = new Map<string, WebSocketLike>(); 37 + private players = new Map<string, Player>(); 38 + private gameState: GameState; 39 + private cleanupInterval: NodeJS.Timeout; 40 + private port: number; 41 + 42 + constructor(port = 3000) { 43 + super(); 44 + this.port = port; 45 + this.gameState = { 46 + grid: Array(10).fill(null).map(() => Array(10).fill(0)), 47 + players: this.players, 48 + gameEndTime: Date.now() + 300000, // 5 minutes default 49 + }; 50 + 51 + this.server = createServer(this.handleHttpRequest.bind(this)); 52 + this.cleanupInterval = setInterval(this.cleanupDisconnectedPlayers.bind(this), 1000); 53 + } 54 + 55 + private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { 56 + if (req.method === 'GET' && req.url === '/') { 57 + res.writeHead(200, { 'Content-Type': 'text/html' }); 58 + res.end(this.generateGameUI()); 59 + return; 60 + } 61 + 62 + if (req.url === '/ws' && req.headers.upgrade === 'websocket') { 63 + this.handleWebSocketUpgrade(req, res); 64 + return; 65 + } 66 + 67 + res.writeHead(404); 68 + res.end('Not Found'); 69 + } 70 + 71 + private handleWebSocketUpgrade(req: IncomingMessage, res: ServerResponse): void { 72 + const key = req.headers['sec-websocket-key']; 73 + if (!key) { 74 + res.writeHead(400); 75 + res.end('Bad Request'); 76 + return; 77 + } 78 + 79 + const acceptKey = this.generateWebSocketAcceptKey(key); 80 + res.writeHead(101, { 81 + 'Upgrade': 'websocket', 82 + 'Connection': 'Upgrade', 83 + 'Sec-WebSocket-Accept': acceptKey, 84 + }); 85 + 86 + const socket = res.socket; 87 + if (!socket) return; 88 + 89 + const playerId = this.generatePlayerId(); 90 + const wsConnection = this.createWebSocketConnection(socket, playerId); 91 + 92 + this.connections.set(playerId, wsConnection); 93 + this.addPlayer(playerId); 94 + this.sendInitialState(wsConnection, playerId); 95 + this.broadcastPlayerCount(); 96 + } 97 + 98 + private generateWebSocketAcceptKey(key: string): string { 99 + const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 100 + return createHash('sha1').update(key + magic).digest('base64'); 101 + } 102 + 103 + private createWebSocketConnection(socket: any, playerId: string): WebSocketLike { 104 + const connection = { 105 + send: (data: string) => { 106 + const frame = this.createWebSocketFrame(data); 107 + socket.write(frame); 108 + }, 109 + close: () => { 110 + socket.end(); 111 + }, 112 + on: (event: string, handler: (data?: any) => void) => { 113 + if (event === 'message') { 114 + socket.on('data', (buffer: Buffer) => { 115 + const message = this.parseWebSocketFrame(buffer); 116 + if (message) handler(message); 117 + }); 118 + } else if (event === 'close') { 119 + socket.on('close', handler); 120 + socket.on('end', handler); 121 + } 122 + } 123 + }; 124 + 125 + connection.on('close', () => { 126 + this.handlePlayerDisconnect(playerId); 127 + }); 128 + 129 + return connection; 130 + } 131 + 132 + private createWebSocketFrame(data: string): Buffer { 133 + const payload = Buffer.from(data, 'utf8'); 134 + const payloadLength = payload.length; 135 + 136 + let frame: Buffer; 137 + if (payloadLength < 126) { 138 + frame = Buffer.allocUnsafe(2 + payloadLength); 139 + frame[0] = 0x81; // FIN + text frame 140 + frame[1] = payloadLength; 141 + payload.copy(frame, 2); 142 + } else if (payloadLength < 65536) { 143 + frame = Buffer.allocUnsafe(4 + payloadLength); 144 + frame[0] = 0x81; 145 + frame[1] = 126; 146 + frame.writeUInt16BE(payloadLength, 2); 147 + payload.copy(frame, 4); 148 + } else { 149 + frame = Buffer.allocUnsafe(10 + payloadLength); 150 + frame[0] = 0x81; 151 + frame[1] = 127; 152 + frame.writeUInt32BE(0, 2); 153 + frame.writeUInt32BE(payloadLength, 6); 154 + payload.copy(frame, 10); 155 + } 156 + 157 + return frame; 158 + } 159 + 160 + private parseWebSocketFrame(buffer: Buffer): string | null { 161 + if (buffer.length < 2) return null; 162 + 163 + const firstByte = buffer[0]; 164 + const secondByte = buffer[1]; 165 + 166 + if ((firstByte & 0x80) === 0) return null; // Not final frame 167 + 168 + const opcode = firstByte & 0x0f; 169 + if (opcode !== 0x01) return null; // Not text frame 170 + 171 + const masked = (secondByte & 0x80) === 0x80; 172 + let payloadLength = secondByte & 0x7f; 173 + let offset = 2; 174 + 175 + if (payloadLength === 126) { 176 + if (buffer.length < 4) return null; 177 + payloadLength = buffer.readUInt16BE(2); 178 + offset = 4; 179 + } else if (payloadLength === 127) { 180 + if (buffer.length < 10) return null; 181 + payloadLength = buffer.readUInt32BE(6); 182 + offset = 10; 183 + } 184 + 185 + if (masked) { 186 + if (buffer.length < offset + 4 + payloadLength) return null; 187 + const maskKey = Buffer.from(buffer.subarray(offset, offset + 4)); 188 + offset += 4; 189 + const payload = Buffer.from(buffer.subarray(offset, offset + payloadLength)); 190 + 191 + for (let i = 0; i < payload.length; i++) { 192 + payload[i] ^= maskKey[i % 4]; 193 + } 194 + 195 + return payload.toString('utf8'); 196 + } else { 197 + if (buffer.length < offset + payloadLength) return null; 198 + return Buffer.from(buffer.subarray(offset, offset + payloadLength)).toString('utf8'); 199 + } 200 + } 201 + 202 + private generatePlayerId(): string { 203 + let id: string; 204 + do { 205 + id = Math.random().toString(16).substring(2, 8).padStart(6, '0'); 206 + } while (this.players.has(id)); 207 + return id; 208 + } 209 + 210 + private addPlayer(playerId: string): void { 211 + const player: Player = { 212 + id: playerId, 213 + connected: true, 214 + lastSeen: Date.now(), 215 + }; 216 + this.players.set(playerId, player); 217 + this.emit('playerJoined', playerId); 218 + } 219 + 220 + private handlePlayerDisconnect(playerId: string): void { 221 + const player = this.players.get(playerId); 222 + if (player) { 223 + player.connected = false; 224 + player.lastSeen = Date.now(); 225 + } 226 + this.connections.delete(playerId); 227 + } 228 + 229 + private cleanupDisconnectedPlayers(): void { 230 + const now = Date.now(); 231 + const toRemove: string[] = []; 232 + 233 + for (const [playerId, player] of this.players) { 234 + if (!player.connected && now - player.lastSeen > 5000) { 235 + toRemove.push(playerId); 236 + } 237 + } 238 + 239 + if (toRemove.length > 0) { 240 + for (const playerId of toRemove) { 241 + this.players.delete(playerId); 242 + this.emit('playerLeft', playerId); 243 + } 244 + this.broadcastPlayerCount(); 245 + } 246 + } 247 + 248 + private sendInitialState(connection: WebSocketLike, playerId: string): void { 249 + const message: ConnectionMessage = { 250 + type: 'init', 251 + data: { 252 + playerId, 253 + grid: this.gameState.grid, 254 + players: Array.from(this.players.values()).map(p => ({ 255 + id: p.id, 256 + connected: p.connected, 257 + })), 258 + remainingTime: Math.max(0, this.gameState.gameEndTime - Date.now()), 259 + }, 260 + }; 261 + connection.send(JSON.stringify(message)); 262 + } 263 + 264 + private broadcastPlayerCount(): void { 265 + const connectedCount = Array.from(this.players.values()).filter(p => p.connected).length; 266 + const message: ConnectionMessage = { 267 + type: 'player_update', 268 + data: { 269 + playerCount: connectedCount, 270 + }, 271 + }; 272 + 273 + const messageStr = JSON.stringify(message); 274 + for (const connection of this.connections.values()) { 275 + connection.send(messageStr); 276 + } 277 + } 278 + 279 + private generateGameUI(): string { 280 + return `<!DOCTYPE html> 281 + <html lang="en"> 282 + <head> 283 + <meta charset="UTF-8"> 284 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 285 + <title>Phoenix VCS Game</title> 286 + <style> 287 + body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f0f0f0; } 288 + .container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; } 289 + .grid { display: grid; grid-template-columns: repeat(10, 40px); gap: 2px; margin: 20px 0; } 290 + .cell { width: 40px; height: 40px; border: 1px solid #ccc; display: flex; align-items: center; justify-content: center; cursor: pointer; } 291 + .cell:hover { background: #e0e0e0; } 292 + .status { margin: 10px 0; padding: 10px; background: #f8f8f8; border-radius: 4px; } 293 + .players { margin: 20px 0; } 294 + .player { padding: 5px; margin: 2px 0; background: #e8f4f8; border-radius: 3px; } 295 + </style> 296 + </head> 297 + <body> 298 + <div class="container"> 299 + <h1>Phoenix VCS Game</h1> 300 + <div class="status"> 301 + <div>Player ID: <span id="playerId">Connecting...</span></div> 302 + <div>Players Online: <span id="playerCount">0</span></div> 303 + <div>Time Remaining: <span id="timeRemaining">--:--</span></div> 304 + </div> 305 + <div class="grid" id="gameGrid"></div> 306 + <div class="players"> 307 + <h3>Players</h3> 308 + <div id="playerList"></div> 309 + </div> 310 + </div> 311 + <script> 312 + const ws = new WebSocket('ws://localhost:${this.port}/ws'); 313 + let gameState = { grid: [], players: [], remainingTime: 0 }; 314 + 315 + ws.onmessage = (event) => { 316 + const message = JSON.parse(event.data); 317 + if (message.type === 'init') { 318 + document.getElementById('playerId').textContent = message.data.playerId; 319 + gameState.grid = message.data.grid; 320 + gameState.players = message.data.players; 321 + gameState.remainingTime = message.data.remainingTime; 322 + updateUI(); 323 + } else if (message.type === 'player_update') { 324 + document.getElementById('playerCount').textContent = message.data.playerCount; 325 + } 326 + }; 327 + 328 + function updateUI() { 329 + updateGrid(); 330 + updatePlayerList(); 331 + updateTimer(); 332 + } 333 + 334 + function updateGrid() { 335 + const grid = document.getElementById('gameGrid'); 336 + grid.innerHTML = ''; 337 + for (let i = 0; i < 10; i++) { 338 + for (let j = 0; j < 10; j++) { 339 + const cell = document.createElement('div'); 340 + cell.className = 'cell'; 341 + cell.textContent = gameState.grid[i][j] || ''; 342 + grid.appendChild(cell); 343 + } 344 + } 345 + } 346 + 347 + function updatePlayerList() { 348 + const list = document.getElementById('playerList'); 349 + list.innerHTML = ''; 350 + gameState.players.forEach(player => { 351 + const div = document.createElement('div'); 352 + div.className = 'player'; 353 + div.textContent = player.id + (player.connected ? ' (online)' : ' (offline)'); 354 + list.appendChild(div); 355 + }); 356 + } 357 + 358 + function updateTimer() { 359 + const minutes = Math.floor(gameState.remainingTime / 60000); 360 + const seconds = Math.floor((gameState.remainingTime % 60000) / 1000); 361 + document.getElementById('timeRemaining').textContent = 362 + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); 363 + } 364 + 365 + setInterval(() => { 366 + if (gameState.remainingTime > 0) { 367 + gameState.remainingTime -= 1000; 368 + updateTimer(); 369 + } 370 + }, 1000); 371 + </script> 372 + </body> 373 + </html>`; 374 + } 375 + 376 + public listen(): Promise<void> { 377 + return new Promise((resolve) => { 378 + this.server.listen(this.port, () => { 379 + resolve(); 380 + }); 381 + }); 382 + } 383 + 384 + public close(): Promise<void> { 385 + return new Promise((resolve) => { 386 + clearInterval(this.cleanupInterval); 387 + for (const connection of this.connections.values()) { 388 + connection.close(); 389 + } 390 + this.server.close(() => { 391 + resolve(); 392 + }); 393 + }); 394 + } 395 + 396 + public getPlayerCount(): number { 397 + return Array.from(this.players.values()).filter(p => p.connected).length; 398 + } 399 + 400 + public getPlayers(): Player[] { 401 + return Array.from(this.players.values()); 402 + } 403 + 404 + public updateGameState(grid: number[][], gameEndTime: number): void { 405 + this.gameState.grid = grid; 406 + this.gameState.gameEndTime = gameEndTime; 407 + } 408 + } 409 + 410 + /** @internal Phoenix VCS traceability — do not remove. */ 411 + export const _phoenix = { 412 + iu_id: '287d727e4c54fc45b5ea5e2484392a34a4b0750386cdb6f88404dcff44b70aa3', 413 + name: 'Connection Handling', 414 + risk_tier: 'medium', 415 + canon_ids: [6 as const], 416 + } as const;
+9
examples/pixel-wars/src/generated/server/index.ts
··· 1 + /** 2 + * Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Server modules. 6 + */ 7 + 8 + export * as connectionHandling from './connection-handling.js'; 9 + export * as rooms from './rooms.js';
+190
examples/pixel-wars/src/generated/server/rooms.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface Player { 4 + id: string; 5 + teamColor: 'red' | 'blue'; 6 + cellsPainted: number; 7 + connectionTime: number; 8 + } 9 + 10 + export interface RoomState { 11 + players: Map<string, Player>; 12 + redTeamCells: number; 13 + blueTeamCells: number; 14 + maxPlayers: number; 15 + } 16 + 17 + export interface RoomEvents { 18 + player_joined: (player: Player) => void; 19 + player_left: (playerId: string) => void; 20 + cells_updated: (playerId: string, newCount: number) => void; 21 + room_full: (playerId: string) => void; 22 + } 23 + 24 + export class GameRoom extends EventEmitter { 25 + private state: RoomState; 26 + 27 + constructor() { 28 + super(); 29 + this.state = { 30 + players: new Map(), 31 + redTeamCells: 0, 32 + blueTeamCells: 0, 33 + maxPlayers: 20, 34 + }; 35 + } 36 + 37 + public addPlayer(playerId: string): Player | null { 38 + if (this.state.players.size >= this.state.maxPlayers) { 39 + this.emit('room_full', playerId); 40 + return null; 41 + } 42 + 43 + if (this.state.players.has(playerId)) { 44 + return this.state.players.get(playerId)!; 45 + } 46 + 47 + const teamColor = this.assignTeamColor(); 48 + const player: Player = { 49 + id: playerId, 50 + teamColor, 51 + cellsPainted: 0, 52 + connectionTime: Date.now(), 53 + }; 54 + 55 + this.state.players.set(playerId, player); 56 + this.emit('player_joined', player); 57 + return player; 58 + } 59 + 60 + public removePlayer(playerId: string): boolean { 61 + const player = this.state.players.get(playerId); 62 + if (!player) { 63 + return false; 64 + } 65 + 66 + this.state.players.delete(playerId); 67 + 68 + // Update team totals 69 + if (player.teamColor === 'red') { 70 + this.state.redTeamCells -= player.cellsPainted; 71 + } else { 72 + this.state.blueTeamCells -= player.cellsPainted; 73 + } 74 + 75 + this.emit('player_left', playerId); 76 + return true; 77 + } 78 + 79 + public updatePlayerCells(playerId: string, newCellCount: number): boolean { 80 + const player = this.state.players.get(playerId); 81 + if (!player) { 82 + return false; 83 + } 84 + 85 + const oldCount = player.cellsPainted; 86 + const difference = newCellCount - oldCount; 87 + 88 + player.cellsPainted = newCellCount; 89 + 90 + // Update team totals 91 + if (player.teamColor === 'red') { 92 + this.state.redTeamCells += difference; 93 + } else { 94 + this.state.blueTeamCells += difference; 95 + } 96 + 97 + this.emit('cells_updated', playerId, newCellCount); 98 + return true; 99 + } 100 + 101 + public getPlayer(playerId: string): Player | undefined { 102 + return this.state.players.get(playerId); 103 + } 104 + 105 + public getPlayerCount(): number { 106 + return this.state.players.size; 107 + } 108 + 109 + public getTeamCounts(): { red: number; blue: number } { 110 + return { 111 + red: this.state.redTeamCells, 112 + blue: this.state.blueTeamCells, 113 + }; 114 + } 115 + 116 + public getAllPlayers(): Player[] { 117 + return Array.from(this.state.players.values()); 118 + } 119 + 120 + public isFull(): boolean { 121 + return this.state.players.size >= this.state.maxPlayers; 122 + } 123 + 124 + private assignTeamColor(): 'red' | 'blue' { 125 + let redCount = 0; 126 + let blueCount = 0; 127 + 128 + for (const player of this.state.players.values()) { 129 + if (player.teamColor === 'red') { 130 + redCount++; 131 + } else { 132 + blueCount++; 133 + } 134 + } 135 + 136 + return redCount <= blueCount ? 'red' : 'blue'; 137 + } 138 + } 139 + 140 + export const globalRoom = new GameRoom(); 141 + 142 + export function joinGlobalRoom(playerId: string): { success: boolean; player?: Player; error?: string } { 143 + if (globalRoom.isFull()) { 144 + return { 145 + success: false, 146 + error: 'room_full', 147 + }; 148 + } 149 + 150 + const player = globalRoom.addPlayer(playerId); 151 + if (!player) { 152 + return { 153 + success: false, 154 + error: 'room_full', 155 + }; 156 + } 157 + 158 + return { 159 + success: true, 160 + player, 161 + }; 162 + } 163 + 164 + export function leaveGlobalRoom(playerId: string): boolean { 165 + return globalRoom.removePlayer(playerId); 166 + } 167 + 168 + export function updatePlayerProgress(playerId: string, cellsPainted: number): boolean { 169 + return globalRoom.updatePlayerCells(playerId, cellsPainted); 170 + } 171 + 172 + export function getRoomStats(): { 173 + playerCount: number; 174 + maxPlayers: number; 175 + teamCounts: { red: number; blue: number }; 176 + } { 177 + return { 178 + playerCount: globalRoom.getPlayerCount(), 179 + maxPlayers: 20, 180 + teamCounts: globalRoom.getTeamCounts(), 181 + }; 182 + } 183 + 184 + /** @internal Phoenix VCS traceability — do not remove. */ 185 + export const _phoenix = { 186 + iu_id: '7c35bfefd6f339577c02cbc4ac3445367375ff8cd6c1a8843b0bd336c4c4bd51', 187 + name: 'Rooms', 188 + risk_tier: 'high', 189 + canon_ids: [3 as const], 190 + } as const;
+121
examples/pixel-wars/src/generated/server/server.ts
··· 1 + /** 2 + * Server — HTTP Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Provides health check, metrics, and module endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as connectionHandling from './connection-handling.js'; 11 + import * as rooms from './rooms.js'; 12 + 13 + // ─── Metrics ───────────────────────────────────────────────────────────────── 14 + 15 + const _svcMetrics = { 16 + requests_total: 0, 17 + requests_by_path: {} as Record<string, number>, 18 + errors_total: 0, 19 + uptime_start: Date.now(), 20 + }; 21 + 22 + // ─── Module Registry ───────────────────────────────────────────────────────── 23 + 24 + const _svcModules = { 25 + 'connection-handling': connectionHandling, 26 + 'rooms': rooms, 27 + }; 28 + 29 + // ─── Router ────────────────────────────────────────────────────────────────── 30 + 31 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 32 + 33 + const routes: Record<string, Handler> = { 34 + '/health': (_req, res) => { 35 + res.writeHead(200, { 'Content-Type': 'application/json' }); 36 + res.end(JSON.stringify({ 37 + status: 'ok', 38 + service: 'Server', 39 + uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 40 + modules: Object.keys(_svcModules), 41 + })); 42 + }, 43 + 44 + '/metrics': (_req, res) => { 45 + res.writeHead(200, { 'Content-Type': 'application/json' }); 46 + res.end(JSON.stringify({ 47 + ..._svcMetrics, 48 + uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 49 + }, null, 2)); 50 + }, 51 + 52 + '/modules': (_req, res) => { 53 + const info = Object.entries(_svcModules).map(([name, mod]) => { 54 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 55 + return { 56 + name, 57 + risk_tier: phoenix?.risk_tier ?? 'unknown', 58 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 59 + }; 60 + }); 61 + res.writeHead(200, { 'Content-Type': 'application/json' }); 62 + res.end(JSON.stringify(info, null, 2)); 63 + }, 64 + }; 65 + 66 + // ─── Server ────────────────────────────────────────────────────────────────── 67 + 68 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 69 + const url = req.url ?? '/'; 70 + const path = url.split('?')[0]; 71 + 72 + _svcMetrics.requests_total++; 73 + _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1; 74 + 75 + const handler = routes[path]; 76 + if (handler) { 77 + try { 78 + handler(req, res); 79 + } catch (err) { 80 + _svcMetrics.errors_total++; 81 + res.writeHead(500, { 'Content-Type': 'application/json' }); 82 + res.end(JSON.stringify({ error: String(err) })); 83 + } 84 + } else { 85 + res.writeHead(404, { 'Content-Type': 'application/json' }); 86 + res.end(JSON.stringify({ 87 + error: 'Not Found', 88 + path, 89 + available: Object.keys(routes), 90 + })); 91 + } 92 + } 93 + 94 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 95 + const requestedPort = port ?? parseInt(process.env.SERVER_PORT ?? process.env.PORT ?? '3001', 10); 96 + const server = createServer(handleRequest); 97 + let actualPort = requestedPort; 98 + 99 + const ready = new Promise<void>(resolve => { 100 + server.listen(requestedPort, () => { 101 + const addr = server.address(); 102 + if (addr && typeof addr === 'object') actualPort = addr.port; 103 + result.port = actualPort; 104 + console.log(`Server listening on http://localhost:${actualPort}`); 105 + console.log(` /health — health check`); 106 + console.log(` /metrics — request metrics`); 107 + console.log(` /modules — registered modules`); 108 + resolve(); 109 + }); 110 + }); 111 + 112 + const result = { server, port: actualPort, ready }; 113 + return result; 114 + } 115 + 116 + // Start when run directly 117 + const isMain = process.argv[1]?.endsWith('/server/server.js') || 118 + process.argv[1]?.endsWith('/server/server.ts'); 119 + if (isMain) { 120 + startServer(); 121 + }
+117
examples/pixel-wars/src/generated/ui/__tests__/ui.test.ts
··· 1 + /** 2 + * Ui — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as feedback from '../feedback.js'; 12 + import * as gridRendering from '../grid-rendering.js'; 13 + import * as layout from '../layout.js'; 14 + import * as responsiveness from '../responsiveness.js'; 15 + 16 + describe('Ui modules', () => { 17 + describe('Feedback', () => { 18 + it('exports Phoenix traceability metadata', () => { 19 + expect(feedback._phoenix).toBeDefined(); 20 + expect(feedback._phoenix.name).toBe('Feedback'); 21 + expect(feedback._phoenix.risk_tier).toBeTruthy(); 22 + }); 23 + 24 + it('has exported functions', () => { 25 + const exports = Object.keys(feedback).filter(k => k !== '_phoenix'); 26 + expect(exports.length).toBeGreaterThan(0); 27 + }); 28 + }); 29 + 30 + describe('Grid Rendering', () => { 31 + it('exports Phoenix traceability metadata', () => { 32 + expect(gridRendering._phoenix).toBeDefined(); 33 + expect(gridRendering._phoenix.name).toBe('Grid Rendering'); 34 + expect(gridRendering._phoenix.risk_tier).toBeTruthy(); 35 + }); 36 + 37 + it('has exported functions', () => { 38 + const exports = Object.keys(gridRendering).filter(k => k !== '_phoenix'); 39 + expect(exports.length).toBeGreaterThan(0); 40 + }); 41 + }); 42 + 43 + describe('Layout', () => { 44 + it('exports Phoenix traceability metadata', () => { 45 + expect(layout._phoenix).toBeDefined(); 46 + expect(layout._phoenix.name).toBe('Layout'); 47 + expect(layout._phoenix.risk_tier).toBeTruthy(); 48 + }); 49 + 50 + it('has exported functions', () => { 51 + const exports = Object.keys(layout).filter(k => k !== '_phoenix'); 52 + expect(exports.length).toBeGreaterThan(0); 53 + }); 54 + }); 55 + 56 + describe('Responsiveness', () => { 57 + it('exports Phoenix traceability metadata', () => { 58 + expect(responsiveness._phoenix).toBeDefined(); 59 + expect(responsiveness._phoenix.name).toBe('Responsiveness'); 60 + expect(responsiveness._phoenix.risk_tier).toBeTruthy(); 61 + }); 62 + 63 + it('has exported functions', () => { 64 + const exports = Object.keys(responsiveness).filter(k => k !== '_phoenix'); 65 + expect(exports.length).toBeGreaterThan(0); 66 + }); 67 + }); 68 + 69 + }); 70 + 71 + describe('Ui server', () => { 72 + const instance = startServer(0); // random port 73 + 74 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 75 + 76 + it('GET /health returns 200', async () => { 77 + await instance.ready; 78 + const res = await fetch(`http://localhost:${instance.port}/health`); 79 + expect(res.status).toBe(200); 80 + const body = await res.json() as Record<string, unknown>; 81 + expect(body.status).toBe('ok'); 82 + expect(body.service).toBe('Ui'); 83 + }); 84 + 85 + it('GET /metrics returns request counts', async () => { 86 + await instance.ready; 87 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 88 + expect(res.status).toBe(200); 89 + const body = await res.json() as Record<string, unknown>; 90 + expect(typeof body.requests_total).toBe('number'); 91 + }); 92 + 93 + it('GET /modules lists all registered modules', async () => { 94 + await instance.ready; 95 + const res = await fetch(`http://localhost:${instance.port}/modules`); 96 + expect(res.status).toBe(200); 97 + const body = await res.json() as Array<Record<string, unknown>>; 98 + expect(body.length).toBe(4); 99 + }); 100 + 101 + it('GET /unknown returns 404', async () => { 102 + await instance.ready; 103 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 104 + expect(res.status).toBe(404); 105 + }); 106 + 107 + it('GET / serves HTML page', async () => { 108 + await instance.ready; 109 + const res = await fetch(`http://localhost:${instance.port}/`); 110 + expect(res.status).toBe(200); 111 + const ct = res.headers.get('content-type') ?? ''; 112 + expect(ct).toContain('text/html'); 113 + const body = await res.text(); 114 + expect(body).toContain('<!DOCTYPE html>'); 115 + expect(body).toContain('<title>Ui</title>'); 116 + }); 117 + });
+206
examples/pixel-wars/src/generated/ui/feedback.ts
··· 1 + export interface FeedbackSystem { 2 + flashCell(row: number, col: number): void; 3 + showCooldownToast(): void; 4 + showWinOverlay(color: string): void; 5 + updateCooldownProgress(progress: number): void; 6 + destroy(): void; 7 + } 8 + 9 + export interface FeedbackOptions { 10 + gridContainer: string; 11 + rootContainer: string; 12 + } 13 + 14 + export class Feedback implements FeedbackSystem { 15 + private gridContainer: string; 16 + private rootContainer: string; 17 + private cooldownBarId: string | null = null; 18 + private activeToastId: string | null = null; 19 + private activeOverlayId: string | null = null; 20 + private styleId: string | null = null; 21 + 22 + constructor(options: FeedbackOptions) { 23 + this.gridContainer = options.gridContainer; 24 + this.rootContainer = options.rootContainer; 25 + this.initializeCooldownBar(); 26 + } 27 + 28 + private generateId(): string { 29 + return `feedback-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; 30 + } 31 + 32 + private initializeCooldownBar(): void { 33 + this.cooldownBarId = this.generateId(); 34 + const progressFillId = this.generateId(); 35 + 36 + const cooldownBarHtml = ` 37 + <div id="${this.cooldownBarId}" style=" 38 + position: absolute; 39 + bottom: -8px; 40 + left: 0; 41 + right: 0; 42 + height: 4px; 43 + background-color: rgba(255, 255, 255, 0.2); 44 + border-radius: 2px; 45 + overflow: hidden; 46 + "> 47 + <div id="${progressFillId}" data-progress-fill="true" style=" 48 + height: 100%; 49 + width: 0%; 50 + background-color: #ff6b6b; 51 + transition: width 0.1s ease-out; 52 + border-radius: 2px; 53 + "></div> 54 + </div> 55 + `; 56 + 57 + // In a real implementation, this would append to the actual DOM element 58 + // For this module, we store the HTML template 59 + } 60 + 61 + flashCell(row: number, col: number): void { 62 + // Generate HTML template for cell flash effect 63 + const flashHtml = ` 64 + <style> 65 + [data-row="${row}"][data-col="${col}"] { 66 + background-color: white !important; 67 + transition: background-color 0.15s ease-out; 68 + } 69 + </style> 70 + `; 71 + 72 + // In a real implementation, this would manipulate the actual DOM 73 + setTimeout(() => { 74 + // Reset styles after flash 75 + }, 150); 76 + } 77 + 78 + showCooldownToast(): void { 79 + if (this.activeToastId) { 80 + // Remove existing toast 81 + this.activeToastId = null; 82 + } 83 + 84 + this.activeToastId = this.generateId(); 85 + const styleId = this.generateId(); 86 + 87 + const toastHtml = ` 88 + <style id="${styleId}"> 89 + @keyframes fadeInOut { 90 + 0%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } 91 + 20%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 92 + } 93 + </style> 94 + <div id="${this.activeToastId}" style=" 95 + position: fixed; 96 + top: 50%; 97 + left: 50%; 98 + transform: translate(-50%, -50%); 99 + background-color: rgba(0, 0, 0, 0.8); 100 + color: white; 101 + padding: 12px 20px; 102 + border-radius: 8px; 103 + font-size: 16px; 104 + font-family: system-ui, -apple-system, sans-serif; 105 + display: flex; 106 + align-items: center; 107 + gap: 8px; 108 + z-index: 1000; 109 + animation: fadeInOut 1s ease-in-out; 110 + "> 111 + ⏳ Please wait... 112 + </div> 113 + `; 114 + 115 + setTimeout(() => { 116 + this.activeToastId = null; 117 + }, 1000); 118 + } 119 + 120 + showWinOverlay(color: string): void { 121 + if (this.activeOverlayId) { 122 + // Remove existing overlay 123 + this.activeOverlayId = null; 124 + } 125 + 126 + this.activeOverlayId = this.generateId(); 127 + this.styleId = this.generateId(); 128 + 129 + const overlayHtml = ` 130 + <style id="${this.styleId}"> 131 + @keyframes overlayFadeIn { 132 + from { opacity: 0; } 133 + to { opacity: 1; } 134 + } 135 + @keyframes messageScale { 136 + 0% { transform: scale(0.5); opacity: 0; } 137 + 50% { transform: scale(1.1); opacity: 1; } 138 + 100% { transform: scale(1); opacity: 1; } 139 + } 140 + </style> 141 + <div id="${this.activeOverlayId}" style=" 142 + position: fixed; 143 + top: 0; 144 + left: 0; 145 + right: 0; 146 + bottom: 0; 147 + background-color: rgba(0, 0, 0, 0.9); 148 + display: flex; 149 + align-items: center; 150 + justify-content: center; 151 + z-index: 2000; 152 + animation: overlayFadeIn 0.3s ease-out; 153 + "> 154 + <div style=" 155 + font-size: 48px; 156 + font-family: system-ui, -apple-system, sans-serif; 157 + font-weight: bold; 158 + color: white; 159 + text-align: center; 160 + animation: messageScale 0.5s ease-out; 161 + "> 162 + 🏆 ${color} wins! 163 + </div> 164 + </div> 165 + `; 166 + 167 + setTimeout(() => { 168 + this.activeOverlayId = null; 169 + this.styleId = null; 170 + }, 5000); 171 + } 172 + 173 + updateCooldownProgress(progress: number): void { 174 + if (!this.cooldownBarId) return; 175 + 176 + const clampedProgress = Math.max(0, Math.min(100, progress)); 177 + 178 + // Generate CSS to update progress bar width 179 + const progressUpdateHtml = ` 180 + <style> 181 + [data-progress-fill="true"] { 182 + width: ${clampedProgress}% !important; 183 + } 184 + </style> 185 + `; 186 + } 187 + 188 + destroy(): void { 189 + this.cooldownBarId = null; 190 + this.activeToastId = null; 191 + this.activeOverlayId = null; 192 + this.styleId = null; 193 + } 194 + } 195 + 196 + export function createFeedback(options: FeedbackOptions): FeedbackSystem { 197 + return new Feedback(options); 198 + } 199 + 200 + /** @internal Phoenix VCS traceability — do not remove. */ 201 + export const _phoenix = { 202 + iu_id: '8c0b86ec528f506c5efc010e2cc6f79ae09fc81f25916d923f4cff73dee3a50f', 203 + name: 'Feedback', 204 + risk_tier: 'medium', 205 + canon_ids: [4 as const], 206 + } as const;
+250
examples/pixel-wars/src/generated/ui/grid-rendering.ts
··· 1 + export interface GridCell { 2 + x: number; 3 + y: number; 4 + ownerId: string | null; 5 + teamColor: string | null; 6 + } 7 + 8 + export interface GridState { 9 + cells: Map<string, GridCell>; 10 + gridSize: number; 11 + cellSize: number; 12 + } 13 + 14 + export interface GridRenderOptions { 15 + gridSize: number; 16 + onCellClick: (x: number, y: number) => void; 17 + } 18 + 19 + export interface CanvasLike { 20 + width: number; 21 + height: number; 22 + style: { cursor: string }; 23 + addEventListener(type: string, listener: (event: any) => void): void; 24 + removeEventListener(type: string, listener: (event: any) => void): void; 25 + getBoundingClientRect(): { left: number; top: number }; 26 + getContext(type: string): CanvasRenderingContext2DLike | null; 27 + } 28 + 29 + export interface CanvasRenderingContext2DLike { 30 + clearRect(x: number, y: number, width: number, height: number): void; 31 + fillRect(x: number, y: number, width: number, height: number): void; 32 + beginPath(): void; 33 + moveTo(x: number, y: number): void; 34 + lineTo(x: number, y: number): void; 35 + stroke(): void; 36 + save(): void; 37 + restore(): void; 38 + fillStyle: string; 39 + strokeStyle: string; 40 + lineWidth: number; 41 + shadowColor: string; 42 + shadowBlur: number; 43 + } 44 + 45 + export class GridRenderer { 46 + private canvas: CanvasLike | null = null; 47 + private ctx: CanvasRenderingContext2DLike | null = null; 48 + private gridSize: number; 49 + private cellSize: number; 50 + private state: GridState; 51 + private hoveredCell: { x: number; y: number } | null = null; 52 + private onCellClick: (x: number, y: number) => void; 53 + private mouseMoveHandler: (event: any) => void; 54 + private mouseLeaveHandler: () => void; 55 + private clickHandler: (event: any) => void; 56 + 57 + constructor(options: GridRenderOptions) { 58 + this.gridSize = options.gridSize; 59 + this.cellSize = 500 / this.gridSize; 60 + this.onCellClick = options.onCellClick; 61 + 62 + this.state = { 63 + cells: new Map(), 64 + gridSize: this.gridSize, 65 + cellSize: this.cellSize 66 + }; 67 + 68 + this.mouseMoveHandler = (event: any) => { 69 + if (!this.canvas) return; 70 + const rect = this.canvas.getBoundingClientRect(); 71 + const x = Math.floor((event.clientX - rect.left) / this.cellSize); 72 + const y = Math.floor((event.clientY - rect.top) / this.cellSize); 73 + 74 + if (x >= 0 && x < this.gridSize && y >= 0 && y < this.gridSize) { 75 + if (!this.hoveredCell || this.hoveredCell.x !== x || this.hoveredCell.y !== y) { 76 + this.hoveredCell = { x, y }; 77 + this.render(); 78 + } 79 + } else { 80 + if (this.hoveredCell) { 81 + this.hoveredCell = null; 82 + this.render(); 83 + } 84 + } 85 + }; 86 + 87 + this.mouseLeaveHandler = () => { 88 + if (this.hoveredCell) { 89 + this.hoveredCell = null; 90 + this.render(); 91 + } 92 + }; 93 + 94 + this.clickHandler = (event: any) => { 95 + if (!this.canvas) return; 96 + const rect = this.canvas.getBoundingClientRect(); 97 + const x = Math.floor((event.clientX - rect.left) / this.cellSize); 98 + const y = Math.floor((event.clientY - rect.top) / this.cellSize); 99 + 100 + if (x >= 0 && x < this.gridSize && y >= 0 && y < this.gridSize) { 101 + this.onCellClick(x, y); 102 + } 103 + }; 104 + } 105 + 106 + public setCanvas(canvas: CanvasLike): void { 107 + this.canvas = canvas; 108 + const ctx = this.canvas.getContext('2d'); 109 + if (!ctx) { 110 + throw new Error('Failed to get 2D rendering context'); 111 + } 112 + this.ctx = ctx; 113 + this.setupCanvas(); 114 + this.attachEventListeners(); 115 + } 116 + 117 + private setupCanvas(): void { 118 + if (!this.canvas) return; 119 + this.canvas.width = 500; 120 + this.canvas.height = 500; 121 + this.canvas.style.cursor = 'pointer'; 122 + } 123 + 124 + private attachEventListeners(): void { 125 + if (!this.canvas) return; 126 + this.canvas.addEventListener('mousemove', this.mouseMoveHandler); 127 + this.canvas.addEventListener('mouseleave', this.mouseLeaveHandler); 128 + this.canvas.addEventListener('click', this.clickHandler); 129 + } 130 + 131 + public updateCell(x: number, y: number, ownerId: string | null, teamColor: string | null): void { 132 + const key = `${x},${y}`; 133 + this.state.cells.set(key, { x, y, ownerId, teamColor }); 134 + this.render(); 135 + } 136 + 137 + public updateGrid(cells: GridCell[]): void { 138 + this.state.cells.clear(); 139 + for (const cell of cells) { 140 + const key = `${cell.x},${cell.y}`; 141 + this.state.cells.set(key, cell); 142 + } 143 + this.render(); 144 + } 145 + 146 + private render(): void { 147 + if (!this.ctx) return; 148 + this.ctx.clearRect(0, 0, 500, 500); 149 + 150 + // Render all cells 151 + for (let x = 0; x < this.gridSize; x++) { 152 + for (let y = 0; y < this.gridSize; y++) { 153 + this.renderCell(x, y); 154 + } 155 + } 156 + 157 + // Render grid lines 158 + this.renderGridLines(); 159 + } 160 + 161 + private renderCell(x: number, y: number): void { 162 + if (!this.ctx) return; 163 + const key = `${x},${y}`; 164 + const cell = this.state.cells.get(key); 165 + const pixelX = x * this.cellSize; 166 + const pixelY = y * this.cellSize; 167 + 168 + // Base cell color 169 + if (cell && cell.ownerId && cell.teamColor) { 170 + // Owned cell with team color and glow effect 171 + this.ctx.fillStyle = cell.teamColor; 172 + this.ctx.fillRect(pixelX, pixelY, this.cellSize, this.cellSize); 173 + 174 + // Add glow effect 175 + this.ctx.save(); 176 + this.ctx.shadowColor = cell.teamColor; 177 + this.ctx.shadowBlur = 8; 178 + this.ctx.fillStyle = cell.teamColor; 179 + this.ctx.fillRect(pixelX + 2, pixelY + 2, this.cellSize - 4, this.cellSize - 4); 180 + this.ctx.restore(); 181 + } else { 182 + // Empty cell - dark gray 183 + this.ctx.fillStyle = '#2a2a3e'; 184 + this.ctx.fillRect(pixelX, pixelY, this.cellSize, this.cellSize); 185 + } 186 + 187 + // Hover highlight 188 + if (this.hoveredCell && this.hoveredCell.x === x && this.hoveredCell.y === y) { 189 + this.ctx.save(); 190 + this.ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; 191 + this.ctx.fillRect(pixelX, pixelY, this.cellSize, this.cellSize); 192 + this.ctx.restore(); 193 + } 194 + } 195 + 196 + private renderGridLines(): void { 197 + if (!this.ctx) return; 198 + this.ctx.strokeStyle = '#1a1a2e'; 199 + this.ctx.lineWidth = 1; 200 + 201 + // Vertical lines 202 + for (let x = 0; x <= this.gridSize; x++) { 203 + const pixelX = x * this.cellSize; 204 + this.ctx.beginPath(); 205 + this.ctx.moveTo(pixelX, 0); 206 + this.ctx.lineTo(pixelX, 500); 207 + this.ctx.stroke(); 208 + } 209 + 210 + // Horizontal lines 211 + for (let y = 0; y <= this.gridSize; y++) { 212 + const pixelY = y * this.cellSize; 213 + this.ctx.beginPath(); 214 + this.ctx.moveTo(0, pixelY); 215 + this.ctx.lineTo(500, pixelY); 216 + this.ctx.stroke(); 217 + } 218 + } 219 + 220 + public getCanvas(): CanvasLike | null { 221 + return this.canvas; 222 + } 223 + 224 + public destroy(): void { 225 + if (this.canvas) { 226 + this.canvas.removeEventListener('mousemove', this.mouseMoveHandler); 227 + this.canvas.removeEventListener('mouseleave', this.mouseLeaveHandler); 228 + this.canvas.removeEventListener('click', this.clickHandler); 229 + } 230 + } 231 + } 232 + 233 + export function createGridRenderer(gridSize: number, onCellClick: (x: number, y: number) => void): GridRenderer { 234 + return new GridRenderer({ 235 + gridSize, 236 + onCellClick 237 + }); 238 + } 239 + 240 + export function generateGridHTML(containerId: string): string { 241 + return `<div id="${containerId}" style="display: flex; justify-content: center; align-items: center; width: 100%; height: 100%;"><canvas width="500" height="500" style="cursor: pointer;"></canvas></div>`; 242 + } 243 + 244 + /** @internal Phoenix VCS traceability — do not remove. */ 245 + export const _phoenix = { 246 + iu_id: 'd219e8bbb48e26fb3e3dad39edc3d9dd63fcdac50788f17f8224cb924096e840', 247 + name: 'Grid Rendering', 248 + risk_tier: 'medium', 249 + canon_ids: [6 as const], 250 + } as const;
+11
examples/pixel-wars/src/generated/ui/index.ts
··· 1 + /** 2 + * Ui 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Ui modules. 6 + */ 7 + 8 + export * as feedback from './feedback.js'; 9 + export * as gridRendering from './grid-rendering.js'; 10 + export * as layout from './layout.js'; 11 + export * as responsiveness from './responsiveness.js';
+178
examples/pixel-wars/src/generated/ui/layout.ts
··· 1 + export interface ScoreboardEntry { 2 + teamId: string; 3 + teamName: string; 4 + score: number; 5 + isPlayer: boolean; 6 + } 7 + 8 + export interface LayoutConfig { 9 + backgroundColor?: string; 10 + gridWidth?: number; 11 + gridHeight?: number; 12 + showScoreboard?: boolean; 13 + } 14 + 15 + export interface LayoutState { 16 + config: LayoutConfig; 17 + scoreboard: ScoreboardEntry[]; 18 + gridContent: string; 19 + } 20 + 21 + export class Layout { 22 + private state: LayoutState; 23 + 24 + constructor(config: LayoutConfig = {}) { 25 + this.state = { 26 + config: { 27 + backgroundColor: '#1a1a2e', 28 + gridWidth: 800, 29 + gridHeight: 600, 30 + showScoreboard: true, 31 + ...config 32 + }, 33 + scoreboard: [], 34 + gridContent: '' 35 + }; 36 + } 37 + 38 + public setGridContent(content: string): void { 39 + this.state.gridContent = content; 40 + } 41 + 42 + public updateScoreboard(entries: ScoreboardEntry[]): void { 43 + this.state.scoreboard = [...entries]; 44 + } 45 + 46 + public setPlayerTeam(teamId: string): void { 47 + this.state.scoreboard = this.state.scoreboard.map(entry => ({ 48 + ...entry, 49 + isPlayer: entry.teamId === teamId 50 + })); 51 + } 52 + 53 + public render(): string { 54 + const { config } = this.state; 55 + 56 + return ` 57 + <div class="layout-container" style=" 58 + background-color: ${config.backgroundColor}; 59 + min-height: 100vh; 60 + display: flex; 61 + flex-direction: column; 62 + align-items: center; 63 + justify-content: center; 64 + padding: 20px; 65 + box-sizing: border-box; 66 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 67 + "> 68 + ${this.renderScoreboard()} 69 + <div class="grid-container" style=" 70 + width: ${config.gridWidth}px; 71 + height: ${config.gridHeight}px; 72 + border: 2px solid #333; 73 + border-radius: 8px; 74 + background-color: rgba(255, 255, 255, 0.05); 75 + display: flex; 76 + align-items: center; 77 + justify-content: center; 78 + margin: 20px 0; 79 + "> 80 + ${this.state.gridContent} 81 + </div> 82 + </div> 83 + `; 84 + } 85 + 86 + private renderScoreboard(): string { 87 + if (!this.state.config.showScoreboard || this.state.scoreboard.length === 0) { 88 + return ''; 89 + } 90 + 91 + const scoreboardEntries = this.state.scoreboard 92 + .sort((a, b) => b.score - a.score) 93 + .map(entry => this.renderScoreboardEntry(entry)) 94 + .join(''); 95 + 96 + return ` 97 + <div class="scoreboard" style=" 98 + background-color: rgba(255, 255, 255, 0.1); 99 + border-radius: 8px; 100 + padding: 16px; 101 + margin-bottom: 20px; 102 + min-width: 300px; 103 + "> 104 + <h3 style=" 105 + color: #fff; 106 + margin: 0 0 12px 0; 107 + font-size: 18px; 108 + text-align: center; 109 + ">Scoreboard</h3> 110 + <div class="scoreboard-entries"> 111 + ${scoreboardEntries} 112 + </div> 113 + </div> 114 + `; 115 + } 116 + 117 + private renderScoreboardEntry(entry: ScoreboardEntry): string { 118 + const isPlayerStyle = entry.isPlayer 119 + ? 'background-color: rgba(74, 144, 226, 0.3); border-left: 4px solid #4a90e2;' 120 + : 'background-color: rgba(255, 255, 255, 0.05);'; 121 + 122 + return ` 123 + <div class="scoreboard-entry" style=" 124 + ${isPlayerStyle} 125 + padding: 8px 12px; 126 + margin: 4px 0; 127 + border-radius: 4px; 128 + display: flex; 129 + justify-content: space-between; 130 + align-items: center; 131 + "> 132 + <span style=" 133 + color: ${entry.isPlayer ? '#4a90e2' : '#fff'}; 134 + font-weight: ${entry.isPlayer ? 'bold' : 'normal'}; 135 + ">${this.escapeHtml(entry.teamName)}</span> 136 + <span style=" 137 + color: ${entry.isPlayer ? '#4a90e2' : '#ccc'}; 138 + font-weight: bold; 139 + ">${entry.score}</span> 140 + </div> 141 + `; 142 + } 143 + 144 + private escapeHtml(text: string): string { 145 + return text 146 + .replace(/&/g, '&amp;') 147 + .replace(/</g, '&lt;') 148 + .replace(/>/g, '&gt;') 149 + .replace(/"/g, '&quot;') 150 + .replace(/'/g, '&#39;'); 151 + } 152 + 153 + public getState(): Readonly<LayoutState> { 154 + return { ...this.state }; 155 + } 156 + 157 + public updateConfig(updates: Partial<LayoutConfig>): void { 158 + this.state.config = { ...this.state.config, ...updates }; 159 + } 160 + } 161 + 162 + export function createLayout(config?: LayoutConfig): Layout { 163 + return new Layout(config); 164 + } 165 + 166 + export function renderCenteredGrid(content: string, width = 800, height = 600): string { 167 + const layout = createLayout({ gridWidth: width, gridHeight: height }); 168 + layout.setGridContent(content); 169 + return layout.render(); 170 + } 171 + 172 + /** @internal Phoenix VCS traceability — do not remove. */ 173 + export const _phoenix = { 174 + iu_id: '9a35a9f5ebc71f65e83ff408274437068be7102b862e2935ac1476754d238566', 175 + name: 'Layout', 176 + risk_tier: 'low', 177 + canon_ids: [2 as const], 178 + } as const;
+191
examples/pixel-wars/src/generated/ui/responsiveness.ts
··· 1 + export interface ViewportInfo { 2 + width: number; 3 + height: number; 4 + devicePixelRatio: number; 5 + isMobile: boolean; 6 + } 7 + 8 + export interface TouchPoint { 9 + x: number; 10 + y: number; 11 + identifier: number; 12 + } 13 + 14 + export interface PointerEvent { 15 + x: number; 16 + y: number; 17 + type: 'start' | 'move' | 'end'; 18 + pressure?: number; 19 + } 20 + 21 + export type PointerEventHandler = (event: PointerEvent) => void; 22 + 23 + export class ResponsiveCanvas { 24 + private canvasId: string; 25 + private containerId: string; 26 + private scale: number = 1; 27 + private offsetX: number = 0; 28 + private offsetY: number = 0; 29 + private pointerHandlers: Set<PointerEventHandler> = new Set(); 30 + private activeTouches: Map<number, TouchPoint> = new Map(); 31 + private canvasWidth: number = 800; 32 + private canvasHeight: number = 600; 33 + private containerWidth: number = 800; 34 + private containerHeight: number = 600; 35 + 36 + constructor(canvasId: string, containerId: string) { 37 + this.canvasId = canvasId; 38 + this.containerId = containerId; 39 + this.updateScale(); 40 + } 41 + 42 + private updateScale(): void { 43 + const scaleX = this.containerWidth / this.canvasWidth; 44 + const scaleY = this.containerHeight / this.canvasHeight; 45 + this.scale = Math.min(scaleX, scaleY, 1); 46 + 47 + const scaledWidth = this.canvasWidth * this.scale; 48 + const scaledHeight = this.canvasHeight * this.scale; 49 + this.offsetX = (this.containerWidth - scaledWidth) / 2; 50 + this.offsetY = (this.containerHeight - scaledHeight) / 2; 51 + } 52 + 53 + private getCanvasCoordinates(clientX: number, clientY: number): { x: number; y: number } { 54 + const x = (clientX - this.offsetX) / this.scale; 55 + const y = (clientY - this.offsetY) / this.scale; 56 + return { x, y }; 57 + } 58 + 59 + public handlePointerStart(clientX: number, clientY: number, pressure: number = 1, identifier?: number): void { 60 + const coords = this.getCanvasCoordinates(clientX, clientY); 61 + 62 + if (identifier !== undefined) { 63 + this.activeTouches.set(identifier, { 64 + x: coords.x, 65 + y: coords.y, 66 + identifier 67 + }); 68 + } 69 + 70 + this.emitPointerEvent({ 71 + x: coords.x, 72 + y: coords.y, 73 + type: 'start', 74 + pressure 75 + }); 76 + } 77 + 78 + public handlePointerMove(clientX: number, clientY: number, pressure: number = 1, identifier?: number): void { 79 + const coords = this.getCanvasCoordinates(clientX, clientY); 80 + 81 + if (identifier !== undefined) { 82 + if (this.activeTouches.has(identifier)) { 83 + this.activeTouches.set(identifier, { 84 + x: coords.x, 85 + y: coords.y, 86 + identifier 87 + }); 88 + } else { 89 + return; 90 + } 91 + } 92 + 93 + this.emitPointerEvent({ 94 + x: coords.x, 95 + y: coords.y, 96 + type: 'move', 97 + pressure 98 + }); 99 + } 100 + 101 + public handlePointerEnd(clientX: number, clientY: number, identifier?: number): void { 102 + const coords = this.getCanvasCoordinates(clientX, clientY); 103 + 104 + if (identifier !== undefined) { 105 + if (this.activeTouches.has(identifier)) { 106 + this.activeTouches.delete(identifier); 107 + } else { 108 + return; 109 + } 110 + } 111 + 112 + this.emitPointerEvent({ 113 + x: coords.x, 114 + y: coords.y, 115 + type: 'end', 116 + pressure: 0 117 + }); 118 + } 119 + 120 + private emitPointerEvent(event: PointerEvent): void { 121 + this.pointerHandlers.forEach(handler => { 122 + try { 123 + handler(event); 124 + } catch (error) { 125 + console.error('Error in pointer event handler:', error); 126 + } 127 + }); 128 + } 129 + 130 + public addPointerEventListener(handler: PointerEventHandler): void { 131 + this.pointerHandlers.add(handler); 132 + } 133 + 134 + public removePointerEventListener(handler: PointerEventHandler): void { 135 + this.pointerHandlers.delete(handler); 136 + } 137 + 138 + public setCanvasSize(width: number, height: number): void { 139 + this.canvasWidth = width; 140 + this.canvasHeight = height; 141 + this.updateScale(); 142 + } 143 + 144 + public setContainerSize(width: number, height: number): void { 145 + this.containerWidth = width; 146 + this.containerHeight = height; 147 + this.updateScale(); 148 + } 149 + 150 + public getViewportInfo(): ViewportInfo { 151 + return { 152 + width: this.containerWidth, 153 + height: this.containerHeight, 154 + devicePixelRatio: 1, 155 + isMobile: this.detectMobile() 156 + }; 157 + } 158 + 159 + private detectMobile(): boolean { 160 + return false; 161 + } 162 + 163 + public getScale(): number { 164 + return this.scale; 165 + } 166 + 167 + public getCanvasHTML(): string { 168 + return `<canvas id="${this.canvasId}" width="${this.canvasWidth}" height="${this.canvasHeight}" style="transform: scale(${this.scale}); transform-origin: top left; position: absolute; left: ${this.offsetX}px; top: ${this.offsetY}px;"></canvas>`; 169 + } 170 + 171 + public destroy(): void { 172 + this.pointerHandlers.clear(); 173 + this.activeTouches.clear(); 174 + } 175 + } 176 + 177 + export function createResponsiveCanvas(canvasId: string, containerId: string): ResponsiveCanvas { 178 + return new ResponsiveCanvas(canvasId, containerId); 179 + } 180 + 181 + export function isMobileDevice(): boolean { 182 + return false; 183 + } 184 + 185 + /** @internal Phoenix VCS traceability — do not remove. */ 186 + export const _phoenix = { 187 + iu_id: '34e0a94555fd05dabb716eb8d9f35d4ad180e267ff13ca20cc74e1b3326659db', 188 + name: 'Responsiveness', 189 + risk_tier: 'low', 190 + canon_ids: [2 as const], 191 + } as const;
+253
examples/pixel-wars/src/generated/ui/server.ts
··· 1 + /** 2 + * Ui — Web Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Serves the web client HTML, plus health/metrics/modules endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as feedback from './feedback.js'; 11 + import * as gridRendering from './grid-rendering.js'; 12 + import * as layout from './layout.js'; 13 + import * as responsiveness from './responsiveness.js'; 14 + 15 + // ─── Metrics ───────────────────────────────────────────────────────────────── 16 + 17 + const _svcMetrics = { 18 + requests_total: 0, 19 + requests_by_path: {} as Record<string, number>, 20 + errors_total: 0, 21 + uptime_start: Date.now(), 22 + }; 23 + 24 + // ─── Module Registry ───────────────────────────────────────────────────────── 25 + 26 + const _svcModules = { 27 + 'feedback': feedback, 28 + 'grid-rendering': gridRendering, 29 + 'layout': layout, 30 + 'responsiveness': responsiveness, 31 + }; 32 + 33 + // ─── HTML Renderer ─────────────────────────────────────────────────────────── 34 + 35 + function renderPage(): string { 36 + // Collect CSS from style modules 37 + let css = ''; 38 + css = 'body { font-family: system-ui, sans-serif; margin: 2rem; }'; 39 + 40 + // Collect HTML from UI modules 41 + const sections: string[] = []; 42 + try { 43 + const uiMod = feedback as Record<string, unknown>; 44 + for (const key of Object.keys(uiMod)) { 45 + const val = uiMod[key]; 46 + // Look for factory functions that return objects with render/renderHTML 47 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 48 + try { 49 + const instance = (val as Function)(); 50 + if (typeof instance === 'string' && instance.includes('<')) { 51 + sections.push(instance); 52 + } else if (instance && typeof instance === 'object') { 53 + const obj = instance as Record<string, unknown>; 54 + if (typeof obj.render === 'function') { 55 + const html = (obj.render as Function)(); 56 + if (typeof html === 'string') sections.push(html); 57 + } else if (typeof obj.renderHTML === 'function') { 58 + const html = (obj.renderHTML as Function)(); 59 + if (typeof html === 'string') sections.push(html); 60 + } 61 + } 62 + } catch { /* factory may require args */ } 63 + } 64 + } 65 + } catch { /* module may not have renderable exports */ } 66 + try { 67 + const uiMod = gridRendering as Record<string, unknown>; 68 + for (const key of Object.keys(uiMod)) { 69 + const val = uiMod[key]; 70 + // Look for factory functions that return objects with render/renderHTML 71 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 72 + try { 73 + const instance = (val as Function)(); 74 + if (typeof instance === 'string' && instance.includes('<')) { 75 + sections.push(instance); 76 + } else if (instance && typeof instance === 'object') { 77 + const obj = instance as Record<string, unknown>; 78 + if (typeof obj.render === 'function') { 79 + const html = (obj.render as Function)(); 80 + if (typeof html === 'string') sections.push(html); 81 + } else if (typeof obj.renderHTML === 'function') { 82 + const html = (obj.renderHTML as Function)(); 83 + if (typeof html === 'string') sections.push(html); 84 + } 85 + } 86 + } catch { /* factory may require args */ } 87 + } 88 + } 89 + } catch { /* module may not have renderable exports */ } 90 + try { 91 + const uiMod = layout as Record<string, unknown>; 92 + for (const key of Object.keys(uiMod)) { 93 + const val = uiMod[key]; 94 + // Look for factory functions that return objects with render/renderHTML 95 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 96 + try { 97 + const instance = (val as Function)(); 98 + if (typeof instance === 'string' && instance.includes('<')) { 99 + sections.push(instance); 100 + } else if (instance && typeof instance === 'object') { 101 + const obj = instance as Record<string, unknown>; 102 + if (typeof obj.render === 'function') { 103 + const html = (obj.render as Function)(); 104 + if (typeof html === 'string') sections.push(html); 105 + } else if (typeof obj.renderHTML === 'function') { 106 + const html = (obj.renderHTML as Function)(); 107 + if (typeof html === 'string') sections.push(html); 108 + } 109 + } 110 + } catch { /* factory may require args */ } 111 + } 112 + } 113 + } catch { /* module may not have renderable exports */ } 114 + try { 115 + const uiMod = responsiveness as Record<string, unknown>; 116 + for (const key of Object.keys(uiMod)) { 117 + const val = uiMod[key]; 118 + // Look for factory functions that return objects with render/renderHTML 119 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 120 + try { 121 + const instance = (val as Function)(); 122 + if (typeof instance === 'string' && instance.includes('<')) { 123 + sections.push(instance); 124 + } else if (instance && typeof instance === 'object') { 125 + const obj = instance as Record<string, unknown>; 126 + if (typeof obj.render === 'function') { 127 + const html = (obj.render as Function)(); 128 + if (typeof html === 'string') sections.push(html); 129 + } else if (typeof obj.renderHTML === 'function') { 130 + const html = (obj.renderHTML as Function)(); 131 + if (typeof html === 'string') sections.push(html); 132 + } 133 + } 134 + } catch { /* factory may require args */ } 135 + } 136 + } 137 + } catch { /* module may not have renderable exports */ } 138 + 139 + return `<!DOCTYPE html> 140 + <html lang="en"> 141 + <head> 142 + <meta charset="utf-8"> 143 + <meta name="viewport" content="width=device-width, initial-scale=1"> 144 + <title>Ui</title> 145 + <style>${css}</style> 146 + </head> 147 + <body> 148 + <div class="game-container"> 149 + ${sections.join('\n')} 150 + </div> 151 + </body> 152 + </html>`; 153 + } 154 + 155 + // ─── Router ────────────────────────────────────────────────────────────────── 156 + 157 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 158 + 159 + const routes: Record<string, Handler> = { 160 + '/': (_req, res) => { 161 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 162 + res.end(renderPage()); 163 + }, 164 + 165 + '/health': (_req, res) => { 166 + res.writeHead(200, { 'Content-Type': 'application/json' }); 167 + res.end(JSON.stringify({ 168 + status: 'ok', 169 + service: 'Ui', 170 + uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 171 + modules: Object.keys(_svcModules), 172 + })); 173 + }, 174 + 175 + '/metrics': (_req, res) => { 176 + res.writeHead(200, { 'Content-Type': 'application/json' }); 177 + res.end(JSON.stringify({ 178 + ..._svcMetrics, 179 + uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000), 180 + }, null, 2)); 181 + }, 182 + 183 + '/modules': (_req, res) => { 184 + const info = Object.entries(_svcModules).map(([name, mod]) => { 185 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 186 + return { 187 + name, 188 + risk_tier: phoenix?.risk_tier ?? 'unknown', 189 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 190 + }; 191 + }); 192 + res.writeHead(200, { 'Content-Type': 'application/json' }); 193 + res.end(JSON.stringify(info, null, 2)); 194 + }, 195 + }; 196 + 197 + // ─── Server ────────────────────────────────────────────────────────────────── 198 + 199 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 200 + const url = req.url ?? '/'; 201 + const path = url.split('?')[0]; 202 + 203 + _svcMetrics.requests_total++; 204 + _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1; 205 + 206 + const handler = routes[path]; 207 + if (handler) { 208 + try { 209 + handler(req, res); 210 + } catch (err) { 211 + _svcMetrics.errors_total++; 212 + res.writeHead(500, { 'Content-Type': 'application/json' }); 213 + res.end(JSON.stringify({ error: String(err) })); 214 + } 215 + } else { 216 + res.writeHead(404, { 'Content-Type': 'application/json' }); 217 + res.end(JSON.stringify({ 218 + error: 'Not Found', 219 + path, 220 + available: Object.keys(routes), 221 + })); 222 + } 223 + } 224 + 225 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 226 + const requestedPort = port ?? parseInt(process.env.UI_PORT ?? process.env.PORT ?? '3002', 10); 227 + const server = createServer(handleRequest); 228 + let actualPort = requestedPort; 229 + 230 + const ready = new Promise<void>(resolve => { 231 + server.listen(requestedPort, () => { 232 + const addr = server.address(); 233 + if (addr && typeof addr === 'object') actualPort = addr.port; 234 + result.port = actualPort; 235 + console.log(`Ui listening on http://localhost:${actualPort}`); 236 + console.log(` / — web client`); 237 + console.log(` /health — health check`); 238 + console.log(` /metrics — request metrics`); 239 + console.log(` /modules — registered modules`); 240 + resolve(); 241 + }); 242 + }); 243 + 244 + const result = { server, port: actualPort, ready }; 245 + return result; 246 + } 247 + 248 + // Start when run directly 249 + const isMain = process.argv[1]?.endsWith('/ui/server.js') || 250 + process.argv[1]?.endsWith('/ui/server.ts'); 251 + if (isMain) { 252 + startServer(); 253 + }
+23
examples/pixel-wars/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "declaration": true, 7 + "outDir": "dist", 8 + "rootDir": "src", 9 + "strict": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "resolveJsonModule": true, 14 + "sourceMap": true 15 + }, 16 + "include": [ 17 + "src/**/*" 18 + ], 19 + "exclude": [ 20 + "node_modules", 21 + "dist" 22 + ] 23 + }
+7
examples/pixel-wars/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ['src/**/__tests__/**/*.test.ts'], 6 + }, 7 + });