掲示板実装
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-19 22:30:59 +09:00
parent f25fd6f802
commit bb9ec2df1d
38 changed files with 1711 additions and 13 deletions

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,37 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# セッションからuser_idを取得してUserを特定する
# 注: セッションストアの設定に依存するが、CookieStoreデフォルトを想定
if verified_user = User.find_by(id: session_user_id)
verified_user
else
reject_unauthorized_connection
end
end
def session_user_id
# RailsのセッションCookieからuser_idを復元する
# _dip_front_session は config/initializers/session_store.rb で設定されているキー名、またはデフォルトの _app_session
# ここでは汎用的に取得を試みる
session_key = Rails.application.config.session_options[:key]
encrypted_session = cookies.encrypted[session_key]
if encrypted_session && encrypted_session["user_id"]
encrypted_session["user_id"]
else
# デバッグ用: 認証失敗時はnilを返す
nil
end
end
end
end

View File

@@ -0,0 +1,34 @@
class BoardChannel < ApplicationCable::Channel
def subscribed
board = Board.find_by(id: params[:board_id])
unless board
reject
return
end
# 権限チェック
# ゲーム参加者を取得current_userから
current_participant = board.game.participants.find_by(user: current_user)
# 参加権限があるか(メンバー、または終了済み、または公開ボード)
# コントローラの判定ロジックと合わせる
# 共通掲示板は誰でも購読OKただしゲーム参加者に限る
if board.global?
stream_from "board_#{board.id}" if current_participant
elsif board.is_public?
stream_from "board_#{board.id}" if current_participant
elsif board.member?(current_participant)
stream_from "board_#{board.id}"
elsif board.history_mode?
stream_from "board_#{board.id}" if current_participant
else
reject
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@@ -0,0 +1,66 @@
class BoardMembershipsController < ApplicationController
before_action :require_login
before_action :set_game
before_action :set_board
before_action :set_current_participant
def create
# メンバー追加処理
unless @board.negotiation? && @board.member?(@current_participant)
return redirect_to game_board_path(@game, @board), alert: "権限がありません"
end
target_participant_id = params[:participant_id]
# 重複チェック(既に参加しているか)
if @board.board_memberships.where(participant_id: target_participant_id, left_at: nil).exists?
return redirect_to game_board_path(@game, @board), alert: "そのプレイヤーは既に参加しています"
end
# 退出済みの場合は再参加left_atをクリア
membership = @board.board_memberships.find_by(participant_id: target_participant_id)
if membership
membership.update!(left_at: nil, joined_at: Time.current)
else
@board.board_memberships.create!(participant_id: target_participant_id, joined_at: Time.current)
end
# メンバー構成が変わったので、他の掲示板と重複していないかチェックが必要だが、
# モデルのバリデーションは「作成時」を想定しているため、ここでは簡易チェックに留めるか、
# あるいはバリデーションエラーをハンドリングする。
# ここではsave成功/失敗で判断
redirect_to game_board_path(@game, @board), notice: "メンバーを追加しました"
rescue ActiveRecord::RecordInvalid => e
redirect_to game_board_path(@game, @board), alert: "メンバー追加に失敗しました: #{e.message}"
end
def leave
# 退出処理
unless @board.negotiation? && @board.member?(@current_participant)
return redirect_to game_board_path(@game, @board), alert: "退出できません"
end
membership = @board.board_memberships.find_by(participant: @current_participant)
if membership
membership.leave!
redirect_to game_boards_path(@game), notice: "掲示板から退出しました"
else
redirect_to game_board_path(@game, @board), alert: "退出に失敗しました"
end
end
private
def set_game
@game = Game.find(params[:game_id])
end
def set_board
@board = @game.boards.find(params[:board_id])
end
def set_current_participant
@current_participant = @game.participants.find_by(user: current_user)
end
end

View File

@@ -0,0 +1,47 @@
class BoardPostsController < ApplicationController
before_action :require_login
before_action :set_game
before_action :set_board
before_action :set_current_participant
def create
# 参加チェック
unless @board.member?(@current_participant) && @game.status == "in_progress"
return redirect_to game_board_path(@game, @board), alert: "投稿権限がありません"
end
@post = @board.board_posts.new(post_params)
@post.participant = @current_participant
# フェーズ情報の付与
# 最新のターン情報を取得してセットする
latest_turn = @game.turns.order(number: :desc).first
if latest_turn
@post.phase = latest_turn.phase
end
if @post.save
redirect_to game_board_path(@game, @board) # noticeはチャット的にうるさいので省略
else
redirect_to game_board_path(@game, @board), alert: "投稿に失敗しました"
end
end
private
def set_game
@game = Game.find(params[:game_id])
end
def set_board
@board = @game.boards.find(params[:board_id])
end
def set_current_participant
@current_participant = @game.participants.find_by(user: current_user)
end
def post_params
params.require(:board_post).permit(:body)
end
end

View File

@@ -0,0 +1,71 @@
class BoardProposalsController < ApplicationController
before_action :require_login
before_action :set_game
before_action :set_board
before_action :set_current_participant
def create
unless @board.member?(@current_participant)
return redirect_to game_board_path(@game, @board), alert: "権限がありません"
end
@proposal = @board.board_proposals.new(proposal_params)
@proposal.proposer = @current_participant
@proposal.status = "pending"
# フェーズ情報の保存
latest_turn = @game.turns.max_by(&:number)
if latest_turn
@proposal.phase = latest_turn.phase
else
@proposal.phase = "S1901M" # 初期値
end
if @proposal.save
redirect_to game_board_path(@game, @board), notice: "提案を作成しました"
else
redirect_to game_board_path(@game, @board), alert: "提案の作成に失敗しました"
end
end
def update
@proposal = @board.board_proposals.find(params[:id])
# 承認・拒否権限:
# 提案者本人以外が承認/拒否できるべきか、あるいは全員できるべきか?
# 通常は「相手」が承認するものだが、多国間の場合は?
# ここでは「メンバーであれば誰でもステータス変更可能」とする(簡易実装)
# ただし、提案者本人が自分で承認するのは変なので、他者のみとするのがベター
unless @board.member?(@current_participant)
return redirect_to game_board_path(@game, @board), alert: "権限がありません"
end
new_status = params[:board_proposal][:status]
if %w[accepted rejected].include?(new_status)
@proposal.update!(status: new_status)
# ログにも残す(投稿として自動投稿しても良いが、今回はステータス変更のみ)
redirect_to game_board_path(@game, @board), notice: "提案を#{new_status == 'accepted' ? '承認' : '拒否'}しました"
else
redirect_to game_board_path(@game, @board), alert: "不正なステータスです"
end
end
private
def set_game
@game = Game.find(params[:game_id])
end
def set_board
@board = @game.boards.find(params[:board_id])
end
def set_current_participant
@current_participant = @game.participants.find_by(user: current_user)
end
def proposal_params
params.require(:board_proposal).permit(:body)
end
end

View File

