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 = ` +
+ ${!isMe ? ` +
+ + ${data.power ? data.power.substring(0, 2) : '?'} + +
+ ` : ''} + +
+
+ ${!isMe ? `${data.power}` : ''} + ${data.created_at} + ${data.phase ? `${data.phase}` : ''} +
+ +
+
${this._escapeHtml(data.body).replace(/\n/g, '
')}
+
+
+
+ ` + + 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 %> +
+ <% unless is_me %> +
+ + + <%= post.participant.power ? post.participant.power[0..1] : '?' %> + +
+ <% end %> + +
+
+ <% unless is_me %> + <%= post.participant.power %> + <% end %> + <%= l post.created_at, format: :short %> + <% if post.phase.present? %> + <%= post.phase %> + <% end %> +
+ +
+ <%= simple_format(h(post.body), {}, wrapper_tag: "div") %> +
+
+
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 @@ +
+ <%= render "games/header", game: @game, display_turn: @game.latest_turn, current_participant: @current_participant, hide_controls: true %> + +
+
+

+ 外交・交渉掲示板 +

+

+ 他国との秘密交渉や全体へのアナウンスを行います。 +

+
+
+ <%= 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 %> + マップに戻る + <% 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 %> + 新しい掲示板を作成 + <% end %> + <% end %> +
+
+ + <% if @game.status == 'finished' %> +
+
+
+ +
+
+

+ このゲームは終了しています。すべての掲示板は履歴モード(閲覧専用)です。 +

+
+
+
+ <% end %> + +
+ +
+ + +
+
    + <% if @boards.empty? %> +
  • + 掲示板がありません +
  • + <% else %> + <% @boards.each do |board| %> +
  • + <%= link_to game_board_path(@game, board), class: "block hover:bg-gray-50" do %> +
    +
    +
    + <% if board.global? %> + + 共通 + +

    + Global Board (全体掲示板) +

    + <% else %> + + 交渉 + +

    + <% members = board.participants.map(&:power).compact.sort %> + <%= members.join(' / ') %> +

    + <% end %> + + <% if board.is_public? %> + + 公開中 + + <% end %> +
    +
    + <% unread_count = board.unread_count_for(@current_participant) %> + <% if unread_count > 0 %> + + <%= unread_count %> 未読 + + <% end %> +
    +
    +
    +
    +

    + <% last_post = board.board_posts.last %> + <% if last_post %> + + <%= last_post.body.truncate(30) %> + - <%= last_post.participant.power %> + <% else %> + 投稿なし + <% end %> +

    +
    +
    + <% if last_post %> + +

    + <%= time_ago_in_words(last_post.created_at) %>前 +

    + <% else %> +

    + 作成: <%= l board.created_at, format: :short %> +

    + <% end %> +
    +
    +
    + <% end %> +
  • + <% end %> + <% end %> +
+
+
+ + +
+
+
+

外交関係マップ

+

+ 交渉チャンネルを持つ国同士の繋がり(相互のみ表示) +

+
+
+ +
+ + + + + <% @diplomacy_matrix.keys.sort.each do |power| %> + + <% end %> + + + + <% @diplomacy_matrix.keys.sort.each do |row_power| %> + + + <% @diplomacy_matrix.keys.sort.each do |col_power| %> + + <% end %> + + <% end %> + +
<%= power[0..2] %>
<%= row_power[0..2] %> + <% if row_power == col_power %> + - + <% elsif @diplomacy_matrix[row_power]&.include?(col_power) %> + + <% else %> + + <% end %> +
+
+ +
+

※ あなたが参加している掲示板、または公開された掲示板の関係のみ表示されます。

+
+
+
+ + +
+
+
+ +
+
+

交渉のヒント

+
+
    +
  • 共通掲示板は全員が見ています。外交方針の発表に使いましょう。
  • +
  • 特定の国と密約を結ぶには「新しい掲示板を作成」から招待してください。
  • +
  • 交渉用掲示板の内容は、メンバー以外には秘密です(「公開」設定にしない限り)。
  • +
+
+
+
+
+
+
+
diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb new file mode 100644 index 0000000..a43b08e --- /dev/null +++ b/app/views/boards/new.html.erb @@ -0,0 +1,72 @@ +
+
+ <%= link_to game_boards_path(@game), class: "text-gray-500 hover:text-gray-700" do %> + 掲示板一覧に戻る + <% end %> +
+ +
+
+

