Files
BreakEscape/planning_notes/rails-engine-migration/SIMPLIFIED_APPROACH.md
Z. Cliffe Schreuders b1356c1157 docs: Add simplified Rails Engine migration approach
RECOMMENDED APPROACH: 12-14 weeks instead of 22 weeks

KEY SIMPLIFICATIONS:
- Use JSON storage for game state (already in that format)
- 3 database tables instead of 10+
  - game_instances (with player_state JSONB)
  - scenarios (with scenario_data JSONB)
  - npc_scripts (Ink JSON)

- 6 API endpoints instead of 15+
  - Bootstrap game
  - Load room (when unlocked)
  - Attempt unlock
  - Update inventory
  - Load NPC script (on encounter)
  - Sync state (periodic)

VALIDATION STRATEGY:
- Validate unlock attempts (server has solutions)
- Validate room/object access (check unlocked state)
- Validate inventory changes (check item in unlocked location)
- NPCs: Load Ink scripts on encounter, run conversations 100% client-side
- NPC door unlocks: Simple check (encountered NPC + scenario permission)

WHAT WE DON'T TRACK:
- Every event (client-side only)
- Every conversation turn (no sync needed)
- Every minigame action (only result matters)
- Complex NPC permissions (simple rule: encountered = trusted)

BENEFITS:
- Faster development (12-14 weeks vs 22 weeks)
- Easier maintenance (JSON matches existing format)
- Better performance (fewer queries, JSONB indexing)
- More flexible (easy to modify game state structure)
- Simpler logic (clear validation rules)

Updated README_UPDATED.md to recommend simplified approach first.
Complex approach documentation retained for reference.
2025-11-20 15:37:37 +00:00

12 KiB

Simplified Rails Engine Migration Approach

Last Updated: 2025-11-20

Core Philosophy

