Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
1import { createServer, IncomingMessage, ServerResponse } from 'node:http';
2import { createHash } from 'node:crypto';
3import { EventEmitter } from 'node:events';
4
5export interface Player {
6 id: string;
7 connected: boolean;
8 lastSeen: number;
9}
10
11export interface GameState {
12 grid: number[][];
13 players: Map<string, Player>;
14 gameEndTime: number;
15}
16
17export 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
28export interface WebSocketLike {
29 send(data: string): void;
30 close(): void;
31 on(event: 'message' | 'close', handler: (data?: any) => void): void;
32}
33
34export 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. */
411export const _phoenix = {
412 iu_id: '287d727e4c54fc45b5ea5e2484392a34a4b0750386cdb6f88404dcff44b70aa3',
413 name: 'Connection Handling',
414 risk_tier: 'medium',
415 canon_ids: [6 as const],
416} as const;