新しい交渉用掲示板を作成

+

+ 交渉したい相手(国)を選択してください。選択した相手とあなただけの秘密の掲示板が作成されます。 +

+
+ +
+ <%= form_with model: [@game, @board], local: true, class: "space-y-6" do |f| %> + <% if @board.errors.any? %> +
+
+
+ +
+
+

入力内容にエラーがあります

+
+
    + <% @board.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ +

少なくとも1人以上選択してください。

+ +
+ <% if @participants.empty? %> +

招待可能な他のプレイヤーがいません。

+ <% else %> + <% @participants.each do |participant| %> +
+
+ +
+
+ +
+
+ <% end %> + <% 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" %> +
+ <% end %> +
+
+
diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb new file mode 100644 index 0000000..62ec4ef --- /dev/null +++ b/app/views/boards/show.html.erb @@ -0,0 +1,221 @@ +
+ <%= render "games/header", game: @game, display_turn: @game.latest_turn, current_participant: @current_participant, hide_controls: true %> + + +
+
+
+ <%= link_to game_boards_path(@game), class: "mr-4 text-gray-500 hover:text-gray-700" do %> + + <% end %> + +

+ <% if @board.global? %> + Global Board + <% else %> + Negotiation Board + <% end %> +

+ + <% if @board.is_public? %> + + 公開中 + + <% end %> + + <% if @board.history_mode? %> + + 履歴モード + + <% end %> +
+ +
+
+ + メンバー: + <% @active_members.each do |m| %> + + <%= m.power %> + + <% end %> +
+
+
+ +
+ + <% 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? %> + 非公開にする + <% else %> + 公開する + <% 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 %> + 退出 + <% end %> + <% end %> +
+
+ +
+ + +
+ + + <% if @board.member?(@current_participant) && !@board.history_mode? %> +
+ <%= form_with model: [@game, @board, @new_post], local: true, class: "flex items-end space-x-2" do |f| %> +
+ <%= 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 %> +
+ + <% end %> +
+ <% elsif @board.history_mode? %> +
+ 履歴モードのため投稿できません +
+ <% else %> +
+ この掲示板には参加していません +
+ <% end %> + + +
+ <% if @posts.empty? %> +
+

まだ投稿がありません。最初のメッセージを投稿しましょう。

+
+ <% else %> + <% @posts.each do |post| %> + <%= render partial: "post", locals: { post: post, current_participant: @current_participant } %> + <% end %> + <% end %> +
+ +
+ + +
+ + + <% if @board.negotiation? %> +
+
+ 条約・提案 +
+
+ <% @board.board_proposals.order(created_at: :desc).each do |proposal| %> +
+
+
+ <%= proposal.proposer.power %> + <% if proposal.phase %> + <%= proposal.phase %> + <% end %> +
+ <%= l proposal.created_at, format: :short %> +
+

<%= proposal.body %>

+ +
+ + <%= proposal.status.upcase %> + + + <% if proposal.pending? && @board.member?(@current_participant) && !@board.history_mode? %> +
+ <%= 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" %> +
+ <% end %> +
+
+ <% end %> + + <% if @board.member?(@current_participant) && !@board.history_mode? %> +
+ + +
+ <% end %> +
+
+ <% end %> + + + <% if @board.negotiation? && @board.member?(@current_participant) && !@board.history_mode? %> +
+
+ メンバー追加 +
+
+ <% if @candidates.present? %> + <%= form_with url: game_board_board_memberships_path(@game, @board), local: true do |f| %> +
+ <%= 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" %> +
+ <% end %> + <% else %> +

招待可能なプレイヤーがいません

+ <% end %> +
+
+ <% end %> + + +
+
+ 参加メンバー +
+
    + <% @board.board_memberships.includes(:participant).each do |membership| %> +
  • +
    + + <%= membership.participant.power %> + + <% if membership.participant.user == current_user %> + (YOU) + <% end %> +
    + <% if membership.left_at %> + 退出済 + <% end %> +
  • + <% end %> +
+
+ +
+
+
+ diff --git a/app/views/games/_header.html.erb b/app/views/games/_header.html.erb new file mode 100644 index 0000000..9d392c3 --- /dev/null +++ b/app/views/games/_header.html.erb @@ -0,0 +1,133 @@ +
+
+

