diff --git a/Gemfile b/Gemfile index c907afd..8415008 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ group :development, :test do gem 'awesome_print' gem 'orderly' gem 'action-cable-testing' + gem 'db-query-matchers' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 86bd510..c6f8b6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -67,6 +67,9 @@ GEM concurrent-ruby (1.1.5) crass (1.0.4) database_cleaner (1.7.0) + db-query-matchers (0.9.0) + activesupport (>= 4.0, <= 6.0) + rspec (~> 3.0) diff-lcs (1.3) docile (1.3.2) erubi (1.8.0) @@ -254,6 +257,7 @@ DEPENDENCIES byebug capybara database_cleaner + db-query-matchers jbuilder (~> 2.5) launchy listen (>= 3.0.5, < 3.2) diff --git a/README.md b/README.md index 3394fc1..bad0a5d 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,15 @@ At this point, we have everything we need for our Rails environment. If you wish ## Websockets Message Events -|From Server |From Client | -|:---: |:---: | -|[Player Joined](#player-joined) | | -|[Game Started](#game-started) | | -|[Hint Provided](#hint-provided) |[Hint Sent](#hint-sent) | -|[Board Update](#board-update) |[Guess Sent](#guess-sent)| -|[Game Over](#game-over) | | -|[Illegal Action](#illegal-action)| | +|From Server |From Client | +|:---: |:---: | +|[Player Joined](#player-joined) |[Select Team](#select-team)| +|[Player Update](#player-update) |[Select Role](#select-role)| +|[Game Started](#game-started) |[Start Game](#start-game) | +|[Hint Provided](#hint-provided) |[Hint Sent](#hint-sent) | +|[Board Update](#board-update) |[Guess Sent](#guess-sent) | +|[Game Over](#game-over) | | +|[Illegal Action](#illegal-action)| | --- @@ -85,7 +86,7 @@ POST /api/v1/games } ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`name`|String: The username that the requesting user would like to use during the game| ##### Successful Response @@ -101,7 +102,7 @@ HTTP/1.1 201 Created } ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`invite_code`|String: A code which can be shared with other players. They will use this code to join the game.| |`id` |Integer: The unique id for the player.| |`name` |String: A confirmation that the requested name was indeed assigned to the player.| @@ -138,7 +139,7 @@ POST /api/v1/games/:invite_code/players } ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`:invite_code`|String: (Within URI) The invite code provided by the person inviting the requesting user to their existing game.| |`name` |String: The username that the requesting user would like to use during the game| @@ -154,7 +155,7 @@ HTTP/1.1 200 OK } ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`id` |Integer: The unique id for the player.| |`name` |String: A confirmation that the requested name was indeed assigned to the player.| |`token`|String: A token unique to the current player, which can be used to identify them in future requests to the server.| @@ -218,7 +219,7 @@ Request the Intel data for a game, allowing the player to see which cards belong GET /api/v1/intel?token= ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`token`|String: A valid token belonging to a Player with the Intel role.| ##### Successful Response @@ -237,7 +238,7 @@ HTTP/1.1 200 OK } ``` |key                                       |description| -|:---: |:--- | +|:--- |:--- | |`cards` |Array: An **ordered** collection of `card` objects which are part of the game. These cards go onto the board left-to-right, top-to-bottom.| |`-->card.id` |Integer: The unique identifier for the card.| |`-->card.word`|String: The word for the card.| @@ -284,7 +285,7 @@ HTTP/1.1 401 Unauthorized ### Player Joined -This message is broadcast to the game channel whenever a player joins the game. It contains the name and ID of the player who joined, as well as a roster of all players currently in the game. +This message is broadcast to the game channel whenever a player joins the game. It contains the name, ID, team, and role of the player who joined, as well as a roster of all players currently in the game. ##### Payload @@ -294,10 +295,14 @@ This message is broadcast to the game channel whenever a player joins the game. data: { id: 0, name: "name", + isBlueTeam: true, + isIntel: true, playerRoster: [ { id: 0, - name: "name" + name: "name", + isBlueTeam: true, + isIntel: true, }, ... ] @@ -311,15 +316,112 @@ This message is broadcast to the game channel whenever a player joins the game. |`data` |Object: The data payload of the message.| |`data.id` |Integer: The unique id of the player who joined.| |`data.name` |String: The name of the player who joined.| +|`data.isBlueTeam` |Boolean: `null` if the player has not been assigned a team, `true` if the player is on the blue team, `false` if they're on the red team.| +|`data.isIntel` |Boolean: `null` if the player has not been assigned a role, `true` if the player has the Intel role, `false` if they have the Spy role.| |`data.playerRoster`|Array: A collection of `player` objects for all players currently in the game, **ordered by** the time they joined the lobby.| |`-->player.id` |Integer: The unique id of the given player.| |`-->player.name` |String: The name of the given player.| +|`-->player.isBlueTeam`|Boolean: `null` if the player has not been assigned a team, `true` if the player is on the blue team, `false` if they're on the red team.| +|`-->player.isIntel` |Boolean: `null` if the player has not been assigned a role, `true` if the player has the Intel role, `false` if they have the Spy role.| + +--- + +### Select Team + +This message is sent from the game client to the server by any player before the game has started. The payload contains the team that the player would like to join. + +##### Call + +```js +cable.selectTeam({ + team: "red" +}) +``` + +|key                                       |Description| +|:--- |:--- | +|`team`|String: The team the player would like to play on. "red" or "blue"| + +--- + +### Select Role + +This message is sent from the game client to the server by any player before the game has started. The payload contains the role that the player would like to play. + +##### Call + +```js +cable.selectRole({ + role: "intel" +}) +``` + +|key                                       |Description| +|:--- |:--- | +|`role`|String: The role the player would like to have. "intel" or "spy"| + +--- + +### Player Update + +This message is broadcast to the game channel whenever a player makes a team or role selection. It contains the name, ID, team, and role of the player who changed, as well as a roster of all players currently in the game. + +##### Payload + +```js +{ + type: "player-update", + data: { + id: 0, + name: "name", + isBlueTeam: true, + isIntel: true, + playerRoster: [ + { + id: 0, + name: "name", + isBlueTeam: true, + isIntel: true, + }, + ... + ] + } +} +``` + +|key                                       |Description| +|:--- |:--- | +|`type` |String: The type of message being broadcast.| +|`data` |Object: The data payload of the message.| +|`data.id` |Integer: The unique id of the player who joined.| +|`data.name` |String: The name of the player who joined.| +|`data.isBlueTeam` |Boolean: `null` if the player has not been assigned a team, `true` if the player is on the blue team, `false` if they're on the red team.| +|`data.isIntel` |Boolean: `null` if the player has not been assigned a role, `true` if the player has the Intel role, `false` if they have the Spy role.| +|`data.playerRoster` |Array: A collection of `player` objects for all players currently in the game, **ordered by** the time they joined the lobby.| +|`-->player.id` |Integer: The unique id of the given player.| +|`-->player.name` |String: The name of the given player.| +|`-->player.isBlueTeam`|Boolean: `null` if the player has not been assigned a team, `true` if the player is on the blue team, `false` if they're on the red team.| +|`-->player.isIntel` |Boolean: `null` if the player has not been assigned a role, `true` if the player has the Intel role, `false` if they have the Spy role.| + +--- + +### Start Game + +This message is sent from the game client to the server by any player after all players have joined, while the game is still on the lobby screen. + +##### Call + +```js +cable.startGame() +``` + +No payload should be provided with this message. --- ### Game Started -This message is broadcast to the game channel once the final player has joined the lobby. It is broadcast _after_ the [player joined](#player-joined) message generated by that player. +This message is broadcast to the game channel after all players have joined the lobby and any player sends the [`start_game`](#start-game) message. It is broadcast _after_ the [player joined](#player-joined) message generated by that player. ##### Payload @@ -515,6 +617,7 @@ This message is broadcast to all players after any illegal action is performed b type: 'illegal-action', data: { error: "", + category: "personal", byPlayerId: 1 } } @@ -525,6 +628,7 @@ This message is broadcast to all players after any illegal action is performed b |`type` |String: The type of message being broadcast.| |`data` |Object: The data payload of the message.| |`data.error` |String: The descriptive error message.| +|`data.category` |String: The category of error. `personal` for messages that should only be displayed to the affected player, `public` for messages that should be broadcast to the lobby, or `info` for messages that should only output to the browser console with `console.warn()`.| |`data.byPlayerId`|Integer: The ID of the player who performed the illegal action.|
The potential illegal actions that are anticipated and caught are: @@ -537,5 +641,7 @@ This message is broadcast to all players after any illegal action is performed b - "\ attempted to submit an invalid hint" - A Spy player submits a guess with a card ID not present in this game - "\ attempted to submit a guess for a card not in this game" +- A Player attempts to select a role or team that is full. + - Various: see [`Player#err`](app/models/player.rb)
diff --git a/app/channels/game_data_channel.rb b/app/channels/game_data_channel.rb index bb7898b..34fd2a5 100644 --- a/app/channels/game_data_channel.rb +++ b/app/channels/game_data_channel.rb @@ -1,4 +1,5 @@ class GameDataChannel < ApplicationCable::Channel + include LobbyActions on_subscribe :welcome_player def subscribed @@ -27,25 +28,15 @@ def send_hint(hint) else game.advance! - saved_hint = current_player.game.hints.create( + saved_hint = game.hints.create( team: current_player.team, word: hint["hintWord"], num: hint["numCards"].to_i ) - payload = { - type: 'hint-provided', - data: { - isBlueTeam: saved_hint.blue?, - hintWord: saved_hint.word, - relatedCards: saved_hint.num, - currentPlayerId: game.current_player.id - } - } - game.guesses_remaining = saved_hint.num + 1 game.save - broadcast_message payload + provide_hint saved_hint end end @@ -69,6 +60,15 @@ def send_guess(card) end end + def start_game + game = current_player.game + game.reload + if all_players_in?(game) && game.game_cards.count == 0 + game.establish! + game_setup + end + end + private ## ###### ####### ## ## @@ -87,22 +87,13 @@ def send_guess(card) ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ###### ####### ## ## ## ####### ###### ######## ## ## ###### - def compose_roster(game) - game.players.map do |p| - { - id: p.id, - name: p.name - } - end - end - def compose_players(game) game.players.map do |p| { id: p.id, name: p.name, - isBlueTeam: p.blue?, - isIntel: p.intel? + isBlueTeam: p.is_blue_team?, + isIntel: p.is_intel? } end end @@ -143,29 +134,25 @@ def welcome_player data: { id: current_player.id, name: current_player.name, - playerRoster: compose_roster(current_player.game) + isBlueTeam: current_player.is_blue_team?, + isIntel: current_player.is_intel?, + playerRoster: compose_players(current_player.game) } } broadcast_message payload - start_game end - def start_game + def game_setup game = current_player.game - game.reload - if all_players_in?(game) && game.game_cards.count == 0 - game.establish! - - payload = { - type: "game-setup", - data: { - cards: compose_cards(game), - players: compose_players(game), - firstPlayerId: game.current_player.id - } + payload = { + type: "game-setup", + data: { + cards: compose_cards(game), + players: compose_players(game), + firstPlayerId: game.current_player.id } - broadcast_message payload - end + } + broadcast_message payload end def illegal_action(message) @@ -179,6 +166,20 @@ def illegal_action(message) broadcast_message payload end + def provide_hint(hint) + game = current_player.game + payload = { + type: 'hint-provided', + data: { + isBlueTeam: hint.blue?, + hintWord: hint.word, + relatedCards: hint.num, + currentPlayerId: game.current_player.id + } + } + broadcast_message payload + end + def board_update(details) card = compose_card details[:card] payload = { diff --git a/app/channels/lobby_actions.rb b/app/channels/lobby_actions.rb new file mode 100644 index 0000000..c356061 --- /dev/null +++ b/app/channels/lobby_actions.rb @@ -0,0 +1,53 @@ +module LobbyActions + def select_team(data) + current_player.reload + approved, message = current_player.can_join_team? data["team"] + + if approved + current_player.team = data["team"] + approve_selection(team: data["team"]) + else + deny_selection(message) + end + end + + def select_role(data) + current_player.reload + approved, message = current_player.can_join_role? data["role"] + + if approved + current_player.role = data["role"] + approve_selection(role: data["role"]) + else + deny_selection(message) + end + end + + private + def approve_selection(team: nil, role: nil) + current_player.save + payload = { + type: "player-update", + data: { + id: current_player.id, + name: current_player.name, + isBlueTeam: current_player.is_blue_team?, + isIntel: current_player.is_intel?, + playerRoster: compose_players(current_player.game) + } + } + broadcast_message payload + end + + def deny_selection(message) + payload = { + type: "illegal-action", + data: { + error: message, + category: "personal", + byPlayerId: current_player.id + } + } + broadcast_message payload + end +end diff --git a/app/models/game.rb b/app/models/game.rb index cf66de1..bbffeba 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -96,6 +96,10 @@ def includes_card?(id) card_ids.include? id.to_i end + def started? + game_cards.count > 0 + end + private ###### ### ## ## ######## diff --git a/app/models/player.rb b/app/models/player.rb index e0fb6c9..4713c8e 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -12,4 +12,79 @@ class Player < ApplicationRecord def name user.name end + + def can_join_team?(team) + return false, err("team")[:game_started] if game.started? + players = game.players + players_on_team = players.count{|p| p.team == team} + if players_on_team < game.player_count / 2 + spies_per_team = (game.player_count - 2) / 2 + if role == "intel" && collision_count(team, role) > 0 + return false, err(team)[:team_intel_full] + elsif role == "spy" && collision_count(team, role) >= spies_per_team + return false, err(team)[:team_spies_full] + end + else + return false, err(team)[:team_full] + end + return true, "" + end + + def can_join_role?(role) + return false, err("role")[:game_started] if game.started? + players = game.players + players_with_role = players.count{|p| p.role == role} + if role == "intel" && players_with_role < 2 + if team && collision_count(team, role) > 0 + return false, err(team)[:team_intel_full] + end + elsif role == "spy" && players_with_role < game.player_count - 2 + spies_per_team = (game.player_count - 2) / 2 + if team && collision_count(team, role) >= spies_per_team + return false, err(team)[:team_spies_full] + end + else + if role == "intel" + return false, err[:intel_full] + else + return false, err[:spies_full] + end + end + return true, "" + end + + def is_blue_team? + if team + blue? + else + nil + end + end + + def is_intel? + if role + intel? + else + nil + end + end + + private + def collision_count(team, role) + players = game.players + players.count do |player| + player.team == team && player.role == role + end + end + + def err(snip = nil) + { + team_full: "The #{snip} team is full.", + team_intel_full: "The #{snip} team already has a player with the Intel role.", + team_spies_full: "The #{snip} team doesn't have room for more Spy players.", + intel_full: "There are already two Intel players.", + spies_full: "There is no more room for Spy players.", + game_started: "Unable to change #{snip}. The game has already begun." + } + end end diff --git a/spec/channels/game_data_channel/base_spec.rb b/spec/channels/game_data_channel/base_spec.rb new file mode 100644 index 0000000..26568c9 --- /dev/null +++ b/spec/channels/game_data_channel/base_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +describe GameDataChannel, type: :channel do + let(:user){User.create(name: "Archer")} + let(:game){Game.create} + + before(:each) do + @player = game.players.create(user: user) + stub_connection current_player: @player + end + + it 'subscribes to a room' do + expect(@player.subscribed?).to eq(false) + + subscribe + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_for(game) + + @player.reload + expect(@player.subscribed?).to eq(true) + end + + it 'unsubscribes from a room' do + subscribe + unsubscribe + + expect(subscription).to_not have_stream_for(game) + + @player.reload + expect(@player.subscribed?).to eq(false) + end + + it 'rejects players rejoining after game over' do + game.update_attribute(:over, true) + + subscribe + + expect(subscription).to be_rejected + end + + it 'broadcasts joining player info' do + expect{ subscribe }.to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-joined") + payload = message[:data] + expect(payload[:id]).to eq(@player.id) + expect(payload[:name]).to eq(@player.name) + expect(payload[:isBlueTeam]).to eq(nil) + expect(payload[:isIntel]).to eq(nil) + + players = payload[:playerRoster] + player_ids = [] + expect(players).to be_instance_of(Array) + players.each do |player| + expect(player).to have_key(:id) + expect(player).to have_key(:name) + expect(player).to have_key(:isBlueTeam) + expect(player).to have_key(:isIntel) + player_ids << player[:id] + end + + player_resources = Player.find(player_ids).to_a + expect(player_resources).to eq(player_resources.sort_by &:updated_at) + } + end + + it 'does not broadcast game start until all players are in' do + expect{ subscribe }.to have_broadcasted_to(game) + .from_channel(GameDataChannel).once + + player2 = Player.create(game: game, user: User.create(name: "Lana")) + stub_connection current_player: player2 + expect{ subscribe }.to have_broadcasted_to(game) + .from_channel(GameDataChannel).once + + player3 = Player.create(game: game, user: User.create(name: "Cyril")) + stub_connection current_player: player3 + + expect{ subscribe }.to have_broadcasted_to(game) + .from_channel(GameDataChannel).once + end +end diff --git a/spec/channels/game_data_channel/game_start_spec.rb b/spec/channels/game_data_channel/game_start_spec.rb new file mode 100644 index 0000000..399252d --- /dev/null +++ b/spec/channels/game_data_channel/game_start_spec.rb @@ -0,0 +1,123 @@ +require 'rails_helper' + +describe GameDataChannel, type: :channel do + let(:user){User.create(name: "Archer")} + let(:game){Game.create} + + before(:each) do + @player = game.players.create(user: user) + stub_connection current_player: @player + end + + it 'does not start game immediately when last player joins' do + subscribe + + player2 = Player.create(game: game, user: User.create(name: "Lana")) + stub_connection current_player: player2 + subscribe + + player3 = Player.create(game: game, user: User.create(name: "Cyril")) + stub_connection current_player: player3 + subscribe + + player4 = Player.create(game: game, user: User.create(name: "Cheryl")) + stub_connection current_player: player4 + + expect{ subscribe }.to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once # with player-joined, but not with game-setup + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-joined") + } + end + + it 'broadcasts game start info once all players are in and any player sends start_game action' do + subscribe + + player2 = Player.create(game: game, user: User.create(name: "Lana")) + stub_connection current_player: player2 + subscribe + + player3 = Player.create(game: game, user: User.create(name: "Cyril")) + stub_connection current_player: player3 + subscribe + + player4 = Player.create(game: game, user: User.create(name: "Cheryl")) + stub_connection current_player: player4 + subscription = subscribe + + expect{ subscription.start_game }.to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("game-setup") + + payload = message[:data] + expect(payload).to have_key(:cards) + expect(payload[:cards].count).to eq(25) + + payload[:cards].each do |card| + expect(card).to have_key(:id) + expect(card).to have_key(:word) + end + + first_card = GameCard.find(payload[:cards].first[:id]) + expect(first_card.address).to eq(0) + + last_card = GameCard.find(payload[:cards].last[:id]) + expect(last_card.address).to eq(24) + + expect(payload).to have_key(:players) + expect(payload[:players].count).to eq(4) + + player_ids = [] + payload[:players].each do |player| + expect(player).to have_key(:id) + expect(player).to have_key(:name) + expect(player).to have_key(:isBlueTeam) + expect(player).to have_key(:isIntel) + player_ids << player[:id] + end + + player_resources = Player.find(player_ids).to_a + expect(player_resources).to eq(player_resources.sort_by &:updated_at) + + expect(payload).to have_key(:firstPlayerId) + } + end + + it 'respects player team/role selections when present' do + subscribe + + player2 = Player.create(game: game, user: User.create(name: "Lana"), team: :red, role: :intel) + stub_connection current_player: player2 + subscribe + + player3 = Player.create(game: game, user: User.create(name: "Cyril"), team: :red, role: :spy) + stub_connection current_player: player3 + subscribe + + player4 = Player.create(game: game, user: User.create(name: "Cheryl"), team: :blue, role: :intel) + stub_connection current_player: player4 + subscription = subscribe + + expect{ subscription.start_game }.to make_database_queries(count: 1, matching: "UPDATE \"players\"") + .and have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("game-setup") + payload = message[:data] + + archer = payload[:players].find do |player| + player[:name] == "Archer" + end + + expect(archer[:isBlueTeam]).to eq(true) + expect(archer[:isIntel]).to eq(false) + } + end +end diff --git a/spec/channels/game_data_channel_guesses_spec.rb b/spec/channels/game_data_channel/guesses_spec.rb similarity index 98% rename from spec/channels/game_data_channel_guesses_spec.rb rename to spec/channels/game_data_channel/guesses_spec.rb index 8684c0f..ff6d9de 100644 --- a/spec/channels/game_data_channel_guesses_spec.rb +++ b/spec/channels/game_data_channel/guesses_spec.rb @@ -14,6 +14,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -56,6 +57,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :intel) stub_connection current_player: spy subscription = subscribe + @game.establish! @game.current_player = Player.where.not(id: spy.id).first @game.guesses_remaining = 1 @@ -81,6 +83,7 @@ random_player = @game.players.create(user: User.create(name: "Cheryl"), role: :intel) stub_connection current_player: random_player subscription = subscribe + @game.establish! built_player = Player.find(random_player.id) built_player.update(role: :intel) @@ -109,6 +112,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -142,6 +146,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -176,6 +181,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -212,6 +218,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -248,6 +255,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -284,6 +292,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -327,6 +336,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) @@ -369,6 +379,7 @@ spy = @game.players.create(user: User.create(name: "Cheryl"), role: :spy, team: :red) stub_connection current_player: spy subscription = subscribe + @game.establish! built_player = Player.find(spy.id) built_player.update(role: :spy) diff --git a/spec/channels/game_data_channel_hints_spec.rb b/spec/channels/game_data_channel/hints_spec.rb similarity index 98% rename from spec/channels/game_data_channel_hints_spec.rb rename to spec/channels/game_data_channel/hints_spec.rb index 3ef4ece..230a617 100644 --- a/spec/channels/game_data_channel_hints_spec.rb +++ b/spec/channels/game_data_channel/hints_spec.rb @@ -14,6 +14,7 @@ intel = @game.players.create(user: User.create(name: "Cheryl"), role: :intel, team: :red) stub_connection current_player: intel subscription = subscribe + @game.establish! built_player = Player.find(intel.id) built_player.update(role: :intel) @@ -50,6 +51,7 @@ intel = @game.players.create(user: User.create(name: "Cheryl"), role: :intel) stub_connection current_player: intel subscription = subscribe + @game.establish! @game.current_player = Player.where.not(id: intel.id).first @game.save @@ -72,6 +74,7 @@ random_player = @game.players.create(user: User.create(name: "Cheryl"), role: :spy) stub_connection current_player: random_player subscription = subscribe + @game.establish! built_player = Player.find(random_player.id) built_player.update(role: :spy) @@ -98,6 +101,7 @@ intel = @game.players.create(user: User.create(name: "Cheryl"), role: :intel) stub_connection current_player: intel subscription = subscribe + @game.establish! built_player = Player.find(intel.id) built_player.update(role: :intel) diff --git a/spec/channels/game_data_channel/player_selections_spec.rb b/spec/channels/game_data_channel/player_selections_spec.rb new file mode 100644 index 0000000..90fa51e --- /dev/null +++ b/spec/channels/game_data_channel/player_selections_spec.rb @@ -0,0 +1,313 @@ +require 'rails_helper' + +describe GameDataChannel, type: :channel do + let(:game){Game.create} + + it 'allows a player to select their team' do + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_team({"team" => "red"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-update") + + payload = message[:data] + expect(payload[:id]).to eq(player.id) + expect(payload[:isBlueTeam]).to eq(false) + expect(payload[:isIntel]).to eq(nil) + } + + player.reload + expect(player.red?).to eq(true) + expect(player.role).to eq(nil) + end + + it 'allows a player to select their role' do + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "intel"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-update") + + payload = message[:data] + expect(payload[:id]).to eq(player.id) + expect(payload[:isBlueTeam]).to eq(nil) + expect(payload[:isIntel]).to eq(true) + } + + player.reload + expect(player.team).to eq(nil) + expect(player.intel?).to eq(true) + end + + it 'rejects team/role selections once the game has started' do + archer = game.players.create(user: User.create(name: "Archer"), subscribed: true) + lana = game.players.create(user: User.create(name: "Lana"), subscribed: true) + cyril = game.players.create(user: User.create(name: "Cyril"), subscribed: true) + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + game.establish! + + expect{subscription.select_team({"team" => "red"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("Unable to change team. The game has already begun.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + expect{subscription.select_role({"role" => "intel"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("Unable to change role. The game has already begun.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + end + + it 'rejects a team selection if the team is full' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red) + lana = game.players.create(user: User.create(name: "Lana"), team: :red) + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_team({"team" => "red"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("The red team is full.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.team).to eq(nil) + expect(player.role).to eq(nil) + end + + it 'rejects a role selection if intel and there are no more slots available for that role' do + archer = game.players.create(user: User.create(name: "Archer"), role: :intel) + lana = game.players.create(user: User.create(name: "Lana"), role: :intel) + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "intel"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("There are already two Intel players.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.team).to eq(nil) + expect(player.role).to eq(nil) + end + + it 'rejects a role selection if spy and there are no more slots available for that role' do + archer = game.players.create(user: User.create(name: "Archer"), role: :spy) + lana = game.players.create(user: User.create(name: "Lana"), role: :spy) + player = game.players.create(user: User.create(name: "Cheryl")) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "spy"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("There is no more room for Spy players.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.team).to eq(nil) + expect(player.role).to eq(nil) + end + + it 'rejects a team selection if player is intel and intel role is taken already' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red, role: :intel) + player = game.players.create(user: User.create(name: "Cheryl"), role: :intel) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_team({"team" => "red"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("The red team already has a player with the Intel role.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.team).to eq(nil) + end + + it 'rejects a team selection if player is spy and spy role is taken already' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red, role: :spy) + player = game.players.create(user: User.create(name: "Cheryl"), role: :spy) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_team({"team" => "red"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("The red team doesn't have room for more Spy players.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.team).to eq(nil) + end + + it 'rejects a role selection if selection is intel and team already has intel' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red, role: :intel) + player = game.players.create(user: User.create(name: "Cheryl"), team: :red) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "intel"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("The red team already has a player with the Intel role.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.role).to eq(nil) + end + + it 'rejects a role selection if selection is spy and team is full of spies' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red, role: :spy) + player = game.players.create(user: User.create(name: "Cheryl"), team: :red) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "spy"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("illegal-action") + + payload = message[:data] + expect(payload[:error]).to eq("The red team doesn't have room for more Spy players.") + expect(payload[:category]).to eq("personal") + expect(payload[:byPlayerId]).to eq(player.id) + } + + player.reload + expect(player.role).to eq(nil) + end + + it 'does not prevent player from changing team if their current team is full' do + archer = game.players.create(user: User.create(name: "Archer"), team: :red) + player = game.players.create(user: User.create(name: "Cheryl"), team: :red) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_team({"team" => "blue"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-update") + + payload = message[:data] + expect(payload[:id]).to eq(player.id) + expect(payload[:isBlueTeam]).to eq(true) + expect(payload[:isIntel]).to eq(nil) + } + + player.reload + expect(player.blue?).to eq(true) + end + + it 'does not prevent player from changing role if their current role is full' do + archer = game.players.create(user: User.create(name: "Archer"), role: :intel) + player = game.players.create(user: User.create(name: "Cheryl"), role: :intel) + stub_connection current_player: player + subscription = subscribe + + expect{subscription.select_role({"role" => "spy"})} + .to have_broadcasted_to(game) + .from_channel(GameDataChannel) + .once + .with{ |data| + message = JSON.parse(data[:message], symbolize_names: true) + expect(message[:type]).to eq("player-update") + + payload = message[:data] + expect(payload[:id]).to eq(player.id) + expect(payload[:isBlueTeam]).to eq(nil) + expect(payload[:isIntel]).to eq(false) + } + + player.reload + expect(player.spy?).to eq(true) + end +end diff --git a/spec/channels/game_data_channel_spec.rb b/spec/channels/game_data_channel_spec.rb deleted file mode 100644 index c9081f1..0000000 --- a/spec/channels/game_data_channel_spec.rb +++ /dev/null @@ -1,125 +0,0 @@ -require 'rails_helper' - -describe GameDataChannel, type: :channel do - let(:user){User.create(name: "Archer")} - let(:game){Game.create} - - before(:each) do - @player = game.players.create(user: user) - stub_connection current_player: @player - end - - it 'subscribes to a room' do - subscribe - - expect(subscription).to be_confirmed - expect(subscription).to have_stream_for(game) - end - - it 'broadcasts joining player info' do - expect{ subscribe }.to have_broadcasted_to(game) - .from_channel(GameDataChannel) - .with{ |data| - message = JSON.parse(data[:message], symbolize_names: true) - expect(message[:type]).to eq("player-joined") - payload = message[:data] - expect(payload[:id]).to eq(@player.id) - expect(payload[:name]).to eq(@player.name) - - players = payload[:playerRoster] - player_ids = [] - expect(players).to be_instance_of(Array) - players.each do |player| - expect(player).to have_key(:id) - expect(player).to have_key(:name) - player_ids << player[:id] - end - - player_resources = Player.find(player_ids).to_a - expect(player_resources).to eq(player_resources.sort_by &:updated_at) - } - end - - it 'does not broadcast game start until all players are in' do - expect{ subscribe }.to have_broadcasted_to(game) - .from_channel(GameDataChannel).once - - player2 = Player.create(game: game, user: User.create(name: "Lana")) - stub_connection current_player: player2 - expect{ subscribe }.to have_broadcasted_to(game) - .from_channel(GameDataChannel).once - - player3 = Player.create(game: game, user: User.create(name: "Cyril")) - stub_connection current_player: player3 - - expect{ subscribe }.to have_broadcasted_to(game) - .from_channel(GameDataChannel).once - end - - it 'broadcasts game start info once all players are in' do - subscribe - - player2 = Player.create(game: game, user: User.create(name: "Lana")) - stub_connection current_player: player2 - subscribe - - player3 = Player.create(game: game, user: User.create(name: "Cyril")) - stub_connection current_player: player3 - subscribe - - player4 = Player.create(game: game, user: User.create(name: "Cheryl")) - stub_connection current_player: player4 - - # track number of times game-setup broadcast - game_setup_count = 0 - - expect{ subscribe }.to have_broadcasted_to(game) - .from_channel(GameDataChannel) - .twice # once with player-joined, once with game-setup - .with{ |data| - message = JSON.parse(data[:message], symbolize_names: true) - unless message[:type] == "player-joined" - expect(message[:type]).to eq("game-setup") - # increment count of game-setup messages - game_setup_count += 1 - - payload = message[:data] - expect(payload).to have_key(:cards) - expect(payload[:cards].count).to eq(25) - - payload[:cards].each do |card| - expect(card).to have_key(:id) - expect(card).to have_key(:word) - end - - first_card = GameCard.find(payload[:cards].first[:id]) - expect(first_card.address).to eq(0) - - last_card = GameCard.find(payload[:cards].last[:id]) - expect(last_card.address).to eq(24) - - expect(payload).to have_key(:players) - expect(payload[:players].count).to eq(4) - - player_ids = [] - payload[:players].each do |player| - expect(player).to have_key(:id) - expect(player).to have_key(:name) - expect(player).to have_key(:isBlueTeam) - expect(player).to have_key(:isIntel) - player_ids << player[:id] - end - - player_resources = Player.find(player_ids).to_a - expect(player_resources).to eq(player_resources.sort_by &:updated_at) - - expect(payload).to have_key(:firstPlayerId) - else - # player-joined message should happen once, so allow that to pass - expect(message[:type]).to eq("player-joined") - end - } - # game-setup should have incremented once in the two broadcasts above - expect(game_setup_count).to eq(1) - end -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index feb956c..c4eb4ae 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -97,6 +97,22 @@ end end +DBQueryMatchers.configure do |config| + # config.ignores = [/SHOW TABLES LIKE/] + config.schemaless = true + + # the payload argument is described here: + # http://edgeguides.rubyonrails.org/active_support_instrumentation.html#sql-active-record + # config.on_query_counted do |payload| + # # do something arbitrary with the query + # end + + config.log_backtrace = true + config.backtrace_filter = Proc.new do |backtrace| + backtrace.select { |line| line.start_with?(Rails.root.to_s) } + end +end + require 'database_cleaner' DatabaseCleaner.strategy = :truncation require './db/seeds/cards'