@@ -0,0 +1,186 @@
class BoardsController < ApplicationController
before_action :require_login
before_action :set_game
before_action :set_board, only: [ :show, :toggle_public ]
def index
# 参加中の掲示板(共通掲示板含む)を取得
# ゲーム終了後は履歴モードとして、過去に参加していた掲示板もすべて表示
@current_participant = @game.participants.find_by(user: current_user)
# マップ表示用:各国がどの掲示板(交渉)を持っているかのデータを作成
# { "FRANCE" => ["GERMANY", "ENGLAND"], ... } のような形式でビューに渡す
@diplomacy_matrix = build_diplomacy_matrix
if @game.status == "finished"
# 履歴モード:かつて参加していた掲示板をすべて表示
# (共通掲示板は全員参加扱いなので含む)
@boards = @game.boards.select { |b| b.member?(@current_participant) || b.global? }
.sort_by { |b| [ b.global? ? 0 : 1, b.created_at ] }
else
return redirect_to game_path(@game), alert: "参加者ではないためアクセスできません" unless @current_participant
# 進行中:参加中の掲示板のみ
@boards = @current_participant.boards.includes(:participants, :board_posts).order(created_at: :desc)
# 共通掲示板がまだ紐付いていない場合のフォールバック(通常は作成時に紐づくはずだが念の為)
global_board = @game.boards.global.first
if global_board && !@boards.include?(global_board)
# 表示上追加するが、DBには保存しない閲覧時に参加処理をする作りも考えられるが、createタイミングで保証する前提
@boards = [ global_board ] + @boards
end
# 先頭に共通掲示板を持ってくる
@boards = @boards.sort_by { |b| b.global? ? 0 : 1 }
end
end
def show
@current_participant = @game.participants.find_by(user: current_user)
# アクセス制御
# アクセス制御
access_allowed = false
if @board.global?
# 共通掲示板はゲーム参加者なら誰でもアクセス可能
# 未読管理のためにメンバーシップがない場合は作成する
access_allowed = true
unless @board.board_memberships.exists?(participant: @current_participant)
@board.board_memberships.create(participant: @current_participant, joined_at: Time.current)
end
elsif @board.member?(@current_participant)
access_allowed = true
elsif @game.status == "finished"
# ゲーム終了後は、元メンバーならアクセス可能(共通掲示板は上記でカバー済み)
is_past_member = @board.board_memberships.exists?(participant: @current_participant)
access_allowed = is_past_member
elsif @board.is_public?
# 公開掲示板は誰でも閲覧可能
access_allowed = true
end
unless access_allowed
return redirect_to game_boards_path(@game), alert: "権限がありません"
end
# 既読更新
if @current_participant && @board.active_memberships.exists?(participant: @current_participant)
last_post = @board.board_posts.last
if last_post
membership = @board.board_memberships.find_by(participant: @current_participant)
membership.mark_read!(last_post.id)
end
end
@posts = @board.board_posts.includes(:participant).order(created_at: :desc)
@new_post = BoardPost.new
@new_proposal = BoardProposal.new
@active_members = @board.active_memberships.includes(:participant).map(&:participant)
# メンバー追加用:招待可能なプレイヤー一覧(自分と既に参加している人を除く)
if @board.negotiation? && !@board.history_mode?
existing_member_ids = @board.board_memberships.where(left_at: nil).pluck(:participant_id)
@candidates = @game.participants.where.not(id: existing_member_ids).where.not(power: nil)
end
end
def new
@board = Board.new(board_type: "negotiation")
@current_participant = @game.participants.find_by(user: current_user)
# 自分以外の参加者一覧
@participants = @game.participants.where.not(id: @current_participant.id).where.not(power: nil)
end
def create
@current_participant = @game.participants.find_by(user: current_user)
# トランザクションで掲示板作成とメンバー追加を一括実行
ActiveRecord::Base.transaction do
@board = @game.boards.new(board_params)
@board.board_type = "negotiation"
@board.created_by_participant_id = @current_participant.id
if @board.save
# 作成者をメンバーに追加
@board.board_memberships.create!(participant: @current_participant, joined_at: Time.current)
# 招待されたメンバーを追加
if params[:invited_participant_ids].present?
params[:invited_participant_ids].each do |pid|
@board.board_memberships.create!(participant_id: pid, joined_at: Time.current)
end
end
redirect_to game_board_path(@game, @board), notice: "交渉用掲示板を作成しました"
else
@participants = @game.participants.where.not(id: @current_participant.id).where.not(power: nil)
render :new, status: :unprocessable_entity
end
end
rescue ActiveRecord::RecordInvalid
@participants = @game.participants.where.not(id: @current_participant.id).where.not(power: nil)
render :new, status: :unprocessable_entity
end
def toggle_public
# 公開宣言機能(追加提案)
@current_participant = @game.participants.find_by(user: current_user)
unless @board.member?(@current_participant)
return redirect_to game_board_path(@game, @board), alert: "権限がありません"
end
@board.update(is_public: !@board.is_public)
status = @board.is_public ? "公開" : "非公開"
redirect_to game_board_path(@game, @board), notice: "掲示板を#{status}に設定しました"
end
private
def set_game
@game = Game.find(params[:game_id])
end
def set_board
@board = @game.boards.find(params[:id])
end
def board_params
# パラメータは特にないが、将来的にタイトルなど追加するかも
params.fetch(:board, {}).permit(:is_public)
end
def build_diplomacy_matrix
# 外交関係マップ構築
# Matrix: { "FRANCE" => { "GERMANY" => true, "ENGLAND" => false }, ... }
# 全組み合わせの初期化
powers = @game.participants.where.not(power: nil).pluck(:power)
matrix = {}
powers.each { |p| matrix[p] = [] }
# 参加中の全掲示板を走査(自分が参加していないものも含めたいが、
# DB設計上 board_memberships を見る必要がある)
# is_publicなもの、または自分が参加しているものについて情報を開示
# 仕様書には「どの国とどの国が掲示板を持っているか」とあるが、
# 秘密交渉なので、本来は「自分が見えている範囲」あるいは「公開宣言されたもの」のみ?
# ここでは「自分が参加している掲示板」および「公開された掲示板」の関係を表示する。
visible_boards = @game.boards.negotiation.includes(:participants)
visible_boards.each do |board|
# アクセス権チェック(簡易)
is_member = board.board_memberships.exists?(participant: @current_participant, left_at: nil)
next unless is_member || board.is_public || @game.status == "finished"
# メンバー間のリンクを作成
members = board.participants.pluck(:power)
members.each do |p1|
members.each do |p2|
next if p1 == p2
matrix[p1] ||= []
matrix[p1] << p2 unless matrix[p1].include?(p2)
end
end
end
matrix
end
end

View File

