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
12 changed files with 240 additions and 25 deletions
Showing only changes of commit 8511e0b3f2 - Show all commits

View File

@ -14,6 +14,7 @@ import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core';
import { Theme } from '@mancala/core';
import LobyPage from './routes/LobyPage';
import PrivacyPage from './routes/PrivacyPage';
import RulesPage from './routes/RulesPage';
import swal from 'sweetalert';
import Util from './util/Util';
import { server } from './const/config';
@ -130,6 +131,7 @@ const MancalaApp: FunctionComponent = () => {
</Route>
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
</Route>
<Route path="rules" element={<RulesPage context={context} theme={theme} />} />
</Route>
</Routes>
</BrowserRouter>}

View File

@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => (
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>
);
export const HelpIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
);

View File

@ -4,13 +4,14 @@ import { Link } from "react-router-dom";
import { AuthUser } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { LogoIcon } from "../Icons";
import { HelpIcon, LogoIcon } from "../Icons";
import AccountMenu from "./AccountMenu";
import ThemeSwatches from "./ThemeSwatches";
// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's
// main action (children).
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => {
// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and
// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the
// rules over the game instead).
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => {
const { t } = useTranslation();
return (
<header className={"hdr" + (authUser ? " has-account" : "")}>
@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> =
<span>{t("Mancala")}</span>
</Link>
<ThemeSwatches context={context} />
{onHelp
? <button className="btn icon ghost" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon /></button>
: <Link className="btn icon ghost" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon /></Link>}
{authUser && <AccountMenu context={context} authUser={authUser} />}
{children}
</header>

View File

@ -47,24 +47,10 @@
.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.meta > span:last-child { text-align: right; }
/* Mini board: the top player's store on the left, the bottom player's on the right */
.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); }
.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms.l { grid-column: 1; }
.mini .ms.r { grid-column: 8; color: var(--accent); }
.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; }
.mini.lg .mp { font-size: 16px; }
.mini.lg .ms { font-size: 20px; padding-bottom: 14px; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; }
.hero h1 { font-size: 25px; }
.hero .btn { flex: 1; }
.mini.lg { gap: 6px; padding: 10px; border-radius: 20px; }
.mini.lg .mp { font-size: 13px; }
.mini.lg .ms { font-size: 15px; padding-bottom: 10px; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.sec { margin: 12px 0 -4px; }
}

View File

@ -0,0 +1,19 @@
/* Mini board: the top player's store on the left, the bottom player's on the right */
.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); }
.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms.l { grid-column: 1; }
.mini .ms.r { grid-column: 8; color: var(--accent); }
.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; }
.mini.lg .mp { font-size: 16px; }
.mini.lg .ms { font-size: 20px; padding-bottom: 14px; }
/* A move shown on the board: the pit played and where its last stone landed */
.mini .played { box-shadow: inset 0 0 0 2px var(--accent); }
.mini .land, .mini .ms.r.land { background: var(--accent); color: var(--onAccent); }
@media (max-width: 640px) {
.mini.lg { gap: 6px; padding: 10px; border-radius: 20px; }
.mini.lg .mp { font-size: 13px; }
.mini.lg .ms { font-size: 15px; padding-bottom: 10px; }
}

View File

@ -1,22 +1,27 @@
import * as React from "react";
import { FunctionComponent } from "react";
import "./MiniBoard.css";
const PLAYER1_PITS = [0, 1, 2, 3, 4, 5];
const PLAYER2_PITS = [7, 8, 9, 10, 11, 12];
const PLAYER1_STORE = 6;
const PLAYER2_STORE = 13;
// Stone counts on a small board, as the bottom player sees it. Empty pits show no number.
const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => {
// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. `played` and `landed` mark
// a move: the pit played and where its last stone landed.
const MiniBoard: FunctionComponent<{
board: number[], revert?: boolean, large?: boolean, played?: number, landed?: number,
}> = ({ board, revert, large, played, landed }) => {
const [topPits, topStore, bottomPits, bottomStore] = revert
? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE]
: [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE];
const pit = (index: number) => <div key={index} className="mp">{board[index] || ""}</div>;
const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : "");
const pit = (index: number) => <div key={index} className={"mp" + marks(index)}>{board[index] || ""}</div>;
return (
<div className={"mini" + (large ? " lg" : "")} aria-hidden="true">
<div className="ms l">{board[topStore]}</div>
<div className={"ms l" + marks(topStore)}>{board[topStore]}</div>
{[...topPits].reverse().map(pit)}
<div className="ms r">{board[bottomStore]}</div>
<div className={"ms r" + marks(bottomStore)}>{board[bottomStore]}</div>
{bottomPits.map(pit)}
</div>
);

View File

