From 1333e6350e2240b0e58c626e64cf5a34f0fda4f2 Mon Sep 17 00:00:00 2001 From: Samir Menon Date: Tue, 20 Feb 2024 14:14:42 -0800 Subject: [PATCH 1/2] app: add button to kick a player Players can only be kicked from the room at the beginning of the first round, before any clues have been answered. --- app/components/player/kick-player.tsx | 87 +++++++++++++++++++++++++++ app/components/player/player.tsx | 34 ++++++++--- app/engine/actions.ts | 3 +- app/engine/engine.test.ts | 14 +++++ app/engine/engine.ts | 29 +++++++++ app/engine/use-engine-context.ts | 1 + app/engine/use-game-engine.ts | 3 +- app/routes/room.$roomId.player.tsx | 12 +++- 8 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 app/components/player/kick-player.tsx diff --git a/app/components/player/kick-player.tsx b/app/components/player/kick-player.tsx new file mode 100644 index 0000000..4ab995a --- /dev/null +++ b/app/components/player/kick-player.tsx @@ -0,0 +1,87 @@ +import { useFetcher } from "@remix-run/react"; +import * as React from "react"; + +import type { Action, Player } from "~/engine"; +import { useEngineContext } from "~/engine"; +import useSoloAction from "~/utils/use-solo-action"; +import { PlayerScoreBox } from "./player"; + +function KickPlayer({ + hasBoardControl, + player, + winning = false, +}: { + hasBoardControl: boolean; + player: Player; + winning?: boolean; +}) { + return ( + +
+

+ {player.name} +

+
+ + {winning && "👑"} +
+
+
+ ); +} + +export function KickPlayerForm({ + roomId, + player, + winning = false, +}: { + roomId: number; + player: Player; + winning?: boolean; +}) { + const { soloDispatch, boardControl } = useEngineContext(); + + const fetcher = useFetcher(); + useSoloAction(fetcher, soloDispatch); + + const formRef = React.useRef(null); + + return ( + + + + + + ); +} diff --git a/app/components/player/player.tsx b/app/components/player/player.tsx index 834bb78..e61ecea 100644 --- a/app/components/player/player.tsx +++ b/app/components/player/player.tsx @@ -5,6 +5,7 @@ import { GameState, useEngineContext } from "~/engine"; import { formatDollars, stringToHslColor } from "~/utils"; import { RoomProps } from "../game"; import { EditPlayerForm } from "./edit-player"; +import { KickPlayerForm } from "./kick-player"; // https://stackoverflow.com/questions/70524820/is-there-still-no-easy-way-to-split-strings-with-compound-emojis-into-an-array const COMPOUND_EMOJI_REGEX = @@ -111,7 +112,8 @@ function getMaxScore(others: Player[], you?: Player) { * - Shows each player's name and score */ export function PlayerScores({ roomId, userId }: RoomProps) { - const { players, boardControl, type, round } = useEngineContext(); + const { players, boardControl, type, round, numAnswered } = + useEngineContext(); const yourPlayer = players.get(userId); @@ -126,6 +128,11 @@ export function PlayerScores({ roomId, userId }: RoomProps) { type !== GameState.GameOver && (type !== GameState.PreviewRound || round !== 0); + const canKick = + (type === GameState.ShowBoard || type === GameState.PreviewRound) && + numAnswered === 0 && + round === 0; + return (
{yourPlayer ? ( @@ -143,14 +150,23 @@ export function PlayerScores({ roomId, userId }: RoomProps) { /> ) ) : null} - {sortedOtherPlayers.map((p, i) => ( - - ))} + {sortedOtherPlayers.map((p, i) => + canKick ? ( + + ) : ( + + ), + )}
); } diff --git a/app/engine/actions.ts b/app/engine/actions.ts index 8cf7dd1..1c7c747 100644 --- a/app/engine/actions.ts +++ b/app/engine/actions.ts @@ -37,7 +37,8 @@ export function isPlayerAction(action: Action): action is { } { return ( (action.type === ActionType.Join || - action.type === ActionType.ChangeName) && + action.type === ActionType.ChangeName || + action.type === ActionType.Kick) && action.payload !== null && typeof action.payload === "object" && "userId" in action.payload && diff --git a/app/engine/engine.test.ts b/app/engine/engine.test.ts index b6ba41c..b493bd2 100644 --- a/app/engine/engine.test.ts +++ b/app/engine/engine.test.ts @@ -30,6 +30,11 @@ const PLAYER1_JOIN_ACTION: Action = { payload: { name: PLAYER1.name, userId: PLAYER1.userId }, }; +const PLAYER1_KICK_ACTION: Action = { + type: ActionType.Kick, + payload: { name: PLAYER1.name, userId: PLAYER1.userId }, +}; + const PLAYER2_JOIN_ACTION: Action = { type: ActionType.Join, payload: { name: PLAYER2.name, userId: PLAYER2.userId }, @@ -146,6 +151,15 @@ describe("gameEngine", () => { draft.players.set(PLAYER2.userId, PLAYER2); }), }, + { + name: "Two players join, first is kicked, second gets board control", + state: initialState, + actions: [PLAYER1_JOIN_ACTION, PLAYER2_JOIN_ACTION, PLAYER1_KICK_ACTION], + expectedState: produce(initialState, (draft) => { + draft.boardControl = PLAYER2.userId; + draft.players.set(PLAYER2.userId, PLAYER2); + }), + }, { name: "Round start", state: initialState, diff --git a/app/engine/engine.ts b/app/engine/engine.ts index 5d6bbe8..1d1f16d 100644 --- a/app/engine/engine.ts +++ b/app/engine/engine.ts @@ -17,6 +17,7 @@ enableMapSet(); export enum ActionType { Join = "join", + Kick = "kick", ChangeName = "change_name", StartRound = "start_round", ChooseClue = "choose_clue", @@ -112,6 +113,34 @@ export function gameEngine(state: State, action: Action): State { draft.boardControl = action.payload.userId; } }); + case ActionType.Kick: + if (!isPlayerAction(action)) { + throw new Error("PlayerKick action must have an associated player"); + } + return produce(state, (draft) => { + // Don't kick players after the game has started. + if ( + (draft.type !== GameState.ShowBoard && + draft.type !== GameState.PreviewRound) || + draft.numAnswered > 0 || + draft.round > 0 + ) { + return; + } + // Don't allow the only player to be kicked. + if (draft.players.size === 1) { + return; + } + // If this player has board control, give it to the next player. + if (draft.boardControl === action.payload.userId) { + const players = Array.from(draft.players.keys()); + players.sort(); + const index = players.indexOf(action.payload.userId); + const nextPlayer = players[(index + 1) % players.length]; + draft.boardControl = nextPlayer; + } + draft.players.delete(action.payload.userId); + }); case ActionType.ChangeName: if (!isPlayerAction(action)) { throw new Error( diff --git a/app/engine/use-engine-context.ts b/app/engine/use-engine-context.ts index cc8d299..bec1124 100644 --- a/app/engine/use-engine-context.ts +++ b/app/engine/use-engine-context.ts @@ -9,6 +9,7 @@ export const GameEngineContext = React.createContext< type: GameState.PreviewRound, activeClue: null, answers: new Map(), + numAnswered: 0, answeredBy: () => false, board: { categories: [], categoryNames: [] }, boardControl: null, diff --git a/app/engine/use-game-engine.ts b/app/engine/use-game-engine.ts index 5f5dc09..84d1ce2 100644 --- a/app/engine/use-game-engine.ts +++ b/app/engine/use-game-engine.ts @@ -8,7 +8,7 @@ import { getSupabase } from "~/supabase"; import type { Action } from "./engine"; import { gameEngine, getWinningBuzzer } from "./engine"; import { applyRoomEventsToState, isTypedRoomEvent } from "./room-event"; -import { getClueValue, State, stateFromGame } from "./state"; +import { State, getClueValue, stateFromGame } from "./state"; export enum ConnectionState { ERROR, @@ -74,6 +74,7 @@ function stateToGameEngine( */ answeredBy, answers: state.answers.get(clueKey) ?? new Map(), + numAnswered: state.numAnswered, board, buzzes: state.buzzes, category, diff --git a/app/routes/room.$roomId.player.tsx b/app/routes/room.$roomId.player.tsx index e9c79b9..7db015d 100644 --- a/app/routes/room.$roomId.player.tsx +++ b/app/routes/room.$roomId.player.tsx @@ -8,7 +8,11 @@ import { getRoom } from "~/models/room.server"; import { getSolve, markAttempted } from "~/models/solves.server"; export async function action({ request, params }: ActionFunctionArgs) { - if (request.method !== "POST" && request.method !== "PATCH") { + if ( + request.method !== "POST" && + request.method !== "PATCH" && + request.method !== "DELETE" + ) { throw new Response("method not allowed", { status: 405 }); } const formData = await request.formData(); @@ -28,7 +32,11 @@ export async function action({ request, params }: ActionFunctionArgs) { } const type = - request.method === "POST" ? ActionType.Join : ActionType.ChangeName; + request.method === "POST" + ? ActionType.Join + : request.method === "PATCH" + ? ActionType.ChangeName + : ActionType.Kick; if (roomId === -1) { return json({ type, payload: { userId, name } }); From c4a57dcb2391602763fbc01a41907deace1b4e2d Mon Sep 17 00:00:00 2001 From: Samir Menon Date: Tue, 20 Feb 2024 14:26:15 -0800 Subject: [PATCH 2/2] app components player: remove unused ref --- app/components/player/kick-player.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/app/components/player/kick-player.tsx b/app/components/player/kick-player.tsx index 4ab995a..25873a2 100644 --- a/app/components/player/kick-player.tsx +++ b/app/components/player/kick-player.tsx @@ -1,5 +1,4 @@ import { useFetcher } from "@remix-run/react"; -import * as React from "react"; import type { Action, Player } from "~/engine"; import { useEngineContext } from "~/engine"; @@ -24,11 +23,10 @@ function KickPlayer({