@@ -0,0 +1,73 @@
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
static values = {
boardId: Number,
currentParticipantId: Number
}
connect() {
this.channel = createConsumer().subscriptions.create(
{ channel: "BoardChannel", board_id: this.boardIdValue },
{
received: (data) => {
this._insertMessage(data)
}
}
)
this.element.scrollTop = this.element.scrollHeight
}
disconnect() {
if (this.channel) {
this.channel.unsubscribe()
}
}
_insertMessage(data) {
const isMe = data.participant_id === this.currentParticipantIdValue
// 最初の投稿の場合、"まだ投稿がありません"メッセージを消す
const noPostsMessage = document.getElementById("no-posts-message")
if (noPostsMessage) {
noPostsMessage.remove()
}
const html = `
<div class="flex ${isMe ? 'justify-end' : 'justify-start'} mb-4 message-item" id="post_${data.post_id}">
${!isMe ? `
<div class="flex-shrink-0 mr-3">
<span class="inline-flex items-center justify-center h-8 w-8 rounded-full text-xs font-bold border border-gray-300 bg-white text-gray-700 shadow-sm" title="${data.power}">
${data.power ? data.power.substring(0, 2) : '?'}
</span>
</div>
` : ''}
<div class="max-w-lg ${isMe ? 'order-1' : 'order-2'}">
<div class="flex items-baseline space-x-2 mb-1 ${isMe ? 'justify-end' : 'justify-start'}">
${!isMe ? `<span class="text-xs font-bold text-gray-900">${data.power}</span>` : ''}
<span class="text-xs text-gray-500">${data.created_at}</span>
${data.phase ? `<span class="text-xs bg-gray-100 px-1 rounded text-gray-600 border border-gray-200">${data.phase}</span>` : ''}
</div>
<div class="px-4 py-2 rounded-lg shadow-sm text-sm ${isMe ? 'bg-indigo-600 text-white rounded-br-none' : 'bg-white border border-gray-200 text-gray-900 rounded-bl-none'}">
<div>${this._escapeHtml(data.body).replace(/\n/g, '<br>')}</div>
</div>
</div>
</div>
`
this.element.insertAdjacentHTML('afterbegin', html)
// this.element.scrollTop = 0 // 必要ならトップへスクロール
}
_escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
}
}

61
app/models/board.rb Normal file
View File

@@ -0,0 +1,61 @@
class Board < ApplicationRecord
belongs_to :game
belongs_to :creator, class_name: "Participant",
foreign_key: "created_by_participant_id", optional: true
has_many :board_memberships, dependent: :destroy
has_many :active_memberships, -> { where(left_at: nil) },
class_name: "BoardMembership"
has_many :participants, through: :board_memberships
has_many :board_posts, dependent: :destroy
has_many :board_proposals, dependent: :destroy
enum :board_type, { global: "global", negotiation: "negotiation" }
validate :no_duplicate_member_combination, if: :negotiation?
# メンバーかどうか判定退出済みはfalse
def member?(participant)
return false unless participant
board_memberships.exists?(participant_id: participant.id, left_at: nil)
end
# 履歴モードかどうかゲーム終了後は常にtrue
def history_mode?
game.status == "finished"
end
# 指定した参加者の未読件数を取得
def unread_count_for(participant)
return 0 unless participant
membership = board_memberships.find_by(participant_id: participant.id)
# メンバーでない、または退出済みの場合は未読なしとするあるいは全件とするか要検討だが、一旦0
return 0 unless membership && membership.active?
last_id = membership.last_read_post_id || 0
board_posts.where("id > ?", last_id).count
end
private
def no_duplicate_member_combination
# 新規作成時のみチェック(更新時はメンバー変動があるため別途考慮が必要だが、今回は作成時のみ想定)
# ただしメンバー追加時にもチェックが必要になる可能性があるため、Boardモデルのバリデーションとして定義
# 比較対象のメンバーIDリストソート済み
current_member_ids = board_memberships.map(&:participant_id).sort
# 同じゲーム内の他の交渉用掲示板を検索
game.boards.negotiation.where.not(id: id).each do |other_board|
# 退出者を含めた全メンバー構成で比較するか、アクティブメンバーのみで比較するか
# 仕様書「同じメンバー構成の掲示板は作成できない」
# ここでは「現在参加しているメンバー」の構成が重複しないようにする
other_member_ids = other_board.board_memberships.where(left_at: nil).pluck(:participant_id).sort
if other_member_ids == current_member_ids
errors.add(:base, "同じメンバー構成の掲示板がすでに存在します")
return
end
end
end
end

View File

@@ -0,0 +1,21 @@
class BoardMembership < ApplicationRecord
belongs_to :board
belongs_to :participant
# 参加中かどうか(退出していない)
def active?
left_at.nil?
end
# 退出処理
def leave!
update!(left_at: Time.current)
end
# 既読位置を更新
def mark_read!(post_id)
# 既存の既読位置より新しい場合のみ更新(巻き戻り防止)
# ただし今回は単純に最新をセットする形で良い
update!(last_read_post_id: post_id)
end
end

26
app/models/board_post.rb Normal file
View File

@@ -0,0 +1,26 @@
class BoardPost < ApplicationRecord
belongs_to :board
belongs_to :participant
validates :body, presence: true
# 作成後にAction Cableで配信
after_create_commit :broadcast_to_board
private
def broadcast_to_board
# ボードごとのチャンネルに配信
ActionCable.server.broadcast(
"board_#{board_id}",
{
post_id: id,
participant_id: participant.id,
power: participant.power, # 国名
body: body,
phase: phase, # フェーズ情報
created_at: created_at.in_time_zone("Asia/Tokyo").strftime("%Y-%m-%d %H:%M")
}
)
end
end

View File

@@ -0,0 +1,9 @@
class BoardProposal < ApplicationRecord
belongs_to :board
belongs_to :proposer, class_name: "Participant",
foreign_key: "proposer_participant_id"
enum :status, { pending: "pending", accepted: "accepted", rejected: "rejected" }
validates :body, presence: true
end

View File

@@ -2,6 +2,8 @@ class Game < ApplicationRecord
has_many :turns, dependent: :destroy has_many :turns, dependent: :destroy
has_many :participants, dependent: :destroy has_many :participants, dependent: :destroy
has_many :users, through: :participants has_many :users, through: :participants
has_many :boards, dependent: :destroy
has_one :latest_turn, -> { order(number: :desc) }, class_name: "Turn"
# パスワード保護 # パスワード保護
has_secure_password :password, validations: false has_secure_password :password, validations: false
@@ -26,6 +28,9 @@ class Game < ApplicationRecord
# ターンスケジュールバリデーション # ターンスケジュールバリデーション
validate :validate_turn_schedule validate :validate_turn_schedule
# コールバック
after_create :create_global_board
# ヘルパーメソッド # ヘルパーメソッド
def password_protected? def password_protected?
password_digest.present? password_digest.present?
@@ -162,4 +167,8 @@ class Game < ApplicationRecord
errors.add(:turn_schedule, "は0〜23の数値をカンマ区切りで入力してください例: 0,18") errors.add(:turn_schedule, "は0〜23の数値をカンマ区切りで入力してください例: 0,18")
end end
end end
def create_global_board
boards.create!(board_type: "global", is_public: true)
end
end end

View File

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

View File