@ -0,0 +1,44 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getMoveOutcome, RuleExample } from "@mancala/core";
import MiniBoard from "../home/MiniBoard";
import { NextIcon } from "../Icons";
// A rule's example: the board before the move with the pit played marked, and after it with the landing pit marked.
// The board after the move comes from the engine.
const RuleBoard: FunctionComponent<{ example: RuleExample }> = ({ example }) => {
if (example.pitIndex === undefined) {
return <div className="rboard single"><MiniBoard board={example.board} /></div>;
}
const outcome = getMoveOutcome(example.board, example.pitIndex);
return (
<div className="rboard">
<MiniBoard board={example.board} played={example.pitIndex} />
<span className="arrow"><NextIcon /></span>
<MiniBoard board={outcome.board} landed={outcome.path[outcome.path.length - 1]} />
<style jsx>{`
.rboard {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.arrow {
color: var(--muted);
display: flex;
}
@media (max-width: 640px) {
.rboard {
grid-template-columns: minmax(0, 1fr);
}
.arrow {
justify-content: center;
transform: rotate(90deg);
}
}
`}</style>
</div>
);
};
export default RuleBoard;

View File

@ -0,0 +1,52 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { CloseIcon } from "../Icons";
import RulesView from "./RulesView";
// The rules over the game page, so a player can look a rule up without leaving the game (R1.4).
const RulesPanel: FunctionComponent<{ onClose: () => void }> = ({ onClose }) => {
const { t } = useTranslation();
return (
<div className="rpanel-back" onClick={onClose}>
<aside className="rpanel" role="dialog" aria-label={t("HowToPlay")} onClick={(event) => event.stopPropagation()}>
<div className="rhead">
<h1>{t("HowToPlay")}</h1>
<button className="btn icon ghost" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
</div>
<RulesView />
</aside>
<style jsx>{`
.rpanel-back {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, .45);
display: flex;
justify-content: flex-end;
}
.rpanel {
width: min(480px, 100%);
height: 100%;
overflow-y: auto;
box-sizing: border-box;
padding: 20px;
background: var(--bg);
box-shadow: -12px 0 30px rgba(0, 0, 0, .3);
}
.rhead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
h1 {
margin: 0;
font-size: 24px;
}
`}</style>
</div>
);
};
export default RulesPanel;

View File

@ -0,0 +1,58 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { RULES } from "@mancala/core";
import RuleBoard from "./RuleBoard";
// Every rule in order with its text and example (docs/specs/tutorial, R1). `showLessons` adds a link to each lesson;
// left out where following it would leave a game.
const RulesView: FunctionComponent<{ showLessons?: boolean }> = ({ showLessons }) => {
const { t } = useTranslation();
return (
<ol className="rules">
{RULES.map((rule) => (
<li key={rule.id} id={rule.id} className="rule panel">
<h2>{t(rule.titleKey)}</h2>
{rule.textKeys.map((key) => <p key={key}>{t(key)}</p>)}
{rule.example && <RuleBoard example={rule.example} />}
{showLessons && rule.lesson && (
<Link className="btn ghost try" to={`/learn/${rule.id}`}>{t("TryIt")}</Link>
)}
</li>
))}
<style jsx>{`
.rules {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.rule {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
margin: 0;
font-size: 20px;
letter-spacing: -.01em;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.rules :global(.try) {
align-self: flex-start;
text-decoration: none;
}
`}</style>
</ol>
);
};
export default RulesView;

View File

@ -28,6 +28,7 @@ import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock';
import PlayerCard from '../components/game/PlayerCard';
import GameOverCard from '../components/game/GameOverCard';
import RulesPanel from '../components/rules/RulesPanel';
import '../styles/game.css';
const GamePage: FunctionComponent<{
@ -61,6 +62,8 @@ const GamePage: FunctionComponent<{
// Set when the server says the game ended, for whatever reason.
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
// The rules over the game; the game goes on behind them.
const [rulesOpen, setRulesOpen] = useState(false);
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
@ -419,11 +422,12 @@ const GamePage: FunctionComponent<{
{showBoardView && renderGame()}
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</main>
{rulesOpen && <RulesPanel onClose={() => setRulesOpen(false)} />}
</PageContainer>
);
function renderHeaderBar() {
return <AppHeader context={context}>
return <AppHeader context={context} onHelp={() => setRulesOpen(true)}>
{renderNewGameBtn
? <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>

View File

@ -0,0 +1,37 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { Theme } from "@mancala/core";
import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer";
import RulesView from "../components/rules/RulesView";
import { Context } from "../context/context";
// How to play: every rule with an example (docs/specs/tutorial, R1).
const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
const { t } = useTranslation();
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap rules-page">
<h1>{t("HowToPlay")}</h1>
<RulesView />
</main>
<style jsx>{`
.rules-page {
max-width: 760px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
h1 {
margin: 0;
font-size: 30px;
letter-spacing: -.02em;
}
`}</style>
</PageContainer>
);
};
export default RulesPage;

View File

@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and
the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests.
- [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
- [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
game page.
- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
reset, scripted opponent replies.