diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..4a43d2f --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -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 diff --git a/app/channels/board_channel.rb b/app/channels/board_channel.rb new file mode 100644 index 0000000..010c261 --- /dev/null +++ b/app/channels/board_channel.rb @@ -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 diff --git a/app/controllers/board_memberships_controller.rb b/app/controllers/board_memberships_controller.rb new file mode 100644 index 0000000..65f4974 --- /dev/null +++ b/app/controllers/board_memberships_controller.rb @@ -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 diff --git a/app/controllers/board_posts_controller.rb b/app/controllers/board_posts_controller.rb new file mode 100644 index 0000000..d9a6e51 --- /dev/null +++ b/app/controllers/board_posts_controller.rb @@ -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 diff --git a/app/controllers/board_proposals_controller.rb b/app/controllers/board_proposals_controller.rb new file mode 100644 index 0000000..8d5d4cf --- /dev/null +++ b/app/controllers/board_proposals_controller.rb @@ -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 diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb new file mode 100644 index 0000000..111e572 --- /dev/null +++ b/app/controllers/boards_controller.rb @@ -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 diff --git a/app/javascript/controllers/board_channel_controller.js b/app/javascript/controllers/board_channel_controller.js new file mode 100644 index 0000000..a68d7bc --- /dev/null +++ b/app/javascript/controllers/board_channel_controller.js @@ -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 = ` +
+ ` + + this.element.insertAdjacentHTML('afterbegin', html) + // this.element.scrollTop = 0 // 必要ならトップへスクロール + } + + _escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + } +} diff --git a/app/models/board.rb b/app/models/board.rb new file mode 100644 index 0000000..0e80b4a --- /dev/null +++ b/app/models/board.rb @@ -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 diff --git a/app/models/board_membership.rb b/app/models/board_membership.rb new file mode 100644 index 0000000..7bd77fc --- /dev/null +++ b/app/models/board_membership.rb @@ -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 diff --git a/app/models/board_post.rb b/app/models/board_post.rb new file mode 100644 index 0000000..106613e --- /dev/null +++ b/app/models/board_post.rb @@ -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 diff --git a/app/models/board_proposal.rb b/app/models/board_proposal.rb new file mode 100644 index 0000000..7d2db4c --- /dev/null +++ b/app/models/board_proposal.rb @@ -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 diff --git a/app/models/game.rb b/app/models/game.rb index a48820f..3a58e50 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -2,6 +2,8 @@ class Game < ApplicationRecord has_many :turns, dependent: :destroy has_many :participants, dependent: :destroy 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 @@ -26,6 +28,9 @@ class Game < ApplicationRecord # ターンスケジュールバリデーション validate :validate_turn_schedule + # コールバック + after_create :create_global_board + # ヘルパーメソッド def password_protected? password_digest.present? @@ -162,4 +167,8 @@ class Game < ApplicationRecord errors.add(:turn_schedule, "は0〜23の数値をカンマ区切りで入力してください(例: 0,18)") end end + + def create_global_board + boards.create!(board_type: "global", is_public: true) + end end diff --git a/app/models/participant.rb b/app/models/participant.rb index 53013d4..3329e3d 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -1,19 +1,23 @@ class Participant < ApplicationRecord belongs_to :user 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, message: "既にこのゲームに参加しています" } - - validates :power, uniqueness: { + + validates :power, uniqueness: { scope: :game_id, message: "この国は既に選択されています" }, allow_nil: true - - validates :power, inclusion: { + + validates :power, inclusion: { in: %w[AUSTRIA ENGLAND FRANCE GERMANY ITALY RUSSIA TURKEY], message: "無効な国です" }, allow_nil: true diff --git a/app/views/boards/_post.html.erb b/app/views/boards/_post.html.erb new file mode 100644 index 0000000..3b8e4ce --- /dev/null +++ b/app/views/boards/_post.html.erb @@ -0,0 +1,27 @@ +<% is_me = post.participant == current_participant %> + diff --git a/app/views/boards/index.html.erb b/app/views/boards/index.html.erb new file mode 100644 index 0000000..950546a --- /dev/null +++ b/app/views/boards/index.html.erb @@ -0,0 +1,194 @@ ++ 他国との秘密交渉や全体へのアナウンスを行います。 +
++ このゲームは終了しています。すべての掲示板は履歴モード(閲覧専用)です。 +
++ Global Board (全体掲示板) +
+ <% else %> + + 交渉 + ++ <% members = board.participants.map(&:power).compact.sort %> + <%= members.join(' / ') %> +
+ <% end %> + + <% if board.is_public? %> + + 公開中 + + <% end %> ++ <% last_post = board.board_posts.last %> + <% if last_post %> + + <%= last_post.body.truncate(30) %> + - <%= last_post.participant.power %> + <% else %> + 投稿なし + <% end %> +
++ <%= time_ago_in_words(last_post.created_at) %>前 +
+ <% else %> ++ 作成: <%= l board.created_at, format: :short %> +
+ <% end %> ++ 交渉チャンネルを持つ国同士の繋がり(相互のみ表示) +
+| + <% @diplomacy_matrix.keys.sort.each do |power| %> + | <%= power[0..2] %> | + <% end %> +
|---|---|
| <%= row_power[0..2] %> | + <% @diplomacy_matrix.keys.sort.each do |col_power| %> ++ <% if row_power == col_power %> + - + <% elsif @diplomacy_matrix[row_power]&.include?(col_power) %> + ● + <% else %> + ・ + <% end %> + | + <% end %> +
※ あなたが参加している掲示板、または公開された掲示板の関係のみ表示されます。
++ 交渉したい相手(国)を選択してください。選択した相手とあなただけの秘密の掲示板が作成されます。 +
+少なくとも1人以上選択してください。
+ +招待可能な他のプレイヤーがいません。
+ <% else %> + <% @participants.each do |participant| %> +まだ投稿がありません。最初のメッセージを投稿しましょう。
+<%= proposal.body %>
+ +招待可能なプレイヤーがいません
+ <% end %> +<%= h(game.memo) %>
+ <% end %> ++ 秘密交渉や全体アナウンスはこちら +
+