@@ -0,0 +1,27 @@
<% is_me = post.participant == current_participant %>
<div class="flex <%= is_me ? 'justify-end' : 'justify-start' %> mb-4 message-item" id="post_<%= post.id %>">
<% unless is_me %>
<div class="flex-shrink-0 mr-3">
<!-- アバター代わりの国名バッジ -->
<span class="inline-flex items-center justify-center h-8 w-8 rounded-full text-xs font-bold border border-gray-300 bg-white text-gray-700 shadow-sm" title="<%= post.participant.power %>">
<%= post.participant.power ? post.participant.power[0..1] : '?' %>
</span>
</div>
<% end %>
<div class="max-w-lg <%= is_me ? 'order-1' : 'order-2' %>">
<div class="flex items-baseline space-x-2 mb-1 <%= is_me ? 'justify-end' : 'justify-start' %>">
<% unless is_me %>
<span class="text-xs font-bold text-gray-900"><%= post.participant.power %></span>
<% end %>
<span class="text-xs text-gray-500"><%= l post.created_at, format: :short %></span>
<% if post.phase.present? %>
<span class="text-xs bg-gray-100 px-1 rounded text-gray-600 border border-gray-200"><%= post.phase %></span>
<% end %>
</div>
<div class="px-4 py-2 rounded-lg shadow-sm text-sm <%= is_me ? 'bg-indigo-600 text-white rounded-br-none' : 'bg-white border border-gray-200 text-gray-900 rounded-bl-none' %>">
<%= simple_format(h(post.body), {}, wrapper_tag: "div") %>
</div>
</div>
</div>

View File

