フロントエンドプレイアブル
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,33 @@
class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
helper_method :current_user, :logged_in?
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def logged_in?
current_user.present?
end
def require_login
unless logged_in?
flash[:alert] = "ログインが必要です"
redirect_to login_path
end
end
def require_admin
unless logged_in? && current_user.admin?
flash[:alert] = "管理者権限が必要です"
redirect_to root_path
end
end
end

View File

View File

@@ -0,0 +1,62 @@
class GameParticipantsController < ApplicationController
before_action :set_game
before_action :require_login
before_action :require_participant, only: [:select_power]
# POST /games/1/game_participants/select_power
def select_power
power_name = params[:power_name]
unless power_name.present?
redirect_to @game, alert: "国を選択してください"
return
end
# 利用可能な国のリストを取得
available_powers = get_available_powers
unless available_powers.include?(power_name)
redirect_to @game, alert: "無効な国です"
return
end
# 既に選択されているかチェック
if @game.game_participants.exists?(power: power_name)
redirect_to @game, alert: "その国は既に選択されています"
return
end
# 国を選択
@current_participant.select_power!(power_name)
# 全員が選択したかチェック
if @game.can_start_order_input?
redirect_to @game, notice: "国を選択しました。全員の選択が完了しました!"
else
redirect_to @game, notice: "国を選択しました。他のプレイヤーの選択を待っています..."
end
end
private
def set_game
@game = Game.find(params[:game_id])
@current_participant = current_user && @game.game_participants.find_by(user: current_user)
end
def require_participant
unless @current_participant
redirect_to @game, alert: "このゲームに参加していません"
end
end
def get_available_powers
# ディプロマシーの標準的な国
standard_powers = %w[Austria England France Germany Italy Russia Turkey]
# 既に選択されている国を除外
selected_powers = @game.game_participants.where.not(power: nil).pluck(:power)
standard_powers - selected_powers
end
end

View File

