フロントエンドプレイアブル
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
2026-02-15 14:57:17 +09:00
commit f25fd6f802
198 changed files with 10342 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

165
app/models/game.rb Normal file
View File

@@ -0,0 +1,165 @@
class Game < ApplicationRecord
has_many :turns, dependent: :destroy
has_many :participants, dependent: :destroy
has_many :users, through: :participants
# パスワード保護
has_secure_password :password, validations: false
# バリデーション
validates :status, inclusion: {
in: %w[recruiting power_selection in_progress finished cancelled]
}
validates :auto_order_mode, inclusion: { in: %w[hold random] }
validates :participants_count,
numericality: { greater_than_or_equal_to: 2, less_than_or_equal_to: 7 },
unless: :is_solo_mode?
# ハウスルールバリデーション
validates :year_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 1901, less_than_or_equal_to: 1999 },
allow_nil: true
validates :victory_sc_count,
numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 34 }
validates :scoring_system, inclusion: { in: %w[none sc_count sc_ratio dss sos] }
# ターンスケジュールバリデーション
validate :validate_turn_schedule
# ヘルパーメソッド
def password_protected?
password_digest.present?
end
def solo_mode?
is_solo_mode
end
def administrator
participants.find_by(is_administrator: true)&.user
end
def available_powers
assigned_powers = participants.where.not(power: nil).pluck(:power)
%w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY] - assigned_powers
end
def all_powers_assigned?
participants.where(power: nil).empty? &&
participants.count == participants_count
end
def all_orders_submitted?
participants.where(power: nil).empty? &&
participants.all?(&:orders_submitted)
end
def unassigned_powers
all_powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY]
assigned_powers = participants.where.not(power: nil).pluck(:power)
all_powers - assigned_powers
end
# ターンスケジュール関連メソッド
def auto_turn?
turn_schedule.present?
end
# 次のデッドラインを計算(日本時間基準)
def calculate_next_deadline
return nil unless turn_schedule.present?
hours = turn_schedule.split(",").map(&:strip).map(&:to_i).sort
now = Time.current.in_time_zone("Asia/Tokyo")
# 今日の残りの時間枠を探すJST基準
next_time = hours.map { |h| now.beginning_of_day + h.hours }
.find { |t| t > now }
# 今日の枠がなければ翌日の最初の枠
next_time || (now.beginning_of_day + 1.day + hours.first.hours)
end
# スケジュール表示用
def schedule_display
return "手動" unless turn_schedule.present?
hours = turn_schedule.split(",").map(&:strip)
"毎日 " + hours.map { |h| "#{h}" }.join("")
end
# ハウスルール関連メソッド
# 年数制限チェック: フェーズ名から年を抽出し、year_limitを超えているか判定
def year_limit_reached?(phase_name)
return false unless year_limit.present? && phase_name.present?
# フェーズ名の例: "S1901M", "F1910R", "W1901A"
year_match = phase_name.match(/[SFW](\d{4})[MRA]/)
return false unless year_match
year_match[1].to_i > year_limit
end
# ソロ勝利判定: いずれかの国のSC数がvictory_sc_count以上か
def solo_victory?(game_state)
centers = game_state&.dig("centers") || {}
centers.any? { |_power, scs| scs.size >= victory_sc_count }
end
# ソロ勝利した国を返す
def solo_victory_power(game_state)
centers = game_state&.dig("centers") || {}
centers.find { |_power, scs| scs.size >= victory_sc_count }&.first
end
# スコア計算
def calculate_scores(game_state)
return {} if scoring_system == "none"
centers = game_state&.dig("centers") || {}
total_scs = centers.values.flatten.size
alive_count = centers.count { |_power, scs| scs.any? }
case scoring_system
when "sc_count"
# 単純SC数
centers.transform_values { |scs| scs.size }
when "sc_ratio"
# SC比率%
centers.transform_values { |scs| total_scs > 0 ? (scs.size.to_f / total_scs * 100).round(1) : 0 }
when "dss"
# Draw Size Scoring: 生存国で均等分割
centers.transform_values { |scs| scs.any? ? (100.0 / alive_count).round(1) : 0 }
when "sos"
# Sum of Squares
sum_of_squares = centers.values.sum { |scs| scs.size ** 2 }.to_f
centers.transform_values { |scs| sum_of_squares > 0 ? ((scs.size ** 2) / sum_of_squares * 100).round(1) : 0 }
else
{}
end
end
# スコアリング方式の日本語名
def scoring_system_name
case scoring_system
when "none" then "なし"
when "sc_count" then "SC数"
when "sc_ratio" then "SC比率"
when "dss" then "DSS均等分割"
when "sos" then "SoS二乗和"
else scoring_system
end
end
private
def validate_turn_schedule
return if turn_schedule.blank?
hours = turn_schedule.split(",").map(&:strip)
unless hours.all? { |h| h.match?(/\A\d{1,2}\z/) && h.to_i.between?(0, 23) }
errors.add(:turn_schedule, "は0〜23の数値をカンマ区切りで入力してください例: 0,18")
end
end
end

View File

