Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
9 changed files with 146 additions and 15 deletions
Showing only changes of commit e66c4a3a41 - Show all commits

View File

@ -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 = (
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
</button>
);
const choose = (level: BotLevel) => {
setLastBotLevel(context.storage, level);
onChoose(level);
};
return (
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
<MenuHeader>{t("ChooseBotLevel")}</MenuHeader>
{BOT_LEVELS.map((level) => (
<MenuItem key={level} type="checkbox" checked={level === lastLevel} onClick={() => choose(level)}>
{t(getBotLevelTextKey(level))}
</MenuItem>
))}
</Menu>
);
};
export default BotLevelMenu;

View File

@ -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 (
<div className="levels" role="group" aria-label={t("ChooseBotLevel")}>
{BOT_LEVELS.map((level) => (
<button key={level} className={`btn ${level === lastLevel ? "pri" : "ghost"}`} onClick={() => choose(level)}>
{t(getBotLevelTextKey(level))}
</button>
))}
<style jsx>{`
.levels {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
`}</style>
</div>
);
};
export default BotLevelPicker;

View File

@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => (
export const PauseIcon = ({ size = 22 }: IconProps) => ( export const PauseIcon = ({ size = 22 }: IconProps) => (
<svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg> <svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg>
); );
export const BotIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
);

View File

@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{
title: string, title: string,
reason?: string, reason?: string,
score: string, score: string,
// "New Game" unless given, e.g. "Play again" after a game against the bot.
newGameLabel?: string,
onNewGame: () => void, onNewGame: () => void,
onClose: () => void, onClose: () => void,
}> = ({ title, reason, score, onNewGame, onClose }) => { }> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{
{reason && <div className="oreason">{reason}</div>} {reason && <div className="oreason">{reason}</div>}
<div className="oscore">{score}</div> <div className="oscore">{score}</div>
<div className="row"> <div className="row">
<button className="btn pri" onClick={onNewGame}>{t("NewGame")}</button> <button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button> <button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
</div> </div>
</div> </div>

View File

@ -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 { 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 { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; 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 { BoardViewModel } from "@mancala/core";
import notyf from '../util/Notyf'; import notyf from '../util/Notyf';
import swal from 'sweetalert'; import swal from 'sweetalert';
@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{
navigate("/loby") 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) => { const onPitSelect = (index: number, pit: Pit) => {
if (!game || isSpectator || !userKey) { if (!game || isSpectator || !userKey) {
return; return;
@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{
title={resultTitle ?? t("GameEnded")} title={resultTitle ?? t("GameEnded")}
reason={resultReason} reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`} 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)} /> onClose={() => setGameOverClosed(true)} />
)} )}
</div> </div>

View File

@ -1,10 +1,11 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react"; import { FunctionComponent, useEffect, useState } from "react";
import { LiveGameSummary } from "@mancala/core"; 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 AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
import { PlusIcon } from "../components/Icons"; import { PlusIcon } from "../components/Icons";
import BotLevelMenu from "../components/BotLevelMenu";
import ActiveGameBanner from "../components/home/ActiveGameBanner"; import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection"; import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection";
@ -43,9 +44,15 @@ const Home: FunctionComponent<{
navigate("/loby") navigate("/loby")
}; };
const onBotLevelChosen = (level: BotLevel) => {
if(Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate(`/loby?bot=${level}`)
};
return ( return (
<PageContainer> <PageContainer>
<AppHeader context={context} authUser={authUser}> <AppHeader context={context} authUser={authUser}>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}> <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span> <PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button> </button>

View File

@ -1,10 +1,11 @@
import { CommonMancalaGame } from 'mancala.js'; import { CommonMancalaGame } from 'mancala.js';
import * as React from 'react'; import * as React from 'react';
import { FunctionComponent, useEffect, useState } 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 AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer'; 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 { Context } from '../context/context';
import { Theme } from '@mancala/core'; import { Theme } from '@mancala/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{
const { t } = useTranslation(); const { t } = useTranslation();
// /loby?bot=<level> 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. // Set when the server refuses a new game because the user has one in progress.
const [activeGameId, setActiveGameId] = useState<string | undefined>(undefined); const [activeGameId, setActiveGameId] = useState<string | undefined>(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 onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId);
const onGameStart = (message: Object) => { const onGameStart = (message: Object) => {
@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{
navigate(`/game/${newGame.id}`) navigate(`/game/${newGame.id}`)
} }
const startBotGame = (level: BotLevel) => {
context.rtmt.sendMessage(channel_new_bot_game, { level });
}
const onCancelNewGameClick = () => { const onCancelNewGameClick = () => {
if (context.rtmt.connectionState === "connected") { if (context.rtmt.connectionState === "connected") {
context.rtmt.sendMessage(channel_cancel_new_game, ""); context.rtmt.sendMessage(channel_cancel_new_game, "");
@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
context.rtmt.sendMessage("new_game", {}); let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => { return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
} }
@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
<main className="wrap"> <main className="wrap">
<section className="search panel" aria-live="polite"> <section className="search panel" aria-live="polite">
{!activeGameId && <span className="dot" />} {!activeGameId && <span className="dot" />}
<h1>{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}</h1> <h1>{activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}</h1>
{!activeGameId && <p>{`${t("PleaseWait")}...`}</p>} {!activeGameId && <p>{`${t("PleaseWait")}...`}</p>}
{!activeGameId && botOffered && (
<div className="offer">
<p>{t("NoOpponentYet")}</p>
<BotLevelPicker context={context} onChoose={startBotGame} />
</div>
)}
<div className="actions"> <div className="actions">
{activeGameId && ( {activeGameId && (
<button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button> <button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button>
@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
} }
.offer {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.actions { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;

View File

@ -32,12 +32,17 @@ export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"];
export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 export const BOT_OFFER_DELAY_MS = 10_000; // R1.1
export function isBotId(userId: string | undefined): boolean; // starts with "bot:" export function isBotId(userId: string | undefined): boolean; // starts with "bot:"
export function getBotLevel(userId: string | undefined): BotLevel | undefined; 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 - `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for
bots (R4.1). bots (R4.1).
- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with - 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", `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor",
"Usta", `PlayAgain` "Play again" / "Tekrar oyna". "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). `BOT_SEARCH_THREADS` (default: half the processors, at least 1).
## Web app ## Web app
- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is preselected and saved in - `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with
local storage (R1.4). `getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4).
- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level - Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account
goes to `/loby?bot=<level>` (R1.3). menu, with the last level checked; choosing a level goes to `/loby?bot=<level>` (R1.3).
- `LobyPage`: with `?bot=<level>`, it sends `new_bot_game` instead of `new_game`. Otherwise, after - `LobyPage`: with `?bot=<level>`, 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 `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. sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays.

View File

@ -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] 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`, - [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. `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. game, bot names everywhere players are shown.
- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 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`, - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`,