<%= h(game.title) %>

+ <% if game.memo.present? %> +

<%= h(game.memo) %>

+ <% end %> +
+ + <%= game.solo_mode? ? 'ソロモード' : 'マルチプレイヤーモード' %> + + <% if game.administrator %> + 管理者: <%= h(game.administrator.username) %> + <% end %> + 状態: <%= game.status %> +
+ <% if game.year_limit.present? || game.victory_sc_count != 18 || game.scoring_system != "none" || game.auto_turn? %> +
+ ハウスルール: + <% if game.auto_turn? %> + + <%= game.schedule_display %> + + <% end %> + <% if game.year_limit.present? %> + + <%= game.year_limit %>年制限 + + <% end %> + <% if game.victory_sc_count != 18 %> + + 目標SC: <%= game.victory_sc_count %> + + <% end %> + <% if game.scoring_system != "none" %> + + <%= game.scoring_system_name %> + + <% end %> +
+ <% end %> + <% if game.auto_turn? && game.next_deadline_at.present? && game.status == "in_progress" %> +
+ + 次のターン締切: <%= game.next_deadline_at.in_time_zone("Asia/Tokyo").strftime("%Y-%m-%d %H:%M") %> + +
+ + <% end %> + +
+ + <% unless local_assigns[:hide_controls] %> +
+ <% 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 %> +
+ <% end %> +
+ +<% # ゲーム画面(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})" + %> + +
+
+
+

<%= display_date %>

+
+ ターン: <%= target_turn.number %> + | + フェーズ: <%= phase %> +
+
+ +
+ <% if defined?(current_participant) && current_participant&.power %> +
+ あなたの担当国: + <%= current_participant.power %> +
+ <% end %> +
+
+
+<% end %> diff --git a/app/views/games/show.html.erb b/app/views/games/show.html.erb index 524b0e8..3ddd5cf 100644 --- a/app/views/games/show.html.erb +++ b/app/views/games/show.html.erb @@ -414,6 +414,45 @@
+ +
+
+
+
+ +
+
+

外交・交渉掲示板

+

+ 秘密交渉や全体アナウンスはこちら +

