diff --git a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md index c207694..8ced494 100644 --- a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md +++ b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md @@ -398,24 +398,134 @@ vim "app/assets/scenarios/${SCENARIO}/scenario.json.erb" #### Repeat for All Scenarios +**Complete conversion script for all main scenarios:** + ```bash -# List all scenario JSON files -for file in scenarios/*.json; do - base=$(basename "$file" .json) - echo "Processing: $base" +#!/bin/bash +# Convert all scenario JSON files to ERB structure - # Create directory - mkdir -p "app/assets/scenarios/${base}" +echo "Converting scenario files to ERB templates..." - # Move file - mv "$file" "app/assets/scenarios/${base}/scenario.json.erb" +# Main game scenarios (these are the production scenarios) +MAIN_SCENARIOS=( + "ceo_exfil" + "cybok_heist" + "biometric_breach" +) - echo " ✓ Moved to app/assets/scenarios/${base}/scenario.json.erb" - echo " → Remember to edit for randomization" +# Test/demo scenarios (keep for testing) +TEST_SCENARIOS=( + "scenario1" + "scenario2" + "scenario3" + "scenario4" + "npc-hub-demo-ghost-protocol" + "npc-patrol-lockpick" + "npc-sprite-test2" + "test-multiroom-npc" + "test-npc-face-player" + "test-npc-patrol" + "test-npc-personal-space" + "test-npc-waypoints" + "test-rfid-multiprotocol" + "test-rfid" + "test_complex_multidirection" + "test_horizontal_layout" + "test_mixed_room_sizes" + "test_multiple_connections" + "test_vertical_layout" + "timed_messages_example" + "title-screen-demo" +) + +# Process main scenarios +echo "" +echo "=== Processing Main Scenarios ===" +for scenario in "${MAIN_SCENARIOS[@]}"; do + if [ -f "scenarios/${scenario}.json" ]; then + echo "Processing: $scenario" + + # Create directory + mkdir -p "app/assets/scenarios/${scenario}" + + # Move and rename (just rename to .erb, don't modify content yet) + mv "scenarios/${scenario}.json" "app/assets/scenarios/${scenario}/scenario.json.erb" + + echo " ✓ Moved to app/assets/scenarios/${scenario}/scenario.json.erb" + echo " → Edit later to add <%= random_password %>, <%= random_pin %>, etc." + else + echo " ⚠ File not found: scenarios/${scenario}.json (skipping)" + fi done + +# Process test scenarios +echo "" +echo "=== Processing Test Scenarios ===" +for scenario in "${TEST_SCENARIOS[@]}"; do + if [ -f "scenarios/${scenario}.json" ]; then + echo "Processing: $scenario" + + # Create directory + mkdir -p "app/assets/scenarios/${scenario}" + + # Move and rename + mv "scenarios/${scenario}.json" "app/assets/scenarios/${scenario}/scenario.json.erb" + + echo " ✓ Moved to app/assets/scenarios/${scenario}/scenario.json.erb" + else + echo " ⚠ File not found: scenarios/${scenario}.json (skipping)" + fi +done + +echo "" +echo "=== Summary ===" +echo "Converted files:" +find app/assets/scenarios -name "scenario.json.erb" | wc -l +echo "" +echo "Directory structure:" +ls -d app/assets/scenarios/*/ +echo "" +echo "✓ Conversion complete!" +echo "" +echo "IMPORTANT:" +echo "- Files have been renamed to .erb but content is still JSON" +echo "- ERB randomization (random_password, etc.) will be added in Phase 4" +echo "- For now, scenarios work as-is with static passwords" ``` -**Note:** You'll need to manually edit each .erb file to add randomization +**Save this script** as `scripts/convert-scenarios.sh` and run: + +```bash +chmod +x scripts/convert-scenarios.sh +./scripts/convert-scenarios.sh +``` + +**Alternative: Manual conversion for main scenarios only:** + +```bash +# If you only want to convert the 3 main scenarios manually: + +# CEO Exfiltration +mkdir -p app/assets/scenarios/ceo_exfil +mv scenarios/ceo_exfil.json app/assets/scenarios/ceo_exfil/scenario.json.erb + +# CybOK Heist +mkdir -p app/assets/scenarios/cybok_heist +mv scenarios/cybok_heist.json app/assets/scenarios/cybok_heist/scenario.json.erb + +# Biometric Breach +mkdir -p app/assets/scenarios/biometric_breach +mv scenarios/biometric_breach.json app/assets/scenarios/biometric_breach/scenario.json.erb + +# Verify +ls -la app/assets/scenarios/*/scenario.json.erb +``` + +**Note:** +- Files are renamed to `.erb` extension but content remains valid JSON +- ERB randomization code (`<%= random_password %>`) will be added later in Phase 4 +- This preserves git history and allows immediate testing +- Test scenarios are useful for development but don't need randomization ### 3.3 Handle Ink Files diff --git a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md index b7deb6f..046f7a4 100644 --- a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md +++ b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md @@ -562,7 +562,243 @@ window.ApiClient = ApiClient; **Save and close** -### 9.3 Update Main Game File +### 9.3 Setup CSRF Token Injection (Critical for Security) + +**CRITICAL:** Rails requires CSRF tokens for all POST/PUT/DELETE requests. The token must be injected from the server-rendered view into JavaScript. + +#### 9.3.1 Update Game Show View + +**Edit the view that renders the game:** + +```bash +vim app/views/break_escape/games/show.html.erb +``` + +**Add JavaScript configuration block with CSRF token:** + +```erb + + + + Break Escape - <%= @game.mission.display_name %> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + <%= stylesheet_link_tag '/break_escape/css/game.css' %> + + + <%= javascript_tag nonce: true do %> + // BreakEscape Configuration + window.breakEscapeConfig = { + gameId: <%= @game.id %>, + apiBasePath: '<%= break_escape_path %>/games/<%= @game.id %>', + assetsPath: '/break_escape/assets', + csrfToken: '<%= form_authenticity_token %>', + missionName: '<%= j @game.mission.display_name %>', + startRoom: '<%= j @game.scenario_data["startRoom"] %>', + debug: <%= Rails.env.development? %> + }; + + console.log('✓ BreakEscape config loaded:', window.breakEscapeConfig); + <% end %> + + +
+ + + + + + + + +``` + +**Key points:** +- `<%= csrf_meta_tags %>` - Required by Rails, adds meta tags +- `<%= form_authenticity_token %>` - Generates CSRF token for this session +- `nonce: true` - Required if using Content Security Policy +- Configuration loaded BEFORE game scripts +- Token stored in `window.breakEscapeConfig.csrfToken` + +**Save and close** + +#### 9.3.2 Verify CSRF Token in Browser + +**After implementing, test in browser console:** + +```javascript +// Check if config loaded +console.log(window.breakEscapeConfig); + +// Should show: +// { +// gameId: 123, +// apiBasePath: "/break_escape/games/123", +// assetsPath: "/break_escape/assets", +// csrfToken: "AaBbCc123...long token...", +// missionName: "CEO Exfiltration", +// startRoom: "reception", +// debug: true +// } + +// Check CSRF token +console.log('CSRF Token:', window.breakEscapeConfig.csrfToken); +// Should be a long base64 string + +// Check meta tag (alternative source) +console.log('Meta tag:', document.querySelector('meta[name="csrf-token"]')?.content); +``` + +#### 9.3.3 Handle Missing CSRF Token + +**Add error handling in config.js:** + +```bash +vim public/break_escape/js/config.js +``` + +**Update to validate CSRF token:** + +```javascript +// 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'; +export const CSRF_TOKEN = window.breakEscapeConfig?.csrfToken; + +// 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 configured!'); + console.error('This will cause all POST/PUT requests to fail with 422 status'); + console.error('Check that <%= form_authenticity_token %> is in the view'); +} + +// Log config for debugging +if (window.breakEscapeConfig?.debug) { + console.log('✓ BreakEscape config validated:', { + gameId: GAME_ID, + apiBasePath: API_BASE, + assetsPath: ASSETS_PATH, + csrfToken: CSRF_TOKEN ? `${CSRF_TOKEN.substring(0, 10)}...` : 'MISSING', + debug: true + }); +} +``` + +**Save and close** + +#### 9.3.4 Test CSRF Protection + +**Create a test endpoint call to verify CSRF works:** + +```bash +# Start Rails server +rails server + +# Open browser to game +# http://localhost:3000/break_escape/games/1 + +# Open console and test +``` + +**In browser console:** + +```javascript +// Test GET request (no CSRF needed) +fetch('/break_escape/games/1/bootstrap', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } +}) + .then(r => r.json()) + .then(d => console.log('✓ GET works:', d)); + +// Test POST without CSRF (should fail with 422) +fetch('/break_escape/games/1/unlock', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'door', targetId: 'test' }) +}) + .then(r => console.log('Status:', r.status)) // Should be 422 + .then(() => console.log('❌ POST without CSRF failed (expected)')); + +// Test POST with CSRF (should work) +const csrfToken = window.breakEscapeConfig.csrfToken; +fetch('/break_escape/games/1/unlock', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + targetType: 'door', + targetId: 'test_room', + attempt: 'test', + method: 'password' + }) +}) + .then(r => r.json()) + .then(d => console.log('✓ POST with CSRF works:', d)); +``` + +**Expected results:** +- GET request: ✓ Works (no CSRF needed) +- POST without CSRF: ❌ Returns 422 (CSRF verification failed) +- POST with CSRF: ✓ Works (may fail validation but gets past CSRF) + +#### 9.3.5 Common CSRF Issues and Solutions + +**Issue 1: "Can't verify CSRF token authenticity"** +``` +ActionController::InvalidAuthenticityToken +``` + +**Solution:** +- Check `<%= csrf_meta_tags %>` is in view +- Check `window.breakEscapeConfig.csrfToken` is set +- Check API client includes `'X-CSRF-Token'` header +- Verify `credentials: 'same-origin'` is set + +**Issue 2: CSRF token is null/undefined** + +**Solution:** +```javascript +// In config.js, add fallback to meta tag +export const CSRF_TOKEN = window.breakEscapeConfig?.csrfToken + || document.querySelector('meta[name="csrf-token"]')?.content; + +if (!CSRF_TOKEN) { + throw new Error('CSRF token not found! Check view template.'); +} +``` + +**Issue 3: Token changes between requests** + +**Solution:** +- Rails regenerates tokens periodically (security feature) +- Always use the current token from `window.breakEscapeConfig` +- Don't cache token in localStorage (security risk) + +**Issue 4: Development vs Production** + +**Solution:** +```ruby +# In config/environments/development.rb (for testing only!) +# DO NOT do this in production! +# config.action_controller.allow_forgery_protection = false +``` + +Don't disable CSRF in production! Fix the token injection instead. + +### 9.4 Update Main Game File ```bash vim public/break_escape/js/main.js @@ -608,35 +844,487 @@ import { ApiClient } from '../api-client.js'; const inkScript = await ApiClient.getNPCScript(npc.id); ``` -### 9.5 Update Unlock Validation +### 9.5 Update Unlock Validation with Loading UI -**Find where unlocks are validated** (likely in `js/systems/interactions.js` or similar) +**CRITICAL:** This section handles the conversion from client-side to server-side unlock validation. The unlock system is in `js/systems/unlock-system.js` and is called after minigames succeed. + +#### 9.5.1 Create Unlock Loading UI Helper + +**Create a new file for visual feedback during async unlock validation:** + +```bash +vim public/break_escape/js/utils/unlock-loading-ui.js +``` + +**Add:** + +```javascript +/** + * UNLOCK LOADING UI + * ================= + * + * Provides visual feedback during async server unlock validation. + * Shows a throbbing tint effect on doors/objects being unlocked. + */ + +/** + * Apply throbbing tint effect to a Phaser sprite + * @param {Phaser.GameObjects.Sprite} sprite - The door or object sprite + * @param {number} duration - How long to show the effect (ms) + * @returns {Promise} Resolves when animation completes + */ +export function showUnlockLoading(sprite) { + if (!sprite || !sprite.scene) { + console.warn('showUnlockLoading: Invalid sprite'); + return Promise.resolve(); + } + + // Store original tint + const originalTint = sprite.tint || 0xffffff; + + // Create throbbing animation (blue tint pulsing) + const scene = sprite.scene; + + // Add blue tint and start pulsing + sprite.setTint(0x4da6ff); // Light blue + + // Create timeline for throbbing effect + const timeline = scene.tweens.createTimeline(); + + // Pulse darker blue + timeline.add({ + targets: sprite, + alpha: { from: 1.0, to: 0.7 }, + duration: 300, + ease: 'Sine.easeInOut' + }); + + // Pulse back to lighter + timeline.add({ + targets: sprite, + alpha: { from: 0.7, to: 1.0 }, + duration: 300, + ease: 'Sine.easeInOut' + }); + + // Loop the pulse + timeline.loop = -1; // Infinite loop + timeline.play(); + + // Store timeline reference on sprite for cleanup + sprite._unlockLoadingTimeline = timeline; + + return timeline; +} + +/** + * Clear unlock loading effect + * @param {Phaser.GameObjects.Sprite} sprite - The door or object sprite + * @param {boolean} success - Whether unlock succeeded + */ +export function clearUnlockLoading(sprite, success = true) { + if (!sprite || !sprite.scene) { + return; + } + + // Stop and remove timeline + if (sprite._unlockLoadingTimeline) { + sprite._unlockLoadingTimeline.stop(); + sprite._unlockLoadingTimeline.remove(); + sprite._unlockLoadingTimeline = null; + } + + // Remove tint with quick flash + const scene = sprite.scene; + + if (success) { + // Success: Quick green flash then clear + sprite.setTint(0x00ff00); // Green + scene.tweens.add({ + targets: sprite, + alpha: { from: 1.0, to: 1.0 }, + duration: 200, + onComplete: () => { + sprite.clearTint(); + sprite.setAlpha(1.0); + } + }); + } else { + // Failure: Quick red flash then clear + sprite.setTint(0xff0000); // Red + scene.tweens.add({ + targets: sprite, + alpha: { from: 1.0, to: 1.0 }, + duration: 200, + onComplete: () => { + sprite.clearTint(); + sprite.setAlpha(1.0); + } + }); + } +} + +/** + * Show loading spinner near sprite (alternative visual) + * @param {Phaser.GameObjects.Sprite} sprite - The door or object sprite + */ +export function showLoadingSpinner(sprite) { + if (!sprite || !sprite.scene) { + return null; + } + + const scene = sprite.scene; + + // Create simple rotating circle as spinner + const spinner = scene.add.graphics(); + spinner.lineStyle(3, 0x4da6ff, 1); + spinner.arc(sprite.x, sprite.y - 30, 10, 0, Math.PI * 1.5); + spinner.setDepth(1000); // Always on top + + // Rotate continuously + scene.tweens.add({ + targets: spinner, + angle: 360, + duration: 1000, + repeat: -1 + }); + + sprite._unlockLoadingSpinner = spinner; + + return spinner; +} + +/** + * Clear loading spinner + * @param {Phaser.GameObjects.Sprite} sprite - The door or object sprite + */ +export function clearLoadingSpinner(sprite) { + if (sprite && sprite._unlockLoadingSpinner) { + sprite._unlockLoadingSpinner.destroy(); + sprite._unlockLoadingSpinner = null; + } +} +``` + +**Save and close** + +#### 9.5.2 Update unlock-system.js for Server Validation + +**Now update the actual unlock system to use server validation:** + +```bash +vim public/break_escape/js/systems/unlock-system.js +``` + +**At the top, add imports:** + +```javascript +import { ApiClient } from '../api-client.js'; +import { showUnlockLoading, clearUnlockLoading } from '../utils/unlock-loading-ui.js'; +``` + +**Find the `unlockTarget` function (around line 468) and wrap it with server validation:** + +**Before (current code):** +```javascript +export function unlockTarget(lockable, type, layer) { + console.log('🔓 unlockTarget called:', { type, lockable }); + + if (type === 'door') { + unlockDoor(lockable); + // ... rest of door unlock logic + } else { + // ... item unlock logic + } +} +``` + +**After (with server validation):** +```javascript +/** + * Unlock a target (door or item) with server validation + * @param {Object} lockable - The door or item sprite + * @param {string} type - 'door' or 'item' + * @param {Object} layer - The Phaser layer + * @param {string} attempt - The password/pin/key used + * @param {string} method - 'password', 'pin', 'key', 'lockpick', etc. + */ +export async function unlockTarget(lockable, type, layer, attempt = null, method = null) { + console.log('🔓 unlockTarget called:', { type, lockable, attempt, method }); + + // Show loading UI + showUnlockLoading(lockable); + + try { + // Get target ID + const targetId = type === 'door' + ? lockable.doorProperties?.connectedRoom || lockable.doorProperties?.roomId + : lockable.scenarioData?.id || lockable.objectId; + + // Validate with server + console.log('🔓 Validating unlock with server...', { targetId, type, method }); + const result = await ApiClient.unlock(type, targetId, attempt, method); + + // Clear loading UI (success) + clearUnlockLoading(lockable, true); + + if (result.success) { + console.log('✅ Server validated unlock'); + + // Perform client-side unlock + if (type === 'door') { + unlockDoor(lockable); + + // Emit door unlocked event + if (window.eventDispatcher) { + const doorProps = lockable.doorProperties || {}; + window.eventDispatcher.emit('door_unlocked', { + roomId: doorProps.roomId, + connectedRoom: doorProps.connectedRoom, + direction: doorProps.direction, + lockType: doorProps.lockType + }); + } + + // Update room data if server provided it + if (result.roomData) { + // Merge server room data with client state + console.log('🔓 Received room data from server:', result.roomData); + } + } else { + // Handle item unlocking + if (lockable.scenarioData) { + lockable.scenarioData.locked = false; + + if (lockable.scenarioData.contents) { + lockable.scenarioData.isUnlockedButNotCollected = true; + + // Emit item unlocked event + if (window.eventDispatcher) { + window.eventDispatcher.emit('item_unlocked', { + itemType: lockable.scenarioData.type, + itemName: lockable.scenarioData.name, + lockType: lockable.scenarioData.lockType + }); + } + + // Auto-launch container minigame + setTimeout(() => { + if (window.handleContainerInteraction) { + console.log('Auto-launching container minigame after unlock'); + window.handleContainerInteraction(lockable); + } + }, 500); + + return; + } + } else { + lockable.locked = false; + if (lockable.contents) { + lockable.isUnlockedButNotCollected = true; + return; + } + } + + // For items without containers, collect them + if (lockable.layer) { + lockable.layer.remove(lockable); + } + + console.log('Collected item:', lockable.scenarioData); + window.gameAlert(`Collected ${lockable.scenarioData?.name || 'item'}`, + 'success', 'Item Collected', 3000); + } + } else { + // Server rejected unlock + console.error('❌ Server rejected unlock:', result.message); + window.gameAlert(result.message || 'Unlock failed', 'error', 'Unlock Failed', 3000); + } + } catch (error) { + // Clear loading UI (failure) + clearUnlockLoading(lockable, false); + + console.error('❌ Unlock validation failed:', error); + window.gameAlert('Failed to validate unlock with server', 'error', 'Network Error', 4000); + } +} + +// Keep original unlockTarget for testing without server (fallback) +export function unlockTargetClientSide(lockable, type, layer) { + console.log('🔓 unlockTargetClientSide (fallback without server validation)'); + // ... original implementation for testing +} +``` + +**Save and close** + +#### 9.5.3 Update Minigame Callbacks + +**The unlock system is triggered AFTER minigames succeed. Update minigame callbacks to pass attempt and method:** + +**Find these sections in `unlock-system.js` and update the callbacks:** + +**For PIN minigame (around line 175):** **Before:** ```javascript -// Client-side validation (insecure!) -if (password === requiredPassword) { - unlockRoom(); +startPinMinigame(lockable, type, lockRequirements.requires, (success) => { + if (success) { + unlockTarget(lockable, type, lockable.layer); + } +}); +``` + +**After:** +```javascript +startPinMinigame(lockable, type, lockRequirements.requires, (success, enteredPin) => { + if (success) { + unlockTarget(lockable, type, lockable.layer, enteredPin, 'pin'); + } +}); +``` + +**For Password minigame (around line 195):** + +**Before:** +```javascript +startPasswordMinigame(lockable, type, lockRequirements.requires, (success) => { + if (success) { + unlockTarget(lockable, type, lockable.layer); + } +}, passwordOptions); +``` + +**After:** +```javascript +startPasswordMinigame(lockable, type, lockRequirements.requires, (success, enteredPassword) => { + if (success) { + unlockTarget(lockable, type, lockable.layer, enteredPassword, 'password'); + } +}, passwordOptions); +``` + +**For Key minigame (around line 107):** + +**Before:** +```javascript +startKeySelectionMinigame(lockable, type, playerKeys, requiredKey, unlockTarget); +``` + +**After:** +```javascript +startKeySelectionMinigame(lockable, type, playerKeys, requiredKey, (lockable, type, layer, keyId) => { + unlockTarget(lockable, type, layer, keyId, 'key'); +}); +``` + +**For Lockpick minigame (around line 157):** + +**Before:** +```javascript +startLockpickingMinigame(lockable, window.game, difficulty, (success) => { + if (success) { + setTimeout(() => { + unlockTarget(lockable, type, lockable.layer); + }, 100); + } +}); +``` + +**After:** +```javascript +startLockpickingMinigame(lockable, window.game, difficulty, (success) => { + if (success) { + setTimeout(() => { + unlockTarget(lockable, type, lockable.layer, 'lockpick', 'lockpick'); + }, 100); + } +}); +``` + +**For Biometric (around line 237):** + +**Before:** +```javascript +if (fingerprintQuality >= requiredThreshold) { + unlockTarget(lockable, type, lockable.layer); } ``` **After:** ```javascript -import { ApiClient } from '../api-client.js'; - -// Server-side validation -try { - const result = await ApiClient.unlock('door', roomId, password, 'password'); - if (result.success) { - unlockRoom(result.roomData); - } else { - showError('Invalid password'); - } -} catch (error) { - showError('Unlock failed'); +if (fingerprintQuality >= requiredThreshold) { + unlockTarget(lockable, type, lockable.layer, requiredFingerprint, 'biometric'); } ``` +**For Bluetooth (around line 287):** + +**Before:** +```javascript +if (requiredDeviceData.signalStrength >= minSignalStrength) { + unlockTarget(lockable, type, lockable.layer); +} +``` + +**After:** +```javascript +if (requiredDeviceData.signalStrength >= minSignalStrength) { + unlockTarget(lockable, type, lockable.layer, requiredDevice, 'bluetooth'); +} +``` + +**For RFID (around line 363):** + +**Before:** +```javascript +onComplete: (success) => { + if (success) { + setTimeout(() => { + unlockTarget(lockable, type, lockable.layer); + }, 100); + } +} +``` + +**After:** +```javascript +onComplete: (success, cardId) => { + if (success) { + setTimeout(() => { + unlockTarget(lockable, type, lockable.layer, cardId, 'rfid'); + }, 100); + } +} +``` + +**Save and close** + +#### 9.5.4 Handle Testing Mode + +**Add ability to bypass server validation during development:** + +```javascript +// At top of unlock-system.js after imports +const USE_SERVER_VALIDATION = !window.DISABLE_SERVER_VALIDATION; + +// In unlockTarget function, add fallback: +export async function unlockTarget(lockable, type, layer, attempt = null, method = null) { + // Check if server validation is disabled for testing + if (!USE_SERVER_VALIDATION || window.DISABLE_SERVER_VALIDATION) { + console.log('⚠️ Server validation disabled - using client-side unlock'); + return unlockTargetClientSide(lockable, type, layer); + } + + // ... rest of server validation code +} +``` + +**This allows testing without server by setting:** +```javascript +window.DISABLE_SERVER_VALIDATION = true; +``` + ### 9.6 Add State Sync **Add periodic state sync** (in main game update loop or create new file)