From 006ed4af3e9c19985a1ecedb4ade6b1cf73fc576 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 21 Nov 2025 15:27:54 +0000 Subject: [PATCH] feat: Add client-side API integration layer - Add config.js for API configuration and CSRF token handling - Add api-client.js wrapper for server communication - Add state-sync.js for periodic state synchronization - Support multiple CSRF token sources (config object and meta tag) - Provide detailed error messages for configuration issues - Enable GET/POST/PUT requests with proper auth headers - Expose ApiClient globally for game code integration --- public/break_escape/js/api-client.js | 114 +++++++++++++++++++++++++++ public/break_escape/js/config.js | 41 ++++++++++ public/break_escape/js/state-sync.js | 40 ++++++++++ 3 files changed, 195 insertions(+) create mode 100644 public/break_escape/js/api-client.js create mode 100644 public/break_escape/js/config.js create mode 100644 public/break_escape/js/state-sync.js diff --git a/public/break_escape/js/api-client.js b/public/break_escape/js/api-client.js new file mode 100644 index 0000000..2bcd7be --- /dev/null +++ b/public/break_escape/js/api-client.js @@ -0,0 +1,114 @@ +import { API_BASE, CSRF_TOKEN } from './config.js'; + +/** + * API Client for BreakEscape server communication + */ +export class ApiClient { + /** + * GET request + */ + static async get(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + /** + * POST request + */ + static async post(endpoint, data = {}) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `API Error: ${response.status}`); + } + + return response.json(); + } + + /** + * PUT request + */ + static async put(endpoint, data = {}) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + // Bootstrap - get initial game data + static async bootstrap() { + return this.get('/bootstrap'); + } + + // Get scenario JSON + static async getScenario() { + return this.get('/scenario'); + } + + // Get NPC script + static async getNPCScript(npcId) { + return this.get(`/ink?npc=${npcId}`); + } + + // Validate unlock attempt + static async unlock(targetType, targetId, attempt, method) { + return this.post('/unlock', { + targetType, + targetId, + attempt, + method + }); + } + + // Update inventory + static async updateInventory(action, item) { + return this.post('/inventory', { + action, + item + }); + } + + // Sync player state + static async syncState(currentRoom, globalVariables) { + return this.put('/sync_state', { + currentRoom, + globalVariables + }); + } +} + +// Export for global access +window.ApiClient = ApiClient; diff --git a/public/break_escape/js/config.js b/public/break_escape/js/config.js new file mode 100644 index 0000000..3932cc2 --- /dev/null +++ b/public/break_escape/js/config.js @@ -0,0 +1,41 @@ +// API configuration from server +export const GAME_ID = window.breakEscapeConfig?.gameId; +export const API_BASE = window.breakEscapeConfig?.apiBasePath || ''; +export const ASSETS_PATH = window.breakEscapeConfig?.assetsPath || '/break_escape/assets'; + +// CSRF Token - Try multiple sources (in order of preference) +export const CSRF_TOKEN = + window.breakEscapeConfig?.csrfToken || // From config object (if set in view) + document.querySelector('meta[name="csrf-token"]')?.content; // From meta tag (Hacktivity layout) + +// Verify critical config loaded +if (!GAME_ID) { + console.error('❌ CRITICAL: Game ID not configured! Check window.breakEscapeConfig'); + console.error('Expected window.breakEscapeConfig.gameId to be set by server'); +} + +if (!CSRF_TOKEN) { + console.error('❌ CRITICAL: CSRF token not found!'); + console.error('This will cause all POST/PUT requests to fail with 422 status'); + console.error('Checked:'); + console.error(' 1. window.breakEscapeConfig.csrfToken'); + console.error(' 2. meta[name="csrf-token"] tag'); + console.error(''); + console.error('Solutions:'); + console.error(' - If using Hacktivity layout: Ensure layout has <%= csrf_meta_tags %>'); + console.error(' - If standalone: Add <%= csrf_meta_tags %> to layout OR'); + console.error(' - Set window.breakEscapeConfig.csrfToken in view'); +} + +// Log config for debugging +if (window.breakEscapeConfig?.debug || !CSRF_TOKEN) { + console.log('✓ BreakEscape config validated:', { + gameId: GAME_ID, + apiBasePath: API_BASE, + assetsPath: ASSETS_PATH, + csrfToken: CSRF_TOKEN ? `${CSRF_TOKEN.substring(0, 10)}...` : '❌ MISSING', + csrfTokenSource: window.breakEscapeConfig?.csrfToken ? 'config object' : + (document.querySelector('meta[name="csrf-token"]') ? 'meta tag' : 'NOT FOUND'), + debug: window.breakEscapeConfig?.debug || false + }); +} diff --git a/public/break_escape/js/state-sync.js b/public/break_escape/js/state-sync.js new file mode 100644 index 0000000..036a307 --- /dev/null +++ b/public/break_escape/js/state-sync.js @@ -0,0 +1,40 @@ +import { ApiClient } from './api-client.js'; + +/** + * Periodic state synchronization with server + */ +export class StateSync { + constructor(interval = 30000) { // 30 seconds + this.interval = interval; + this.timer = null; + } + + start() { + this.timer = setInterval(() => this.sync(), this.interval); + console.log('State sync started (every 30s)'); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async sync() { + try { + // Get current game state + const currentRoom = window.currentRoom?.name; + const globalVariables = window.gameState?.globalVariables || {}; + + // Sync to server + await ApiClient.syncState(currentRoom, globalVariables); + console.log('✓ State synced to server'); + } catch (error) { + console.error('State sync failed:', error); + } + } +} + +// Create global instance +window.stateSync = new StateSync();