mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
docs: Add simplified 2-table schema (missions + games)
Major simplification to migration plan: - Remove NpcScript model entirely - Reduce to 2 tables: missions (metadata) + games (state + scenario snapshot) - Serve ink files directly from filesystem via game endpoints - Move scenario_data to game instances (enables per-instance randomization) - Eliminates Issue #2 (NPC schema complexity) - Reduces P0 fixes from 10 hours to 2-3 hours - Much simpler seed process (metadata only)
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
# Simplified Database Schema (2 Tables)
|
||||
|
||||
**Date:** November 20, 2025
|
||||
**Status:** RECOMMENDED APPROACH
|
||||
|
||||
This document presents a **dramatically simplified** schema that eliminates the NPC registry complexity.
|
||||
|
||||
---
|
||||
|
||||
## Key Simplifications
|
||||
|
||||
### What Changed
|
||||
|
||||
**REMOVED:**
|
||||
- ❌ `break_escape_npc_scripts` table (no NPC registry!)
|
||||
- ❌ `break_escape_scenario_npcs` join table
|
||||
- ❌ `scenario_data` JSONB in scenarios table
|
||||
- ❌ Complex NPC seed logic
|
||||
|
||||
**SIMPLIFIED:**
|
||||
- ✅ 2 tables instead of 3-4
|
||||
- ✅ Scenarios are just metadata
|
||||
- ✅ scenario_data generated per game instance (via ERB)
|
||||
- ✅ Ink files served directly from filesystem
|
||||
- ✅ No database bloat with duplicate scripts
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
**"Files on filesystem, metadata in database"**
|
||||
|
||||
- **Scenarios** → Directories with ERB templates (filesystem)
|
||||
- **Ink scripts** → .json files (filesystem)
|
||||
- **Database** → Only track which scenario, player progress
|
||||
- **ERB generation** → Happens when game instance is created
|
||||
|
||||
---
|
||||
|
||||
## Complete Schema
|
||||
|
||||
---
|
||||
|
||||
## Table 1: missions (scenarios)
|
||||
|
||||
Stores scenario metadata only.
|
||||
|
||||
```ruby
|
||||
create_table :break_escape_missions do |t|
|
||||
t.string :name, null: false # 'ceo_exfil'
|
||||
t.string :display_name, null: false # 'CEO Exfiltration'
|
||||
t.text :description # Scenario brief
|
||||
t.boolean :published, default: false
|
||||
t.integer :difficulty_level, default: 1 # 1-5
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :break_escape_missions, :name, unique: true
|
||||
add_index :break_escape_missions, :published
|
||||
```
|
||||
|
||||
**What it stores:**
|
||||
- Scenario identifier (name)
|
||||
- Display information
|
||||
- Published status
|
||||
|
||||
**What it does NOT store:**
|
||||
- ❌ Scenario JSON (generated on demand via ERB)
|
||||
- ❌ NPC scripts (served from filesystem)
|
||||
- ❌ Room data (in scenario directory)
|
||||
|
||||
---
|
||||
|
||||
## Table 2: games (game instances)
|
||||
|
||||
Stores player game state with scenario data snapshot.
|
||||
|
||||
```ruby
|
||||
create_table :break_escape_games do |t|
|
||||
# Polymorphic player
|
||||
t.references :player, polymorphic: true, null: false
|
||||
|
||||
# Scenario reference
|
||||
t.references :mission, null: false, foreign_key: { to_table: :break_escape_missions }
|
||||
|
||||
# Scenario snapshot (generated via ERB at creation)
|
||||
t.jsonb :scenario_data, null: false # ← MOVED HERE!
|
||||
|
||||
# Player state (all game progress)
|
||||
t.jsonb :player_state, null: false, default: {
|
||||
currentRoom: nil,
|
||||
unlockedRooms: [],
|
||||
unlockedObjects: [],
|
||||
inventory: [],
|
||||
encounteredNPCs: [],
|
||||
globalVariables: {},
|
||||
biometricSamples: [],
|
||||
biometricUnlocks: [],
|
||||
bluetoothDevices: [],
|
||||
notes: [],
|
||||
health: 100
|
||||
}
|
||||
|
||||
# Game 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
|
||||
end
|
||||
|
||||
add_index :break_escape_games,
|
||||
[:player_type, :player_id, :mission_id],
|
||||
unique: true,
|
||||
name: 'index_games_on_player_and_mission'
|
||||
add_index :break_escape_games, :scenario_data, using: :gin
|
||||
add_index :break_escape_games, :player_state, using: :gin
|
||||
add_index :break_escape_games, :status
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- `scenario_data` is now **per game instance** (supports randomization!)
|
||||
- `player_state` includes `health` (no separate column)
|
||||
- Removed `position` (not needed for now)
|
||||
- Added minigame fields
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### Migration 1: Create Missions
|
||||
|
||||
```ruby
|
||||
# db/migrate/001_create_break_escape_missions.rb
|
||||
class CreateBreakEscapeMissions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :break_escape_missions do |t|
|
||||
t.string :name, null: false
|
||||
t.string :display_name, null: false
|
||||
t.text :description
|
||||
t.boolean :published, default: false
|
||||
t.integer :difficulty_level, default: 1
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :break_escape_missions, :name, unique: true
|
||||
add_index :break_escape_missions, :published
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Migration 2: Create Games
|
||||
|
||||
```ruby
|
||||
# db/migrate/002_create_break_escape_games.rb
|
||||
class CreateBreakEscapeGames < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :break_escape_games do |t|
|
||||
# Polymorphic player
|
||||
t.references :player, polymorphic: true, null: false, index: true
|
||||
|
||||
# Mission reference
|
||||
t.references :mission, null: false, foreign_key: { to_table: :break_escape_missions }
|
||||
|
||||
# Scenario snapshot (ERB-generated)
|
||||
t.jsonb :scenario_data, null: false
|
||||
|
||||
# Player state (all game progress)
|
||||
t.jsonb :player_state, null: false, default: {
|
||||
currentRoom: nil,
|
||||
unlockedRooms: [],
|
||||
unlockedObjects: [],
|
||||
inventory: [],
|
||||
encounteredNPCs: [],
|
||||
globalVariables: {},
|
||||
biometricSamples: [],
|
||||
biometricUnlocks: [],
|
||||
bluetoothDevices: [],
|
||||
notes: [],
|
||||
health: 100
|
||||
}
|
||||
|
||||
# Metadata
|
||||
t.string :status, default: 'in_progress'
|
||||
t.datetime :started_at
|
||||
t.datetime :completed_at
|
||||
t.integer :score, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :break_escape_games,
|
||||
[:player_type, :player_id, :mission_id],
|
||||
unique: true,
|
||||
name: 'index_games_on_player_and_mission'
|
||||
add_index :break_escape_games, :scenario_data, using: :gin
|
||||
add_index :break_escape_games, :player_state, using: :gin
|
||||
add_index :break_escape_games, :status
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
### Mission Model
|
||||
|
||||
```ruby
|
||||
# app/models/break_escape/mission.rb
|
||||
module BreakEscape
|
||||
class Mission < ApplicationRecord
|
||||
self.table_name = 'break_escape_missions'
|
||||
|
||||
has_many :games, class_name: 'BreakEscape::Game', dependent: :destroy
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
validates :display_name, presence: true
|
||||
|
||||
scope :published, -> { where(published: true) }
|
||||
|
||||
# Path to scenario directory
|
||||
def scenario_path
|
||||
Rails.root.join('app', 'assets', 'scenarios', name)
|
||||
end
|
||||
|
||||
# Generate scenario data via ERB
|
||||
def generate_scenario_data
|
||||
template_path = scenario_path.join('scenario.json.erb')
|
||||
raise "Scenario not found: #{name}" unless File.exist?(template_path)
|
||||
|
||||
erb = ERB.new(File.read(template_path))
|
||||
binding_context = ScenarioBinding.new
|
||||
output = erb.result(binding_context.get_binding)
|
||||
|
||||
JSON.parse(output)
|
||||
rescue JSON::ParserError => e
|
||||
raise "Invalid JSON in #{name}: #{e.message}"
|
||||
end
|
||||
|
||||
# Binding context for ERB
|
||||
class ScenarioBinding
|
||||
def initialize
|
||||
@random_password = SecureRandom.alphanumeric(8)
|
||||
@random_pin = rand(1000..9999).to_s
|
||||
@random_code = SecureRandom.hex(4)
|
||||
end
|
||||
|
||||
attr_reader :random_password, :random_pin, :random_code
|
||||
|
||||
def get_binding
|
||||
binding
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Game Model
|
||||
|
||||
```ruby
|
||||
# app/models/break_escape/game.rb
|
||||
module BreakEscape
|
||||
class Game < ApplicationRecord
|
||||
self.table_name = 'break_escape_games'
|
||||
|
||||
# Associations
|
||||
belongs_to :player, polymorphic: true
|
||||
belongs_to :mission, class_name: 'BreakEscape::Mission'
|
||||
|
||||
# Validations
|
||||
validates :player, presence: true
|
||||
validates :mission, presence: true
|
||||
validates :status, inclusion: { in: %w[in_progress completed abandoned] }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(status: 'in_progress') }
|
||||
scope :completed, -> { where(status: 'completed') }
|
||||
|
||||
# Callbacks
|
||||
before_create :generate_scenario_data
|
||||
before_create :initialize_player_state
|
||||
before_create :set_started_at
|
||||
|
||||
# Room management
|
||||
def unlock_room!(room_id)
|
||||
player_state['unlockedRooms'] ||= []
|
||||
player_state['unlockedRooms'] << room_id unless player_state['unlockedRooms'].include?(room_id)
|
||||
save!
|
||||
end
|
||||
|
||||
def room_unlocked?(room_id)
|
||||
player_state['unlockedRooms']&.include?(room_id) || start_room?(room_id)
|
||||
end
|
||||
|
||||
def start_room?(room_id)
|
||||
scenario_data['startRoom'] == room_id
|
||||
end
|
||||
|
||||
# Object management
|
||||
def unlock_object!(object_id)
|
||||
player_state['unlockedObjects'] ||= []
|
||||
player_state['unlockedObjects'] << object_id unless player_state['unlockedObjects'].include?(object_id)
|
||||
save!
|
||||
end
|
||||
|
||||
def object_unlocked?(object_id)
|
||||
player_state['unlockedObjects']&.include?(object_id)
|
||||
end
|
||||
|
||||
# Inventory management
|
||||
def add_inventory_item!(item)
|
||||
player_state['inventory'] ||= []
|
||||
player_state['inventory'] << item
|
||||
save!
|
||||
end
|
||||
|
||||
def remove_inventory_item!(item_id)
|
||||
player_state['inventory']&.reject! { |item| item['id'] == item_id }
|
||||
save!
|
||||
end
|
||||
|
||||
# NPC tracking
|
||||
def encounter_npc!(npc_id)
|
||||
player_state['encounteredNPCs'] ||= []
|
||||
player_state['encounteredNPCs'] << npc_id unless player_state['encounteredNPCs'].include?(npc_id)
|
||||
save!
|
||||
end
|
||||
|
||||
def npc_encountered?(npc_id)
|
||||
player_state['encounteredNPCs']&.include?(npc_id)
|
||||
end
|
||||
|
||||
# Global variables (synced with client)
|
||||
def update_global_variable!(key, value)
|
||||
player_state['globalVariables'] ||= {}
|
||||
player_state['globalVariables'][key] = value
|
||||
save!
|
||||
end
|
||||
|
||||
# Minigame state
|
||||
def add_biometric_sample!(sample)
|
||||
player_state['biometricSamples'] ||= []
|
||||
player_state['biometricSamples'] << sample
|
||||
save!
|
||||
end
|
||||
|
||||
def add_bluetooth_device!(device)
|
||||
player_state['bluetoothDevices'] ||= []
|
||||
player_state['bluetoothDevices'] << device unless player_state['bluetoothDevices'].any? { |d| d['mac'] == device['mac'] }
|
||||
save!
|
||||
end
|
||||
|
||||
def add_note!(note)
|
||||
player_state['notes'] ||= []
|
||||
player_state['notes'] << note
|
||||
save!
|
||||
end
|
||||
|
||||
# Health management
|
||||
def update_health!(value)
|
||||
player_state['health'] = value
|
||||
save!
|
||||
end
|
||||
|
||||
# Scenario data access
|
||||
def room_data(room_id)
|
||||
scenario_data.dig('rooms', room_id)
|
||||
end
|
||||
|
||||
def filtered_room_data(room_id)
|
||||
room = room_data(room_id)&.deep_dup
|
||||
return nil unless room
|
||||
|
||||
# Remove solutions
|
||||
room.delete('requires')
|
||||
room.delete('lockType') if room['locked']
|
||||
|
||||
# Remove solutions from objects
|
||||
room['objects']&.each do |obj|
|
||||
obj.delete('requires')
|
||||
obj.delete('lockType') if obj['locked']
|
||||
obj.delete('contents') if obj['locked']
|
||||
end
|
||||
|
||||
room
|
||||
end
|
||||
|
||||
# Unlock validation
|
||||
def validate_unlock(target_type, target_id, attempt, method)
|
||||
if target_type == 'door'
|
||||
room = room_data(target_id)
|
||||
return false unless room && room['locked']
|
||||
|
||||
case method
|
||||
when 'key'
|
||||
room['requires'] == attempt
|
||||
when 'pin', 'password'
|
||||
room['requires'].to_s == attempt.to_s
|
||||
when 'lockpick'
|
||||
true # Client minigame succeeded
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
# Find object in all rooms
|
||||
scenario_data['rooms'].each do |_room_id, room_data|
|
||||
object = room_data['objects']&.find { |obj| obj['id'] == target_id }
|
||||
next unless object && object['locked']
|
||||
|
||||
case method
|
||||
when 'key'
|
||||
return object['requires'] == attempt
|
||||
when 'pin', 'password'
|
||||
return object['requires'].to_s == attempt.to_s
|
||||
when 'lockpick'
|
||||
return true
|
||||
end
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_scenario_data
|
||||
self.scenario_data = mission.generate_scenario_data
|
||||
end
|
||||
|
||||
def initialize_player_state
|
||||
self.player_state ||= {}
|
||||
self.player_state['currentRoom'] ||= scenario_data['startRoom']
|
||||
self.player_state['unlockedRooms'] ||= [scenario_data['startRoom']]
|
||||
self.player_state['unlockedObjects'] ||= []
|
||||
self.player_state['inventory'] ||= []
|
||||
self.player_state['encounteredNPCs'] ||= []
|
||||
self.player_state['globalVariables'] ||= {}
|
||||
self.player_state['biometricSamples'] ||= []
|
||||
self.player_state['biometricUnlocks'] ||= []
|
||||
self.player_state['bluetoothDevices'] ||= []
|
||||
self.player_state['notes'] ||= []
|
||||
self.player_state['health'] ||= 100
|
||||
end
|
||||
|
||||
def set_started_at
|
||||
self.started_at ||= Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Simplified API
|
||||
|
||||
```ruby
|
||||
# config/routes.rb
|
||||
BreakEscape::Engine.routes.draw do
|
||||
resources :missions, only: [:index, :show]
|
||||
|
||||
resources :games, only: [:show, :create] do
|
||||
member do
|
||||
# Serve scenario JSON for this game instance
|
||||
get 'scenario', to: 'games#scenario'
|
||||
|
||||
# Serve NPC ink scripts
|
||||
get 'ink', to: 'games#ink'
|
||||
|
||||
# API endpoints
|
||||
get 'bootstrap', to: 'api/games#bootstrap'
|
||||
put 'sync_state', to: 'api/games#sync_state'
|
||||
post 'unlock', to: 'api/games#unlock'
|
||||
post 'inventory', to: 'api/games#inventory'
|
||||
end
|
||||
end
|
||||
|
||||
root to: 'missions#index'
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Games Controller
|
||||
|
||||
```ruby
|
||||
# app/controllers/break_escape/games_controller.rb
|
||||
module BreakEscape
|
||||
class GamesController < ApplicationController
|
||||
before_action :set_game, only: [:show, :scenario, :ink]
|
||||
|
||||
def show
|
||||
authorize @game if defined?(Pundit)
|
||||
# Render game view
|
||||
end
|
||||
|
||||
# GET /games/:id/scenario
|
||||
def scenario
|
||||
authorize @game if defined?(Pundit)
|
||||
|
||||
render json: @game.scenario_data
|
||||
end
|
||||
|
||||
# GET /games/:id/ink?npc=helper1
|
||||
def ink
|
||||
authorize @game if defined?(Pundit)
|
||||
|
||||
npc_id = params[:npc]
|
||||
return head :bad_request unless npc_id.present?
|
||||
|
||||
# Find NPC in scenario data
|
||||
npc = find_npc_in_scenario(npc_id)
|
||||
return head :not_found unless npc
|
||||
|
||||
# Load ink file from filesystem
|
||||
ink_path = resolve_ink_path(npc['storyPath'])
|
||||
return head :not_found unless File.exist?(ink_path)
|
||||
|
||||
render json: JSON.parse(File.read(ink_path))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_game
|
||||
@game = Game.find(params[:id])
|
||||
end
|
||||
|
||||
def find_npc_in_scenario(npc_id)
|
||||
@game.scenario_data['rooms']&.each do |_room_id, room_data|
|
||||
npc = room_data['npcs']&.find { |n| n['id'] == npc_id }
|
||||
return npc if npc
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_ink_path(story_path)
|
||||
# story_path is like "scenarios/ink/helper-npc.json"
|
||||
Rails.root.join(story_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Simplified Seed Process
|
||||
|
||||
```ruby
|
||||
# db/seeds.rb
|
||||
puts "Creating missions..."
|
||||
|
||||
# Just create mission metadata - no scenario data!
|
||||
missions = [
|
||||
{ name: 'ceo_exfil', display_name: 'CEO Exfiltration', difficulty: 3 },
|
||||
{ name: 'cybok_heist', display_name: 'CybOK Heist', difficulty: 4 },
|
||||
{ name: 'biometric_breach', display_name: 'Biometric Breach', difficulty: 2 }
|
||||
]
|
||||
|
||||
missions.each do |mission_data|
|
||||
mission = BreakEscape::Mission.find_or_create_by!(name: mission_data[:name]) do |m|
|
||||
m.display_name = mission_data[:display_name]
|
||||
m.difficulty_level = mission_data[:difficulty]
|
||||
m.published = true
|
||||
end
|
||||
|
||||
puts " ✓ #{mission.display_name}"
|
||||
end
|
||||
|
||||
puts "Done! Created #{BreakEscape::Mission.count} missions."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client Integration
|
||||
|
||||
### Loading Scenario
|
||||
|
||||
```javascript
|
||||
// Before: Load from static file
|
||||
const scenario = await fetch('/scenarios/ceo_exfil.json').then(r => r.json());
|
||||
|
||||
// After: Load from game instance
|
||||
const gameId = window.breakEscapeConfig.gameId;
|
||||
const scenario = await fetch(`/break_escape/games/${gameId}/scenario`).then(r => r.json());
|
||||
```
|
||||
|
||||
### Loading NPC Scripts
|
||||
|
||||
```javascript
|
||||
// Before: Load from static file
|
||||
const inkScript = await fetch('/scenarios/ink/helper-npc.json').then(r => r.json());
|
||||
|
||||
// After: Load from game instance
|
||||
const gameId = window.breakEscapeConfig.gameId;
|
||||
const inkScript = await fetch(`/break_escape/games/${gameId}/ink?npc=helper_npc`).then(r => r.json());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
### ✅ Dramatically Simpler
|
||||
- **2 tables** instead of 3-4
|
||||
- **No NPC registry** complexity
|
||||
- **No join tables**
|
||||
- **Simpler seed script** (just metadata)
|
||||
|
||||
### ✅ Better Randomization
|
||||
- Each game instance gets its own ERB-generated scenario
|
||||
- Different passwords/pins per player
|
||||
- No shared scenario data
|
||||
|
||||
### ✅ No Database Bloat
|
||||
- Ink scripts stay on filesystem
|
||||
- No duplicate NPC storage
|
||||
- Scenario templates on filesystem
|
||||
|
||||
### ✅ Easier Development
|
||||
- No complex migrations for NPC changes
|
||||
- Update .ink files directly
|
||||
- No seed script for NPCs
|
||||
|
||||
### ✅ Authorization Built-In
|
||||
- Can't access scenario data without game instance
|
||||
- Can't access NPC scripts without game instance
|
||||
- Player-specific access control
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Old vs New
|
||||
|
||||
| Aspect | Old Approach (3-4 tables) | New Approach (2 tables) |
|
||||
|--------|---------------------------|-------------------------|
|
||||
| **Tables** | scenarios, npc_scripts, scenario_npcs, games | missions, games |
|
||||
| **Scenario data** | In scenarios table | Generated per game |
|
||||
| **NPC scripts** | In database | On filesystem |
|
||||
| **Seed complexity** | High (import scenarios + NPCs) | Low (just metadata) |
|
||||
| **Randomization** | Shared scenario data | Per-instance generation |
|
||||
| **File access** | Via database | Via game instance endpoints |
|
||||
| **Migration effort** | Complex | Simple |
|
||||
| **Database size** | Large (duplicates) | Small (metadata only) |
|
||||
|
||||
---
|
||||
|
||||
## Migration Timeline Impact
|
||||
|
||||
**Old Approach:**
|
||||
- Phase 4: 3-4 complex migrations
|
||||
- Phase 5: Complex seed with NPC imports
|
||||
- Total: ~8 hours
|
||||
|
||||
**New Approach:**
|
||||
- Phase 4: 2 simple migrations
|
||||
- Phase 5: Simple seed (metadata only)
|
||||
- Total: ~3 hours
|
||||
|
||||
**Time Saved:** 5 hours
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**This is the recommended approach!**
|
||||
|
||||
- ✅ 2 tables instead of 3-4
|
||||
- ✅ Files on filesystem (where they belong)
|
||||
- ✅ Metadata in database (what it's good for)
|
||||
- ✅ Per-instance scenario generation (better randomization)
|
||||
- ✅ Simpler, faster, cleaner
|
||||
|
||||
**Next Step:** Update implementation plan to use this schema.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Updated Critical Issues (After Simplification)
|
||||
|
||||
**Date:** November 20, 2025
|
||||
**Status:** With 2-table simplified schema
|
||||
|
||||
This document updates the critical issues list based on the **simplified 2-table approach** (missions + games).
|
||||
|
||||
---
|
||||
|
||||
## Issues RESOLVED by Simplification
|
||||
|
||||
### ✅ Issue #2: Shared NPC Schema - RESOLVED
|
||||
**Status:** NO LONGER APPLICABLE
|
||||
|
||||
With no NPC registry, this issue is completely eliminated!
|
||||
|
||||
**Old Problem:** Database schema didn't support shared NPCs
|
||||
**New Solution:** No database storage of NPCs at all - served from filesystem
|
||||
|
||||
**Time Saved:** 4 hours of complex schema design
|
||||
|
||||
---
|
||||
|
||||
## Issues STILL REQUIRING FIXES
|
||||
|
||||
### Issue #1: Ink File Structure Mismatch
|
||||
|
||||
**Severity:** 🟡 MEDIUM (was CRITICAL, now less critical)
|
||||
**Impact:** File serving logic needs to handle both `.json` and `.ink.json`
|
||||
|
||||
**Problem:**
|
||||
- Some files: `helper-npc.json`
|
||||
- Some files: `alice-chat.ink.json`
|
||||
- Scenarios reference various patterns
|
||||
|
||||
**Solution:**
|
||||
```ruby
|
||||
# app/controllers/break_escape/games_controller.rb
|
||||
def resolve_ink_path(story_path)
|
||||
# story_path: "scenarios/ink/helper-npc.json"
|
||||
path = Rails.root.join(story_path)
|
||||
|
||||
# Try exact path first
|
||||
return path if File.exist?(path)
|
||||
|
||||
# Try .ink.json variant
|
||||
ink_json_path = path.to_s.gsub(/\.json$/, '.ink.json')
|
||||
return Pathname.new(ink_json_path) if File.exist?(ink_json_path)
|
||||
|
||||
# Try .json variant (remove .ink. if present)
|
||||
json_path = path.to_s.gsub(/\.ink\.json$/, '.json')
|
||||
return Pathname.new(json_path) if File.exist?(json_path)
|
||||
|
||||
# Not found
|
||||
path
|
||||
end
|
||||
```
|
||||
|
||||
**Effort:** 30 minutes
|
||||
**Priority:** P1 (should fix before Phase 6)
|
||||
|
||||
---
|
||||
|
||||
### Issue #3: Missing Ink Compilation Pipeline
|
||||
|
||||
**Severity:** 🔴 CRITICAL
|
||||
**Impact:** NPCs won't work without compiled scripts
|
||||
|
||||
**Problem:** Same as before - need to compile .ink → .json
|
||||
|
||||
**Solution:** Still need compilation script
|
||||
|
||||
```bash
|
||||
# scripts/compile_ink.sh
|
||||
#!/bin/bash
|
||||
for ink_file in scenarios/ink/*.ink; do
|
||||
base=$(basename "$ink_file" .ink)
|
||||
json_out="scenarios/ink/${base}.json"
|
||||
|
||||
# Skip if up-to-date
|
||||
if [ -f "$json_out" ] && [ "$json_out" -nt "$ink_file" ]; then
|
||||
echo "✓ $base.json is up to date"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Compiling $base.ink..."
|
||||
inklecate -o "$json_out" "$ink_file"
|
||||
done
|
||||
```
|
||||
|
||||
**Effort:** 2-3 hours (script + docs + Rake task)
|
||||
**Priority:** P0 (must fix before Phase 3)
|
||||
|
||||
---
|
||||
|
||||
### Issue #4: Incomplete Global State
|
||||
|
||||
**Severity:** 🟢 LOW (was MEDIUM, now built into schema)
|
||||
**Impact:** Already fixed in new schema!
|
||||
|
||||
**Solution:** New player_state schema already includes:
|
||||
```ruby
|
||||
player_state: {
|
||||
biometricSamples: [],
|
||||
biometricUnlocks: [],
|
||||
bluetoothDevices: [],
|
||||
notes: [],
|
||||
health: 100
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** ✅ RESOLVED by new schema
|
||||
|
||||
---
|
||||
|
||||
### Issue #5: Room Asset Loading
|
||||
|
||||
**Severity:** 🟢 LOW
|
||||
**Impact:** Just needs documentation clarification
|
||||
|
||||
**Solution:**
|
||||
- Tiled maps stay in `public/break_escape/assets/rooms/`
|
||||
- Served as static files
|
||||
- Scenario data served via `/games/:id/scenario`
|
||||
|
||||
**Effort:** Documentation update only
|
||||
**Priority:** P2
|
||||
|
||||
---
|
||||
|
||||
## NEW Issues from Simplified Approach
|
||||
|
||||
### New Issue #6: Scenario Data Size in Database
|
||||
|
||||
**Severity:** 🟡 MEDIUM
|
||||
**Impact:** Each game instance stores full scenario JSON
|
||||
|
||||
**Problem:**
|
||||
- Each game stores complete scenario_data JSONB
|
||||
- 100 players × 50KB scenario = 5MB scenario data
|
||||
- Not terrible, but worth monitoring
|
||||
|
||||
**Mitigation:**
|
||||
- PostgreSQL JSONB is efficient
|
||||
- GIN index for fast queries
|
||||
- Consider cleanup of abandoned games
|
||||
|
||||
**Alternative:**
|
||||
- Store scenario_data reference only
|
||||
- Generate on-demand (slower but smaller DB)
|
||||
|
||||
**Recommendation:** Keep current approach, monitor DB size
|
||||
|
||||
**Effort:** 0 hours (just awareness)
|
||||
**Priority:** P3 (monitor only)
|
||||
|
||||
---
|
||||
|
||||
### New Issue #7: Ink File Security
|
||||
|
||||
**Severity:** 🟡 MEDIUM
|
||||
**Impact:** Need to validate NPC access per game
|
||||
|
||||
**Problem:**
|
||||
```ruby
|
||||
# Can player access this NPC?
|
||||
# Need to verify NPC is actually in their game's scenario
|
||||
```
|
||||
|
||||
**Solution:** Already implemented in `find_npc_in_scenario`
|
||||
```ruby
|
||||
def find_npc_in_scenario(npc_id)
|
||||
@game.scenario_data['rooms']&.each do |_room_id, room_data|
|
||||
npc = room_data['npcs']&.find { |n| n['id'] == npc_id }
|
||||
return npc if npc
|
||||
end
|
||||
nil # Returns nil if NPC not in this game
|
||||
end
|
||||
```
|
||||
|
||||
**Status:** ✅ Already handled
|
||||
**Priority:** N/A
|
||||
|
||||
---
|
||||
|
||||
## Updated Priority Summary
|
||||
|
||||
| Issue | Old Severity | New Severity | Status | Effort |
|
||||
|-------|--------------|--------------|--------|--------|
|
||||
| #1: Ink files | 🔴 Critical | 🟡 Medium | Open | 30 min |
|
||||
| #2: NPC schema | 🟠 High | ✅ Resolved | Resolved | 0h |
|
||||
| #3: Ink compilation | 🔴 Critical | 🔴 Critical | Open | 2-3h |
|
||||
| #4: Global state | 🟡 Medium | ✅ Resolved | Resolved | 0h |
|
||||
| #5: Room assets | 🟡 Medium | 🟢 Low | Open | Docs only |
|
||||
| #6: DB size | N/A | 🟡 Medium | Monitor | 0h |
|
||||
| #7: Ink security | N/A | ✅ Handled | Resolved | 0h |
|
||||
|
||||
---
|
||||
|
||||
## Updated Fix Timeline
|
||||
|
||||
### P0: Must-Fix Before Implementation (2-3 hours)
|
||||
|
||||
1. **Ink Compilation Pipeline** - 2-3 hours
|
||||
- Install inklecate
|
||||
- Create compilation script
|
||||
- Add Rake task
|
||||
- Document in Phase 2
|
||||
|
||||
**Total:** 2-3 hours (down from 10 hours!)
|
||||
|
||||
---
|
||||
|
||||
### P1: Should-Fix Before Phase 6 (30 minutes)
|
||||
|
||||
2. **Ink File Path Resolution** - 30 minutes
|
||||
- Add fallback logic to `resolve_ink_path`
|
||||
- Handle .json and .ink.json variants
|
||||
|
||||
---
|
||||
|
||||
### P2: Nice-to-Have (Docs only)
|
||||
|
||||
3. **Room Asset Clarification** - 15 minutes
|
||||
- Document that Tiled maps are static
|
||||
- Update architecture docs
|
||||
|
||||
---
|
||||
|
||||
## Summary of Simplification Benefits
|
||||
|
||||
**Eliminated:**
|
||||
- ✅ NPC schema complexity (Issue #2)
|
||||
- ✅ Global state tracking (Issue #4 - now in schema)
|
||||
- ✅ Ink security concerns (Issue #7 - already handled)
|
||||
|
||||
**Remaining:**
|
||||
- ⚠️ Ink compilation (still critical)
|
||||
- ⚠️ File path handling (now easier)
|
||||
|
||||
**Time Saved:**
|
||||
- Old approach: ~10 hours of P0 fixes
|
||||
- New approach: ~3 hours of P0 fixes
|
||||
- **Savings: 7 hours**
|
||||
|
||||
---
|
||||
|
||||
## New Recommendation
|
||||
|
||||
**Before Starting Phase 1:**
|
||||
1. ✅ Install inklecate compiler
|
||||
2. ✅ Create compilation script
|
||||
3. ✅ Compile all .ink files
|
||||
4. ✅ Document compilation in Phase 2
|
||||
|
||||
**Total Prep:** 2-3 hours (was 10-14 hours!)
|
||||
|
||||
**Timeline Impact:** +0.5 days (was +1.75 days)
|
||||
|
||||
---
|
||||
|
||||
**Result:** Much simpler path to implementation! 🎉
|
||||
Reference in New Issue
Block a user