diff --git a/app/components/player/kick-player.tsx b/app/components/player/kick-player.tsx new file mode 100644 index 0000000..25873a2 --- /dev/null +++ b/app/components/player/kick-player.tsx @@ -0,0 +1,79 @@ +import { useFetcher } from "@remix-run/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} + + + + {/* Heroicon name: solid/x-mark */} + + + + + {winning && "👑"} + + + + ); +} + +export function KickPlayerForm({ + roomId, + player, + winning = false, +}: { + roomId: number; + player: Player; + winning?: boolean; +}) { + const { soloDispatch, boardControl } = useEngineContext(); + + const fetcher = useFetcher(); + useSoloAction(fetcher, soloDispatch); + + 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 } });
+ {player.name} +