@@ -0,0 +1,421 @@
class GamesController < ApplicationController
include GamesHelper
before_action :set_game, only: %i[ show edit update destroy join_game start_power_selection start_order_input turn_data vote_draw force_draw ]
before_action :require_login, only: %i[ new create join_game ]
before_action :require_game_admin, only: %i[ edit update destroy start_power_selection start_order_input ]
helper_method :get_available_powers_for_select
# GET /games or /games.json
def index
@recruiting_games = Game.where(status: "recruiting").includes(:participants)
@my_games = current_user ? Game.joins(:participants).where(participants: { user_id: current_user.id }).includes(:participants, :turns) : []
@games = Game.all.includes(:participants, :turns)
end
# GET /games/1 or /games/1.json
def show
@latest_turn = @game.turns.last
# ゲーム終了判定
@game_finished = @game.status == "finished"
# 表示するターンの決定
if @game_finished
# 終了済みの場合:
# params[:turn_number] があればそのターン
# なければ 最初のターン (Turn 1) を表示
if params[:turn_number].present?
@display_turn = @game.turns.find_by(number: params[:turn_number].to_i)
end
# 指定がない、または見つからない場合は初期ターン(number=1)を表示
# もし存在しなければ最新(というかあるやつ)
@display_turn ||= @game.turns.find_by(number: 1) || @latest_turn
# 最終結果の取得 (最後のターン情報から)
if @latest_turn.game_state
centers = @latest_turn.game_state["centers"] || {}
alive_powers = centers.keys
# ソロ勝利判定
solo_winner = @game.solo_victory_power(@latest_turn.game_state)
if solo_winner
result_type = "Solo Victory"
winners = [ solo_winner ]
else
result_type = "Draw"
winners = alive_powers
end
@winner_info = {
type: result_type,
winners: winners,
scores: @game.calculate_scores(@latest_turn.game_state)
}
end
else
# 進行中の場合: params[:turn_number] があればそのターン、なければ最新ターンを表示
if params[:turn_number].present?
@display_turn = @game.turns.find_by(number: params[:turn_number].to_i)
end
@display_turn ||= @latest_turn
end
if @display_turn
@game_state = @display_turn.game_state
# フェーズ名のパース
@current_season_year = parse_phase(@display_turn.phase)
# 国別情報の集計 (表示対象ターンのデータ)
centers = @game_state["centers"] || {}
units = @game_state["units"] || {}
# 全7カ国固定
powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY]
@country_statuses = powers.map do |power|
participant = @game.participants.find_by(power: power)
# 終了済みなら全員完了扱い、そうでなければターンごとの提出状況
submitted = @game_finished ? true : @display_turn.orders_submitted_for?(power)
{
power: power,
sc_count: centers[power]&.size || 0,
unit_count: units[power]&.size || 0,
submitted: submitted,
participant: participant,
is_user: current_user && participant&.user_id == current_user.id
}
end
# 自国を先頭に移動
if current_user
user_power_index = @country_statuses.find_index { |s| s[:is_user] }
if user_power_index
user_status = @country_statuses.delete_at(user_power_index)
@country_statuses.unshift(user_status)
end
end
end
end
# GET /games/new
def new
@game = Game.new
@game.is_solo_mode = current_user&.admin? ? false : false
end
# GET /games/1/edit
def edit
end
# POST /games or /games.json
def create
@game = Game.new(game_params)
# ソロモードかどうかを判定(管理者のみ選択可能)
if current_user&.admin? && params.dig(:game, :game_mode) == "admin_mode"
@game.is_solo_mode = true
else
@game.is_solo_mode = false
end
respond_to do |format|
if @game.save
# ゲーム作成者は自動的に参加者として登録(管理者として)
Participant.create!(
game: @game,
user: current_user,
is_administrator: true
)
# ソロモードの場合、即座に最初のターンを作成
if @game.solo_mode?
service = GameSetupService.new(@game)
result = service.setup_initial_turn
if result[:success]
@game.update!(status: "in_progress")
else
# 失敗したらロールバックしたいところだが・・・
# 現状はsave後なので、エラー表示だけにするかdestroyするか。
# 今回は簡易的にログに残す
logger.error result[:message]
end
end
format.html { redirect_to @game, notice: "ゲームが正常に作成されました。" }
format.json { render :show, status: :created, location: @game }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @game.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /games/1 or /games/1.json
def update
respond_to do |format|
if @game.update(game_params)
# スケジュール変更時にデッドラインを再計算
if @game.status == "in_progress"
@game.update_column(:next_deadline_at, @game.auto_turn? ? @game.calculate_next_deadline : nil)
end
format.html { redirect_to @game, notice: "ゲームが正常に更新されました。", status: :see_other }
format.json { render :show, status: :ok, location: @game }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @game.errors, status: :unprocessable_entity }
end
end
end
# DELETE /games/1 or /games/1.json
def destroy
@game.destroy!
respond_to do |format|
format.html { redirect_to games_path, notice: "ゲームが正常に削除されました。", status: :see_other }
format.json { head :no_content }
end
end
# POST /games/1/join
def join_game
unless current_user
redirect_to login_path, alert: "ゲームに参加するにはログインしてください"
return
end
# パスワードチェック
if @game.password_protected?
password = params.dig(:participant, :password)
unless @game.authenticate_password(password)
redirect_to @game, alert: "パスワードが正しくありません"
return
end
end
# 既に参加しているかチェック
if @game.participants.exists?(user: current_user)
redirect_to @game, alert: "既にこのゲームに参加しています"
return
end
# 満員チェック
if @game.participants.count >= @game.participants_count
redirect_to @game, alert: "このゲームは満員です"
return
end
# 参加者を作成
Participant.create!(
game: @game,
user: current_user
)
# 定員到達チェック
if @game.participants.count == @game.participants_count
@game.update(status: "power_selection")
end
redirect_to @game, notice: "ゲームに正常に参加しました!"
end
# POST /games/1/start_power_selection
def start_power_selection
unless @game.participants.count >= 2
redirect_to @game, alert: "最低2人の参加者が必要です"
return
end
@game.update!(status: "power_selection")
redirect_to @game, notice: "国選択フェーズを開始しました!"
end
# POST /games/1/start_order_input
def start_order_input
unless @game.all_powers_assigned?
redirect_to @game, alert: "全員が国を選択する必要があります"
return
end
# 最初のターンを作成
service = GameSetupService.new(@game)
result = service.setup_initial_turn
if result[:success]
update_attrs = { status: "in_progress" }
update_attrs[:next_deadline_at] = @game.calculate_next_deadline if @game.auto_turn?
@game.update!(update_attrs)
redirect_to @game, notice: "命令入力フェーズを開始しました!"
else
redirect_to @game, alert: result[:message]
end
end
# GET /games/1/turn_data.json
def turn_data
if params[:turn_number].present?
target_turn = @game.turns.find_by(number: params[:turn_number])
end
target_turn ||= @game.turns.last
return head :not_found unless target_turn
# 国別ステータス情報の再計算JSON用
# showアクションと同様のロジック
game_state = target_turn.game_state
centers = game_state["centers"] || {}
units = game_state["units"] || {}
powers = %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY]
country_statuses = powers.map do |power|
# 終了済みなら全員完了扱い
submitted = @game.status == "finished" ? true : target_turn.orders_submitted_for?(power)
{
power: power,
sc_count: centers[power]&.size || 0,
unit_count: units[power]&.size || 0,
submitted: submitted
}
end
render json: {
turn_number: target_turn.number,
phase: target_turn.phase,
possible_orders: target_turn.possible_orders,
decided_orders: target_turn.orders || {},
svg_orders: target_turn.svg_orders || {},
# svg_date カラムにデフォルトSVGが入っていると仮定または svg_orders["NONE"]
# ここでは svg_date が単一の画像パスかSVG文字列かによるが、
# 既存の show.html.erb 実装を見ると svg_orders["NONE"] を使っている可能性が高い
# 既存実装: default_svg: last_turn.svg_date となっていたのでそのまま変更なしでいくが
# target_turn.svg_date を返すようにする
default_svg: target_turn.svg_date,
# 完了状況
all_orders_submitted: @game.status == "finished" || @game.all_orders_submitted?,
missing_orders_powers: @game.status == "finished" ? [] : @game.participants.where(orders_submitted: false).pluck(:power).compact,
country_statuses: country_statuses
}
end
def vote_draw
return unless @game.status == "in_progress"
turn = @game.turns.last
participant = @game.participants.find_by(user: current_user)
unless participant && participant.power
redirect_to @game, alert: "権限がありません。"
return
end
power = participant.power
if turn.draw_voted?(power)
turn.revoke_draw_vote(power)
flash[:notice] = "引き分け投票を取り消しました。"
else
turn.vote_draw(power)
flash[:notice] = "引き分けに投票しました。"
end
if turn.unanimous_draw?
if execute_draw(turn)
flash[:notice] = "全会一致により、ゲームは引き分けとなりました。"
else
# エラーメッセージは execute_draw 内で flash[:alert] に設定される
end
end
redirect_to @game
end
def force_draw
return unless current_user.admin?
turn = @game.turns.last
if execute_draw(turn)
redirect_to @game, notice: "ゲームを強制的に引き分けにしました。"
else
redirect_to @game
end
end
private
def execute_draw(turn)
client = GameApiClient.new
# 生存国を勝者として扱う(引き分け)
winners = turn.powers
begin
response = client.api_game_draw(turn.game_state, winners: winners)
if response
# 新しいターン(完了状態)を作成
Turn.create!(
game: @game,
number: turn.number + 1,
# year, season カラムは存在しないため削除
# phase を COMPLETED に設定
game_state: response,
orders: {},
possible_orders: {},
phase: "COMPLETED",
svg_date: turn.svg_date,
svg_orders: turn.svg_orders
)
@game.update(status: "finished")
true
else
flash[:alert] = "ゲームサーバーからの応答が不正です。"
false
end
rescue Faraday::ConnectionFailed => e
flash[:alert] = "ゲームサーバーへの接続に失敗しました。管理者へ連絡してください。"
Rails.logger.error "API Connection Failed: #{e.message}"
false
rescue Faraday::TimeoutError => e
flash[:alert] = "ゲームサーバーとの通信がタイムアウトしました。"
Rails.logger.error "API Timeout: #{e.message}"
false
rescue StandardError => e
flash[:alert] = "予期せぬエラーが発生しました: #{e.message}"
Rails.logger.error "Execute Draw Error: #{e.message}"
false
end
end
def set_game
@game = Game.find(params.expect(:id))
end
def require_game_admin
unless current_user&.admin? || @game.administrator == current_user
redirect_to @game, alert: "ゲーム管理者のみこの機能にアクセスできます"
end
end
def game_params
house_rules = [ :year_limit, :victory_sc_count, :scoring_system, :turn_schedule ]
if action_name == "update"
params.expect(game: [ :title, :memo, :auto_order_mode ] + house_rules)
else
permitted = [ :title, :memo ] + house_rules
if current_user&.admin?
permitted += [ :participants_count, :password, :auto_order_mode ]
else
# 一般ユーザーはマルチプレイヤーモードのみ
permitted += [ :participants_count, :password, :auto_order_mode ]
end
params.expect(game: permitted)
end
end
end