Keep it simple: Use JSON storage for game state (it's already in that format), validate only what matters, and minimize server round-trips.


What We Actually Need to Track

1. Scenario JSON (Filtered for Client)

Server stores: Complete scenario with solutions Client receives: Filtered JSON with:

  • Room layouts (connections, types)
  • Objects visible but lock requirements hidden
  • NPCs present but Ink scripts loaded on-demand
  • No PINs, passwords, key IDs, or container contents

2. Player Current State (Simple JSON)

{
  "currentRoom": "room_reception",
  "position": { "x": 100, "y": 200 },
  "unlockedRooms": ["room_reception", "room_office"],
  "unlockedObjects": ["desk_drawer_123", "safe_456"],
  "inventory": [
    { "type": "key", "name": "Office Key", "key_id": "office_key_1" },
    { "type": "lockpick", "name": "Lockpick Set" }
  ],
  "encounteredNPCs": ["security_guard", "receptionist"],
  "globalVariables": {
    "alarm_triggered": false,
    "player_favor": 5
  }
}

That's it! One JSON blob per player per scenario.

3. NPC Ink Scripts (Lazy Loaded)

  • When player encounters NPC → load Ink script
  • All conversation happens client-side
  • Only validate if NPC grants door unlock (simple check: has player encountered this NPC?)

Simplified Database Schema

One Main Table: game_instances

create_table :game_instances do |t|
  t.references :user, null: false
  t.references :scenario, null: false

  # Game state as JSON
  t.jsonb :player_state, default: {
    currentRoom: 'room_reception',
    position: { x: 0, y: 0 },
    unlockedRooms: [],
    unlockedObjects: [],
    inventory: [],
    encounteredNPCs: [],
    globalVariables: {}
  }

  # Metadata
  t.string :status, default: 'in_progress' # in_progress, completed, abandoned
  t.datetime :started_at
  t.datetime :completed_at
  t.integer :score, default: 0

  t.timestamps

  t.index [:user_id, :scenario_id], unique: true
  t.index :player_state, using: :gin  # For JSON queries
end

create_table :scenarios do |t|
  t.string :name, null: false
  t.text :description
  t.jsonb :scenario_data  # Complete scenario JSON (with solutions)
  t.boolean :published, default: false
  t.timestamps
end

create_table :npc_scripts do |t|
  t.references :scenario, null: false
  t.string :npc_id, null: false
  t.text :ink_script  # Ink JSON
  t.timestamps

  t.index [:scenario_id, :npc_id], unique: true
end

That's it! 3 tables instead of 10+


Simplified API Endpoints

1. Bootstrap Game

GET /api/games/:game_id/bootstrap

Response:
{
  "startRoom": "room_reception",
  "scenarioName": "CEO Heist",
  "playerState": { currentRoom, position, inventory, ... },
  "roomLayout": {
    // Just room IDs and connections, no solutions
    "room_reception": {
      "connections": { "north": "room_office" },
      "locked": false
    },
    "room_office": {
      "connections": { "south": "room_reception", "north": "room_ceo" },
      "locked": true  // But no lockType or requires!
    }
  }
}

2. Load Room (When Unlocked)

GET /api/games/:game_id/rooms/:room_id

Server checks: Is room in playerState.unlockedRooms?
  - Yes → Return room data (objects, but still no lock solutions)
  - No → 403 Forbidden

Response:
{
  "roomId": "room_office",
  "objects": [
    {
      "type": "desk",
      "name": "Desk",
      "locked": true,  // But no "requires" field
      "observations": "A locked desk drawer"
    }
  ]
}

3. Attempt Unlock

POST /api/games/:game_id/unlock

Body:
{
  "targetType": "door|object",
  "targetId": "room_ceo|desk_drawer_123",
  "method": "key|pin|password|lockpick",
  "attempt": "1234|password123|key_id_5"
}

Server:
- Loads complete scenario JSON
- Checks if attempt matches requirement
- If valid:
  - Adds to playerState.unlockedRooms or unlockedObjects
  - Returns unlocked content
- If invalid:
  - Returns failure message

Response (success):
{
  "success": true,
  "type": "door",
  "roomData": { ... } // If door
  // OR
  "contents": [ ... ] // If container
}

4. Update Inventory

POST /api/games/:game_id/inventory

Body:
{
  "action": "add|remove",
  "item": { "type": "key", "name": "Office Key", "key_id": "..." }
}

Server validates:
- For "add": Is item in an unlocked container/room?
- Updates playerState.inventory

Response:
{ "success": true, "inventory": [...] }

5. Load NPC Script (On Encounter)

GET /api/games/:game_id/npcs/:npc_id/script

Server checks: Is NPC in current room OR already in playerState.encounteredNPCs?
  - Yes → Return Ink script
  - No → 403 Forbidden

Side effect: Add to playerState.encounteredNPCs

Response:
{
  "npcId": "security_guard",
  "inkScript": { ... },  // Full Ink JSON
  "eventMappings": [...],
  "timedMessages": [...]
}

6. Sync State (Periodic)

PUT /api/games/:game_id/state

Body:
{
  "currentRoom": "room_office",
  "position": { "x": 150, "y": 220 },
  "globalVariables": { "alarm_triggered": false }
}

Server: Merges into playerState (validates room is unlocked)

That's it! 6 endpoints instead of 15+


What We DON'T Track Server-Side

Every Event

  • No event logging (unless needed for analytics later)
  • NPCs listen to events client-side only

Every Conversation Turn

  • All NPC dialogue happens client-side
  • No conversation history sync needed
  • No story state tracking

Every Minigame Action

  • Minigames run 100% client-side
  • Only unlock validation matters

Complex Permissions

  • No NPCPermission table
  • Simple rule: If player encountered NPC, NPC can do its thing

Validation Strategy (Simplified)

When Server Validates

  1. Unlock Attempts

    • Check attempt against scenario JSON
    • Update unlocked state
  2. Inventory Changes

    • Verify item exists in unlocked location
    • Update inventory JSON
  3. Room Access

    • Check room in unlockedRooms
    • Return filtered room data
  4. NPC Script Loading

    • Check NPC encountered or in current room
    • Return Ink script

When Server Doesn't Validate

  1. Conversations - All client-side
  2. Movement - Trust client position
  3. Events - Client-side only
  4. Minigame Actions - Only result matters
  5. Global Variables - Sync periodically, don't validate every change

NPC Door Unlock Simplification

Scenario defines: NPC can unlock door X

{
  "id": "security_guard",
  "canUnlock": ["room_server"]
}

Client requests: NPC unlock door

POST /api/games/:game_id/npc_unlock

Body:
{
  "npcId": "security_guard",
  "doorId": "room_server"
}

Server validates:
- Is NPC in playerState.encounteredNPCs?
- Does scenario say NPC can unlock this door?
- If yes: Add to unlockedRooms, return room data

No conversation tracking, no trust levels, no complex permissions!


Migration Timeline (Simplified)

Total: 12-14 weeks (vs 22 weeks original)

Weeks 1-2: Setup

  • Create Rails Engine
  • 3 database tables
  • Import scenarios as JSON

Weeks 3-4: Core API

  • Bootstrap endpoint
  • Room loading
  • Unlock validation

Weeks 5-6: Client Integration

  • Modify loadRoom() to fetch from server
  • Add unlock validation
  • NPC script lazy loading

Weeks 7-8: Inventory & State Sync

  • Inventory API
  • State sync endpoint
  • Offline queue

Weeks 9-10: NPC Integration

  • NPC script loading
  • NPC door unlock (simple validation)
  • Import all Ink scripts

Weeks 11-12: Testing & Polish

  • Integration testing
  • Performance optimization
  • Security audit

Weeks 13-14: Deployment

  • Staging deployment
  • Load testing
  • Production deployment

Savings: 8-10 weeks by simplifying!


Implementation Example: Unlock Validation

Server-Side (Simple)

class Api::UnlockController < ApplicationController
  def create
    game = GameInstance.find(params[:game_id])

    # Load complete scenario (has solutions)
    scenario = game.scenario.scenario_data

    target_type = params[:target_type] # 'door' or 'object'
    target_id = params[:target_id]
    attempt = params[:attempt]
    method = params[:method]

    # Find target in scenario
    target = find_target(scenario, target_type, target_id)

    # Validate attempt
    is_valid = validate_attempt(target, attempt, method)

    if is_valid
      # Update player state JSON
      if target_type == 'door'
        game.player_state['unlockedRooms'] << target_id
        room_data = get_filtered_room_data(scenario, target_id)

        game.save!
        render json: { success: true, roomData: room_data }
      else
        game.player_state['unlockedObjects'] << target_id
        contents = target['contents'] || []

        game.save!
        render json: { success: true, contents: contents }
      end
    else
      render json: { success: false, message: 'Invalid attempt' }, status: 422
    end
  end

  private

  def validate_attempt(target, attempt, method)
    case method
    when 'key'
      target['requires'] == attempt  # key_id matches
    when 'pin', 'password'
      target['requires'] == attempt  # PIN/password matches
    when 'lockpick'
      true  # Client-side minigame passed, trust it
    end
  end

  def get_filtered_room_data(scenario, room_id)
    room = scenario['rooms'][room_id].dup

    # Remove solutions from objects
    room['objects']&.each do |obj|
      obj.delete('requires')
      obj.delete('contents') if obj['locked']
    end

    room
  end
end

Client-Side (Unchanged)

async function handleUnlock(lockable, type) {
  // Show minigame or prompt (unchanged)
  const attempt = await getUnlockAttempt(lockable);

  // NEW: Validate with server
  const response = await fetch(`/api/games/${gameId}/unlock`, {
    method: 'POST',
    body: JSON.stringify({
      targetType: type,
      targetId: lockable.objectId,
      method: lockable.lockType,
      attempt: attempt
    })
  });

  const result = await response.json();

  if (result.success) {
    // Unlock locally
    unlockTarget(lockable, type);

    // If door, load room
    if (type === 'door' && result.roomData) {
      createRoom(result.roomData.roomId, result.roomData, position);
    }
  }
}

Benefits of Simplified Approach

1. Faster Development

  • 12-14 weeks vs 22 weeks
  • 3 tables vs 10+
  • 6 endpoints vs 15+

2. Easier Maintenance

  • JSON storage matches existing format
  • No ORM complexity
  • Simple queries

3. Better Performance

  • Fewer database queries
  • Single JSON blob vs many joins
  • JSONB indexing is fast

4. Flexible

  • Easy to add new fields to JSON
  • No migrations for game state changes
  • Scenarios stay as JSON files

5. Simpler Logic

  • No complex permissions
  • No conversation state tracking
  • Clear validation rules

What We Give Up (And Why It's OK)

Conversation History Persistence

Why OK: NPCs work great client-side. If player refreshes, conversation resets. This is fine for a game session.

Detailed Event Analytics

Why OK: Can add later if needed. Start simple.

Global Variable Validation

Why OK: Sync periodically, rollback if server detects cheating. Don't block gameplay.

Fine-Grained Permissions

Why OK: Simple rules work: encountered NPC = trusted. Scenario defines what NPCs can do.


Security Model (Simplified)

What's Secure

  • Scenario solutions never sent to client
  • Unlock validation server-side
  • Room/object access controlled
  • NPC scripts lazy-loaded

What's Client-Trust ⚠️

  • Player position (low risk)
  • Global variables (sync server, detect cheating)
  • Minigame success (only door unlock matters)

What We Detect

  • Player accessing unearned rooms → 403
  • Invalid unlock attempts → 422
  • Impossible inventory items → reject

Good enough for educational game!


Conclusion

Original approach: Complex, over-engineered, 22 weeks Simplified approach: Pragmatic, maintainable, 12-14 weeks

Key insight: We don't need to track everything. Just track:

  1. What's unlocked (rooms/objects)
  2. What player has (inventory)
  3. Who player met (NPCs)
  4. Where player is (room/position)

Everything else can stay client-side!

Ready to implement this simpler approach.