mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
feat: Enhance mission management with CyBOK integration and collection filtering
- Added `Cybok` model to manage CyBOK entries associated with missions. - Implemented `by_collection` scope in `Mission` model for filtering missions by collection. - Updated `missions_controller` to filter missions based on the selected collection. - Introduced `CybokSyncService` for syncing CyBOK data from mission metadata to the database. - Created new views and partials for displaying CyBOK information with tooltips using Tippy.js. - Added metadata fields to `break_escape_missions` for `secgen_scenario` and `collection`. - Enhanced mission seeding logic to support new metadata and CyBOK entries. - Added multiple new mission scenarios with associated metadata.
This commit is contained in:
4
app/assets/images/break_escape/cybok_logo_white.svg
Normal file
4
app/assets/images/break_escape/cybok_logo_white.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 30" width="100" height="30">
|
||||
<rect x="0" y="0" width="100" height="30" fill="none"/>
|
||||
<text x="5" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white">CyBOK</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 264 B |
63
app/assets/stylesheets/break_escape/labels.css
Normal file
63
app/assets/stylesheets/break_escape/labels.css
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* BreakEscape Label Styles
|
||||
* Mirrors Hacktivity's label styling for consistency
|
||||
*/
|
||||
|
||||
/* Base label styles */
|
||||
.label {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* CyBOK label specific styling */
|
||||
.label-cybok {
|
||||
background-color: #2d5a27;
|
||||
color: #ffffff;
|
||||
border: 1px solid #3d7a37;
|
||||
}
|
||||
|
||||
.label-cybok:hover {
|
||||
background-color: #3d7a37;
|
||||
}
|
||||
|
||||
/* Difficulty labels */
|
||||
.label-difficulty {
|
||||
background-color: #00ff00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.label-difficulty-1 {
|
||||
background-color: #4ade80;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.label-difficulty-2 {
|
||||
background-color: #22c55e;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.label-difficulty-3 {
|
||||
background-color: #f59e0b;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.label-difficulty-4 {
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.label-difficulty-5 {
|
||||
background-color: #7c2d12;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Collection labels */
|
||||
.label-collection {
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
border: 1px solid #2563eb;
|
||||
}
|
||||
76
app/assets/stylesheets/break_escape/tooltips.css
Normal file
76
app/assets/stylesheets/break_escape/tooltips.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* BreakEscape Tooltip Styles
|
||||
* Mirrors Hacktivity's Tippy.js tooltip theming
|
||||
*/
|
||||
|
||||
/* Hacktivity-style theme for Tippy tooltips */
|
||||
.tippy-box[data-theme~='break-escape'] {
|
||||
border: 2px solid grey;
|
||||
box-shadow: inset 0px 0px 0px 1px grey;
|
||||
background-color: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape'][data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: grey;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: grey;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape'][data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: grey;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape'][data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: grey;
|
||||
}
|
||||
|
||||
/* Dark theme variant */
|
||||
.tippy-box[data-theme~='break-escape-dark'] {
|
||||
border: 2px solid #333333;
|
||||
background-color: #333333;
|
||||
box-shadow: inset 0px 0px 0px 1px #333333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-dark'][data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: #333333;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-dark'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: #333333;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-dark'][data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: #333333;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-dark'][data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: #333333;
|
||||
}
|
||||
|
||||
/* Green accent theme matching BreakEscape branding */
|
||||
.tippy-box[data-theme~='break-escape-green'] {
|
||||
border: 2px solid #00ff00;
|
||||
background-color: #1a1a1a;
|
||||
box-shadow: inset 0px 0px 0px 1px #00ff00;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-green'][data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: #00ff00;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-green'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: #00ff00;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-green'][data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: #00ff00;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='break-escape-green'][data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: #00ff00;
|
||||
}
|
||||
@@ -6,6 +6,14 @@ module BreakEscape
|
||||
else
|
||||
Mission.published
|
||||
end
|
||||
|
||||
# Filter by collection if specified
|
||||
if params[:collection].present?
|
||||
@missions = @missions.by_collection(params[:collection])
|
||||
end
|
||||
|
||||
# Eager load CyBOK data for display
|
||||
@missions = @missions.includes(:break_escape_cyboks)
|
||||
end
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
module BreakEscape
|
||||
module ApplicationHelper
|
||||
# Generate a random ID for DOM elements
|
||||
def generate_random_id
|
||||
SecureRandom.hex(8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
63
app/models/break_escape/cybok.rb
Normal file
63
app/models/break_escape/cybok.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module BreakEscape
|
||||
class Cybok < ApplicationRecord
|
||||
self.table_name = 'break_escape_cyboks'
|
||||
|
||||
belongs_to :cybokable, polymorphic: true
|
||||
|
||||
# Mirror Hacktivity's KA_CODES for consistency
|
||||
KA_CODES = {
|
||||
'IC' => 'Introduction to CyBOK',
|
||||
'FM' => 'Formal Methods',
|
||||
'RMG' => 'Risk Management & Governance',
|
||||
'LR' => 'Law & Regulation',
|
||||
'HF' => 'Human Factors',
|
||||
'POR' => 'Privacy & Online Rights',
|
||||
'MAT' => 'Malware & Attack Technologies',
|
||||
'AB' => 'Adversarial Behaviours',
|
||||
'SOIM' => 'Security Operations & Incident Management',
|
||||
'F' => 'Forensics',
|
||||
'C' => 'Cryptography',
|
||||
'AC' => 'Applied Cryptography',
|
||||
'OSV' => 'Operating Systems & Virtualisation Security',
|
||||
'DSS' => 'Distributed Systems Security',
|
||||
'AAA' => 'Authentication, Authorisation and Accountability',
|
||||
'SS' => 'Software Security',
|
||||
'WAM' => 'Web & Mobile Security',
|
||||
'SSL' => 'Secure Software Lifecycle',
|
||||
'NS' => 'Network Security',
|
||||
'HS' => 'Hardware Security',
|
||||
'CPS' => 'Cyber Physical Systems',
|
||||
'PLT' => 'Physical Layer and Telecommunications Security'
|
||||
}.freeze
|
||||
|
||||
CATEGORY_MAPPING = {
|
||||
'Introductory Concepts' => ['IC'],
|
||||
'Human, Organisational & Regulatory Aspects' => %w[RMG LR HF POR],
|
||||
'Attacks & Defences' => %w[MAT AB SOIM F],
|
||||
'Systems Security' => %w[C OSV DSS AAA FM],
|
||||
'Software and Platform Security' => %w[SS WAM SSL],
|
||||
'Infrastructure Security' => %w[AC NS HS CPS PLT]
|
||||
}.freeze
|
||||
|
||||
def ka_full_name
|
||||
KA_CODES[ka] || 'Unknown KA'
|
||||
end
|
||||
|
||||
def ka_category
|
||||
CATEGORY_MAPPING.each do |category, kas|
|
||||
return category if kas.include?(ka)
|
||||
end
|
||||
'Unknown Category'
|
||||
end
|
||||
|
||||
# Parse keywords string back to array (matches Hacktivity behavior)
|
||||
def keywords_array
|
||||
return [] if keywords.blank?
|
||||
|
||||
# Handle both array-coerced strings and plain comma-separated
|
||||
keywords.gsub(/[\[\]"]/, '').split(',').map(&:strip)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,17 +4,63 @@ module BreakEscape
|
||||
|
||||
has_many :games, class_name: 'BreakEscape::Game', dependent: :destroy
|
||||
|
||||
# CyBOK associations - always use our table for standalone mode
|
||||
has_many :break_escape_cyboks,
|
||||
class_name: 'BreakEscape::Cybok',
|
||||
as: :cybokable,
|
||||
dependent: :destroy
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
validates :display_name, presence: true
|
||||
validates :difficulty_level, inclusion: { in: 1..5 }
|
||||
|
||||
scope :published, -> { where(published: true) }
|
||||
scope :by_collection, ->(collection) { where(collection: collection) }
|
||||
|
||||
# Get all distinct collections
|
||||
def self.collections
|
||||
distinct.pluck(:collection).compact.sort
|
||||
end
|
||||
|
||||
# Path to scenario directory
|
||||
def scenario_path
|
||||
BreakEscape::Engine.root.join('scenarios', name)
|
||||
end
|
||||
|
||||
# Path to mission metadata file
|
||||
def mission_json_path
|
||||
scenario_path.join('mission.json')
|
||||
end
|
||||
|
||||
# Check if mission.json exists
|
||||
def has_mission_json?
|
||||
File.exist?(mission_json_path)
|
||||
end
|
||||
|
||||
# Load mission metadata from JSON file
|
||||
def load_mission_metadata
|
||||
return nil unless has_mission_json?
|
||||
|
||||
JSON.parse(File.read(mission_json_path))
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Invalid mission.json for #{name}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Get all CyBOK entries (uses Hacktivity's if available for reads)
|
||||
def all_cyboks
|
||||
if defined?(::Cybok) && respond_to?(:cyboks)
|
||||
cyboks
|
||||
else
|
||||
break_escape_cyboks
|
||||
end
|
||||
end
|
||||
|
||||
# Check if Hacktivity mode is available
|
||||
def self.hacktivity_mode?
|
||||
defined?(::Cybok)
|
||||
end
|
||||
|
||||
# Generate scenario data via ERB
|
||||
def generate_scenario_data
|
||||
template_path = scenario_path.join('scenario.json.erb')
|
||||
|
||||
88
app/services/break_escape/cybok_sync_service.rb
Normal file
88
app/services/break_escape/cybok_sync_service.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module BreakEscape
|
||||
# Service to sync CyBOK data from mission.json to database tables.
|
||||
# Writes to both BreakEscape and Hacktivity tables when Hacktivity is present.
|
||||
class CybokSyncService
|
||||
class << self
|
||||
# Sync CyBOK data for a mission from parsed JSON data
|
||||
# @param mission [BreakEscape::Mission] the mission to sync
|
||||
# @param cybok_data [Array, nil] array of CyBOK entries from mission.json
|
||||
def sync_for_mission(mission, cybok_data)
|
||||
return 0 if cybok_data.blank?
|
||||
|
||||
# Normalize input (handle both array and hash formats)
|
||||
cybok_entries = Array.wrap(cybok_data)
|
||||
|
||||
# Clear existing entries from BreakEscape table
|
||||
mission.break_escape_cyboks.destroy_all
|
||||
|
||||
# Clear Hacktivity entries if available
|
||||
clear_hacktivity_cyboks(mission) if hacktivity_mode?
|
||||
|
||||
count = 0
|
||||
cybok_entries.each do |entry|
|
||||
ka = entry['ka'] || entry[:ka]
|
||||
topic = entry['topic'] || entry[:topic]
|
||||
keywords = entry['keywords'] || entry[:keywords]
|
||||
|
||||
# Serialize keywords array to string (Hacktivity format)
|
||||
keywords_str = serialize_keywords(keywords)
|
||||
|
||||
# Always write to BreakEscape table
|
||||
mission.break_escape_cyboks.create!(
|
||||
ka: ka,
|
||||
topic: topic,
|
||||
keywords: keywords_str
|
||||
)
|
||||
|
||||
# Also write to Hacktivity table if available
|
||||
create_hacktivity_cybok(mission, ka, topic, keywords_str) if hacktivity_mode?
|
||||
|
||||
count += 1
|
||||
end
|
||||
|
||||
count
|
||||
end
|
||||
|
||||
# Check if Hacktivity mode is active (::Cybok constant exists)
|
||||
def hacktivity_mode?
|
||||
defined?(::Cybok)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_keywords(keywords)
|
||||
case keywords
|
||||
when Array
|
||||
keywords.join(', ')
|
||||
when String
|
||||
keywords
|
||||
else
|
||||
keywords.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def clear_hacktivity_cyboks(mission)
|
||||
return unless hacktivity_mode?
|
||||
|
||||
::Cybok.where(
|
||||
cybokable_type: 'BreakEscape::Mission',
|
||||
cybokable_id: mission.id
|
||||
).destroy_all
|
||||
end
|
||||
|
||||
def create_hacktivity_cybok(mission, ka, topic, keywords_str)
|
||||
return unless hacktivity_mode?
|
||||
|
||||
::Cybok.create!(
|
||||
cybokable_type: 'BreakEscape::Mission',
|
||||
cybokable_id: mission.id,
|
||||
ka: ka,
|
||||
topic: topic,
|
||||
keywords: keywords_str
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,10 @@
|
||||
<title>BreakEscape - Select Mission</title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= stylesheet_link_tag "break_escape/application", media: "all" %>
|
||||
<!-- Tippy.js for tooltips -->
|
||||
<script src="https://unpkg.com/@popperjs/core@2"></script>
|
||||
<script src="https://unpkg.com/tippy.js@6"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -17,6 +21,25 @@
|
||||
text-align: center;
|
||||
color: #00ff00;
|
||||
}
|
||||
.collection-filter {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.collection-filter a {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
margin: 4px;
|
||||
background: #2a2a2a;
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
border: 1px solid #00ff00;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.collection-filter a:hover,
|
||||
.collection-filter a.active {
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
}
|
||||
.missions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
@@ -31,6 +54,7 @@
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: block;
|
||||
}
|
||||
.mission-card:hover {
|
||||
transform: translateY(-5px);
|
||||
@@ -47,6 +71,12 @@
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.mission-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.mission-difficulty {
|
||||
display: inline-block;
|
||||
background: #00ff00;
|
||||
@@ -56,11 +86,30 @@
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mission-collection {
|
||||
display: inline-block;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔓 BreakEscape - Select Your Mission</h1>
|
||||
|
||||
<% if BreakEscape::Mission.collections.length > 1 %>
|
||||
<div class="collection-filter">
|
||||
<%= link_to "All", missions_path, class: params[:collection].blank? ? 'active' : '' %>
|
||||
<% BreakEscape::Mission.collections.each do |collection| %>
|
||||
<%= link_to collection.titleize, missions_path(collection: collection),
|
||||
class: params[:collection] == collection ? 'active' : '' %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="missions">
|
||||
<% @missions.each do |mission| %>
|
||||
<%= link_to mission_path(mission), class: 'mission-card' do %>
|
||||
@@ -68,11 +117,33 @@
|
||||
<div class="mission-description">
|
||||
<%= mission.description || "An exciting escape room challenge awaits..." %>
|
||||
</div>
|
||||
<div class="mission-difficulty">
|
||||
Difficulty: <%= "⭐" * mission.difficulty_level %>
|
||||
<div class="mission-meta">
|
||||
<span class="mission-difficulty">
|
||||
Difficulty: <%= "⭐" * mission.difficulty_level %>
|
||||
</span>
|
||||
<% if mission.collection.present? && mission.collection != 'default' %>
|
||||
<span class="mission-collection">
|
||||
<%= mission.collection.titleize %>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= render partial: 'break_escape/shared/cybok_label',
|
||||
locals: { cyboks: mission.break_escape_cyboks } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize Tippy.js tooltips
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
tippy('[data-tippy-content]', {
|
||||
theme: 'break-escape',
|
||||
allowHTML: true,
|
||||
placement: 'top',
|
||||
arrow: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
25
app/views/break_escape/shared/_cybok_label.html.erb
Normal file
25
app/views/break_escape/shared/_cybok_label.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<% if cyboks.any? %>
|
||||
<% grouped_cyboks = cyboks.group_by(&:ka).sort.to_h %>
|
||||
|
||||
<% all_ka_values = [] %>
|
||||
<% all_tippy_content = "#{image_tag 'break_escape/cybok_logo_white.svg', style: 'max-height: 20px'} " %>
|
||||
|
||||
<% grouped_cyboks.each do |ka, ka_cyboks| %>
|
||||
<% all_ka_values << ka %>
|
||||
<% tippy_content = "" %>
|
||||
|
||||
<% ka_cyboks.group_by(&:topic).each do |topic, topic_cyboks| %>
|
||||
<% unique_keywords = topic_cyboks.flat_map { |c| c.keywords_array }.uniq.map(&:downcase).join(', ') %>
|
||||
<% tippy_content += "<i>#{topic.titlecase}:</i> #{unique_keywords}<br/>" %>
|
||||
<% end %>
|
||||
|
||||
<% all_tippy_content += "<b>#{ka_cyboks.first.ka_full_name} (#{ka}):</b><br/> #{tippy_content}" %>
|
||||
<% end %>
|
||||
|
||||
<%= render partial: 'break_escape/shared/label', locals: {
|
||||
label_class: 'cybok',
|
||||
tippy_content: all_tippy_content,
|
||||
icon_class: defined?(icon_class) ? icon_class : nil,
|
||||
label_text: "CyBOK: #{all_ka_values.uniq.join(', ')}"
|
||||
} %>
|
||||
<% end %>
|
||||
7
app/views/break_escape/shared/_label.html.erb
Normal file
7
app/views/break_escape/shared/_label.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<% random_id = generate_random_id %>
|
||||
<span id="label_<%= random_id %>" class="label label-<%= label_class %>" data-tippy-content="<%= tippy_content %>">
|
||||
<% if defined?(icon_class) && icon_class.present? %>
|
||||
<i class="<%= icon_class %>"></i>
|
||||
<% end %>
|
||||
<%= label_text %>
|
||||
</span>
|
||||
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddMetadataToBreakEscapeMissions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :break_escape_missions, :secgen_scenario, :string
|
||||
add_column :break_escape_missions, :collection, :string, default: 'default'
|
||||
|
||||
add_index :break_escape_missions, :collection
|
||||
end
|
||||
end
|
||||
19
db/migrate/20251125000002_create_break_escape_cyboks.rb
Normal file
19
db/migrate/20251125000002_create_break_escape_cyboks.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateBreakEscapeCyboks < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :break_escape_cyboks do |t|
|
||||
t.string :ka # Knowledge Area code (e.g., "AC", "F", "WAM")
|
||||
t.string :topic # Topic within the KA
|
||||
t.string :keywords # Keywords as comma-separated string (matches Hacktivity)
|
||||
t.string :cybokable_type # Polymorphic type
|
||||
t.integer :cybokable_id # Polymorphic ID
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :break_escape_cyboks, :cybokable_id
|
||||
add_index :break_escape_cyboks, %i[cybokable_type cybokable_id]
|
||||
add_index :break_escape_cyboks, :ka
|
||||
end
|
||||
end
|
||||
109
db/seeds.rb
109
db/seeds.rb
@@ -1,25 +1,110 @@
|
||||
puts "Creating BreakEscape missions..."
|
||||
# frozen_string_literal: true
|
||||
|
||||
puts 'Creating/Updating BreakEscape missions...'
|
||||
|
||||
# Directories to skip (not actual playable scenarios)
|
||||
SKIP_DIRS = %w[common compiled ink].freeze
|
||||
|
||||
# Infer collection from scenario name for test/demo scenarios
|
||||
def infer_collection(scenario_name)
|
||||
return 'testing' if scenario_name.start_with?('test', 'npc-', 'scenario')
|
||||
return 'testing' if scenario_name.include?('demo') || scenario_name.include?('test')
|
||||
|
||||
'default'
|
||||
end
|
||||
|
||||
# Apply default metadata when mission.json is missing
|
||||
def apply_default_metadata(mission, scenario_name)
|
||||
mission.display_name = scenario_name.titleize if mission.display_name.blank?
|
||||
mission.description = "Play the #{scenario_name.titleize} scenario" if mission.description.blank?
|
||||
mission.difficulty_level = 3 if mission.difficulty_level.blank? || mission.difficulty_level.zero?
|
||||
mission.collection = infer_collection(scenario_name) if mission.collection.blank?
|
||||
mission.published = true
|
||||
mission
|
||||
end
|
||||
|
||||
# List all scenario directories
|
||||
scenario_dirs = Dir.glob(BreakEscape::Engine.root.join('scenarios/*')).select { |f| File.directory?(f) }
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
cybok_total = 0
|
||||
|
||||
scenario_dirs.each do |dir|
|
||||
scenario_name = File.basename(dir)
|
||||
next if scenario_name == 'common' # Skip common directory if it exists
|
||||
next if SKIP_DIRS.include?(scenario_name)
|
||||
|
||||
# Check for scenario.json.erb (required for valid mission)
|
||||
scenario_template = File.join(dir, 'scenario.json.erb')
|
||||
unless File.exist?(scenario_template)
|
||||
puts " ⊘ Skipped: #{scenario_name} (no scenario.json.erb)"
|
||||
skipped_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Create mission metadata
|
||||
mission = BreakEscape::Mission.find_or_initialize_by(name: scenario_name)
|
||||
is_new = mission.new_record?
|
||||
mission_json_path = File.join(dir, 'mission.json')
|
||||
|
||||
if mission.new_record?
|
||||
mission.display_name = scenario_name.titleize
|
||||
mission.description = "Play the #{scenario_name.titleize} scenario"
|
||||
mission.published = true
|
||||
mission.difficulty_level = 3 # Default, can be updated later
|
||||
mission.save!
|
||||
puts " ✓ Created: #{mission.display_name}"
|
||||
if File.exist?(mission_json_path)
|
||||
# Load metadata from mission.json
|
||||
begin
|
||||
metadata = JSON.parse(File.read(mission_json_path))
|
||||
|
||||
mission.display_name = metadata['display_name'] || scenario_name.titleize
|
||||
mission.description = metadata['description'] || "Play the #{scenario_name.titleize} scenario"
|
||||
mission.difficulty_level = metadata['difficulty_level'] || 3
|
||||
mission.secgen_scenario = metadata['secgen_scenario']
|
||||
mission.collection = metadata['collection'] || 'default'
|
||||
mission.published = true
|
||||
|
||||
if mission.save
|
||||
# Sync CyBOK data
|
||||
if metadata['cybok'].present?
|
||||
cybok_count = BreakEscape::CybokSyncService.sync_for_mission(mission, metadata['cybok'])
|
||||
cybok_total += cybok_count
|
||||
puts " ✓ #{is_new ? 'Created' : 'Updated'}: #{mission.display_name} (#{cybok_count} CyBOK entries)"
|
||||
else
|
||||
puts " ✓ #{is_new ? 'Created' : 'Updated'}: #{mission.display_name}"
|
||||
end
|
||||
is_new ? created_count += 1 : updated_count += 1
|
||||
else
|
||||
puts " ✗ Failed: #{scenario_name} - #{mission.errors.full_messages.join(', ')}"
|
||||
end
|
||||
rescue JSON::ParserError => e
|
||||
puts " ⚠ Invalid mission.json for #{scenario_name}: #{e.message}"
|
||||
# Fall back to defaults
|
||||
apply_default_metadata(mission, scenario_name)
|
||||
if mission.save
|
||||
puts " ✓ #{is_new ? 'Created' : 'Updated'} (defaults): #{mission.display_name}"
|
||||
is_new ? created_count += 1 : updated_count += 1
|
||||
else
|
||||
puts " ✗ Failed: #{scenario_name} - #{mission.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
else
|
||||
puts " - Exists: #{mission.display_name}"
|
||||
# No mission.json - use defaults
|
||||
apply_default_metadata(mission, scenario_name)
|
||||
if mission.save
|
||||
puts " ✓ #{is_new ? 'Created' : 'Updated'} (defaults): #{mission.display_name}"
|
||||
is_new ? created_count += 1 : updated_count += 1
|
||||
else
|
||||
puts " ✗ Failed: #{scenario_name} - #{mission.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "Done! Created #{BreakEscape::Mission.count} missions."
|
||||
puts ''
|
||||
puts '=' * 50
|
||||
puts "Done! #{BreakEscape::Mission.count} missions total."
|
||||
puts " Created: #{created_count}, Updated: #{updated_count}, Skipped: #{skipped_count}"
|
||||
puts " CyBOK entries synced: #{cybok_total}"
|
||||
puts " Collections: #{BreakEscape::Mission.collections.join(', ')}"
|
||||
if BreakEscape::CybokSyncService.hacktivity_mode?
|
||||
puts ' Mode: Hacktivity (CyBOK data synced to both tables)'
|
||||
else
|
||||
puts ' Mode: Standalone (CyBOK data in break_escape_cyboks only)'
|
||||
end
|
||||
puts '=' * 50
|
||||
|
||||
|
||||
@@ -541,6 +541,38 @@ If issues arise:
|
||||
|
||||
---
|
||||
|
||||
## View Layer Implementation
|
||||
|
||||
### CyBOK Label Partial
|
||||
**File**: `app/views/break_escape/shared/_cybok_label.html.erb`
|
||||
|
||||
Mirrors Hacktivity's `_cybok_label.html.erb` partial:
|
||||
- Groups CyBOK entries by Knowledge Area (KA)
|
||||
- Builds Tippy.js tooltip content with topics and keywords
|
||||
- Uses the shared `_label.html.erb` partial for rendering
|
||||
|
||||
### Label Partial
|
||||
**File**: `app/views/break_escape/shared/_label.html.erb`
|
||||
|
||||
Generic label component with:
|
||||
- Random ID generation for DOM uniqueness
|
||||
- Tippy.js `data-tippy-content` attribute
|
||||
- Icon support (optional)
|
||||
|
||||
### Stylesheets
|
||||
- `app/assets/stylesheets/break_escape/labels.css` - Label component styles
|
||||
- `app/assets/stylesheets/break_escape/tooltips.css` - Tippy.js theme matching Hacktivity
|
||||
|
||||
### Helper Methods
|
||||
**File**: `app/helpers/break_escape/application_helper.rb`
|
||||
|
||||
- `generate_random_id` - Creates unique DOM IDs using SecureRandom
|
||||
|
||||
### Assets
|
||||
- `app/assets/images/break_escape/cybok_logo_white.svg` - CyBOK logo for tooltips
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Admin UI**: Add mission metadata editing in admin panel
|
||||
|
||||
29
scenarios/biometric_breach/mission.json
Normal file
29
scenarios/biometric_breach/mission.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"display_name": "Biometric Breach",
|
||||
"description": "Investigate a security breach at a high-security research facility. Use biometric forensics tools to identify the intruder, track their movements through the facility, and recover stolen research data before it leaves the building.",
|
||||
"difficulty_level": 3,
|
||||
"secgen_scenario": null,
|
||||
"collection": "security_investigations",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "AAA",
|
||||
"topic": "Authentication",
|
||||
"keywords": ["Biometric authentication", "Fingerprint analysis", "Identity verification", "Multi-factor authentication"]
|
||||
},
|
||||
{
|
||||
"ka": "F",
|
||||
"topic": "Artifact Analysis",
|
||||
"keywords": ["Digital forensics", "Evidence collection", "Fingerprint forensics"]
|
||||
},
|
||||
{
|
||||
"ka": "SOIM",
|
||||
"topic": "Security Operations & Incident Management",
|
||||
"keywords": ["Incident response", "Security monitoring", "Access control investigation"]
|
||||
},
|
||||
{
|
||||
"ka": "HF",
|
||||
"topic": "Human Factors",
|
||||
"keywords": ["Physical security bypass", "Social engineering awareness"]
|
||||
}
|
||||
]
|
||||
}
|
||||
29
scenarios/ceo_exfil/mission.json
Normal file
29
scenarios/ceo_exfil/mission.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"display_name": "CEO Exfiltration",
|
||||
"description": "A corporate espionage scenario where you must navigate executive offices to extract sensitive data. Test your skills in data exfiltration and covert operations while avoiding detection.",
|
||||
"difficulty_level": 4,
|
||||
"secgen_scenario": null,
|
||||
"collection": "data_exfiltration",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "AB",
|
||||
"topic": "Adversarial Behaviours",
|
||||
"keywords": ["Corporate espionage", "Data theft", "Covert operations", "Insider threat"]
|
||||
},
|
||||
{
|
||||
"ka": "MAT",
|
||||
"topic": "Malware & Attack Technologies",
|
||||
"keywords": ["Data exfiltration techniques", "Covert channels"]
|
||||
},
|
||||
{
|
||||
"ka": "F",
|
||||
"topic": "Forensics",
|
||||
"keywords": ["Anti-forensics", "Evidence handling", "Data recovery"]
|
||||
},
|
||||
{
|
||||
"ka": "HF",
|
||||
"topic": "Human Factors",
|
||||
"keywords": ["Physical security", "Access control bypass"]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
scenarios/cybok_heist/mission.json
Normal file
24
scenarios/cybok_heist/mission.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"display_name": "CyBOK Heist",
|
||||
"description": "Recover the Professor's backup of the CyBOK LaTeX source files. Navigate through the department offices, solve puzzles, pick locks, and crack the safe containing the precious backup HDD.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "physical_security",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "HF",
|
||||
"topic": "Human Factors",
|
||||
"keywords": ["Physical security", "Lock bypass", "Security awareness", "Social engineering"]
|
||||
},
|
||||
{
|
||||
"ka": "C",
|
||||
"topic": "Cryptography",
|
||||
"keywords": ["Safe combinations", "Code breaking", "Cipher fundamentals"]
|
||||
},
|
||||
{
|
||||
"ka": "AC",
|
||||
"topic": "Applied Cryptography",
|
||||
"keywords": ["Encoding", "Cipher solving", "Cryptanalysis basics"]
|
||||
}
|
||||
]
|
||||
}
|
||||
14
scenarios/npc-hub-demo-ghost-protocol/mission.json
Normal file
14
scenarios/npc-hub-demo-ghost-protocol/mission.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"display_name": "NPC Hub Demo - Ghost Protocol",
|
||||
"description": "Demonstration scenario for NPC hub interactions and the Ghost Protocol narrative.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "HF",
|
||||
"topic": "Human Factors",
|
||||
"keywords": ["NPC interaction", "Social engineering", "Dialogue systems"]
|
||||
}
|
||||
]
|
||||
}
|
||||
14
scenarios/npc-patrol-lockpick/mission.json
Normal file
14
scenarios/npc-patrol-lockpick/mission.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"display_name": "NPC Patrol Lockpick",
|
||||
"description": "Test scenario demonstrating NPC patrol mechanics combined with lockpicking gameplay.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "HF",
|
||||
"topic": "Human Factors",
|
||||
"keywords": ["Physical security", "Lock bypass", "Patrol avoidance"]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
scenarios/npc-sprite-test2/mission.json
Normal file
7
scenarios/npc-sprite-test2/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Sprite Test 2",
|
||||
"description": "Technical test scenario for NPC sprite rendering and animations.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/npc-sprite-test3/mission.json
Normal file
7
scenarios/npc-sprite-test3/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Sprite Test 3",
|
||||
"description": "Technical test scenario for advanced NPC sprite behaviours.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/scenario1/mission.json
Normal file
7
scenarios/scenario1/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Scenario 1",
|
||||
"description": "A basic introductory scenario demonstrating core gameplay mechanics.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/scenario2/mission.json
Normal file
7
scenarios/scenario2/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Scenario 2",
|
||||
"description": "A test scenario for gameplay features and room navigation.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/scenario3/mission.json
Normal file
7
scenarios/scenario3/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Scenario 3",
|
||||
"description": "A test scenario for advanced gameplay mechanics.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/scenario4/mission.json
Normal file
7
scenarios/scenario4/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Scenario 4",
|
||||
"description": "A test scenario for complex gameplay interactions.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test-multiroom-npc/mission.json
Normal file
7
scenarios/test-multiroom-npc/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Multi-room NPC Test",
|
||||
"description": "Technical test for NPCs navigating between multiple rooms.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test-npc-face-player/mission.json
Normal file
7
scenarios/test-npc-face-player/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Face Player Test",
|
||||
"description": "Technical test for NPC facing player mechanic.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test-npc-patrol/mission.json
Normal file
7
scenarios/test-npc-patrol/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Patrol Test",
|
||||
"description": "Technical test for NPC patrol route system.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test-npc-personal-space/mission.json
Normal file
7
scenarios/test-npc-personal-space/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Personal Space Test",
|
||||
"description": "Technical test for NPC personal space and collision avoidance.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test-npc-waypoints/mission.json
Normal file
7
scenarios/test-npc-waypoints/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "NPC Waypoints Test",
|
||||
"description": "Technical test for NPC waypoint navigation system.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
19
scenarios/test-rfid-multiprotocol/mission.json
Normal file
19
scenarios/test-rfid-multiprotocol/mission.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"display_name": "RFID Multiprotocol Test",
|
||||
"description": "Test scenario demonstrating multiple RFID protocol types and cloning mechanics.",
|
||||
"difficulty_level": 2,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "AAA",
|
||||
"topic": "Authentication",
|
||||
"keywords": ["RFID authentication", "Access control", "Multi-protocol systems"]
|
||||
},
|
||||
{
|
||||
"ka": "HS",
|
||||
"topic": "Hardware Security",
|
||||
"keywords": ["RFID protocols", "Card cloning", "Proximity cards"]
|
||||
}
|
||||
]
|
||||
}
|
||||
19
scenarios/test-rfid/mission.json
Normal file
19
scenarios/test-rfid/mission.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"display_name": "RFID Test",
|
||||
"description": "Test scenario for RFID card mechanics and access control.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing",
|
||||
"cybok": [
|
||||
{
|
||||
"ka": "AAA",
|
||||
"topic": "Authentication",
|
||||
"keywords": ["RFID authentication", "Access control", "Physical tokens"]
|
||||
},
|
||||
{
|
||||
"ka": "HS",
|
||||
"topic": "Hardware Security",
|
||||
"keywords": ["RFID technology", "Smart cards"]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
scenarios/test_complex_multidirection/mission.json
Normal file
7
scenarios/test_complex_multidirection/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Complex Multidirection Test",
|
||||
"description": "Technical test for complex multi-directional room navigation.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test_horizontal_layout/mission.json
Normal file
7
scenarios/test_horizontal_layout/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Horizontal Layout Test",
|
||||
"description": "Technical test for horizontally-oriented room layouts.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test_mixed_room_sizes/mission.json
Normal file
7
scenarios/test_mixed_room_sizes/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Mixed Room Sizes Test",
|
||||
"description": "Technical test for scenarios with varying room dimensions.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test_multiple_connections/mission.json
Normal file
7
scenarios/test_multiple_connections/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Multiple Connections Test",
|
||||
"description": "Technical test for rooms with multiple door connections.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/test_vertical_layout/mission.json
Normal file
7
scenarios/test_vertical_layout/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Vertical Layout Test",
|
||||
"description": "Technical test for vertically-oriented room layouts.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/timed_messages_example/mission.json
Normal file
7
scenarios/timed_messages_example/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Timed Messages Example",
|
||||
"description": "Example scenario demonstrating timed message delivery mechanics.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
7
scenarios/title-screen-demo/mission.json
Normal file
7
scenarios/title-screen-demo/mission.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"display_name": "Title Screen Demo",
|
||||
"description": "Demonstration of the title screen and menu system.",
|
||||
"difficulty_level": 1,
|
||||
"secgen_scenario": null,
|
||||
"collection": "testing"
|
||||
}
|
||||
@@ -10,7 +10,20 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_11_20_160000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_11_25_000002) do
|
||||
create_table "break_escape_cyboks", force: :cascade do |t|
|
||||
t.string "ka"
|
||||
t.string "topic"
|
||||
t.string "keywords"
|
||||
t.string "cybokable_type"
|
||||
t.integer "cybokable_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["cybokable_id"], name: "index_break_escape_cyboks_on_cybokable_id"
|
||||
t.index ["cybokable_type", "cybokable_id"], name: "index_break_escape_cyboks_on_cybokable_type_and_cybokable_id"
|
||||
t.index ["ka"], name: "index_break_escape_cyboks_on_ka"
|
||||
end
|
||||
|
||||
create_table "break_escape_demo_users", force: :cascade do |t|
|
||||
t.string "handle", null: false
|
||||
t.string "role", default: "user", null: false
|
||||
@@ -45,6 +58,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_20_160000) do
|
||||
t.integer "difficulty_level", default: 1, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "secgen_scenario"
|
||||
t.string "collection", default: "default"
|
||||
t.index ["collection"], name: "index_break_escape_missions_on_collection"
|
||||
t.index ["name"], name: "index_break_escape_missions_on_name", unique: true
|
||||
t.index ["published"], name: "index_break_escape_missions_on_published"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user