View File

@@ -0,0 +1,87 @@
class ParticipantsController < ApplicationController
before_action :set_game
# POST /games/:game_id/participants
def create
# パスワード確認
if @game.password_protected?
unless @game.authenticate_password(params[:password])
redirect_to @game, alert: "パスワードが正しくありません"
return
end
end
# 参加処理
@participant = @game.participants.build(
user: current_user,
is_administrator: false
)
if @participant.save
# 定員到達チェック
if @game.participants.count == @game.participants_count
@game.update(status: "power_selection")
end
redirect_to @game, notice: "ゲームに参加しました"
else
redirect_to @game, alert: @participant.errors.full_messages.join(", ")
end
end
# PATCH /games/:game_id/participants/:id/select_power
def select_power
@participant = @game.participants.find(params[:id])
unless @participant.user == current_user
redirect_to @game, alert: "権限がありません"
return
end
if @participant.update(power: params[:power])
# 全員が国を選択したかチェック
if @game.all_powers_assigned?
service = GameSetupService.new(@game)
result = service.setup_initial_turn
if result[:success]
@game.update(status: "in_progress")
flash[:notice] = "国を選択し、ゲームが開始されました!"
else
flash[:alert] = "ゲーム開始に失敗しました: #{result[:message]}"
end
else
flash[:notice] = "国を選択しました"
end
redirect_to @game
else
redirect_to @game, alert: @participant.errors.full_messages.join(", ")
end
end
# DELETE /games/:game_id/participants/:id
def destroy
@participant = @game.participants.find(params[:id])
unless @participant.user == current_user || current_user&.admin?
redirect_to @game, alert: "権限がありません"
return
end
@participant.destroy
# ゲーム状態を更新
if @game.status == "power_selection"
@game.update(status: "recruiting")
end
redirect_to games_path, notice: "ゲームから退出しました"
end
private
def set_game
@game = Game.find(params[:game_id])
end
end