@@ -0,0 +1,194 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<%= render "games/header", game: @game, display_turn: @game.latest_turn, current_participant: @current_participant, hide_controls: true %>
<div class="md:flex md:items-center md:justify-between mb-6">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
外交・交渉掲示板
</h2>
<p class="mt-1 text-sm text-gray-500">
他国との秘密交渉や全体へのアナウンスを行います。
</p>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4">
<%= link_to game_path(@game), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %>
<i class="fa-solid fa-map mr-2"></i> マップに戻る
<% end %>
<% if @game.status == 'in_progress' %>
<%= link_to new_game_board_path(@game), class: "ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %>
<i class="fa-solid fa-plus mr-2"></i> 新しい掲示板を作成
<% end %>
<% end %>
</div>
</div>
<% if @game.status == 'finished' %>
<div class="mb-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fa-solid fa-history text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
このゲームは終了しています。すべての掲示板は履歴モード(閲覧専用)です。
</p>
</div>
</div>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左カラム: 掲示板一覧 -->
<div class="lg:col-span-2 space-y-6">
<!-- 掲示板リスト -->
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
<% if @boards.empty? %>
<li class="px-4 py-4 sm:px-6 text-center text-gray-500 text-sm">
掲示板がありません
</li>
<% else %>
<% @boards.each do |board| %>
<li>
<%= link_to game_board_path(@game, board), class: "block hover:bg-gray-50" do %>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<% if board.global? %>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 mr-3">
<i class="fa-solid fa-earth-americas mr-1"></i> 共通
</span>
<p class="text-sm font-medium text-indigo-600 truncate">
Global Board (全体掲示板)
</p>
<% else %>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 mr-3">
<i class="fa-solid fa-handshake mr-1"></i> 交渉
</span>
<p class="text-sm font-medium text-indigo-600 truncate">
<% members = board.participants.map(&:power).compact.sort %>
<%= members.join(' / ') %>
</p>
<% end %>
<% if board.is_public? %>
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
<i class="fa-solid fa-bullhorn mr-1"></i> 公開中
</span>
<% end %>
</div>
<div class="ml-2 flex-shrink-0 flex">
<% unread_count = board.unread_count_for(@current_participant) %>
<% if unread_count > 0 %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<%= unread_count %> 未読
</span>
<% end %>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
<% last_post = board.board_posts.last %>
<% if last_post %>
<i class="fa-solid fa-comment-dots flex-shrink-0 mr-1.5 text-gray-400"></i>
<span class="truncate max-w-xs"><%= last_post.body.truncate(30) %></span>
<span class="ml-2 text-xs text-gray-400">- <%= last_post.participant.power %></span>
<% else %>
<span class="text-gray-400">投稿なし</span>
<% end %>
</p>
</div>
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<% if last_post %>
<i class="fa-regular fa-clock flex-shrink-0 mr-1.5 text-gray-400"></i>
<p>
<%= time_ago_in_words(last_post.created_at) %>前
</p>
<% else %>
<p>
作成: <%= l board.created_at, format: :short %>
</p>
<% end %>
</div>
</div>
</div>
<% end %>
</li>
<% end %>
<% end %>
</ul>
</div>
</div>
<!-- 右カラム: 外交関係マップ -->
<div class="space-y-6">
<div class="bg-white shadow rounded-lg border border-gray-200">
<div class="px-4 py-3 bg-gray-50 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">外交関係マップ</h3>
<p class="text-xs text-gray-500 mt-1">
交渉チャンネルを持つ国同士の繋がり(相互のみ表示)
</p>
</div>
<div class="p-4">
<!-- 簡易的なマトリックス表示 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-xs">
<thead>
<tr>
<th class="px-2 py-1"></th>
<% @diplomacy_matrix.keys.sort.each do |power| %>
<th class="px-2 py-1 font-bold text-gray-500 rotate-45 origin-bottom-left transform translate-x-2"><%= power[0..2] %></th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% @diplomacy_matrix.keys.sort.each do |row_power| %>
<tr>
<th class="px-2 py-1 font-bold text-gray-500 text-left"><%= row_power[0..2] %></th>
<% @diplomacy_matrix.keys.sort.each do |col_power| %>
<td class="px-2 py-1 text-center">
<% if row_power == col_power %>
<span class="text-gray-200">-</span>
<% elsif @diplomacy_matrix[row_power]&.include?(col_power) %>
<span class="text-green-600 font-bold">●</span>
<% else %>
<span class="text-gray-200">・</span>
<% end %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="mt-4 text-xs text-gray-500">
<p>※ あなたが参加している掲示板、または公開された掲示板の関係のみ表示されます。</p>
</div>
</div>
</div>
<!-- ヒント -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fa-solid fa-circle-info text-blue-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">交渉のヒント</h3>
<div class="mt-2 text-sm text-blue-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<li>共通掲示板は全員が見ています。外交方針の発表に使いましょう。</li>
<li>特定の国と密約を結ぶには「新しい掲示板を作成」から招待してください。</li>
<li>交渉用掲示板の内容は、メンバー以外には秘密です(「公開」設定にしない限り)。</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<%= link_to game_boards_path(@game), class: "text-gray-500 hover:text-gray-700" do %>
<i class="fa-solid fa-arrow-left mr-1"></i> 掲示板一覧に戻る
<% end %>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">新しい交渉用掲示板を作成</h3>
<p class="mt-1 text-sm text-gray-500">
交渉したい相手(国)を選択してください。選択した相手とあなただけの秘密の掲示板が作成されます。
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= form_with model: [@game, @board], local: true, class: "space-y-6" do |f| %>
<% if @board.errors.any? %>
<div class="rounded-md bg-red-50 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fa-solid fa-circle-xmark text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">入力内容にエラーがあります</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<% @board.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div>
<label class="text-base font-medium text-gray-900">招待するメンバー</label>
<p class="text-sm text-gray-500 mb-3">少なくとも1人以上選択してください。</p>
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
<% if @participants.empty? %>
<p class="text-sm text-gray-500 italic">招待可能な他のプレイヤーがいません。</p>
<% else %>
<% @participants.each do |participant| %>
<div class="relative flex items-start py-2 border rounded p-3 hover:bg-gray-50 cursor-pointer">
<div class="min-w-0 flex-1 text-sm">
<label for="participant_<%= participant.id %>" class="font-medium text-gray-700 select-none cursor-pointer flex items-center">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border border-gray-200 mr-2 <%= power_color_class(participant.power) %>">
<%= participant.power %>
</span>
<%= participant.user.username %>
</label>
</div>
<div class="ml-3 flex items-center h-5">
<input id="participant_<%= participant.id %>" name="invited_participant_ids[]" value="<%= participant.id %>" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
</div>
<% end %>
<% end %>
</div>
</div>
<div class="pt-5 border-t border-gray-200 flex justify-end">
<%= link_to "キャンセル", game_boards_path(@game), class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3" %>
<%= f.submit "掲示板を作成", class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,221 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 h-screen-minus-header flex flex-col">
<%= render "games/header", game: @game, display_turn: @game.latest_turn, current_participant: @current_participant, hide_controls: true %>
<!-- ヘッダー -->
<div class="md:flex md:items-center md:justify-between mb-4 border-b pb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center">
<%= link_to game_boards_path(@game), class: "mr-4 text-gray-500 hover:text-gray-700" do %>
<i class="fa-solid fa-arrow-left"></i>
<% end %>
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
<% if @board.global? %>
<i class="fa-solid fa-earth-americas text-blue-500 mr-2"></i> Global Board
<% else %>
<i class="fa-solid fa-handshake text-green-500 mr-2"></i> Negotiation Board
<% end %>
</h2>
<% if @board.is_public? %>
<span class="ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<i class="fa-solid fa-bullhorn mr-1"></i> 公開中
</span>
<% end %>
<% if @board.history_mode? %>
<span class="ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<i class="fa-solid fa-scroll mr-1"></i> 履歴モード
</span>
<% end %>
</div>
<div class="mt-1 flex flex-col sm:flex-row sm:flex-wrap sm:mt-0 sm:space-x-6">
<div class="mt-2 flex items-center text-sm text-gray-500">
<i class="fa-solid fa-users flex-shrink-0 mr-1.5 text-gray-400"></i>
メンバー:
<% @active_members.each do |m| %>
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border <%= power_color_class(m.power) %>">
<%= m.power %>
</span>
<% end %>
</div>
</div>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 space-x-2">
<!-- 公開設定切り替え (交渉用かつ参加中のみ) -->
<% if @board.negotiation? && @board.member?(@current_participant) && !@board.history_mode? %>
<%= button_to toggle_public_game_board_path(@game, @board), method: :patch, class: "inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %>
<% if @board.is_public? %>
<i class="fa-solid fa-lock mr-1"></i> 非公開にする
<% else %>
<i class="fa-solid fa-bullhorn mr-1"></i> 公開する
<% end %>
<% end %>
<% end %>
<!-- 退出ボタン (交渉用かつ参加中のみ) -->
<% if @board.negotiation? && @board.member?(@current_participant) && !@board.history_mode? %>
<%= button_to leave_game_board_board_memberships_path(@game, @board), method: :delete, data: { turbo_confirm: "本当に退出しますか?退出後はこの掲示板に投稿できなくなります。" }, class: "inline-flex items-center px-3 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" do %>
<i class="fa-solid fa-right-from-bracket mr-1"></i> 退出
<% end %>
<% end %>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- 左: チャットエリア -->
<!-- 左: チャットエリア -->
<div class="flex-1 flex flex-col bg-white border border-gray-200 rounded-lg shadow-sm mr-4 overflow-hidden">
<!-- 投稿フォーム (上部に移動) -->
<% if @board.member?(@current_participant) && !@board.history_mode? %>
<div class="p-4 bg-gray-50 border-b border-gray-200">
<%= form_with model: [@game, @board, @new_post], local: true, class: "flex items-end space-x-2" do |f| %>
<div class="flex-1">
<%= f.text_area :body, rows: 2, placeholder: "メッセージを入力...", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", required: true %>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 h-10">
<i class="fa-solid fa-paper-plane"></i>
</button>
<% end %>
</div>
<% elsif @board.history_mode? %>
<div class="p-4 bg-gray-100 border-b border-gray-200 text-center text-gray-500 text-sm">
履歴モードのため投稿できません
</div>
<% else %>
<div class="p-4 bg-gray-100 border-b border-gray-200 text-center text-gray-500 text-sm">
この掲示板には参加していません
</div>
<% end %>
<!-- メッセージリスト -->
<div id="board-posts"
class="flex-1 p-4 overflow-y-auto space-y-4"
data-controller="board-channel"
data-board-channel-board-id-value="<%= @board.id %>"
data-board-channel-current-participant-id-value="<%= @current_participant&.id %>">
<% if @posts.empty? %>
<div class="text-center text-gray-400 py-10" id="no-posts-message">
<p>まだ投稿がありません。最初のメッセージを投稿しましょう。</p>
</div>
<% else %>
<% @posts.each do |post| %>
<%= render partial: "post", locals: { post: post, current_participant: @current_participant } %>
<% end %>
<% end %>
</div>
</div>
<!-- 右: サイドパネル(メンバー管理・提案) -->
<div class="w-80 flex flex-col space-y-4 overflow-y-auto">
<!-- 提案 (Proposals) -->
<% if @board.negotiation? %>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200 font-medium text-sm text-gray-700">
条約・提案
</div>
<div class="p-3 space-y-3">
<% @board.board_proposals.order(created_at: :desc).each do |proposal| %>
<div class="border rounded p-2 text-sm <%= proposal.status == 'accepted' ? 'bg-green-50 border-green-200' : (proposal.status == 'rejected' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200') %>">
<div class="flex justify-between items-start mb-1">
<div>
<span class="font-bold text-xs mr-1"><%= proposal.proposer.power %></span>
<% if proposal.phase %>
<span class="text-xs bg-gray-100 text-gray-500 px-1 rounded border border-gray-200"><%= proposal.phase %></span>
<% end %>
</div>
<span class="text-xs text-gray-400"><%= l proposal.created_at, format: :short %></span>
</div>
<p class="mb-2"><%= proposal.body %></p>
<div class="flex justify-between items-center">
<span class="text-xs font-medium px-2 py-0.5 rounded
<%= case proposal.status
when 'pending' then 'bg-yellow-100 text-yellow-800'
when 'accepted' then 'bg-green-100 text-green-800'
when 'rejected' then 'bg-red-100 text-red-800'
end %>">
<%= proposal.status.upcase %>
</span>
<% if proposal.pending? && @board.member?(@current_participant) && !@board.history_mode? %>
<div class="flex space-x-1">
<%= button_to "承認", game_board_board_proposal_path(@game, @board, proposal, board_proposal: { status: 'accepted' }), method: :patch, class: "text-xs bg-green-100 hover:bg-green-200 text-green-800 px-2 py-1 rounded" %>
<%= button_to "拒否", game_board_board_proposal_path(@game, @board, proposal, board_proposal: { status: 'rejected' }), method: :patch, class: "text-xs bg-red-100 hover:bg-red-200 text-red-800 px-2 py-1 rounded" %>
</div>
<% end %>
</div>
</div>
<% end %>
<% if @board.member?(@current_participant) && !@board.history_mode? %>
<div class="mt-2 pt-2 border-t text-center">
<button type="button" onclick="document.getElementById('new-proposal-form').classList.toggle('hidden')" class="text-xs text-indigo-600 hover:text-indigo-800">
+ 新しい提案を作成
</button>
<div id="new-proposal-form" class="hidden mt-2">
<%= form_with model: [@game, @board, @new_proposal], local: true do |f| %>
<%= f.text_area :body, rows: 2, class: "w-full text-xs border-gray-300 rounded mb-2", placeholder: "提案内容 (例: 1901秋冬は不可侵)" %>
<%= f.submit "提案する", class: "w-full bg-indigo-600 text-white text-xs py-1 rounded hover:bg-indigo-700" %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- メンバー招待 -->
<% if @board.negotiation? && @board.member?(@current_participant) && !@board.history_mode? %>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200 font-medium text-sm text-gray-700">
メンバー追加
</div>
<div class="p-3">
<% if @candidates.present? %>
<%= form_with url: game_board_board_memberships_path(@game, @board), local: true do |f| %>
<div class="flex space-x-2">
<%= f.collection_select :participant_id, @candidates, :id, :power, { prompt: "国を選択" }, { class: "block w-full text-xs border-gray-300 rounded" } %>
<%= f.submit "招待", class: "bg-indigo-600 text-white text-xs px-3 py-1 rounded hover:bg-indigo-700" %>
</div>
<% end %>
<% else %>
<p class="text-xs text-gray-500">招待可能なプレイヤーがいません</p>
<% end %>
</div>
</div>
<% end %>
<!-- メンバー一覧 -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200 font-medium text-sm text-gray-700">
参加メンバー
</div>
<ul class="divide-y divide-gray-200">
<% @board.board_memberships.includes(:participant).each do |membership| %>
<li class="px-3 py-2 flex items-center justify-between text-sm">
<div class="flex items-center">
<span class="<%= 'line-through text-gray-400' if membership.left_at %>">
<%= membership.participant.power %>
</span>
<% if membership.participant.user == current_user %>
<span class="ml-1 text-xs text-indigo-500">(YOU)</span>
<% end %>
</div>
<% if membership.left_at %>
<span class="text-xs text-red-500">退出済</span>
<% end %>
</li>
<% end %>
</ul>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,133 @@
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900"><%= h(game.title) %></h1>
<% if game.memo.present? %>
<p class="mt-1 text-sm text-gray-500"><%= h(game.memo) %></p>
<% end %>
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-600">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= game.solo_mode? ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800' %>">
<%= game.solo_mode? ? 'ソロモード' : 'マルチプレイヤーモード' %>
</span>
<% if game.administrator %>
<span>管理者: <%= h(game.administrator.username) %></span>
<% end %>
<span>状態: <%= game.status %></span>
</div>
<% if game.year_limit.present? || game.victory_sc_count != 18 || game.scoring_system != "none" || game.auto_turn? %>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span class="font-medium text-gray-500">ハウスルール:</span>
<% if game.auto_turn? %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
<i class="fa-solid fa-clock mr-1"></i> <%= game.schedule_display %>
</span>
<% end %>
<% if game.year_limit.present? %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
<i class="fa-solid fa-hourglass-half mr-1"></i> <%= game.year_limit %>年制限
</span>
<% end %>
<% if game.victory_sc_count != 18 %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-green-100 text-green-800">
<i class="fa-solid fa-crown mr-1"></i> 目標SC: <%= game.victory_sc_count %>
</span>
<% end %>
<% if game.scoring_system != "none" %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800">
<i class="fa-solid fa-chart-bar mr-1"></i> <%= game.scoring_system_name %>
</span>
<% end %>
</div>
<% end %>
<% if game.auto_turn? && game.next_deadline_at.present? && game.status == "in_progress" %>
<div class="mt-2 px-3 py-1.5 rounded-md bg-blue-50 border border-blue-200 text-sm text-blue-800 flex items-center gap-2">
<i class="fa-solid fa-clock"></i>
<span>次のターン締切: <strong><%= game.next_deadline_at.in_time_zone("Asia/Tokyo").strftime("%Y-%m-%d %H:%M") %></strong></span>
<span class="text-blue-600" id="deadline-countdown"></span>
</div>
<script>
(function() {
const deadline = new Date("<%= game.next_deadline_at.iso8601 %>");
const countdownEl = document.getElementById('deadline-countdown');
function updateCountdown() {
const now = new Date();
const diff = deadline - now;
if (diff <= 0) {
countdownEl.textContent = '(締切済み — まもなく処理されます)';
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
countdownEl.textContent = `(残り ${hours}時間 ${minutes}分)`;
}
if (countdownEl) {
updateCountdown();
setInterval(updateCountdown, 60000);
}
})();
</script>
<% end %>
</div>
<% unless local_assigns[:hide_controls] %>
<div class="flex space-x-3">
<% if current_user&.admin? || game.administrator == current_user %>
<%= link_to "設定", edit_game_path(game), class: "inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<%= button_to "削除", game, method: :delete, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500", data: { turbo_confirm: "本当に削除しますか?" } %>
<% end %>
</div>
<% end %>
</div>
<% # ゲーム画面games/showと統一されたデザインでターン情報を表示 %>
<%
target_turn = defined?(display_turn) && display_turn ? display_turn : game.latest_turn
%>
<% if target_turn && target_turn.phase.present? %>
<%
# フェーズ名パース (例: S1901M)
phase = target_turn.phase
year = phase[1..4]
season_code = phase[0]
type_code = phase[-1]
season = case season_code
when 'S' then '春'
when 'F' then '秋'
when 'W' then '冬'
else ''
end
type = case type_code
when 'M' then '移動'
when 'R' then '撤退'
when 'A' then '調整'
else ''
end
display_date = "#{year}年 #{season} (#{type})"
%>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mb-6">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50">
<div>
<h2 class="text-2xl font-bold text-gray-900"><%= display_date %></h2>
<div class="mt-1 flex items-center space-x-4 text-sm text-gray-600">
<span>ターン: <%= target_turn.number %></span>
<span class="text-gray-400">|</span>
<span>フェーズ: <%= phase %></span>
</div>
</div>
<div class="flex items-center space-x-4">
<% if defined?(current_participant) && current_participant&.power %>
<div class="flex items-center bg-white px-4 py-2 rounded border border-gray-300 shadow-sm">
<span class="mr-2 text-sm text-gray-500">あなたの担当国:</span>
<span class="text-lg font-bold text-indigo-700"><%= current_participant.power %></span>
</div>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -414,6 +414,45 @@
<!-- 右カラム: 情報パネル (1/3) --> <!-- 右カラム: 情報パネル (1/3) -->
<div class="space-y-6"> <div class="space-y-6">
<!-- 掲示板リンク -->
<div class="bg-white shadow rounded-lg border border-gray-200 overflow-hidden">
<div class="p-4 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0 bg-indigo-100 rounded-md p-3">
<i class="fa-solid fa-comments text-indigo-600 text-xl"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">外交・交渉掲示板</h3>
<p class="text-sm text-gray-500">
秘密交渉や全体アナウンスはこちら
</p>
</div>
</div>
<div>
<%= link_to game_boards_path(@game), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %>
掲示板へ
<%
# 未読件数取得(参加中の全掲示板の合計)
current_participant = @game.participants.find_by(user: current_user)
if current_participant
total_unread = 0
current_participant.boards.each do |board|
total_unread += board.unread_count_for(current_participant)
end
if total_unread > 0
%>
<span class="ml-2 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-100 bg-red-600 rounded-full">
<%= total_unread %>
</span>
<%
end
end
%>
<% end %>
</div>
</div>
</div>
<!-- 国ステータス表 --> <!-- 国ステータス表 -->
<div class="bg-white shadow rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white shadow rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b border-gray-200"> <div class="px-4 py-3 bg-gray-50 border-b border-gray-200">

View File

@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= action_cable_meta_tag %>
<%= yield :head %> <%= yield :head %>

View File

@@ -3,7 +3,9 @@
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view # not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear. # to make the web console appear.
development: development:
adapter: async adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
test: test:
adapter: test adapter: test

View File

@@ -5,3 +5,5 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/controllers", under: "controllers"
pin "actioncable" # @5.2.8
pin "@rails/actioncable", to: "@rails--actioncable.js" # @8.1.200

View File

@@ -21,6 +21,19 @@ Rails.application.routes.draw do
patch :select_power patch :select_power
end end
end end
resources :boards, only: [ :index, :new, :create, :show ] do
member do
patch :toggle_public
end
resources :board_posts, only: [ :create ], path: "posts"
resources :board_memberships, only: [ :create ], path: "members" do
collection do
delete :leave
end
end
resources :board_proposals, only: [ :create, :update ], path: "proposals"
end
end end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

View File

@@ -0,0 +1,16 @@
class CreateBoards < ActiveRecord::Migration[8.1]
def change
create_table :boards do |t|
t.integer :game_id, null: false
t.string :board_type, null: false, default: 'negotiation'
t.integer :created_by_participant_id
t.boolean :is_public, null: false, default: false
t.timestamps
end
add_index :boards, :game_id
add_index :boards, :board_type
add_foreign_key :boards, :games
end
end

View File

@@ -0,0 +1,19 @@
class CreateBoardMemberships < ActiveRecord::Migration[8.1]
def change
create_table :board_memberships do |t|
t.integer :board_id, null: false
t.integer :participant_id, null: false
t.datetime :joined_at, null: false
t.datetime :left_at
t.integer :last_read_post_id
t.timestamps
end
add_index :board_memberships, :board_id
add_index :board_memberships, :participant_id
add_index :board_memberships, [ :board_id, :participant_id ], unique: true
add_foreign_key :board_memberships, :boards
add_foreign_key :board_memberships, :participants
end
end

View File

@@ -0,0 +1,17 @@
class CreateBoardPosts < ActiveRecord::Migration[8.1]
def change
create_table :board_posts do |t|
t.integer :board_id, null: false
t.integer :participant_id, null: false
t.text :body, null: false
t.string :phase
t.timestamps
end
add_index :board_posts, :board_id
add_index :board_posts, :participant_id
add_foreign_key :board_posts, :boards
add_foreign_key :board_posts, :participants
end
end

View File

@@ -0,0 +1,17 @@
class CreateBoardProposals < ActiveRecord::Migration[8.1]
def change
create_table :board_proposals do |t|
t.integer :board_id, null: false
t.integer :proposer_participant_id, null: false
t.text :body, null: false
t.string :status, null: false, default: 'pending'
t.timestamps
end
add_index :board_proposals, :board_id
add_index :board_proposals, :proposer_participant_id
add_foreign_key :board_proposals, :boards
add_foreign_key :board_proposals, :participants, column: :proposer_participant_id
end
end

View File

@@ -0,0 +1,13 @@
class CreateSolidCableMessages < ActiveRecord::Migration[7.1]
def change
create_table "solid_cable_messages" do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index [ "channel" ], name: "index_solid_cable_messages_on_channel"
t.index [ "channel_hash" ], name: "index_solid_cable_messages_on_channel_hash"
t.index [ "created_at" ], name: "index_solid_cable_messages_on_created_at"
end
end
end

View File

@@ -0,0 +1,5 @@
class AddPhaseToBoardProposals < ActiveRecord::Migration[8.1]
def change
add_column :board_proposals, :phase, :string
end
end

66
db/schema.rb generated
View File

@@ -10,7 +10,54 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_02_13_122531) do ActiveRecord::Schema[8.1].define(version: 2026_02_18_134459) do
create_table "board_memberships", force: :cascade do |t|
t.integer "board_id", null: false
t.datetime "created_at", null: false
t.datetime "joined_at", null: false
t.integer "last_read_post_id"
t.datetime "left_at"
t.integer "participant_id", null: false
t.datetime "updated_at", null: false
t.index ["board_id", "participant_id"], name: "index_board_memberships_on_board_id_and_participant_id", unique: true
t.index ["board_id"], name: "index_board_memberships_on_board_id"
t.index ["participant_id"], name: "index_board_memberships_on_participant_id"
end
create_table "board_posts", force: :cascade do |t|
t.integer "board_id", null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.integer "participant_id", null: false
t.string "phase"
t.datetime "updated_at", null: false
t.index ["board_id"], name: "index_board_posts_on_board_id"
t.index ["participant_id"], name: "index_board_posts_on_participant_id"
end
create_table "board_proposals", force: :cascade do |t|
t.integer "board_id", null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.string "phase"
t.integer "proposer_participant_id", null: false
t.string "status", default: "pending", null: false
t.datetime "updated_at", null: false
t.index ["board_id"], name: "index_board_proposals_on_board_id"
t.index ["proposer_participant_id"], name: "index_board_proposals_on_proposer_participant_id"
end
create_table "boards", force: :cascade do |t|
t.string "board_type", default: "negotiation", null: false
t.datetime "created_at", null: false
t.integer "created_by_participant_id"
t.integer "game_id", null: false
t.boolean "is_public", default: false, null: false
t.datetime "updated_at", null: false
t.index ["board_type"], name: "index_boards_on_board_type"
t.index ["game_id"], name: "index_boards_on_game_id"
end
create_table "games", force: :cascade do |t| create_table "games", force: :cascade do |t|
t.string "auto_order_mode", default: "hold", null: false t.string "auto_order_mode", default: "hold", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -43,6 +90,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_13_122531) do
t.index ["user_id"], name: "index_participants_on_user_id" t.index ["user_id"], name: "index_participants_on_user_id"
end end
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.integer "channel_hash", limit: 8, null: false
t.datetime "created_at", null: false
t.binary "payload", limit: 536870912, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
create_table "solid_queue_blocked_executions", force: :cascade do |t| create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.string "concurrency_key", null: false t.string "concurrency_key", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -189,6 +246,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_13_122531) do
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
end end
add_foreign_key "board_memberships", "boards"
add_foreign_key "board_memberships", "participants"
add_foreign_key "board_posts", "boards"
add_foreign_key "board_posts", "participants"
add_foreign_key "board_proposals", "boards"
add_foreign_key "board_proposals", "participants", column: "proposer_participant_id"
add_foreign_key "boards", "games"
add_foreign_key "participants", "games" add_foreign_key "participants", "games"
add_foreign_key "participants", "users" add_foreign_key "participants", "users"
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade

View File

@@ -0,0 +1,79 @@
require "test_helper"
class BoardsControllerTest < ActionDispatch::IntegrationTest
setup do
@game = games(:one)
# fixtureのgames(:one)はtitleだけの状態から修正されている前提だが、
# 念のためupdateして整合性を取る
@game.update!(
status: "in_progress",
participants_count: 7,
victory_sc_count: 18,
scoring_system: "none",
turn_schedule: "0,12"
)
@user = users(:austria)
@user_germany = users(:germany)
# 参加者作成
# BoardTest同様、fixtureに依存せずParticipantを作る
@p_austria = Participant.create!(game: @game, user: @user, power: "AUSTRIA")
@p_germany = Participant.create!(game: @game, user: @user_germany, power: "GERMANY")
# 共通掲示板作成
@game.boards.create!(board_type: "global", is_public: true)
end
test "should get index" do
login_as(@user)
get game_boards_url(@game)
assert_response :success
assert_select "h2", "外交・交渉掲示板"
end
test "should get new negotiation board" do
login_as(@user)
get new_game_board_url(@game)
assert_response :success
end
test "should create negotiation board" do
login_as(@user)
assert_difference("Board.count") do
post game_boards_url(@game), params: {
invited_participant_ids: [ @p_germany.id ]
}
end
assert_redirected_to game_board_url(@game, Board.last)
assert Board.last.member?(@p_austria)
assert Board.last.member?(@p_germany)
end
test "should show board" do
login_as(@user)
board = @game.boards.global.first
get game_board_url(@game, board)
assert_response :success
end
test "should not show board to non-member" do
# 第三者作成
user_russia = users(:russia)
p_russia = Participant.create!(game: @game, user: user_russia, power: "RUSSIA")
# オーストリアとドイツの掲示板
board = @game.boards.create!(board_type: "negotiation", created_by_participant_id: @p_austria.id)
board.board_memberships.create!(participant: @p_austria, joined_at: Time.current)
board.board_memberships.create!(participant: @p_germany, joined_at: Time.current)
# ロシアでログイン
login_as(user_russia)
get game_board_url(@game, board)
assert_redirected_to game_boards_url(@game)
follow_redirect!
assert_select "div[role='alert']", /権限がありません/
end
end