+
+
+
+ <%= 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 + %> + + <%= total_unread %> + + <% + end + end + %> + <% end %> +
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ee20209..cf206fd 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,6 +8,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> + <%= action_cable_meta_tag %> <%= yield :head %> diff --git a/config/cable.yml b/config/cable.yml index b9adc5a..b2b9685 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -3,7 +3,9 @@ # not a terminal started via bin/rails console! Add "console" to any action or any ERB template view # to make the web console appear. development: - adapter: async + adapter: solid_cable + polling_interval: 0.1.seconds + message_retention: 1.day test: adapter: test diff --git a/config/importmap.rb b/config/importmap.rb index 909dfc5..7579217 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,3 +5,5 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" +pin "actioncable" # @5.2.8 +pin "@rails/actioncable", to: "@rails--actioncable.js" # @8.1.200 diff --git a/config/routes.rb b/config/routes.rb index 2819e17..2f7f8ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,19 @@ Rails.application.routes.draw do patch :select_power 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 # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/db/migrate/20260218084831_create_boards.rb b/db/migrate/20260218084831_create_boards.rb new file mode 100644 index 0000000..651805a --- /dev/null +++ b/db/migrate/20260218084831_create_boards.rb @@ -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 diff --git a/db/migrate/20260218084842_create_board_memberships.rb b/db/migrate/20260218084842_create_board_memberships.rb new file mode 100644 index 0000000..2487da0 --- /dev/null +++ b/db/migrate/20260218084842_create_board_memberships.rb @@ -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 diff --git a/db/migrate/20260218084851_create_board_posts.rb b/db/migrate/20260218084851_create_board_posts.rb new file mode 100644 index 0000000..7e19a3c --- /dev/null +++ b/db/migrate/20260218084851_create_board_posts.rb @@ -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 diff --git a/db/migrate/20260218084903_create_board_proposals.rb b/db/migrate/20260218084903_create_board_proposals.rb new file mode 100644 index 0000000..4cc9232 --- /dev/null +++ b/db/migrate/20260218084903_create_board_proposals.rb @@ -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 diff --git a/db/migrate/20260218130000_create_solid_cable_messages.rb b/db/migrate/20260218130000_create_solid_cable_messages.rb new file mode 100644 index 0000000..61e90a5 --- /dev/null +++ b/db/migrate/20260218130000_create_solid_cable_messages.rb @@ -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 diff --git a/db/migrate/20260218134459_add_phase_to_board_proposals.rb b/db/migrate/20260218134459_add_phase_to_board_proposals.rb new file mode 100644 index 0000000..4c647ef --- /dev/null +++ b/db/migrate/20260218134459_add_phase_to_board_proposals.rb @@ -0,0 +1,5 @@ +class AddPhaseToBoardProposals < ActiveRecord::Migration[8.1] + def change + add_column :board_proposals, :phase, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 413988c..dbdc45d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,54 @@ # # 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| t.string "auto_order_mode", default: "hold", 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" 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| t.string "concurrency_key", 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 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", "users" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb new file mode 100644 index 0000000..e069c54 --- /dev/null +++ b/test/controllers/boards_controller_test.rb @@ -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 diff --git a/test/fixtures/games.yml b/test/fixtures/games.yml index 7f69ebe..7918216 100644 --- a/test/fixtures/games.yml +++ b/test/fixtures/games.yml @@ -1,11 +1,13 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - title: MyString - participants_count: 1 - memo: MyText + title: GameOne + status: in_progress + turn_schedule: "0,12" + participants_count: 7 two: - title: MyString + title: GameTwo + status: recruiting + turn_schedule: "0,12" participants_count: 1 - memo: MyText diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 2dc9d23..7aec0d1 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -9,3 +9,21 @@ two: email: two@example.com password_digest: <%= BCrypt::Password.create('password') %> 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 diff --git a/test/models/board_test.rb b/test/models/board_test.rb new file mode 100644 index 0000000..a594876 --- /dev/null +++ b/test/models/board_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..8a696f8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,5 +11,8 @@ module ActiveSupport fixtures :all # 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 diff --git a/vendor/javascript/@rails--actioncable.js b/vendor/javascript/@rails--actioncable.js new file mode 100644 index 0000000..093687a --- /dev/null +++ b/vendor/javascript/@rails--actioncable.js @@ -0,0 +1,4 @@ +// @rails/actioncable@8.1.200 downloaded from https://ga.jspm.io/npm:@rails/actioncable@8.1.200/app/assets/javascripts/actioncable.esm.js + +var t={logger:typeof console!=="undefined"?console:void 0,WebSocket:typeof WebSocket!=="undefined"?WebSocket:void 0};var e={log(...e){if(this.enabled){e.push(Date.now());t.logger.log("[ActionCable]",...e)}}};const n=()=>(new Date).getTime();const i=t=>(n()-t)/1e3;class ConnectionMonitor{constructor(t){this.visibilityDidChange=this.visibilityDidChange.bind(this);this.connection=t;this.reconnectAttempts=0}start(){if(!this.isRunning()){this.startedAt=n();delete this.stoppedAt;this.startPolling();addEventListener("visibilitychange",this.visibilityDidChange);e.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`)}}stop(){if(this.isRunning()){this.stoppedAt=n();this.stopPolling();removeEventListener("visibilitychange",this.visibilityDidChange);e.log("ConnectionMonitor stopped")}}isRunning(){return this.startedAt&&!this.stoppedAt}recordMessage(){this.pingedAt=n()}recordConnect(){this.reconnectAttempts=0;delete this.disconnectedAt;e.log("ConnectionMonitor recorded connect")}recordDisconnect(){this.disconnectedAt=n();e.log("ConnectionMonitor recorded disconnect")}startPolling(){this.stopPolling();this.poll()}stopPolling(){clearTimeout(this.pollTimeout)}poll(){this.pollTimeout=setTimeout((()=>{this.reconnectIfStale();this.poll()}),this.getPollInterval())}getPollInterval(){const{staleThreshold:t,reconnectionBackoffRate:e}=this.constructor;const n=Math.pow(1+e,Math.min(this.reconnectAttempts,10));const i=this.reconnectAttempts===0?1:e;const s=i*Math.random();return t*1e3*n*(1+s)}reconnectIfStale(){if(this.connectionIsStale()){e.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${i(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);this.reconnectAttempts++;if(this.disconnectedRecently())e.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${i(this.disconnectedAt)} s`);else{e.log("ConnectionMonitor reopening");this.connection.reopen()}}}get refreshedAt(){return this.pingedAt?this.pingedAt:this.startedAt}connectionIsStale(){return i(this.refreshedAt)>this.constructor.staleThreshold}disconnectedRecently(){return this.disconnectedAt&&i(this.disconnectedAt){if(this.connectionIsStale()||!this.connection.isOpen()){e.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`);this.connection.reopen()}}),200)}}ConnectionMonitor.staleThreshold=6;ConnectionMonitor.reconnectionBackoffRate=.15;var s={message_types:{welcome:"welcome",disconnect:"disconnect",ping:"ping",confirmation:"confirm_subscription",rejection:"reject_subscription"},disconnect_reasons:{unauthorized:"unauthorized",invalid_request:"invalid_request",server_restart:"server_restart",remote:"remote"},default_mount_path:"/cable",protocols:["actioncable-v1-json","actioncable-unsupported"]};const{message_types:o,protocols:r}=s;const c=r.slice(0,r.length-1);const u=[].indexOf;class Connection{constructor(t){this.open=this.open.bind(this);this.consumer=t;this.subscriptions=this.consumer.subscriptions;this.monitor=new ConnectionMonitor(this);this.disconnected=true}send(t){if(this.isOpen()){this.webSocket.send(JSON.stringify(t));return true}return false}open(){if(this.isActive()){e.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);return false}{const n=[...r,...this.consumer.subprotocols||[]];e.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${n}`);this.webSocket&&this.uninstallEventHandlers();this.webSocket=new t.WebSocket(this.consumer.url,n);this.installEventHandlers();this.monitor.start();return true}}close({allowReconnect:t}={allowReconnect:true}){t||this.monitor.stop();if(this.isOpen())return this.webSocket.close()}reopen(){e.log(`Reopening WebSocket, current state is ${this.getState()}`);if(!this.isActive())return this.open();try{return this.close()}catch(t){e.log("Failed to reopen WebSocket",t)}finally{e.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);setTimeout(this.open,this.constructor.reopenDelay)}}getProtocol(){if(this.webSocket)return this.webSocket.protocol}isOpen(){return this.isState("open")}isActive(){return this.isState("open","connecting")}triedToReconnect(){return this.monitor.reconnectAttempts>0}isProtocolSupported(){return u.call(c,this.getProtocol())>=0}isState(...t){return u.call(t,this.getState())>=0}getState(){if(this.webSocket)for(let e in t.WebSocket)if(t.WebSocket[e]===this.webSocket.readyState)return e.toLowerCase();return null}installEventHandlers(){for(let t in this.events){const e=this.events[t].bind(this);this.webSocket[`on${t}`]=e}}uninstallEventHandlers(){for(let t in this.events)this.webSocket[`on${t}`]=function(){}}}Connection.reopenDelay=500;Connection.prototype.events={message(t){if(!this.isProtocolSupported())return;const{identifier:n,message:i,reason:s,reconnect:r,type:c}=JSON.parse(t.data);this.monitor.recordMessage();switch(c){case o.welcome:this.triedToReconnect()&&(this.reconnectAttempted=true);this.monitor.recordConnect();return this.subscriptions.reload();case o.disconnect:e.log(`Disconnecting. Reason: ${s}`);return this.close({allowReconnect:r});case o.ping:return null;case o.confirmation:this.subscriptions.confirmSubscription(n);if(this.reconnectAttempted){this.reconnectAttempted=false;return this.subscriptions.notify(n,"connected",{reconnected:true})}return this.subscriptions.notify(n,"connected",{reconnected:false});case o.rejection:return this.subscriptions.reject(n);default:return this.subscriptions.notify(n,"received",i)}},open(){e.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);this.disconnected=false;if(!this.isProtocolSupported()){e.log("Protocol is unsupported. Stopping monitor and disconnecting.");return this.close({allowReconnect:false})}},close(t){e.log("WebSocket onclose event");if(!this.disconnected){this.disconnected=true;this.monitor.recordDisconnect();return this.subscriptions.notifyAll("disconnected",{willAttemptReconnect:this.monitor.isRunning()})}},error(){e.log("WebSocket onerror event")}};const h=function(t,e){if(e!=null)for(let n in e){const i=e[n];t[n]=i}return t};class Subscription{constructor(t,e={},n){this.consumer=t;this.identifier=JSON.stringify(e);h(this,n)}perform(t,e={}){e.action=t;return this.send(e)}send(t){return this.consumer.send({command:"message",identifier:this.identifier,data:JSON.stringify(t)})}unsubscribe(){return this.consumer.subscriptions.remove(this)}}class SubscriptionGuarantor{constructor(t){this.subscriptions=t;this.pendingSubscriptions=[]}guarantee(t){if(this.pendingSubscriptions.indexOf(t)==-1){e.log(`SubscriptionGuarantor guaranteeing ${t.identifier}`);this.pendingSubscriptions.push(t)}else e.log(`SubscriptionGuarantor already guaranteeing ${t.identifier}`);this.startGuaranteeing()}forget(t){e.log(`SubscriptionGuarantor forgetting ${t.identifier}`);this.pendingSubscriptions=this.pendingSubscriptions.filter((e=>e!==t))}startGuaranteeing(){this.stopGuaranteeing();this.retrySubscribing()}stopGuaranteeing(){clearTimeout(this.retryTimeout)}retrySubscribing(){this.retryTimeout=setTimeout((()=>{this.subscriptions&&typeof this.subscriptions.subscribe==="function"&&this.pendingSubscriptions.map((t=>{e.log(`SubscriptionGuarantor resubscribing ${t.identifier}`);this.subscriptions.subscribe(t)}))}),500)}}class Subscriptions{constructor(t){this.consumer=t;this.guarantor=new SubscriptionGuarantor(this);this.subscriptions=[]}create(t,e){const n=t;const i=typeof n==="object"?n:{channel:n};const s=new Subscription(this.consumer,i,e);return this.add(s)}add(t){this.subscriptions.push(t);this.consumer.ensureActiveConnection();this.notify(t,"initialized");this.subscribe(t);return t}remove(t){this.forget(t);this.findAll(t.identifier).length||this.sendCommand(t,"unsubscribe");return t}reject(t){return this.findAll(t).map((t=>{this.forget(t);this.notify(t,"rejected");return t}))}forget(t){this.guarantor.forget(t);this.subscriptions=this.subscriptions.filter((e=>e!==t));return t}findAll(t){return this.subscriptions.filter((e=>e.identifier===t))}reload(){return this.subscriptions.map((t=>this.subscribe(t)))}notifyAll(t,...e){return this.subscriptions.map((n=>this.notify(n,t,...e)))}notify(t,e,...n){let i;i=typeof t==="string"?this.findAll(t):[t];return i.map((t=>typeof t[e]==="function"?t[e](...n):void 0))}subscribe(t){this.sendCommand(t,"subscribe")&&this.guarantor.guarantee(t)}confirmSubscription(t){e.log(`Subscription confirmed ${t}`);this.findAll(t).map((t=>this.guarantor.forget(t)))}sendCommand(t,e){const{identifier:n}=t;return this.consumer.send({command:e,identifier:n})}}class Consumer{constructor(t){this._url=t;this.subscriptions=new Subscriptions(this);this.connection=new Connection(this);this.subprotocols=[]}get url(){return l(this._url)}send(t){return this.connection.send(t)}connect(){return this.connection.open()}disconnect(){return this.connection.close({allowReconnect:false})}ensureActiveConnection(){if(!this.connection.isActive())return this.connection.open()}addSubProtocol(t){this.subprotocols=[...this.subprotocols,t]}}function l(t){typeof t==="function"&&(t=t());if(t&&!/^wss?:/i.test(t)){const e=document.createElement("a");e.href=t;e.href=e.href;e.protocol=e.protocol.replace("http","ws");return e.href}return t}function a(t=d("url")||s.default_mount_path){return new Consumer(t)}function d(t){const e=document.head.querySelector(`meta[name='action-cable-${t}']`);if(e)return e.getAttribute("content")}export{Connection,ConnectionMonitor,Consumer,s as INTERNAL,Subscription,SubscriptionGuarantor,Subscriptions,t as adapters,a as createConsumer,l as createWebSocketURL,d as getConfig,e as logger}; + diff --git a/vendor/javascript/actioncable.js b/vendor/javascript/actioncable.js new file mode 100644 index 0000000..36ebaf5 --- /dev/null +++ b/vendor/javascript/actioncable.js @@ -0,0 +1,4 @@ +// actioncable@5.2.8 downloaded from https://ga.jspm.io/npm:actioncable@5.2.8-1/lib/assets/compiled/action_cable.js + +var t="undefined"!==typeof globalThis?globalThis:"undefined"!==typeof self?self:global;var n={};(function(){var o=this||t;(function(){(function(){var n=[].slice;(this||t).ActionCable={INTERNAL:{message_types:{welcome:"welcome",ping:"ping",confirmation:"confirm_subscription",rejection:"reject_subscription"},default_mount_path:"/cable",protocols:["actioncable-v1-json","actioncable-unsupported"]},WebSocket:window.WebSocket,logger:window.console,createConsumer:function(n){var o;null==n&&(n=null!=(o=this.getConfig("url"))?o:(this||t).INTERNAL.default_mount_path);return new e.Consumer(this.createWebSocketURL(n))},getConfig:function(t){var n;n=document.head.querySelector("meta[name='action-cable-"+t+"']");return null!=n?n.getAttribute("content"):void 0},createWebSocketURL:function(t){var n;if(t&&!/^wss?:/i.test(t)){n=document.createElement("a");n.href=t;n.href=n.href;n.protocol=n.protocol.replace("http","ws");return n.href}return t},startDebugging:function(){return(this||t).debugging=true},stopDebugging:function(){return(this||t).debugging=null},log:function(){var o,e;o=1<=arguments.length?n.call(arguments,0):[];if((this||t).debugging){o.push(Date.now());return(e=(this||t).logger).log.apply(e,["[ActionCable]"].concat(n.call(o)))}}}}).call(this||t)}).call(o);var e=o.ActionCable;(function(){(function(){var bind=function(t,n){return function(){return t.apply(n,arguments)}};e.ConnectionMonitor=function(){var n,o,i;ConnectionMonitor.pollInterval={min:3,max:30};ConnectionMonitor.staleThreshold=6;function ConnectionMonitor(n){(this||t).connection=n;(this||t).visibilityDidChange=bind((this||t).visibilityDidChange,this||t);(this||t).reconnectAttempts=0}ConnectionMonitor.prototype.start=function(){if(!this.isRunning()){(this||t).startedAt=o();delete(this||t).stoppedAt;this.startPolling();document.addEventListener("visibilitychange",(this||t).visibilityDidChange);return e.log("ConnectionMonitor started. pollInterval = "+this.getPollInterval()+" ms")}};ConnectionMonitor.prototype.stop=function(){if(this.isRunning()){(this||t).stoppedAt=o();this.stopPolling();document.removeEventListener("visibilitychange",(this||t).visibilityDidChange);return e.log("ConnectionMonitor stopped")}};ConnectionMonitor.prototype.isRunning=function(){return null!=(this||t).startedAt&&null==(this||t).stoppedAt};ConnectionMonitor.prototype.recordPing=function(){return(this||t).pingedAt=o()};ConnectionMonitor.prototype.recordConnect=function(){(this||t).reconnectAttempts=0;this.recordPing();delete(this||t).disconnectedAt;return e.log("ConnectionMonitor recorded connect")};ConnectionMonitor.prototype.recordDisconnect=function(){(this||t).disconnectedAt=o();return e.log("ConnectionMonitor recorded disconnect")};ConnectionMonitor.prototype.startPolling=function(){this.stopPolling();return this.poll()};ConnectionMonitor.prototype.stopPolling=function(){return clearTimeout((this||t).pollTimeout)};ConnectionMonitor.prototype.poll=function(){return(this||t).pollTimeout=setTimeout(function(t){return function(){t.reconnectIfStale();return t.poll()}}(this||t),this.getPollInterval())};ConnectionMonitor.prototype.getPollInterval=function(){var o,e,i,r;r=(this||t).constructor.pollInterval,i=r.min,e=r.max;o=5*Math.log((this||t).reconnectAttempts+1);return Math.round(1e3*n(o,i,e))};ConnectionMonitor.prototype.reconnectIfStale=function(){if(this.connectionIsStale()){e.log("ConnectionMonitor detected stale connection. reconnectAttempts = "+(this||t).reconnectAttempts+", pollInterval = "+this.getPollInterval()+" ms, time disconnected = "+i((this||t).disconnectedAt)+" s, stale threshold = "+(this||t).constructor.staleThreshold+" s");(this||t).reconnectAttempts++;if(this.disconnectedRecently())return e.log("ConnectionMonitor skipping reopening recent disconnect");e.log("ConnectionMonitor reopening");return(this||t).connection.reopen()}};ConnectionMonitor.prototype.connectionIsStale=function(){var n;return i(null!=(n=(this||t).pingedAt)?n:(this||t).startedAt)>(this||t).constructor.staleThreshold};ConnectionMonitor.prototype.disconnectedRecently=function(){return(this||t).disconnectedAt&&i((this||t).disconnectedAt)<(this||t).constructor.staleThreshold};ConnectionMonitor.prototype.visibilityDidChange=function(){if("visible"===document.visibilityState)return setTimeout(function(t){return function(){if(t.connectionIsStale()||!t.connection.isOpen()){e.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = "+document.visibilityState);return t.connection.reopen()}}}(this||t),200)};o=function(){return(new Date).getTime()};i=function(t){return(o()-t)/1e3};n=function(t,n,o){return Math.max(n,Math.min(o,t))};return ConnectionMonitor}()}).call(this||t);(function(){var n,o,i,r,s,c=[].slice,bind=function(t,n){return function(){return t.apply(n,arguments)}},u=[].indexOf||function(n){for(var o=0,e=(this||t).length;o=0};Connection.prototype.isState=function(){var t,n;n=1<=arguments.length?c.call(arguments,0):[];return t=this.getState(),u.call(n,t)>=0};Connection.prototype.getState=function(){var n,o,e;for(o in WebSocket){e=WebSocket[o];if(e===(null!=(n=(this||t).webSocket)?n.readyState:void 0))return o.toLowerCase()}return null};Connection.prototype.installEventHandlers=function(){var n,o;for(n in(this||t).events){o=(this||t).events[n].bind(this||t);(this||t).webSocket["on"+n]=o}};Connection.prototype.uninstallEventHandlers=function(){var n;for(n in(this||t).events)(this||t).webSocket["on"+n]=function(){}};Connection.prototype.events={message:function(n){var e,i,r,s;if(this.isProtocolSupported()){r=JSON.parse(n.data),e=r.identifier,i=r.message,s=r.type;switch(s){case o.welcome:(this||t).monitor.recordConnect();return(this||t).subscriptions.reload();case o.ping:return(this||t).monitor.recordPing();case o.confirmation:return(this||t).subscriptions.notify(e,"connected");case o.rejection:return(this||t).subscriptions.reject(e);default:return(this||t).subscriptions.notify(e,"received",i)}}},open:function(){e.log("WebSocket onopen event, using '"+this.getProtocol()+"' subprotocol");(this||t).disconnected=false;if(!this.isProtocolSupported()){e.log("Protocol is unsupported. Stopping monitor and disconnecting.");return this.close({allowReconnect:false})}},close:function(n){e.log("WebSocket onclose event");if(!(this||t).disconnected){(this||t).disconnected=true;(this||t).monitor.recordDisconnect();return(this||t).subscriptions.notifyAll("disconnected",{willAttemptReconnect:(this||t).monitor.isRunning()})}},error:function(){return e.log("WebSocket onerror event")}};return Connection}()}).call(this||t);(function(){var n=[].slice;e.Subscriptions=function(){function Subscriptions(n){(this||t).consumer=n;(this||t).subscriptions=[]}Subscriptions.prototype.create=function(n,o){var i,r,s;i=n;r="object"===typeof i?i:{channel:i};s=new e.Subscription((this||t).consumer,r,o);return this.add(s)};Subscriptions.prototype.add=function(n){(this||t).subscriptions.push(n);(this||t).consumer.ensureActiveConnection();this.notify(n,"initialized");this.sendCommand(n,"subscribe");return n};Subscriptions.prototype.remove=function(t){this.forget(t);this.findAll(t.identifier).length||this.sendCommand(t,"unsubscribe");return t};Subscriptions.prototype.reject=function(t){var n,o,e,i,r;e=this.findAll(t);i=[];for(n=0,o=e.length;n