View File

@@ -0,0 +1,22 @@
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
session[:user_id] = user.id
flash[:notice] = "ログインしました"
redirect_to root_path
else
flash.now[:alert] = "メールアドレスまたはパスワードが正しくありません"
render :new, status: :unprocessable_entity
end
end
def destroy
session[:user_id] = nil
flash[:notice] = "ログアウトしました"
redirect_to root_path
end
end

View File

@@ -0,0 +1,108 @@
class TurnsController < ApplicationController
before_action :require_login
before_action :set_turn, only: %i[ show edit update destroy submit_orders process_turn ]
# GET /turns or /turns.json
def index
@turns = Turn.all
end
# GET /turns/1 or /turns/1.json
def show
end
# GET /turns/new
def new
@turn = Turn.new
end
# GET /turns/1/edit
def edit
end
# POST /turns or /turns.json
def create
@turn = Turn.new(turn_params)
respond_to do |format|
if @turn.save
format.html { redirect_to @turn, notice: "Turn was successfully created." }
format.json { render :show, status: :created, location: @turn }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @turn.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /turns/1 or /turns/1.json
def update
respond_to do |format|
if @turn.update(turn_params)
format.html { redirect_to @turn, notice: "Turn was successfully updated.", status: :see_other }
format.json { render :show, status: :ok, location: @turn }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @turn.errors, status: :unprocessable_entity }
end
end
end
# DELETE /turns/1 or /turns/1.json
def destroy
@turn.destroy!
respond_to do |format|
format.html { redirect_to turns_path, notice: "Turn was successfully destroyed.", status: :see_other }
format.json { head :no_content }
end
end
# PATCH /turns/1/submit_orders
def submit_orders
@turn = Turn.find(params[:id])
power = params[:power]
orders = params[:orders]&.permit!&.to_h || {}
service = OrderSubmissionService.new(@turn, current_user)
result = service.submit(power: power, orders: orders)
if result[:success]
redirect_to game_path(@turn.game), notice: result[:message]
else
redirect_to game_path(@turn.game), alert: result[:message]
end
end
# POST /turns/1/process_turn
def process_turn
@turn = Turn.find(params[:id])
@game = @turn.game
# Check admin/turn ending permissions
unless @game.solo_mode? || current_user&.admin? || @game.administrator == current_user
redirect_to game_path(@game), alert: "ゲーム管理者のみターンを終了できます"
return
end
service = TurnProcessingService.new(@turn)
result = service.process(force: params[:force])
if result[:success]
redirect_to game_path(@game), notice: result[:message]
else
redirect_to game_path(@game), alert: result[:message]
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_turn
@turn = Turn.find(params.expect(:id))
end
# Only allow a list of trusted parameters through.
def turn_params
params.expect(turn: [ :number, :phase, :game_state, :svg_date, :game_id, :possible_orders, :orders ])
end
end

