320 lines
9.4 KiB
Ruby
320 lines
9.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Rails 8.0+ Hotwire Architecture Example
|
|
# Demonstrates ViewComponent, Turbo Streams, and Stimulus patterns
|
|
# for game admin dashboards with ethical engagement features
|
|
|
|
require 'bundler/inline'
|
|
|
|
gem 'rails', '~> 8.0'
|
|
gem 'view_component', '~> 3.0'
|
|
gem 'redis', '~> 5.0'
|
|
|
|
# =============================================================================
|
|
# CORE APPLICATION CONFIGURATION
|
|
# =============================================================================
|
|
|
|
module GameAdmin
|
|
# Rails 8 application demonstrating Hotwire architecture patterns
|
|
#
|
|
# This module provides:
|
|
# - Component-based UI architecture via ViewComponent
|
|
# - Real-time updates via Turbo Streams
|
|
# - Interactive behaviors via Stimulus controllers
|
|
# - Ethical engagement features (transparent odds, rest-state mechanics)
|
|
#
|
|
# @example Mount in config/routes.rb
|
|
# mount GameAdmin::Application, at: '/admin'
|
|
class Application
|
|
include Rails.application.config
|
|
end
|
|
|
|
# =============================================================================
|
|
# VIEW COMPONENT BASE
|
|
# =============================================================================
|
|
|
|
# Base ViewComponent with accessibility and ethical engagement patterns
|
|
#
|
|
# All admin dashboard components inherit from this base class
|
|
#
|
|
# @security Brakeman scans for permit! usage (Mass Assignment protection)
|
|
# @test RSpec unit tests for component rendering
|
|
class AdminComponent < ViewComponent::Base
|
|
# Default accessibility attributes
|
|
def aria_label
|
|
@aria_label || self.class.name.downcase.demodulize
|
|
end
|
|
|
|
# Ethical engagement: display transparent odds information
|
|
#
|
|
# @param drop_table [Hash] loot table with odds
|
|
# @return [String] formatted odds display
|
|
def render_loot_odts(drop_table)
|
|
content_tag :div, class: 'loot-odds', aria: {
|
|
label: 'Drop rates and expected value'
|
|
} do
|
|
concat content_tag(:p, 'Transparent Drop Rates')
|
|
concat render_drop_rates(drop_table)
|
|
end
|
|
end
|
|
|
|
# Rest-State Mechanics: offline regeneration indicator
|
|
#
|
|
# Shows player's offline resource accumulation
|
|
#
|
|
# @param player_id [Integer] player identifier
|
|
# @return [String] rest-state display
|
|
def render_rest_state(player_id)
|
|
offline_resources = ::PlayerOfflineResources.find_by(player_id: player_id)
|
|
|
|
return '' unless offline_resources
|
|
|
|
content_tag :div, class: 'rest-state', data: {
|
|
stimulus_target: 'restState',
|
|
player_id: player_id
|
|
} do
|
|
concat content_tag(:p, "Offline Regeneration")
|
|
concat content_tag(:span, offline_resources.resources_gained.to_s)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Render individual drop rates with expected value
|
|
def render_drop_rates(drop_table)
|
|
items = drop_table.items.map do |item|
|
|
{
|
|
name: item.name,
|
|
rate: item.drop_rate,
|
|
expected_value: item.market_value * item.drop_rate
|
|
}
|
|
end
|
|
|
|
items.sort_by { |i| -i.expected_value }.map do |item|
|
|
content_tag(:div, class: 'drop-rate-row') do
|
|
concat content_tag(:span, item.name, class: 'item-name')
|
|
concat content_tag(:span, "#{item.rate}%", class: 'drop-rate')
|
|
concat content_tag(:span, "EV: #{item.expected_value}", class: 'expected-value')
|
|
end
|
|
end.join
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# TURBO STREAMS SERVICE
|
|
# =============================================================================
|
|
|
|
# Real-time dashboard updates via Turbo Streams
|
|
#
|
|
# Provides WebSocket-based live updates for:
|
|
# - Player analytics
|
|
# - Economy monitoring
|
|
# - Moderation alerts
|
|
#
|
|
# @dependency Redis pub/sub for stream broadcasting
|
|
module TurboStreams
|
|
class DashboardBroadcaster
|
|
def initialize(channel: 'dashboard')
|
|
@channel = channel
|
|
@redis = Redis.new
|
|
end
|
|
|
|
# Broadcast player metric update
|
|
#
|
|
# @param metric [String] metric name
|
|
# @param value [Float] current value
|
|
# @param timestamp [Time] update time
|
|
def broadcast_metric(metric:, value:, timestamp: Time.current)
|
|
payload = {
|
|
metric: metric,
|
|
value: value,
|
|
timestamp: timestamp.iso8601
|
|
}
|
|
|
|
@redis.publish(@channel, ActiveSupport::JSON.encode(payload))
|
|
end
|
|
|
|
# Broadcast economy faucet/sink event
|
|
#
|
|
# @param event_type [String] :faucet or :sink
|
|
# @param amount [Integer] resource amount
|
|
# @param source [String] event source
|
|
def broadcast_economy_event(event_type:, amount:, source:)
|
|
payload = {
|
|
event_type: event_type,
|
|
amount: amount,
|
|
source: source,
|
|
timestamp: Time.current.iso8601
|
|
}
|
|
|
|
@redis.publish('economy', ActiveSupport::JSON.encode(payload))
|
|
end
|
|
|
|
# Broadcast moderation alert
|
|
#
|
|
# @param alert_type [String] alert classification
|
|
# @param player_id [Integer] flagged player
|
|
# @param details [Hash] alert context
|
|
def broadcast_alert(alert_type:, player_id:, details:)
|
|
payload = {
|
|
alert_type: alert_type,
|
|
player_id: player_id,
|
|
details: details,
|
|
timestamp: Time.current.iso8601
|
|
}
|
|
|
|
@redis.publish('alerts', ActiveSupport::JSON.encode(payload))
|
|
end
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# STIMulus CONTROLLER BASE
|
|
# =============================================================================
|
|
|
|
# Stimulus controller for interactive admin dashboard
|
|
#
|
|
# Handles:
|
|
# - Real-time chart updates
|
|
# - Filter/sort operations
|
|
# - Accessibility keyboard shortcuts
|
|
#
|
|
# @example HTML usage
|
|
# <div data-controller="dashboard" data-dashboard-refresh-interval="30">
|
|
class DashboardController
|
|
# Stimulus controller pattern for vanilla JavaScript
|
|
# This serves as documentation for the frontend implementation
|
|
|
|
# Static method reference for Stimulus registration
|
|
def self.register
|
|
{
|
|
name: 'dashboard',
|
|
targets: ['metrics', 'alerts', 'filters'],
|
|
actions: {
|
|
'click->refresh',
|
|
'keydown->handleShortcut',
|
|
'turbo:load->reconnect'
|
|
},
|
|
values: {
|
|
refreshInterval: Number,
|
|
playerId: String,
|
|
timeZone: String
|
|
}
|
|
}
|
|
end
|
|
|
|
# Accessibility: keyboard shortcut handler
|
|
#
|
|
# @param event [KeyboardEvent] keydown event
|
|
# @note Ctrl+M: Toggle metrics panel
|
|
# @note Ctrl+A: Show alerts
|
|
# @note Ctrl+F: Focus filter
|
|
def handleShortcut(event)
|
|
return unless event.ctrlKey
|
|
|
|
case event.key
|
|
when 'm'
|
|
toggle_metrics_panel
|
|
when 'a'
|
|
show_alerts_panel
|
|
when 'f'
|
|
focus_filter_input
|
|
end
|
|
|
|
event.preventDefault
|
|
end
|
|
|
|
# Refresh dashboard data via Turbo Frame
|
|
def refresh
|
|
# Trigger Turbo Frame refresh
|
|
# Implementation in corresponding JS file
|
|
end
|
|
|
|
private
|
|
|
|
def toggle_metrics_panel
|
|
# Toggle visibility of metrics panel
|
|
# Maintain focus state for accessibility
|
|
end
|
|
|
|
def show_alerts_panel
|
|
# Display moderation alerts
|
|
# Clear unread counter
|
|
end
|
|
|
|
def focus_filter_input
|
|
# Focus filter input field
|
|
# Set aria-expanded attribute
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# CONFIGURATION SERVICE
|
|
# =============================================================================
|
|
|
|
# Environment-aware configuration loader
|
|
#
|
|
# Loads from examples/ruby/config/{development,test,production}.yaml
|
|
#
|
|
# @see ConfigLoader for base implementation
|
|
module Configuration
|
|
class DashboardConfig
|
|
VALID_ENVIRONMENTS = %w[production test development].freeze
|
|
|
|
def self.load(environment: nil, config_dir: nil)
|
|
env = resolve_environment(environment)
|
|
dir = config_dir || default_config_dir
|
|
file_path = File.join(dir, "#{env}.yaml")
|
|
|
|
raise ConfigNotFoundError, environment unless File.exist?(file_path)
|
|
|
|
config = YAML.safe_load(File.read(file_path), permitted_classes: [Symbol])
|
|
|
|
validate_config!(config, env)
|
|
|
|
OpenStruct.new(config)
|
|
end
|
|
|
|
private
|
|
|
|
def self.resolve_environment(env)
|
|
env || ENV.fetch('APP_ENV', 'development')
|
|
end
|
|
|
|
def self.default_config_dir
|
|
File.expand_path('../../../config', __dir__)
|
|
end
|
|
|
|
def self.validate_config!(config, env)
|
|
required = %w[app_name version database]
|
|
missing = required - config.keys.map(&:to_s)
|
|
return if missing.empty?
|
|
|
|
raise InvalidConfigError.new(env, "Missing: #{missing.join(', ')}")
|
|
end
|
|
end
|
|
|
|
ConfigNotFoundError = Class.new(StandardError)
|
|
InvalidConfigError = Class.new(StandardError)
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# RAILS INITIALIZATION
|
|
# =============================================================================
|
|
|
|
# Initialize Rails application with Hotwire integration
|
|
# This demonstrates the complete stack setup
|
|
|
|
Rails.application.initialize!
|
|
|
|
# Mount ViewComponent helpers
|
|
ActionView::Base.include ViewComponent::Helpers
|
|
|
|
# Configure Turbo Streams adapter
|
|
ActionCable.server.config.pubsub_adapter = :redis
|
|
|
|
# Register Stimulus controllers
|
|
# In production: config/javascript_pack_tags.rb
|
|
|
|
puts 'Rails 8 Hotwire application initialized' |