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
This commit is contained in:
Z. Cliffe Schreuders
2025-11-21 15:27:54 +00:00
parent 4140a23ab4
commit 006ed4af3e
3 changed files with 195 additions and 0 deletions

View File

@@ -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;

View File

@@ -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
});
}

View File

@@ -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();