View File

@@ -1,11 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one: one:
title: MyString title: GameOne
participants_count: 1 status: in_progress
memo: MyText turn_schedule: "0,12"
participants_count: 7
two: two:
title: MyString title: GameTwo
status: recruiting
turn_schedule: "0,12"
participants_count: 1 participants_count: 1
memo: MyText

View File

@@ -9,3 +9,21 @@ two:
email: two@example.com email: two@example.com
password_digest: <%= BCrypt::Password.create('password') %> password_digest: <%= BCrypt::Password.create('password') %>
admin: false admin: false
austria:
username: AustriaUser
email: austria@example.com
password_digest: <%= BCrypt::Password.create('password') %>
admin: false
germany:
username: GermanyUser
email: germany@example.com
password_digest: <%= BCrypt::Password.create('password') %>
admin: false
russia:
username: RussiaUser
email: russia@example.com
password_digest: <%= BCrypt::Password.create('password') %>
admin: false

85
test/models/board_test.rb Normal file
View File

@@ -0,0 +1,85 @@
require "test_helper"
class BoardTest < ActiveSupport::TestCase
def setup
@game = games(:one) # fixturesから取得
@austria = users(:austria)
@germany = users(:germany)
@russia = users(:russia)
# 参加者作成fixtureに依存せず動的に作る方が安全だが簡略化
@p_austria = Participant.create!(game: @game, user: @austria, power: "AUSTRIA")
@p_germany = Participant.create!(game: @game, user: @germany, power: "GERMANY")
@p_russia = Participant.create!(game: @game, user: @russia, power: "RUSSIA")
end
test "should create global board automatically" do
new_game = Game.create!(
title: "New Game",
status: "recruiting",
turn_schedule: "0,12",
participants_count: 7,
victory_sc_count: 18,
scoring_system: "none"
)
assert new_game.boards.global.exists?
end
test "should create negotiation board" do
board = @game.boards.new(board_type: "negotiation", created_by_participant_id: @p_austria.id)
assert board.save
# メンバー追加
board.board_memberships.create!(participant: @p_austria, joined_at: Time.current)
board.board_memberships.create!(participant: @p_germany, joined_at: Time.current)
assert board.member?(@p_austria)
assert board.member?(@p_germany)
assert_not board.member?(@p_russia)
end
test "should not allow duplicate member combination" do
# 既存のボード: Austria & Germany
board1 = @game.boards.create!(board_type: "negotiation", created_by_participant_id: @p_austria.id)
board1.board_memberships.create!(participant: @p_austria, joined_at: Time.current)
board1.board_memberships.create!(participant: @p_germany, joined_at: Time.current)
# 新しいボード: Austria & Germany (重複)
board2 = @game.boards.new(board_type: "negotiation", created_by_participant_id: @p_austria.id)
board2.board_memberships.build(participant: @p_austria, joined_at: Time.current)
board2.board_memberships.build(participant: @p_germany, joined_at: Time.current)
assert_not board2.valid?
assert_includes board2.errors[:base], "同じメンバー構成の掲示板がすでに存在します"
# メンバーが違えばOK: Austria & Russia
board3 = @game.boards.new(board_type: "negotiation", created_by_participant_id: @p_austria.id)
board3.board_memberships.build(participant: @p_austria, joined_at: Time.current)
board3.board_memberships.build(participant: @p_russia, joined_at: Time.current)
assert board3.valid?
end
test "unread count" do
board = @game.boards.create!(board_type: "negotiation", created_by_participant_id: @p_austria.id)
m_austria = board.board_memberships.create!(participant: @p_austria, joined_at: Time.current)
# 投稿前の未読は0
assert_equal 0, board.unread_count_for(@p_austria)
# 投稿作成
post1 = board.board_posts.create!(participant: @p_austria, body: "Hello")
post2 = board.board_posts.create!(participant: @p_austria, body: "World")
# Last read が nil なので全件未読扱いにはならない(ロジック上は last_read_post_id || 0 なので `id > 0` の件数になる)
# `id` はauto incrementで正の数なので、全件カウントされるはず
assert_equal 2, board.unread_count_for(@p_austria)
# 既読更新
m_austria.mark_read!(post1.id)
assert_equal 1, board.unread_count_for(@p_austria)
m_austria.mark_read!(post2.id)
assert_equal 0, board.unread_count_for(@p_austria)
end
end

View File

@@ -11,5 +11,8 @@ module ActiveSupport
fixtures :all fixtures :all
# Add more helper methods to be used by all tests here... # Add more helper methods to be used by all tests here...
def login_as(user, password: "password")
post login_url, params: { email: user.email, password: password }
end
end end
end end

File diff suppressed because one or more lines are too long

4
vendor/javascript/actioncable.js vendored Normal file

File diff suppressed because one or more lines are too long