@@ -0,0 +1,52 @@
class GameParticipant < ApplicationRecord
belongs_to :game
belongs_to :user
validates :status, inclusion: { in: %w[joined ready finished] }
validates :user_id, uniqueness: { scope: :game_id }
validates :power, uniqueness: { scope: :game_id }, allow_nil: true
# ステータス管理メソッド
def joined?
status == 'joined'
end
def ready?
status == 'ready'
end
def finished?
status == 'finished'
end
# パワー選択関連
def has_selected_power?
power.present?
end
def select_power!(power_name)
update!(power: power_name, status: 'ready')
end
# ゲーム参加メソッド
class << self
def join_game!(game, user, password = nil)
# パスワードチェック(プレイヤーモードの場合)
if game.player_mode? && game.password.present?
raise "Invalid password" unless game.password == password
end
# 既存参加チェック
if game.game_participants.exists?(user_id: user.id)
raise "Already joined this game"
end
# 定員チェック
if game.player_mode? && game.full?
raise "Game is full"
end
create!(game: game, user: user, joined_at: Time.current, status: 'joined')
end
end
end

20
app/models/participant.rb Normal file
View File

@@ -0,0 +1,20 @@
class Participant < ApplicationRecord
belongs_to :user
belongs_to :game
# バリデーション
validates :user_id, uniqueness: {
scope: :game_id,
message: "既にこのゲームに参加しています"
}
validates :power, uniqueness: {
scope: :game_id,
message: "この国は既に選択されています"
}, allow_nil: true
validates :power, inclusion: {
in: %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY],
message: "無効な国です"
}, allow_nil: true
end

124
app/models/turn.rb Normal file
View File

@@ -0,0 +1,124 @@
class Turn < ApplicationRecord
belongs_to :game
# 特定の国の実行可能な命令を取得
# 例: turn.possible_orders_for("FRANCE")
def possible_orders_for(power_name)
possible_orders&.dig("possible_orders", power_name.to_s.upcase)
end
# 特定の国の決定済み命令を取得
# 例: turn.orders_for("FRANCE")
def orders_for(power_name)
# orders は直接ハッシュを保存する場合を想定(必要に応じてネストを調整)
orders&.dig(power_name.to_s.upcase)
end
# 命令が存在するすべての国名リストを取得
def powers
possible_orders&.dig("possible_orders")&.keys || []
end
# 特定の国の命令が提出済みかチェック
def orders_submitted_for?(power_name)
orders&.key?(power_name.to_s.upcase)
end
# 未提出の国のリストを取得
def pending_powers
game.participants.where.not(power: nil).map(&:power).reject do |power|
orders_submitted_for?(power)
end
end
# 自動命令を生成(参加者がいない国用)
def generate_auto_orders_for_unassigned_powers
unassigned = game.unassigned_powers
return if unassigned.empty?
client = GameApiClient.new
current_orders = orders || {}
unassigned.each do |power|
if game.auto_order_mode == "hold"
# HOLD命令を生成
current_orders[power] = generate_hold_orders(power)
else
# ランダム命令を生成
auto_orders_response = client.api_calculate_auto_orders(game_state, power)
if auto_orders_response
# APIレスポンスから命令を抽出
# レスポンスが {"orders": [...]} の形式の場合
if auto_orders_response.is_a?(Hash) && auto_orders_response["orders"]
current_orders[power] = auto_orders_response["orders"]
elsif auto_orders_response.is_a?(Array)
current_orders[power] = auto_orders_response
else
# フォールバック: HOLD命令を使用
current_orders[power] = generate_hold_orders(power)
end
end
end
end
update(orders: current_orders)
update(orders: current_orders)
end
# 引き分け投票
def vote_draw(power_name)
current_votes = draw_votes || []
power = power_name.to_s.upcase
unless current_votes.include?(power)
current_votes << power
update(draw_votes: current_votes)
end
end
# 引き分け投票取り消し
def revoke_draw_vote(power_name)
current_votes = draw_votes || []
power = power_name.to_s.upcase
if current_votes.include?(power)
current_votes.delete(power)
update(draw_votes: current_votes)
end
end
# 投票済みかチェック
def draw_voted?(power_name)
(draw_votes || []).include?(power_name.to_s.upcase)
end
# 全会一致で引き分けかチェック
def unanimous_draw?
# 生存している国
active_powers = powers
return false if active_powers.empty?
# プレイヤーが割り当てられている国のみを対象とする
# (未割り当ての国は投票できないため、除外する)
assigned_powers = game.participants.where.not(power: nil).pluck(:power).map(&:upcase)
# 投票権を持つ国 = 生存している かつ プレイヤーがいる
eligible_powers = active_powers.map(&:upcase) & assigned_powers
return false if eligible_powers.empty?
current_votes = draw_votes || []
eligible_powers.all? { |power| current_votes.include?(power) }
end
private
def generate_hold_orders(power)
possible = possible_orders_for(power)
return {} unless possible
hold_orders = {}
possible.each do |unit, moves|
hold_orders[unit] = [ "H" ] if moves.is_a?(Array)
end
hold_orders
end
end

15
app/models/user.rb Normal file
View File

@@ -0,0 +1,15 @@
class User < ApplicationRecord
has_secure_password
validates :username, presence: true, length: { minimum: 3, maximum: 50 }
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, if: -> { new_record? || !password.nil? }
before_save { self.email = email.downcase }
def admin?
admin
end
end