View File

@@ -0,0 +1,88 @@
class UsersController < ApplicationController
before_action :require_admin, only: [:index, :destroy, :toggle_admin]
before_action :set_user, only: [:show, :edit, :update, :destroy, :toggle_admin]
before_action :require_admin_or_owner, only: [:show, :edit, :update]
def index
@users = User.all.order(created_at: :desc)
end
def show
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
session[:user_id] = @user.id
flash[:notice] = "アカウントを作成しました"
redirect_to root_path
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
# パスワードが空の場合は更新しない
if user_update_params[:password].blank?
user_update_params.delete(:password)
user_update_params.delete(:password_confirmation)
end
if @user.update(user_update_params)
flash[:notice] = "ユーザー情報を更新しました"
redirect_to user_path(@user)
else
render :edit, status: :unprocessable_entity
end
end
def destroy
if @user == current_user
flash[:alert] = "自分自身を削除することはできません"
redirect_to users_path
else
@user.destroy
flash[:notice] = "ユーザーを削除しました"
redirect_to users_path
end
end
def toggle_admin
if @user == current_user
flash[:alert] = "自分自身の管理者権限は変更できません"
else
@user.update(admin: !@user.admin)
flash[:notice] = "管理者権限を#{@user.admin? ? '付与' : '削除'}しました"
end
redirect_to users_path
end
private
def set_user
@user = User.find(params[:id])
end
def require_admin_or_owner
unless current_user&.admin? || current_user == @user
flash[:alert] = "アクセス権限がありません"
redirect_to root_path
end
end
def user_params
params.require(:user).permit(:username, :email, :password, :password_confirmation)
end
def user_update_params
# メールアドレスは変更不可
params.require(:user).permit(:username, :password, :password_confirmation)
end
end