diff --git a/apps/frontend/src/components/BotLevelMenu.tsx b/apps/frontend/src/components/BotLevelMenu.tsx new file mode 100644 index 0000000..6a43278 --- /dev/null +++ b/apps/frontend/src/components/BotLevelMenu.tsx @@ -0,0 +1,36 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; +import { BotIcon } from "./Icons"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// "Play with bot" in the header: a menu of the levels, the one chosen last marked. +const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const menuButton = ( + + ); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( + + {t("ChooseBotLevel")} + {BOT_LEVELS.map((level) => ( + choose(level)}> + {t(getBotLevelTextKey(level))} + + ))} + + ); +}; + +export default BotLevelMenu; diff --git a/apps/frontend/src/components/BotLevelPicker.tsx b/apps/frontend/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..e190d71 --- /dev/null +++ b/apps/frontend/src/components/BotLevelPicker.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; + +// The four bot levels as buttons; the level chosen last is highlighted and remembered on this device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( +
+ {BOT_LEVELS.map((level) => ( + + ))} + +
+ ); +}; + +export default BotLevelPicker; diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 49fadab..3f7e5c0 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => ( export const PauseIcon = ({ size = 22 }: IconProps) => ( ); + +export const BotIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx index cd01de7..23e3789 100644 --- a/apps/frontend/src/components/game/GameOverCard.tsx +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{ title: string, reason?: string, score: string, + // "New Game" unless given, e.g. "Play again" after a game against the bot. + newGameLabel?: string, onNewGame: () => void, onClose: () => void, -}> = ({ title, reason, score, onNewGame, onClose }) => { +}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => { const { t } = useTranslation(); const navigate = useNavigate(); return ( @@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{ {reason &&
{reason}
}
{score}
- +
diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 7f33d2e..bd3f812 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -12,7 +12,7 @@ import { LeaveIcon, PlusIcon } from '../components/Icons'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme, getPlayerDisplayName } from '@mancala/core'; +import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; import notyf from '../util/Notyf'; import swal from 'sweetalert'; @@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{ navigate("/loby") }; + // In a game against the bot, the game over card starts another one at the same level. + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + + const onPlayAgainClick = () => { + if (Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${opponentBotLevel}`) + }; + const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; @@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{ title={resultTitle ?? t("GameEnded")} reason={resultReason} score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`} - onNewGame={onNewGameClick} + newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined} + onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick} onClose={() => setGameOverClosed(true)} /> )} diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index e61acf6..d6e0690 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { LiveGameSummary } from "@mancala/core"; -import { AuthUser, Theme } from "@mancala/core"; +import { AuthUser, BotLevel, Theme } from "@mancala/core"; import AppHeader from "../components/headerbar/AppHeader"; import PageContainer from "../components/PageContainer"; import { PlusIcon } from "../components/Icons"; +import BotLevelMenu from "../components/BotLevelMenu"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; @@ -43,9 +44,15 @@ const Home: FunctionComponent<{ navigate("/loby") }; + const onBotLevelChosen = (level: BotLevel) => { + if(Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${level}`) + }; + return ( + diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index f5e7f38..49371f3 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -1,10 +1,11 @@ import { CommonMancalaGame } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import AppHeader from '../components/headerbar/AppHeader'; import PageContainer from '../components/PageContainer'; -import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import { Context } from '../context/context'; import { Theme } from '@mancala/core'; import { useTranslation } from 'react-i18next'; @@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{ const { t } = useTranslation(); + // /loby?bot= starts a game against the bot at once instead of searching for an opponent. + const [searchParams] = useSearchParams(); + const botParam = searchParams.get("bot"); + const botLevel: BotLevel | undefined = isBotLevel(botParam) ? botParam : undefined; + // Set when the server refuses a new game because the user has one in progress. const [activeGameId, setActiveGameId] = useState(undefined); + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = useState(false); + const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId); const onGameStart = (message: Object) => { @@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{ navigate(`/game/${newGame.id}`) } + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } + const onCancelNewGameClick = () => { if (context.rtmt.connectionState === "connected") { context.rtmt.sendMessage(channel_cancel_new_game, ""); @@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{ useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); } @@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
{!activeGameId && } -

{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}

+

{activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}

{!activeGameId &&

{`${t("PleaseWait")}...`}

} + {!activeGameId && botOffered && ( +
+

{t("NoOpponentYet")}

+ +
+ )}
{activeGameId && ( @@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{ margin: 0; color: var(--muted); } + .offer { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid var(--line); + } .actions { display: flex; gap: 10px; diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 3d14fa8..7d25bae 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -32,12 +32,17 @@ export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 export function isBotId(userId: string | undefined): boolean; // starts with "bot:" export function getBotLevel(userId: string | undefined): BotLevel | undefined; +export function isBotLevel(value: unknown): value is BotLevel; +export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium +export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4) +export function setLastBotLevel(storage, level: BotLevel): void; ``` - `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for bots (R4.1). - Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with - the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `Bot` "Bot" / "Bot", `BotLevelEasy`, + the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot + is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`, `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", "Usta", `PlayAgain` "Play again" / "Tekrar oyna". @@ -185,10 +190,10 @@ Games being worked on are kept in a set by identity, so a game restored with the `BOT_SEARCH_THREADS` (default: half the processors, at least 1). ## Web app -- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is preselected and saved in - local storage (R1.4). -- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level - goes to `/loby?bot=` (R1.3). +- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with + `getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account + menu, with the last level checked; choosing a level goes to `/loby?bot=` (R1.3). - `LobyPage`: with `?bot=`, it sends `new_bot_game` instead of `new_game`. Otherwise, after `BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 432a5fc..dc3a7d3 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -14,7 +14,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. -- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot +- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. - [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`,