Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -23,8 +23,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
|
|||||||
|
|
||||||
Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay),
|
Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay),
|
||||||
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
|
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
|
||||||
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus) and `game-bot` (playing against a bot
|
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus), `game-bot` (playing against a bot
|
||||||
at four levels).
|
at four levels) and `tutorial` (rules page, lessons, hints in bot games).
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
@ -114,6 +114,11 @@ which helps to check what the server holds.
|
|||||||
(`theme/themes.ts`) are passed to core's `ThemeManager`, which the mobile app uses with its own default themes.
|
(`theme/themes.ts`) are passed to core's `ThemeManager`, which the mobile app uses with its own default themes.
|
||||||
- Styles that need container queries (`@container`, `cqw`), such as the board's, are plain `.css` files imported by the
|
- Styles that need container queries (`@container`, `cqw`), such as the board's, are plain `.css` files imported by the
|
||||||
component; styled-jsx 5.0 does not handle them. `BoardView` fills its container and stands upright below 600 px.
|
component; styled-jsx 5.0 does not handle them. `BoardView` fills its container and stands upright below 600 px.
|
||||||
|
- `PitAnimator` sends the same `BoardViewModel` object, changed, at every animation step. Put a new object (or a new
|
||||||
|
id in its own state, as `GamePage` does) into React state, or React skips the update and only the first step shows.
|
||||||
|
- The rules, lessons and move explanations live in `packages/core/src/tutorial`; their boards after a move come from
|
||||||
|
the engine (`getMoveOutcome`). After a rule change, run core's tests: they fail if a lesson no longer works, and
|
||||||
|
the rule texts in `en.ts`/`tr.ts` may need updating.
|
||||||
- When a script drives the app in Chrome, wait for the header (`.hdr`) and a few seconds after the first load before
|
- When a script drives the app in Chrome, wait for the header (`.hdr`) and a few seconds after the first load before
|
||||||
navigating; navigating during the guest login leaves an invalid token. Open the lobby with the "New Game" button,
|
navigating; navigating during the guest login leaves an invalid token. Open the lobby with the "New Game" button,
|
||||||
not by URL, or the matchmaking message goes out before the WebSocket is open.
|
not by URL, or the matchmaking message goes out before the WebSocket is open.
|
||||||
|
|||||||
10
TODO.md
10
TODO.md
@ -65,6 +65,16 @@ See `docs/specs/game-bot`.
|
|||||||
- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed)
|
- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed)
|
||||||
- [ ] Try the bot on a mobile device; only the type check and the bundle were run
|
- [ ] Try the bot on a mobile device; only the type check and the bundle were run
|
||||||
|
|
||||||
|
## Tutorial
|
||||||
|
|
||||||
|
See `docs/specs/tutorial`.
|
||||||
|
|
||||||
|
- [ ] Lessons and hints in the mobile app, once it has the new design (it has the rules screen)
|
||||||
|
- [ ] Hints that look ahead, e.g. a warning when a move lets the opponent capture (the bot's search could do it)
|
||||||
|
- [ ] Strategy lessons beyond the rules
|
||||||
|
- [ ] Tutorial progress per account instead of per device
|
||||||
|
- [ ] If the end-of-game rule changes (Game rules below), update `RuleEndOfGameText2` in `en.ts` and `tr.ts`
|
||||||
|
|
||||||
## Web redesign
|
## Web redesign
|
||||||
|
|
||||||
See `docs/specs/web-redesign`.
|
See `docs/specs/web-redesign`.
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core';
|
|||||||
import { Theme } from '@mancala/core';
|
import { Theme } from '@mancala/core';
|
||||||
import LobyPage from './routes/LobyPage';
|
import LobyPage from './routes/LobyPage';
|
||||||
import PrivacyPage from './routes/PrivacyPage';
|
import PrivacyPage from './routes/PrivacyPage';
|
||||||
|
import RulesPage from './routes/RulesPage';
|
||||||
|
import LearnPage from './routes/LearnPage';
|
||||||
|
import LessonPage from './routes/LessonPage';
|
||||||
import swal from 'sweetalert';
|
import swal from 'sweetalert';
|
||||||
import Util from './util/Util';
|
import Util from './util/Util';
|
||||||
import { server } from './const/config';
|
import { server } from './const/config';
|
||||||
@ -130,6 +133,9 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
|
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="rules" element={<RulesPage context={context} theme={theme} />} />
|
||||||
|
<Route path="learn" element={<LearnPage context={context} theme={theme} />} />
|
||||||
|
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>}
|
</BrowserRouter>}
|
||||||
|
|||||||
@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => (
|
|||||||
export const BotIcon = ({ size = 20 }: 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>
|
<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>
|
||||||
|
);
|
||||||
|
|||||||
47
apps/frontend/src/components/PracticeGameCard.tsx
Normal file
47
apps/frontend/src/components/PracticeGameCard.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setHintsEnabled } from "@mancala/core";
|
||||||
|
import { Context } from "../context/context";
|
||||||
|
import Util from "../util/Util";
|
||||||
|
|
||||||
|
// The tutorial's last step: a game against the Easy bot with hints on (R3.1).
|
||||||
|
const PracticeGameCard: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const start = () => {
|
||||||
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||||
|
setHintsEnabled(context.storage, true);
|
||||||
|
navigate("/loby?bot=easy");
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section className="practice panel">
|
||||||
|
<div>
|
||||||
|
<strong>{t("PracticeGame")}</strong>
|
||||||
|
<p>{t("PracticeGameText")}</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn pri" onClick={start}>{t("PlayWithBot")}</button>
|
||||||
|
<style jsx>{`
|
||||||
|
.practice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PracticeGameCard;
|
||||||
@ -165,6 +165,28 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hintchip {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 120%;
|
||||||
|
padding: 2px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@container (max-width: 600px) {
|
@container (max-width: 600px) {
|
||||||
.board {
|
.board {
|
||||||
--pw: min(170px, calc((100cqw - 70px) / 2));
|
--pw: min(170px, calc((100cqw - 70px) / 2));
|
||||||
|
|||||||
@ -27,7 +27,9 @@ const BoardView: FunctionComponent<{
|
|||||||
lastMoveIndex?: number;
|
lastMoveIndex?: number;
|
||||||
// Board index where the shown move's last stone landed.
|
// Board index where the shown move's last stone landed.
|
||||||
landIndex?: number;
|
landIndex?: number;
|
||||||
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => {
|
// What the bottom player's moves would do, by board index (hints in games against the bot).
|
||||||
|
hints?: Record<number, string>;
|
||||||
|
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
|
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
|
||||||
const board = game.mancalaGame.board;
|
const board = game.mancalaGame.board;
|
||||||
@ -45,6 +47,7 @@ const BoardView: FunctionComponent<{
|
|||||||
preview: preview[index],
|
preview: preview[index],
|
||||||
land: previewLand !== undefined ? index === previewLand : index === landIndex,
|
land: previewLand !== undefined ? index === previewLand : index === landIndex,
|
||||||
last: index === lastMoveIndex,
|
last: index === lastMoveIndex,
|
||||||
|
hint: hints?.[index],
|
||||||
});
|
});
|
||||||
|
|
||||||
const topPits = revert ? board.player1Pits : board.player2Pits;
|
const topPits = revert ? board.player1Pits : board.player2Pits;
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export type PitMarks = {
|
|||||||
land?: boolean;
|
land?: boolean;
|
||||||
// The last move was played from here.
|
// The last move was played from here.
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
|
// What playing this pit would do, in games against the bot with hints on, e.g. "Extra turn".
|
||||||
|
hint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMarkClasses(marks: PitMarks): string {
|
export function getMarkClasses(marks: PitMarks): string {
|
||||||
@ -47,6 +49,7 @@ const PitView: FunctionComponent<{
|
|||||||
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
||||||
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
||||||
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||||
|
{!!marks.hint && <span className="hintchip">{marks.hint}</span>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (!onClick) {
|
if (!onClick) {
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import { Link } from "react-router-dom";
|
|||||||
import { AuthUser } from "@mancala/core";
|
import { AuthUser } from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
import { LogoIcon } from "../Icons";
|
import { HelpIcon, LogoIcon } from "../Icons";
|
||||||
import AccountMenu from "./AccountMenu";
|
import AccountMenu from "./AccountMenu";
|
||||||
import ThemeSwatches from "./ThemeSwatches";
|
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
|
// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and
|
||||||
// main action (children).
|
// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the
|
||||||
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => {
|
// rules over the game instead).
|
||||||
|
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<header className={"hdr" + (authUser ? " has-account" : "")}>
|
<header className={"hdr" + (authUser ? " has-account" : "")}>
|
||||||
@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> =
|
|||||||
<span>{t("Mancala")}</span>
|
<span>{t("Mancala")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<ThemeSwatches context={context} />
|
<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} />}
|
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
||||||
{children}
|
{children}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -47,24 +47,10 @@
|
|||||||
.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.meta > span:last-child { text-align: right; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; }
|
.hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; }
|
||||||
.hero h1 { font-size: 25px; }
|
.hero h1 { font-size: 25px; }
|
||||||
.hero .btn { flex: 1; }
|
.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; }
|
.grid { grid-template-columns: 1fr; gap: 14px; }
|
||||||
.sec { margin: 12px 0 -4px; }
|
.sec { margin: 12px 0 -4px; }
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/frontend/src/components/home/MiniBoard.css
Normal file
19
apps/frontend/src/components/home/MiniBoard.css
Normal 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; }
|
||||||
|
}
|
||||||
@ -1,22 +1,27 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
|
import "./MiniBoard.css";
|
||||||
|
|
||||||
const PLAYER1_PITS = [0, 1, 2, 3, 4, 5];
|
const PLAYER1_PITS = [0, 1, 2, 3, 4, 5];
|
||||||
const PLAYER2_PITS = [7, 8, 9, 10, 11, 12];
|
const PLAYER2_PITS = [7, 8, 9, 10, 11, 12];
|
||||||
const PLAYER1_STORE = 6;
|
const PLAYER1_STORE = 6;
|
||||||
const PLAYER2_STORE = 13;
|
const PLAYER2_STORE = 13;
|
||||||
|
|
||||||
// Stone counts on a small board, as the bottom player sees it. Empty pits show no number.
|
// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. `played` and `landed` mark
|
||||||
const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => {
|
// 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
|
const [topPits, topStore, bottomPits, bottomStore] = revert
|
||||||
? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE]
|
? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE]
|
||||||
: [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_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 (
|
return (
|
||||||
<div className={"mini" + (large ? " lg" : "")} aria-hidden="true">
|
<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)}
|
{[...topPits].reverse().map(pit)}
|
||||||
<div className="ms r">{board[bottomStore]}</div>
|
<div className={"ms r" + marks(bottomStore)}>{board[bottomStore]}</div>
|
||||||
{bottomPits.map(pit)}
|
{bottomPits.map(pit)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
47
apps/frontend/src/components/home/TutorialBanner.tsx
Normal file
47
apps/frontend/src/components/home/TutorialBanner.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { hideTutorialBanner, shouldShowTutorialBanner } from "@mancala/core";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
|
||||||
|
// For newcomers: an offer to learn the game, until they open the lessons, play a game or say not now (R4).
|
||||||
|
const TutorialBanner: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [visible, setVisible] = useState(() => shouldShowTutorialBanner(context.storage));
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<section className="tbanner panel">
|
||||||
|
<strong>{t("NewToMangala")}</strong>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn pri" onClick={() => navigate("/learn")}>{t("StartTutorial")}</button>
|
||||||
|
<button className="btn ghost" onClick={() => {
|
||||||
|
hideTutorialBanner(context.storage);
|
||||||
|
setVisible(false);
|
||||||
|
}}>{t("NotNow")}</button>
|
||||||
|
</div>
|
||||||
|
<style jsx>{`
|
||||||
|
.tbanner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TutorialBanner;
|
||||||
44
apps/frontend/src/components/rules/RuleBoard.tsx
Normal file
44
apps/frontend/src/components/rules/RuleBoard.tsx
Normal 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;
|
||||||
52
apps/frontend/src/components/rules/RulesPanel.tsx
Normal file
52
apps/frontend/src/components/rules/RulesPanel.tsx
Normal 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;
|
||||||
58
apps/frontend/src/components/rules/RulesView.tsx
Normal file
58
apps/frontend/src/components/rules/RulesView.tsx
Normal 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;
|
||||||
@ -9,7 +9,7 @@ import LoadingComponent from '../components/LoadingComponent';
|
|||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
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, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } 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, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
||||||
@ -28,6 +28,7 @@ import GameClockView from '../components/GameClockView';
|
|||||||
import useGameClock from '../hooks/useGameClock';
|
import useGameClock from '../hooks/useGameClock';
|
||||||
import PlayerCard from '../components/game/PlayerCard';
|
import PlayerCard from '../components/game/PlayerCard';
|
||||||
import GameOverCard from '../components/game/GameOverCard';
|
import GameOverCard from '../components/game/GameOverCard';
|
||||||
|
import RulesPanel from '../components/rules/RulesPanel';
|
||||||
import '../styles/game.css';
|
import '../styles/game.css';
|
||||||
|
|
||||||
const GamePage: FunctionComponent<{
|
const GamePage: FunctionComponent<{
|
||||||
@ -61,6 +62,10 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
// Set when the server says the game ended, for whatever reason.
|
// Set when the server says the game ended, for whatever reason.
|
||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
// The rules over the game; the game goes on behind them.
|
||||||
|
const [rulesOpen, setRulesOpen] = useState(false);
|
||||||
|
// Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device.
|
||||||
|
const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage));
|
||||||
|
|
||||||
// Set when the player closes the game over card to look at the board.
|
// Set when the player closes the game over card to look at the board.
|
||||||
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
||||||
@ -275,6 +280,34 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
||||||
const isReviewing = viewedMove !== undefined && viewedMove < moves.length;
|
const isReviewing = viewedMove !== undefined && viewedMove < moves.length;
|
||||||
|
|
||||||
|
// Hints are only for games against the bot, never between people (R3.5).
|
||||||
|
const hintsOn = !!opponentBotLevel && hintsEnabled;
|
||||||
|
const toggleHints = () => {
|
||||||
|
setHintsEnabled(context.storage, !hintsEnabled);
|
||||||
|
setHintsEnabledState(!hintsEnabled);
|
||||||
|
};
|
||||||
|
const getHints = (): Record<number, string> | undefined => {
|
||||||
|
if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined;
|
||||||
|
if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined;
|
||||||
|
const stones = mancalaGame.board.getStoneArray();
|
||||||
|
const first = isPlayer2 ? 7 : 0;
|
||||||
|
const hints: Record<number, string> = {};
|
||||||
|
for (let index = first; index < first + 6; index++) {
|
||||||
|
if (!stones[index]) continue;
|
||||||
|
const outcome = getMoveOutcome(stones, index);
|
||||||
|
if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured });
|
||||||
|
else if (outcome.extraTurn) hints[index] = t("HintExtraTurn");
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
};
|
||||||
|
// What the last move did and why, under the board (R3.3).
|
||||||
|
const getLastMoveExplanation = (): string | undefined => {
|
||||||
|
if (!hintsOn || moves.length === 0 || isReviewing) return undefined;
|
||||||
|
const last = moves[moves.length - 1];
|
||||||
|
const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex);
|
||||||
|
return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t);
|
||||||
|
};
|
||||||
const shownBoardViewModel = isReviewing && mancalaGame
|
const shownBoardViewModel = isReviewing && mancalaGame
|
||||||
? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme)
|
? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme)
|
||||||
: boardViewModel;
|
: boardViewModel;
|
||||||
@ -348,6 +381,7 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const renderGame = () => {
|
const renderGame = () => {
|
||||||
const board = mancalaGame!.board;
|
const board = mancalaGame!.board;
|
||||||
|
const lastMoveExplanation = getLastMoveExplanation();
|
||||||
const [topStore, bottomStore] = isPlayer2
|
const [topStore, bottomStore] = isPlayer2
|
||||||
? [board.player1BankIndex(), board.player2BankIndex()]
|
? [board.player1BankIndex(), board.player2BankIndex()]
|
||||||
: [board.player2BankIndex(), board.player1BankIndex()];
|
: [board.player2BankIndex(), board.player1BankIndex()];
|
||||||
@ -370,6 +404,11 @@ const GamePage: FunctionComponent<{
|
|||||||
{!isGameOver && <span className="dot" />}
|
{!isGameOver && <span className="dot" />}
|
||||||
{getTurnText()}
|
{getTurnText()}
|
||||||
</div>
|
</div>
|
||||||
|
{opponentBotLevel && (
|
||||||
|
<button className={"btn hints-toggle " + (hintsOn ? "pri" : "ghost")} aria-pressed={hintsOn} onClick={toggleHints}>
|
||||||
|
{t("Hints")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
||||||
<div className="a-board">
|
<div className="a-board">
|
||||||
@ -388,7 +427,9 @@ const GamePage: FunctionComponent<{
|
|||||||
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
|
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
|
||||||
topName={topLocatedUser.name}
|
topName={topLocatedUser.name}
|
||||||
bottomName={bottomLocatedUser.name}
|
bottomName={bottomLocatedUser.name}
|
||||||
revert={isPlayer2} />
|
revert={isPlayer2}
|
||||||
|
hints={getHints()} />
|
||||||
|
{lastMoveExplanation && <p className="hint-line" aria-live="polite">{lastMoveExplanation}</p>}
|
||||||
{isGameOver && !gameOverClosed && (
|
{isGameOver && !gameOverClosed && (
|
||||||
<GameOverCard
|
<GameOverCard
|
||||||
title={resultTitle ?? t("GameEnded")}
|
title={resultTitle ?? t("GameEnded")}
|
||||||
@ -419,11 +460,12 @@ const GamePage: FunctionComponent<{
|
|||||||
{showBoardView && renderGame()}
|
{showBoardView && renderGame()}
|
||||||
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
||||||
</main>
|
</main>
|
||||||
|
{rulesOpen && <RulesPanel onClose={() => setRulesOpen(false)} />}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
function renderHeaderBar() {
|
function renderHeaderBar() {
|
||||||
return <AppHeader context={context}>
|
return <AppHeader context={context} onHelp={() => setRulesOpen(true)}>
|
||||||
{renderNewGameBtn
|
{renderNewGameBtn
|
||||||
? <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>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import PageContainer from "../components/PageContainer";
|
|||||||
import { PlusIcon } from "../components/Icons";
|
import { PlusIcon } from "../components/Icons";
|
||||||
import BotLevelMenu from "../components/BotLevelMenu";
|
import BotLevelMenu from "../components/BotLevelMenu";
|
||||||
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
||||||
|
import TutorialBanner from "../components/home/TutorialBanner";
|
||||||
import LiveGamesSection from "../components/home/LiveGamesSection";
|
import LiveGamesSection from "../components/home/LiveGamesSection";
|
||||||
import RecentGamesSection from "../components/home/RecentGamesSection";
|
import RecentGamesSection from "../components/home/RecentGamesSection";
|
||||||
import PopularGamesSection from "../components/home/PopularGamesSection";
|
import PopularGamesSection from "../components/home/PopularGamesSection";
|
||||||
@ -58,6 +59,7 @@ const Home: FunctionComponent<{
|
|||||||
</button>
|
</button>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<main className="wrap">
|
<main className="wrap">
|
||||||
|
<TutorialBanner context={context} />
|
||||||
{activeGame && authUser && (
|
{activeGame && authUser && (
|
||||||
<ActiveGameBanner
|
<ActiveGameBanner
|
||||||
game={activeGame}
|
game={activeGame}
|
||||||
|
|||||||
96
apps/frontend/src/routes/LearnPage.tsx
Normal file
96
apps/frontend/src/routes/LearnPage.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getCompletedLessons, hideTutorialBanner, LESSON_RULES, Theme } from "@mancala/core";
|
||||||
|
import AppHeader from "../components/headerbar/AppHeader";
|
||||||
|
import PageContainer from "../components/PageContainer";
|
||||||
|
import { Context } from "../context/context";
|
||||||
|
import PracticeGameCard from "../components/PracticeGameCard";
|
||||||
|
|
||||||
|
// The lessons in order, the ones done marked (docs/specs/tutorial, R2.6).
|
||||||
|
const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => hideTutorialBanner(context.storage), []);
|
||||||
|
const completed = getCompletedLessons(context.storage);
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap learn">
|
||||||
|
<h1>{t("LearnMangala")}</h1>
|
||||||
|
<ol className="lessons">
|
||||||
|
{LESSON_RULES.map((rule, index) => {
|
||||||
|
const done = completed.includes(rule.id);
|
||||||
|
return (
|
||||||
|
<li key={rule.id}>
|
||||||
|
<Link className={"lesson-link panel" + (done ? " done" : "")} to={`/learn/${rule.id}`}>
|
||||||
|
<span className="num">{done ? "✓" : index + 1}</span>
|
||||||
|
<span className="name">{t(rule.titleKey)}</span>
|
||||||
|
{done && <span className="pill">{t("LessonDone")}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<PracticeGameCard context={context} />
|
||||||
|
<Link className="rules-link" to="/rules">{t("HowToPlay")}</Link>
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.learn {
|
||||||
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.lessons {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.learn :global(.lesson-link) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.learn :global(.lesson-link:hover) {
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.learn :global(.num) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.learn :global(.done .num) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--onAccent);
|
||||||
|
}
|
||||||
|
.learn :global(.name) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.learn :global(.rules-link) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearnPage;
|
||||||
203
apps/frontend/src/routes/LessonPage.tsx
Normal file
203
apps/frontend/src/routes/LessonPage.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { CommonMancalaGame } from "mancala.js";
|
||||||
|
import {
|
||||||
|
BoardViewModel, completeLesson, explainMove, fulfilsTask, Game, getMoveOutcome, getNextLessonRule, getRule,
|
||||||
|
hideTutorialBanner, LESSON_RULES, MoveOutcome, PitAnimator, Theme,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import AppHeader from "../components/headerbar/AppHeader";
|
||||||
|
import PageContainer from "../components/PageContainer";
|
||||||
|
import BoardView from "../components/board/BoardView";
|
||||||
|
import { Context } from "../context/context";
|
||||||
|
import PracticeGameCard from "../components/PracticeGameCard";
|
||||||
|
|
||||||
|
const YOU = "you";
|
||||||
|
const OPPONENT = "opponent";
|
||||||
|
|
||||||
|
// A game showing the lesson's board, with the real rules, the player at the bottom to move.
|
||||||
|
function createLessonGame(board: number[]): Game {
|
||||||
|
const mancalaGame = new CommonMancalaGame("lesson", YOU, OPPONENT);
|
||||||
|
board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
||||||
|
mancalaGame.turnPlayerId = YOU;
|
||||||
|
mancalaGame.state = "playing";
|
||||||
|
return {
|
||||||
|
id: "lesson",
|
||||||
|
mancalaGame,
|
||||||
|
gameUsersConnectionInfo: {
|
||||||
|
user1ConnectionInfo: { userId: YOU, isOnline: true },
|
||||||
|
user2ConnectionInfo: { userId: OPPONENT, isOnline: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = "playing" | "animating" | "done";
|
||||||
|
|
||||||
|
// One rule taught on a prepared board (docs/specs/tutorial, R2). Everything runs in the browser on the TypeScript
|
||||||
|
// engine; nothing is sent to the server.
|
||||||
|
const LessonPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { ruleId } = useParams();
|
||||||
|
const rule = getRule(ruleId);
|
||||||
|
const lesson = rule?.lesson;
|
||||||
|
const [game, setGame] = useState<Game | undefined>(undefined);
|
||||||
|
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
|
||||||
|
const [phase, setPhase] = useState<Phase>("playing");
|
||||||
|
const [outcome, setOutcome] = useState<MoveOutcome | undefined>(undefined);
|
||||||
|
const [playedIndex, setPlayedIndex] = useState<number | undefined>(undefined);
|
||||||
|
const animator = useRef<PitAnimator | undefined>(undefined);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!lesson || !animator.current) return;
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
const lessonGame = createLessonGame(lesson.board);
|
||||||
|
setGame(lessonGame);
|
||||||
|
setOutcome(undefined);
|
||||||
|
setPhase("playing");
|
||||||
|
animator.current.setNewGame(lessonGame);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hideTutorialBanner(context.storage);
|
||||||
|
// The animator sends the same model, changed, at every step; a new one makes React draw each step.
|
||||||
|
animator.current = new PitAnimator(context.themeManager, (model) => {
|
||||||
|
setBoardViewModel(new BoardViewModel(v4(), model.pits));
|
||||||
|
});
|
||||||
|
start();
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
animator.current?.dispose();
|
||||||
|
};
|
||||||
|
}, [ruleId]);
|
||||||
|
|
||||||
|
if (!rule || !lesson) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap"><Link to="/learn">{t("AllLessons")}</Link></main>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPitSelect = (index: number) => {
|
||||||
|
if (phase !== "playing" || !game || !(lesson.board[index] > 0) || index > 5) return;
|
||||||
|
const moveOutcome = getMoveOutcome(lesson.board, index);
|
||||||
|
const played = createLessonGame(lesson.board);
|
||||||
|
played.mancalaGame.moveByPlayerPit(YOU, index);
|
||||||
|
setGame(played);
|
||||||
|
setPlayedIndex(index);
|
||||||
|
setPhase("animating");
|
||||||
|
animator.current?.setUpdatedGame(played);
|
||||||
|
const steps = played.mancalaGame.history[0].gameSteps.length;
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
setOutcome(moveOutcome);
|
||||||
|
setPhase("done");
|
||||||
|
if (fulfilsTask(lesson.task, moveOutcome)) completeLesson(context.storage, rule.id);
|
||||||
|
}, (steps + 1) * animator.current!.animationInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fulfilled = outcome !== undefined && fulfilsTask(lesson.task, outcome);
|
||||||
|
const next = getNextLessonRule(rule.id);
|
||||||
|
const number = LESSON_RULES.findIndex((r) => r.id === rule.id) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap lesson">
|
||||||
|
<div className="lhead">
|
||||||
|
<Link to="/learn" className="crumb">{t("LessonNumber", { number, count: LESSON_RULES.length })}</Link>
|
||||||
|
<h1>{t(rule.titleKey)}</h1>
|
||||||
|
{rule.textKeys.map((key) => <p key={key}>{t(key)}</p>)}
|
||||||
|
</div>
|
||||||
|
<div className={"task panel" + (fulfilled ? " ok" : "")} aria-live="polite">
|
||||||
|
{phase !== "done" && <strong>{t(lesson.taskKey)}</strong>}
|
||||||
|
{phase === "done" && outcome && (
|
||||||
|
<>
|
||||||
|
{fulfilled && <strong>{t("WellDone")}</strong>}
|
||||||
|
<span>{explainMove(outcome, "you", t)}</span>
|
||||||
|
<div className="actions">
|
||||||
|
{fulfilled && next && (
|
||||||
|
<button className="btn pri" onClick={() => navigate(`/learn/${next.id}`)}>{t("NextLesson")}</button>
|
||||||
|
)}
|
||||||
|
{fulfilled && !next && (
|
||||||
|
<button className="btn pri" onClick={() => navigate("/learn")}>{t("AllLessons")}</button>
|
||||||
|
)}
|
||||||
|
<button className={"btn " + (fulfilled ? "ghost" : "pri")} onClick={start}>{t("TryAgain")}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{fulfilled && !next && <PracticeGameCard context={context} />}
|
||||||
|
{game && boardViewModel && (
|
||||||
|
<div className="lboard">
|
||||||
|
<BoardView
|
||||||
|
game={game}
|
||||||
|
boardViewModel={boardViewModel}
|
||||||
|
context={context}
|
||||||
|
revert={false}
|
||||||
|
topName={t("Opponent")}
|
||||||
|
bottomName={t("You")}
|
||||||
|
onPitSelect={(index) => onPitSelect(index)}
|
||||||
|
canPlay={phase === "playing"}
|
||||||
|
lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.lesson {
|
||||||
|
max-width: 980px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.lhead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.lesson :global(.crumb) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task.ok {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lboard {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
@ -5,6 +5,7 @@ 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 { 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 { 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 { markGamePlayed } from '@mancala/core';
|
||||||
import BotLevelPicker from '../components/BotLevelPicker';
|
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';
|
||||||
@ -35,6 +36,7 @@ const LobyPage: FunctionComponent<{
|
|||||||
|
|
||||||
const onGameStart = (message: Object) => {
|
const onGameStart = (message: Object) => {
|
||||||
const newGame: CommonMancalaGame = message as CommonMancalaGame;
|
const newGame: CommonMancalaGame = message as CommonMancalaGame;
|
||||||
|
markGamePlayed(context.storage);
|
||||||
navigate(`/game/${newGame.id}`)
|
navigate(`/game/${newGame.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
apps/frontend/src/routes/RulesPage.tsx
Normal file
50
apps/frontend/src/routes/RulesPage.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
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">
|
||||||
|
<div className="rtitle">
|
||||||
|
<h1>{t("HowToPlay")}</h1>
|
||||||
|
<Link className="btn pri" to="/learn">{t("Lessons")}</Link>
|
||||||
|
</div>
|
||||||
|
<RulesView showLessons />
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.rules-page {
|
||||||
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.rtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rules-page :global(.rtitle .btn) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RulesPage;
|
||||||
@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.a-opp { grid-area: opp; min-width: 0; }
|
.a-opp { grid-area: opp; min-width: 0; }
|
||||||
.a-turn { grid-area: turn; min-width: 0; }
|
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
|
||||||
.a-me { grid-area: me; justify-self: end; min-width: 0; }
|
.a-me { grid-area: me; justify-self: end; min-width: 0; }
|
||||||
.a-board { grid-area: board; position: relative; min-width: 0; }
|
.a-board { grid-area: board; position: relative; min-width: 0; }
|
||||||
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
||||||
@ -100,6 +100,11 @@
|
|||||||
|
|
||||||
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
||||||
|
|
||||||
|
/* Hints in games against the bot */
|
||||||
|
|
||||||
|
.hints-toggle { min-height: 36px; padding: 0 14px; font-size: 14px; }
|
||||||
|
.hint-line { margin: 14px 0 0; text-align: center; color: var(--muted); font-weight: 600; line-height: 1.45; }
|
||||||
|
|
||||||
/* Review banner, above the board */
|
/* Review banner, above the board */
|
||||||
|
|
||||||
.review {
|
.review {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { NativeModules, I18nManager, Platform } from 'react-native'
|
|||||||
// https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020
|
// https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020
|
||||||
import 'react-native-get-random-values';
|
import 'react-native-get-random-values';
|
||||||
import HelpScreen from './screens/HelpScreen';
|
import HelpScreen from './screens/HelpScreen';
|
||||||
|
import RulesScreen from './screens/RulesScreen';
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
import { server } from './const/config';
|
import { server } from './const/config';
|
||||||
|
|
||||||
@ -120,6 +121,8 @@ function App() {
|
|||||||
options={{ title: t("Mancala"), headerStyle, headerTintColor }} />
|
options={{ title: t("Mancala"), headerStyle, headerTintColor }} />
|
||||||
<Stack.Screen name="Help" component={HelpScreen}
|
<Stack.Screen name="Help" component={HelpScreen}
|
||||||
options={{ title: t("Help"), headerStyle, headerTintColor }} />
|
options={{ title: t("Help"), headerStyle, headerTintColor }} />
|
||||||
|
<Stack.Screen name="Rules" component={RulesScreen}
|
||||||
|
options={{ title: t("HowToPlay"), headerStyle, headerTintColor }} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
49
apps/mobile/src/components/RuleBoard.tsx
Normal file
49
apps/mobile/src/components/RuleBoard.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { getColorByBrightness, getMoveOutcome, RuleExample, Theme } from '@mancala/core';
|
||||||
|
|
||||||
|
const TOP_PITS = [12, 11, 10, 9, 8, 7];
|
||||||
|
const BOTTOM_PITS = [0, 1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
// Stone counts on a small board as the bottom player sees it; `played` and `landed` mark a move.
|
||||||
|
const MiniBoard: FunctionComponent<{ board: number[], theme: Theme, played?: number, landed?: number }> = ({ board, theme, played, landed }) => {
|
||||||
|
const textOn = (color: string) => getColorByBrightness(color, theme.textColor, theme.textLightColor);
|
||||||
|
const cell = (index: number, store: boolean) => {
|
||||||
|
const color = index === landed ? theme.pitSelectedColor : theme.pitColor;
|
||||||
|
return (
|
||||||
|
<View key={index} style={{
|
||||||
|
flex: store ? 1.2 : 1, aspectRatio: store ? undefined : 1, minHeight: store ? undefined : 0,
|
||||||
|
borderRadius: 100, backgroundColor: color, alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderWidth: index === played ? 2 : 0, borderColor: theme.playerTurnColor,
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 11, fontWeight: '700', color: textOn(color) }}>{board[index] || ''}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 4, padding: 6, borderRadius: 12, backgroundColor: theme.boardColor }}>
|
||||||
|
{cell(13, true)}
|
||||||
|
<View style={{ flex: 6, gap: 4 }}>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 4 }}>{TOP_PITS.map((index) => cell(index, false))}</View>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 4 }}>{BOTTOM_PITS.map((index) => cell(index, false))}</View>
|
||||||
|
</View>
|
||||||
|
{cell(6, true)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A rule's example: the board before the move and after it, as the engine plays it.
|
||||||
|
const RuleBoard: FunctionComponent<{ example: RuleExample, theme: Theme }> = ({ example, theme }) => {
|
||||||
|
if (example.pitIndex === undefined) return <MiniBoard board={example.board} theme={theme} />;
|
||||||
|
const outcome = getMoveOutcome(example.board, example.pitIndex);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<MiniBoard board={example.board} theme={theme} played={example.pitIndex} />
|
||||||
|
<Text style={{ textAlign: 'center', color: theme.textColor }}>↓</Text>
|
||||||
|
<MiniBoard board={outcome.board} theme={theme} landed={outcome.path[outcome.path.length - 1]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleBoard;
|
||||||
@ -255,7 +255,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
|||||||
<CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}</Text>} />
|
<CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}</Text>} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => {
|
<Pressable onPress={() => {
|
||||||
navigation.push("Help", { context })
|
navigation.push("Rules", { context })
|
||||||
}}>
|
}}>
|
||||||
<SvgXml xml={helpSvg.replaceAll("customColor", textColorOnAppBar)} width="24" height="24" />
|
<SvgXml xml={helpSvg.replaceAll("customColor", textColorOnAppBar)} width="24" height="24" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@ -31,6 +31,9 @@ export default function HelpScreen({ navigation, route }: HelpScreenProps) {
|
|||||||
alignContent: "center",
|
alignContent: "center",
|
||||||
justifyContent: "center"
|
justifyContent: "center"
|
||||||
}}>
|
}}>
|
||||||
|
<Pressable onPress={() => navigation.push("Rules", { context })}>
|
||||||
|
<Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('HowToPlay')}</Text>
|
||||||
|
</Pressable>
|
||||||
<Pressable onPress={onGotoWebAppClick}>
|
<Pressable onPress={onGotoWebAppClick}>
|
||||||
<Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('WebApp')}</Text>
|
<Text style={{ textAlign: "center", color: context.themeManager.theme.textColor }}>{t('WebApp')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
26
apps/mobile/src/screens/RulesScreen.tsx
Normal file
26
apps/mobile/src/screens/RulesScreen.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RULES } from '@mancala/core';
|
||||||
|
import { RulesScreenProps } from '../types';
|
||||||
|
import RuleBoard from '../components/RuleBoard';
|
||||||
|
|
||||||
|
// How to play: the same rules and examples as the web app (docs/specs/tutorial, R5.1).
|
||||||
|
export default function RulesScreen({ route }: RulesScreenProps) {
|
||||||
|
const { context } = route.params;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = context.themeManager.theme;
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1, backgroundColor: theme.background }} contentContainerStyle={{ padding: 16, gap: 24 }}>
|
||||||
|
{RULES.map((rule) => (
|
||||||
|
<View key={rule.id} style={{ gap: 8 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: theme.textColor }}>{t(rule.titleKey)}</Text>
|
||||||
|
{rule.textKeys.map((key) => (
|
||||||
|
<Text key={key} style={{ color: theme.textColor, lineHeight: 20 }}>{t(key)}</Text>
|
||||||
|
))}
|
||||||
|
{rule.example && <RuleBoard example={rule.example} theme={theme} />}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ export type RootStackParamList = {
|
|||||||
Loby: { context: Context, botLevel?: BotLevel },
|
Loby: { context: Context, botLevel?: BotLevel },
|
||||||
Game: { context: Context, gameId: string, userKey: string }
|
Game: { context: Context, gameId: string, userKey: string }
|
||||||
Help: { context: Context }
|
Help: { context: Context }
|
||||||
|
Rules: { context: Context }
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
|
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
|
||||||
@ -17,3 +18,5 @@ export type LobyScreenProps = NativeStackScreenProps<RootStackParamList, 'Loby'>
|
|||||||
export type GameScreenProps = NativeStackScreenProps<RootStackParamList, 'Game'>;
|
export type GameScreenProps = NativeStackScreenProps<RootStackParamList, 'Game'>;
|
||||||
|
|
||||||
export type HelpScreenProps = NativeStackScreenProps<RootStackParamList, 'Help'>;
|
export type HelpScreenProps = NativeStackScreenProps<RootStackParamList, 'Help'>;
|
||||||
|
|
||||||
|
export type RulesScreenProps = NativeStackScreenProps<RootStackParamList, 'Rules'>;
|
||||||
|
|||||||
153
docs/specs/tutorial/design.md
Normal file
153
docs/specs/tutorial/design.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# Tutorial: design
|
||||||
|
|
||||||
|
See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `web-redesign` (board, pages,
|
||||||
|
themes) and `game-bot` (the Easy bot for the practice game).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Reason |
|
||||||
|
|---|---|
|
||||||
|
| Rules, lessons and hints are defined once in `packages/core` | The web and mobile apps show the same rules (R5.1) and the texts stay in one place. |
|
||||||
|
| The "after" board of every example and the outcome of every lesson move come from running the engine (`mancala.js`), never written by hand | The texts and pictures cannot drift from what the game does (R1.3). A rule change shows up in the tutorial at once, and a test fails if a lesson no longer works. |
|
||||||
|
| Lessons run entirely in the client on the TypeScript engine | No server, no login, no waiting (R2.5). The engine is already in every client. |
|
||||||
|
| A lesson's task is an outcome ("end in your store"), not a pit | Players who find another move with the same effect are right too; the check is the engine's game steps, the same ones the move list uses. |
|
||||||
|
| The practice game is an ordinary Easy bot game with hints on; hints are a client feature of any bot game | No new server state (R3.1). Hints only show the immediate effect of a move, which the board's preview mostly shows already, so they give little away; still, they are limited to bot games (R3.5). |
|
||||||
|
| Hints look one move ahead only | They explain the rules, not strategy. Looking further would need the bot's search on the client or a new server message (out of scope). |
|
||||||
|
| Progress, "not now" and the hints setting are kept in the device's storage | Enough for learning the rules (R2.6, R3.4, R4.1); accounts are not needed for any of it. |
|
||||||
|
| Mobile gets the rules screen only for now | The mobile app still has the old board and game screen; lessons and hints there would be built twice (R5.2). |
|
||||||
|
| The end-of-game lesson teaches the current rule | `TODO.md` (Game rules) asks whether the remaining stones should always go to the player whose side is empty. The tutorial follows the engine; because its boards come from the engine, a rule change only needs the texts updated. |
|
||||||
|
|
||||||
|
## The rules as taught (R1.3)
|
||||||
|
|
||||||
|
Checked against `packages/mancala.js` (`Board.move`, the four `GR…` rules):
|
||||||
|
|
||||||
|
1. **Board and goal**: two rows of 6 pits with 4 stones each, a store on each side. Stones in your store are yours;
|
||||||
|
at the end, more stones in your store wins (48 stones: 25 always wins). A draw is possible.
|
||||||
|
2. **Sowing**: take all stones from one of your pits; put the first back into the same pit and one into each pit
|
||||||
|
after it. A single stone moves to the next pit. Your own store gets a stone on the way, the opponent's is
|
||||||
|
skipped.
|
||||||
|
3. **Extra turn**: if the last stone lands in your store, you play again.
|
||||||
|
4. **Even capture**: if the last stone lands in an opponent's pit and makes its count even, you take all stones in
|
||||||
|
it.
|
||||||
|
5. **Empty pit capture**: if the last stone lands in an empty pit of yours and the opponent's pit opposite has
|
||||||
|
stones, you take both.
|
||||||
|
6. **End of the game**: the game ends when one side's pits are empty; the player whose side is empty gets the
|
||||||
|
stones left on the other side. Then the stores are counted.
|
||||||
|
7. **Clock**: 5 minutes per player for the whole game; the clock of the player to move runs; when it reaches zero,
|
||||||
|
that player loses.
|
||||||
|
|
||||||
|
## Shared code (`packages/core/src/tutorial/`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock";
|
||||||
|
|
||||||
|
interface RuleExample {
|
||||||
|
board: number[]; // 14 stone counts, player 1 (bottom) to move
|
||||||
|
pit: number; // board index played
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame";
|
||||||
|
|
||||||
|
interface Lesson {
|
||||||
|
board: number[]; // player 1 (bottom) to move
|
||||||
|
task: LessonTask;
|
||||||
|
taskKey: string; // e.g. "LessonTaskExtraTurn": "Find the pit whose last stone lands in your store"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: RuleId;
|
||||||
|
titleKey: string;
|
||||||
|
textKeys: string[]; // at most three sentences (R1.2)
|
||||||
|
example?: RuleExample;
|
||||||
|
lesson?: Lesson; // every rule except the clock (R2.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RULES: Rule[];
|
||||||
|
|
||||||
|
// The immediate effect of playing a pit, by running the engine on a copy of the board.
|
||||||
|
interface MoveOutcome {
|
||||||
|
path: number[]; // where the stones went (getSowingPath)
|
||||||
|
extraTurn: boolean;
|
||||||
|
capture?: "even" | "emptyPit";
|
||||||
|
captured: number; // stones taken from the opponent's side
|
||||||
|
storeGain: number;
|
||||||
|
endsGame: boolean;
|
||||||
|
board: number[]; // after the move
|
||||||
|
}
|
||||||
|
export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome; // the mover follows from the pit
|
||||||
|
export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean;
|
||||||
|
|
||||||
|
// "Why did that happen" for a wrong lesson move and for the practice game (R2.3, R3.3).
|
||||||
|
export function explainMove(outcome: MoveOutcome, mover: "you" | "bot", t): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
`getMoveOutcome` sets the stones on a `CommonMancalaGame`, plays the pit and reads the game steps, as `describeMove`
|
||||||
|
does for the move list. None of the six lessons needs the opponent to move, so lessons have no scripted replies
|
||||||
|
(R2.4 has nothing to do yet). `describeMove` gains `capture` kind and `captured`, so the
|
||||||
|
move list, hints and explanations share one reading of the steps.
|
||||||
|
|
||||||
|
Lesson positions (player 1, bottom, to move; pits 0–5, store 6), checked with the rules above. Task 2 tests that
|
||||||
|
each has a move that fulfils its task and, where there is more than one legal move, one that does not:
|
||||||
|
|
||||||
|
| Lesson | Board | Task | Moves that fulfil it |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Board and goal | start position | `anyMove` | all |
|
||||||
|
| Sowing | `[4,4,1,4,4,4, 1, 4,4,4,4,4,4, 2]` | `anyMove`; the text points at pit 3's single stone, which moves to pit 4 | all |
|
||||||
|
| Extra turn | start position | `extraTurn` | pit 4 |
|
||||||
|
| Even capture | `[4,4,4,4,4,4, 0, 4,3,4,4,4,4, 1]` | `evenCapture` | pit 6: its last stone makes the opponent's 3 a 4 |
|
||||||
|
| Empty pit capture | `[2,0,4,4,4,4, 3, 4,4,4,4,5,4, 2]` | `emptyPitCapture` | pit 1: its last stone lands in the empty pit 2, opposite 5 stones |
|
||||||
|
| End of the game | `[0,0,0,0,0,1, 20, 2,3,1,0,2,1, 18]` | `endGame` | pit 6 (the only move): your side is empty and the 9 stones opposite are yours, 30–18 |
|
||||||
|
|
||||||
|
Texts: `Rule<Id>Title`, `Rule<Id>Text1..3`, `LessonTask…`, `MoveExplain…`, `HowToPlay`, `Learn`, `Hints`,
|
||||||
|
`NewToMangala`, `StartTutorial`, `NotNow`, `PracticeGame`, in `en.ts` and `tr.ts`.
|
||||||
|
|
||||||
|
Storage helpers next to the rules: `getCompletedLessons` / `completeLesson`, `isTutorialBannerHidden` /
|
||||||
|
`hideTutorialBanner`, `getHintsEnabled` / `setHintsEnabled`, `markGamePlayed`.
|
||||||
|
|
||||||
|
## Web app
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
- `/rules`: `RulesPage` with `RulesView`.
|
||||||
|
- `/learn`: the lesson list with progress, then the practice game button.
|
||||||
|
- `/learn/:ruleId`: `LessonPage`.
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- `RulesView`: the rules in order; each with its texts, a `RuleBoard` and "Try it" linking to the lesson (R1.5).
|
||||||
|
- `RuleBoard`: two `MiniBoard`s, before and after the example move, the played pit and the landing pit marked.
|
||||||
|
- `RulesPanel`: `RulesView` in a side panel (full screen below 600 px) over the game page; opened from a "How to
|
||||||
|
play" button in the game page's header; the game keeps running behind it (R1.4).
|
||||||
|
- `AppHeader`: a help icon button "How to play" on every page, going to `/rules` (or opening `RulesPanel` on the game
|
||||||
|
page).
|
||||||
|
- `LessonPage`: the rule's text and task above a `BoardView` on a local game (`createGameFromBoard`), animated with
|
||||||
|
`PitAnimator` like a live game. On a move: `getMoveOutcome`, animate, then praise and "Next lesson" or
|
||||||
|
`explainMove` and "Try again", which resets the board.
|
||||||
|
- `TutorialBanner` on the home page for newcomers (R4).
|
||||||
|
- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map
|
||||||
|
(pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below
|
||||||
|
the board a line with `explainMove` of the last move. A "Hints" switch next to the turn indicator, only in bot
|
||||||
|
games; hidden while looking back at earlier moves.
|
||||||
|
- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`.
|
||||||
|
|
||||||
|
## Mobile app
|
||||||
|
|
||||||
|
`RulesScreen`: the rules' texts and a simple stone-count drawing of each example (before/after), reachable from
|
||||||
|
`HelpScreen` and from the game screen's header (R5.1).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Core: every lesson position fulfils its task with some move and (except `anyMove`) fails it with another;
|
||||||
|
`getMoveOutcome` against the engine on random positions; every rule example produces the effect its rule is about
|
||||||
|
(the even capture example really captures, etc.); `explainMove` for each kind of move in both languages; every
|
||||||
|
text key exists in `en` and `tr`.
|
||||||
|
- Web: build and the temporary type check.
|
||||||
|
- End to end in Chrome: a newcomer sees the banner and "Not now" hides it; all six lessons, each first with a wrong
|
||||||
|
move (explanation, reset) and then a right one; the list shows them done; the rules page and the rules panel during
|
||||||
|
a game (the clock goes on); a practice game shows hints and explanations, and a game between two people shows
|
||||||
|
none.
|
||||||
|
- Mobile: type check and bundle; the rules screen on a device when one is available.
|
||||||
|
|
||||||
|
Checked end to end in Chrome on 2026-10-16: a newcomer's banner, "Not now" hiding it, and the banner gone after the
|
||||||
|
lessons; all six lessons from the banner, the four with a wrong move first (explained, then reset) and each done;
|
||||||
|
the list showing all six done and the last lesson offering the practice game; the rules page and, during a game,
|
||||||
|
the rules panel with the clock running behind it (5:00 → 4:57); a practice game with hint chips that match the board
|
||||||
|
and an explanation of each move, hints turned off with the switch; a game between two people with hints turned on
|
||||||
|
on both devices showing no switch and no hints; the rules page and the lesson list at phone width.
|
||||||
83
docs/specs/tutorial/requirements.md
Normal file
83
docs/specs/tutorial/requirements.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Tutorial: requirements
|
||||||
|
|
||||||
|
Status: implemented on branch `feature/tutorial` (based on `develop`).
|
||||||
|
Scope: `packages/core`, web app; the mobile app gets the rules only (see R5).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Someone who has never played Mangala can learn it in a few minutes, and anyone can look a rule up later, also in
|
||||||
|
the middle of a game. Three parts, from reading to playing:
|
||||||
|
|
||||||
|
1. **Rules**: one page with every rule, a short text and a small board for each. Always reachable.
|
||||||
|
2. **Lessons**: one short interactive step per rule; the player makes the move the rule is about on a prepared
|
||||||
|
board and sees what happens. Runs in the browser, without the server.
|
||||||
|
3. **Practice game**: a real game against the Easy bot with hints: which moves give an extra turn or capture, and
|
||||||
|
after every move a sentence about what happened.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Store**: the large pit on each side (in the texts "hazine"); stones in it count for the player.
|
||||||
|
- **Lesson**: one rule taught on a prepared position with a task, e.g. "End your move in your store".
|
||||||
|
- **Hint**: in the practice game, the effect a move would have right away (extra turn, capture, stones to store).
|
||||||
|
Hints never look further ahead than the move itself.
|
||||||
|
- **Newcomer**: someone on a device where no game has been played and the tutorial has not been opened or
|
||||||
|
dismissed.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1. Rules
|
||||||
|
- R1.1 A rules page ("How to play" / "Nasıl oynanır") lists, in order: the board and the goal, sowing, the extra
|
||||||
|
turn, capturing an even pit, capturing from an empty pit, the end of the game and who wins, and the clock.
|
||||||
|
- R1.2 Each rule has at most three sentences and a small board that shows it (before and after the move).
|
||||||
|
- R1.3 The texts describe exactly what the game does (`packages/mancala.js` and the backend's port), including the
|
||||||
|
details beginners miss: the first stone goes back into the pit it was taken from, a single stone moves to the next
|
||||||
|
pit, the opponent's store is skipped.
|
||||||
|
- R1.4 The rules can be reached from the header on every page, and from the game page without leaving the game
|
||||||
|
(a panel over the game; the game and its clock go on).
|
||||||
|
- R1.5 Each rule links to its lesson.
|
||||||
|
|
||||||
|
### R2. Lessons
|
||||||
|
- R2.1 There is a lesson for each rule in R1.1 except the clock: six in all, in the order of R1.1.
|
||||||
|
- R2.2 A lesson shows the rule's text, a board with a prepared position and a task. The player plays on the real
|
||||||
|
board (same look, animation and move preview as a game).
|
||||||
|
- R2.3 A move that fulfils the task is praised and the next lesson is offered. Any other move is animated, then
|
||||||
|
explained ("This move ended in the opponent's pit, so the turn passed") and the position is set back so they can
|
||||||
|
try again.
|
||||||
|
- R2.4 Where a lesson needs the opponent to move, the opponent's move is scripted and shown like the bot's.
|
||||||
|
- R2.5 Lessons work without a connection to the server and need no login.
|
||||||
|
- R2.6 Completed lessons are remembered on the device and marked in the list. Lessons can be taken in any order
|
||||||
|
and repeated.
|
||||||
|
- R2.7 After the last lesson, the player is offered the practice game.
|
||||||
|
|
||||||
|
### R3. Practice game
|
||||||
|
- R3.1 The practice game is a game against the Easy bot with hints turned on, started from the tutorial. It is
|
||||||
|
otherwise a normal bot game (clock, saving, history).
|
||||||
|
- R3.2 While it is the player's turn, every pit whose move gives an extra turn or captures is marked, with what it
|
||||||
|
does ("Extra turn", "Captures 5").
|
||||||
|
- R3.3 After every move, of either side, one sentence says what happened, built from the move: e.g. "The bot's
|
||||||
|
last stone landed in its store: it plays again" or "You captured 6 stones from an even pit".
|
||||||
|
- R3.4 Hints can be turned on and off in every game against the bot; the choice is remembered on the device.
|
||||||
|
- R3.5 Hints are only available in games against the bot. Games between people never show them, also not to
|
||||||
|
spectators.
|
||||||
|
|
||||||
|
### R4. Newcomers
|
||||||
|
- R4.1 A newcomer sees a banner on the home page: "New to Mangala? Learn it in 3 minutes" with "Start" and
|
||||||
|
"Not now". "Not now" hides it for good on that device.
|
||||||
|
- R4.2 The banner does not show once the tutorial has been opened or a game has been played on the device.
|
||||||
|
- R4.3 Nothing is forced: a newcomer can start a game without the tutorial.
|
||||||
|
|
||||||
|
### R5. Mobile app
|
||||||
|
- R5.1 The mobile app shows the same rules (same texts, from `packages/core`) on a rules screen reachable from the
|
||||||
|
help screen and from the game screen's header.
|
||||||
|
- R5.2 Lessons and the practice game's hints on mobile are out of scope until the mobile app gets the new design
|
||||||
|
(`TODO.md`, Web redesign).
|
||||||
|
|
||||||
|
### R6. Languages
|
||||||
|
- R6.1 All texts exist in English and Turkish. Turkish uses the usual Mangala words (kuyu, hazine, taş).
|
||||||
|
|
||||||
|
## Out of scope (TODO)
|
||||||
|
- Lessons and hints in the mobile app (R5.2).
|
||||||
|
- Hints that look ahead (warning about a move that gives the opponent a capture); the bot's search could provide
|
||||||
|
them later.
|
||||||
|
- Strategy lessons beyond the rules.
|
||||||
|
- Keeping tutorial progress per account instead of per device.
|
||||||
16
docs/specs/tutorial/tasks.md
Normal file
16
docs/specs/tutorial/tasks.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Tutorial: tasks
|
||||||
|
|
||||||
|
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
- [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
|
||||||
|
game page.
|
||||||
|
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
||||||
|
reset, scripted opponent replies.
|
||||||
|
- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
|
||||||
|
- [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
|
||||||
|
- [x] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
|
||||||
|
- [x] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
|
||||||
|
items).
|
||||||
@ -20,6 +20,8 @@ export interface PlayedMove {
|
|||||||
gameSteps: GameStep[];
|
gameSteps: GameStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CaptureKind = "even" | "emptyPit";
|
||||||
|
|
||||||
export interface MoveDescription {
|
export interface MoveDescription {
|
||||||
// 1-based.
|
// 1-based.
|
||||||
number: number;
|
number: number;
|
||||||
@ -28,6 +30,9 @@ export interface MoveDescription {
|
|||||||
pit: number;
|
pit: number;
|
||||||
extraTurn: boolean;
|
extraTurn: boolean;
|
||||||
capture: boolean;
|
capture: boolean;
|
||||||
|
captureKind?: CaptureKind;
|
||||||
|
// Stones taken from the opponent's pit by a capture.
|
||||||
|
captured: number;
|
||||||
// Stones the mover's store gained with this move.
|
// Stones the mover's store gained with this move.
|
||||||
storeGain: number;
|
storeGain: number;
|
||||||
gameOver: boolean;
|
gameOver: boolean;
|
||||||
@ -59,11 +64,30 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr
|
|||||||
pit: move.moveIndex - firstPitIndex + 1,
|
pit: move.moveIndex - firstPitIndex + 1,
|
||||||
extraTurn: stepTypes.includes(GAME_STEP_LAST_STONE_IN_BANK),
|
extraTurn: stepTypes.includes(GAME_STEP_LAST_STONE_IN_BANK),
|
||||||
capture: stepTypes.includes(GAME_STEP_LAST_STONE_IN_EMPTY_PIT) || stepTypes.includes(GAME_STEP_DOUBLE_STONE_IN_PIT),
|
capture: stepTypes.includes(GAME_STEP_LAST_STONE_IN_EMPTY_PIT) || stepTypes.includes(GAME_STEP_DOUBLE_STONE_IN_PIT),
|
||||||
|
captureKind: getCaptureKind(move.gameSteps),
|
||||||
|
captured: getCapturedStones(before, move.moveIndex, move.gameSteps),
|
||||||
storeGain: move.boardSnapshot[storeIndex] - before[storeIndex],
|
storeGain: move.boardSnapshot[storeIndex] - before[storeIndex],
|
||||||
gameOver: stepTypes.includes(GAME_STEP_BOARD_CLEARED),
|
gameOver: stepTypes.includes(GAME_STEP_BOARD_CLEARED),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stones a capture took from the opponent's pit: what was there plus what this move dropped in. 0 without a capture.
|
||||||
|
export function getCapturedStones(before: number[], pitIndex: number, gameSteps: GameStep[]): number {
|
||||||
|
const path = getSowingPath(before, pitIndex);
|
||||||
|
const capturedFrom = (index: number) => before[index] + path.filter((i) => i === index).length;
|
||||||
|
for (const step of gameSteps) {
|
||||||
|
if (step.type === GAME_STEP_DOUBLE_STONE_IN_PIT) return capturedFrom(step.data.pitIndex);
|
||||||
|
if (step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) return capturedFrom(step.data.oppositeIndex);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaptureKind(gameSteps: GameStep[]): CaptureKind | undefined {
|
||||||
|
if (gameSteps.some((step) => step.type === GAME_STEP_DOUBLE_STONE_IN_PIT)) return "even";
|
||||||
|
if (gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT)) return "emptyPit";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see
|
// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see
|
||||||
// Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an
|
// Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an
|
||||||
// empty pit or a store.
|
// empty pit or a store.
|
||||||
|
|||||||
@ -10,3 +10,4 @@ export * from './viewmodel/index'
|
|||||||
export * from './service/index'
|
export * from './service/index'
|
||||||
export * from './history/index'
|
export * from './history/index'
|
||||||
export * from './clock/index'
|
export * from './clock/index'
|
||||||
|
export * from './tutorial/index'
|
||||||
|
|||||||
@ -115,4 +115,65 @@ export const en = {
|
|||||||
BotLevelHard: "Hard",
|
BotLevelHard: "Hard",
|
||||||
BotLevelMaster: "Master",
|
BotLevelMaster: "Master",
|
||||||
PlayAgain: "Play again",
|
PlayAgain: "Play again",
|
||||||
|
RuleBoardTitle: "The board and the goal",
|
||||||
|
RuleBoardText1: "Each player has the 6 pits on their side of the board and a store, the large pit at the end of their row. Every pit starts with 4 stones.",
|
||||||
|
RuleBoardText2: "Stones that reach your store are yours. On your turn you play one of your pits, then it is the opponent's turn.",
|
||||||
|
RuleBoardText3: "When the game ends, the player with more stones in their store wins. There are 48 stones, so 25 always win.",
|
||||||
|
RuleSowingTitle: "Sowing",
|
||||||
|
RuleSowingText1: "Take all the stones from one of your pits. Put the first back into the same pit and one into each pit after it, towards your store.",
|
||||||
|
RuleSowingText2: "A pit with a single stone moves it to the next pit.",
|
||||||
|
RuleSowingText3: "Your store gets a stone when you pass it; the opponent's store is skipped.",
|
||||||
|
RuleExtraTurnTitle: "Extra turn",
|
||||||
|
RuleExtraTurnText1: "If your last stone lands in your store, you play again.",
|
||||||
|
RuleExtraTurnText2: "Count the stones: a pit whose stones end exactly in your store gives you another move.",
|
||||||
|
RuleEvenCaptureTitle: "Capturing an even pit",
|
||||||
|
RuleEvenCaptureText1: "If your last stone lands in one of the opponent's pits and makes the stones there even, you take all of them into your store.",
|
||||||
|
RuleEvenCaptureText2: "If the number is odd, nothing is taken and the turn passes.",
|
||||||
|
RuleEmptyPitCaptureTitle: "Capturing from an empty pit",
|
||||||
|
RuleEmptyPitCaptureText1: "If your last stone lands in an empty pit of yours and the opponent's pit opposite has stones, you take that stone and all the stones opposite.",
|
||||||
|
RuleEmptyPitCaptureText2: "If the pit opposite is empty, nothing is taken.",
|
||||||
|
RuleEndOfGameTitle: "End of the game",
|
||||||
|
RuleEndOfGameText1: "The game ends as soon as all pits on one side are empty.",
|
||||||
|
RuleEndOfGameText2: "The player whose side is empty gets all the stones left on the other side.",
|
||||||
|
RuleEndOfGameText3: "Then the stores are counted: more stones wins, the same number is a draw.",
|
||||||
|
RuleClockTitle: "The clock",
|
||||||
|
RuleClockText1: "Each player has 5 minutes for the whole game. Only the clock of the player whose turn it is runs.",
|
||||||
|
RuleClockText2: "A player whose time runs out loses.",
|
||||||
|
LessonTaskAnyMove: "Play one of your pits in the bottom row and watch the stones.",
|
||||||
|
LessonTaskSowing: "Play any pit. Try the pit with a single stone too.",
|
||||||
|
LessonTaskExtraTurn: "Find the pit whose last stone lands in your store.",
|
||||||
|
LessonTaskEvenCapture: "Make one of the opponent's pits even with your last stone.",
|
||||||
|
LessonTaskEmptyPitCapture: "End your move in your empty pit to take the stones opposite.",
|
||||||
|
LessonTaskEndGame: "Play your last stone and see who gets the stones that are left.",
|
||||||
|
MoveExplainExtraTurnYou: "Your last stone landed in your store: you play again.",
|
||||||
|
MoveExplainExtraTurnBot: "The bot's last stone landed in its store: it plays again.",
|
||||||
|
MoveExplainEvenYou: "Your last stone made an opponent's pit even: you took {{count}} stones.",
|
||||||
|
MoveExplainEvenBot: "The bot's last stone made one of your pits even: it took {{count}} stones.",
|
||||||
|
MoveExplainEmptyPitYou: "Your last stone landed in an empty pit of yours: you took it and the {{count}} stones opposite.",
|
||||||
|
MoveExplainEmptyPitBot: "The bot's last stone landed in an empty pit of its own: it took it and your {{count}} stones opposite.",
|
||||||
|
MoveExplainOddYou: "Your last stone left {{count}} stones in the opponent's pit, an odd number: nothing was taken and the turn passes.",
|
||||||
|
MoveExplainOddBot: "The bot's last stone left {{count}} stones in your pit, an odd number: it took nothing.",
|
||||||
|
MoveExplainOwnPitYou: "Your last stone landed in one of your pits that was not empty: the turn passes.",
|
||||||
|
MoveExplainOwnPitBot: "The bot's last stone landed in one of its own pits: your turn.",
|
||||||
|
MoveExplainEmptyOppositeYou: "Your last stone landed in an empty pit of yours, but the pit opposite was empty: nothing to take.",
|
||||||
|
MoveExplainEmptyOppositeBot: "The bot's last stone landed in its empty pit, but your pit opposite was empty: it took nothing.",
|
||||||
|
MoveExplainGameOver: "One side is empty, so the game is over.",
|
||||||
|
HowToPlay: "How to play",
|
||||||
|
LearnMangala: "Learn Mangala",
|
||||||
|
Lessons: "Lessons",
|
||||||
|
TryIt: "Try it",
|
||||||
|
Hints: "Hints",
|
||||||
|
HintExtraTurn: "Extra turn",
|
||||||
|
HintCaptures: "Takes {{count}}",
|
||||||
|
NewToMangala: "New to Mangala? Learn it in 3 minutes.",
|
||||||
|
StartTutorial: "Start",
|
||||||
|
NotNow: "Not now",
|
||||||
|
PracticeGame: "Practice game",
|
||||||
|
PracticeGameText: "Play the Easy bot with hints on.",
|
||||||
|
WellDone: "Well done!",
|
||||||
|
NextLesson: "Next lesson",
|
||||||
|
TryAgain: "Try again",
|
||||||
|
LessonDone: "Done",
|
||||||
|
AllLessons: "All lessons",
|
||||||
|
LessonNumber: "Lesson {{number}} of {{count}}",
|
||||||
}
|
}
|
||||||
@ -115,4 +115,65 @@ export const tr = {
|
|||||||
BotLevelHard: "Zor",
|
BotLevelHard: "Zor",
|
||||||
BotLevelMaster: "Usta",
|
BotLevelMaster: "Usta",
|
||||||
PlayAgain: "Tekrar oyna",
|
PlayAgain: "Tekrar oyna",
|
||||||
|
RuleBoardTitle: "Tahta ve amaç",
|
||||||
|
RuleBoardText1: "Her oyuncunun tahtada kendi tarafında 6 kuyusu ve sırasının sonunda büyük bir hazinesi vardır. Her kuyuda başta 4 taş bulunur.",
|
||||||
|
RuleBoardText2: "Hazinene giren taşlar senindir. Sıran geldiğinde kendi kuyularından birini oynarsın, sonra sıra rakibe geçer.",
|
||||||
|
RuleBoardText3: "Oyun bittiğinde hazinesinde daha çok taş olan kazanır. Toplam 48 taş var, yani 25 taş her zaman kazandırır.",
|
||||||
|
RuleSowingTitle: "Taşları dağıtmak",
|
||||||
|
RuleSowingText1: "Kuyularından birindeki bütün taşları al. İlkini aynı kuyuya geri bırak, kalanları hazinene doğru sıradaki kuyulara birer birer bırak.",
|
||||||
|
RuleSowingText2: "Kuyuda tek taş varsa o taş bir sonraki kuyuya geçer.",
|
||||||
|
RuleSowingText3: "Hazinenin üzerinden geçerken ona da bir taş bırakırsın; rakibin hazinesi atlanır.",
|
||||||
|
RuleExtraTurnTitle: "Bir hamle daha",
|
||||||
|
RuleExtraTurnText1: "Son taşın hazinene düşerse bir kez daha oynarsın.",
|
||||||
|
RuleExtraTurnText2: "Taşları say: taşları tam hazinende biten kuyu sana bir hamle daha kazandırır.",
|
||||||
|
RuleEvenCaptureTitle: "Çift yapıp almak",
|
||||||
|
RuleEvenCaptureText1: "Son taşın rakibin bir kuyusuna düşer ve oradaki taş sayısını çift yaparsa, o kuyudaki bütün taşları hazinene alırsın.",
|
||||||
|
RuleEvenCaptureText2: "Sayı tek olursa bir şey alınmaz, sıra rakibe geçer.",
|
||||||
|
RuleEmptyPitCaptureTitle: "Boş kuyudan almak",
|
||||||
|
RuleEmptyPitCaptureText1: "Son taşın kendi tarafındaki boş bir kuyuya düşer ve rakibin karşıdaki kuyusunda taş varsa, o taşı ve karşıdaki bütün taşları hazinene alırsın.",
|
||||||
|
RuleEmptyPitCaptureText2: "Karşıdaki kuyu boşsa bir şey alınmaz.",
|
||||||
|
RuleEndOfGameTitle: "Oyunun sonu",
|
||||||
|
RuleEndOfGameText1: "Bir taraftaki bütün kuyular boşaldığı anda oyun biter.",
|
||||||
|
RuleEndOfGameText2: "Tarafı boşalan oyuncu, karşı tarafta kalan bütün taşları alır.",
|
||||||
|
RuleEndOfGameText3: "Sonra hazineler sayılır: çok taşı olan kazanır, eşitse oyun berabere biter.",
|
||||||
|
RuleClockTitle: "Süre",
|
||||||
|
RuleClockText1: "Her oyuncunun bütün oyun için 5 dakikası vardır. Yalnızca sırası gelen oyuncunun süresi işler.",
|
||||||
|
RuleClockText2: "Süresi biten oyuncu kaybeder.",
|
||||||
|
LessonTaskAnyMove: "Alt sıradaki kuyularından birini oyna ve taşları izle.",
|
||||||
|
LessonTaskSowing: "Herhangi bir kuyuyu oyna. Tek taşlı kuyuyu da dene.",
|
||||||
|
LessonTaskExtraTurn: "Son taşı hazinene düşen kuyuyu bul.",
|
||||||
|
LessonTaskEvenCapture: "Son taşınla rakibin bir kuyusunu çift yap.",
|
||||||
|
LessonTaskEmptyPitCapture: "Hamleni boş kuyunda bitir ve karşıdaki taşları al.",
|
||||||
|
LessonTaskEndGame: "Son taşını oyna ve kalan taşların kime gittiğini gör.",
|
||||||
|
MoveExplainExtraTurnYou: "Son taşın hazinene düştü: bir kez daha oynuyorsun.",
|
||||||
|
MoveExplainExtraTurnBot: "Botun son taşı kendi hazinesine düştü: bir kez daha oynuyor.",
|
||||||
|
MoveExplainEvenYou: "Son taşın rakibin kuyusunu çift yaptı: {{count}} taş aldın.",
|
||||||
|
MoveExplainEvenBot: "Botun son taşı senin bir kuyunu çift yaptı: {{count}} taş aldı.",
|
||||||
|
MoveExplainEmptyPitYou: "Son taşın kendi boş kuyuna düştü: onu ve karşıdaki {{count}} taşı aldın.",
|
||||||
|
MoveExplainEmptyPitBot: "Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki {{count}} taşını aldı.",
|
||||||
|
MoveExplainOddYou: "Son taşın rakibin kuyusunda {{count}} taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti.",
|
||||||
|
MoveExplainOddBot: "Botun son taşı senin kuyunda {{count}} taş bıraktı, bu tek sayı: bir şey almadı.",
|
||||||
|
MoveExplainOwnPitYou: "Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti.",
|
||||||
|
MoveExplainOwnPitBot: "Botun son taşı kendi kuyusuna düştü: sıra sende.",
|
||||||
|
MoveExplainEmptyOppositeYou: "Son taşın boş kuyuna düştü ama karşıdaki kuyu da boştu: alınacak taş yok.",
|
||||||
|
MoveExplainEmptyOppositeBot: "Botun son taşı boş kuyusuna düştü ama karşısındaki kuyun boştu: bir şey almadı.",
|
||||||
|
MoveExplainGameOver: "Bir taraf boşaldı, oyun bitti.",
|
||||||
|
HowToPlay: "Nasıl oynanır",
|
||||||
|
LearnMangala: "Mangala öğren",
|
||||||
|
Lessons: "Dersler",
|
||||||
|
TryIt: "Dene",
|
||||||
|
Hints: "İpuçları",
|
||||||
|
HintExtraTurn: "Bir hamle daha",
|
||||||
|
HintCaptures: "{{count}} taş alır",
|
||||||
|
NewToMangala: "Mangala'da yeni misin? 3 dakikada öğren.",
|
||||||
|
StartTutorial: "Başla",
|
||||||
|
NotNow: "Şimdi değil",
|
||||||
|
PracticeGame: "Alıştırma oyunu",
|
||||||
|
PracticeGameText: "İpuçları açıkken Kolay bota karşı oyna.",
|
||||||
|
WellDone: "Harika!",
|
||||||
|
NextLesson: "Sonraki ders",
|
||||||
|
TryAgain: "Tekrar dene",
|
||||||
|
LessonDone: "Tamamlandı",
|
||||||
|
AllLessons: "Tüm dersler",
|
||||||
|
LessonNumber: "Ders {{number}} / {{count}}",
|
||||||
};
|
};
|
||||||
108
packages/core/src/tutorial/MoveOutcome.ts
Normal file
108
packages/core/src/tutorial/MoveOutcome.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
CommonMancalaGame,
|
||||||
|
GAME_STEP_BOARD_CLEARED,
|
||||||
|
GAME_STEP_LAST_STONE_IN_BANK,
|
||||||
|
} from "mancala.js";
|
||||||
|
import { CaptureKind, getCapturedStones, getCaptureKind, getSowingPath } from "../history/MoveHistory";
|
||||||
|
|
||||||
|
const PLAYER1 = "player1";
|
||||||
|
const PLAYER2 = "player2";
|
||||||
|
const PLAYER1_STORE = 6;
|
||||||
|
const PLAYER2_STORE = 13;
|
||||||
|
|
||||||
|
// Where a move's last stone landed, from the mover's point of view.
|
||||||
|
export type Landing = "ownStore" | "ownPit" | "opponentPit";
|
||||||
|
|
||||||
|
// The immediate effect of playing a pit, worked out by the engine (docs/specs/tutorial).
|
||||||
|
export interface MoveOutcome {
|
||||||
|
// Board index that was played; 0-5 for player 1, 7-12 for player 2.
|
||||||
|
pitIndex: number;
|
||||||
|
player1Moved: boolean;
|
||||||
|
// Board indexes the stones were dropped into, in order.
|
||||||
|
path: number[];
|
||||||
|
landing: Landing;
|
||||||
|
// Stones in the landing pit before the last stone arrived.
|
||||||
|
stonesBeforeLanding: number;
|
||||||
|
extraTurn: boolean;
|
||||||
|
capture?: CaptureKind;
|
||||||
|
// Stones taken from the opponent's pit by a capture.
|
||||||
|
captured: number;
|
||||||
|
// Stones the mover's store gained.
|
||||||
|
storeGain: number;
|
||||||
|
endsGame: boolean;
|
||||||
|
// Stones in every pit after the move.
|
||||||
|
board: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPlayer1Pit = (index: number) => index >= 0 && index < PLAYER1_STORE;
|
||||||
|
export const isPlayer2Pit = (index: number) => index > PLAYER1_STORE && index < PLAYER2_STORE;
|
||||||
|
|
||||||
|
// Plays the pit on a copy of the board with the real rules. Throws for a store or an empty pit.
|
||||||
|
export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome {
|
||||||
|
const player1Moved = isPlayer1Pit(pitIndex);
|
||||||
|
if (!player1Moved && !isPlayer2Pit(pitIndex)) throw new Error(`Not a pit: ${pitIndex}`);
|
||||||
|
if (!(board[pitIndex] > 0)) throw new Error(`Pit ${pitIndex} is empty`);
|
||||||
|
const game = new CommonMancalaGame("move-outcome", PLAYER1, PLAYER2);
|
||||||
|
board.forEach((stoneCount, index) => (game.board.pits[index].stoneCount = stoneCount));
|
||||||
|
const mover = player1Moved ? PLAYER1 : PLAYER2;
|
||||||
|
game.turnPlayerId = mover;
|
||||||
|
game.moveByPlayerPit(mover, player1Moved ? pitIndex : pitIndex - PLAYER1_STORE - 1);
|
||||||
|
const gameSteps = game.history[game.history.length - 1].gameSteps;
|
||||||
|
const after = game.board.getStoneArray();
|
||||||
|
const path = getSowingPath(board, pitIndex);
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
const ownStore = player1Moved ? PLAYER1_STORE : PLAYER2_STORE;
|
||||||
|
const isOwnPit = player1Moved ? isPlayer1Pit : isPlayer2Pit;
|
||||||
|
const dropsBeforeLast = path.slice(0, -1).filter((i) => i === last).length;
|
||||||
|
return {
|
||||||
|
pitIndex,
|
||||||
|
player1Moved,
|
||||||
|
path,
|
||||||
|
landing: last === ownStore ? "ownStore" : isOwnPit(last) ? "ownPit" : "opponentPit",
|
||||||
|
stonesBeforeLanding: (last === pitIndex ? 0 : board[last]) + dropsBeforeLast,
|
||||||
|
extraTurn: gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_BANK),
|
||||||
|
capture: getCaptureKind(gameSteps),
|
||||||
|
captured: getCapturedStones(board, pitIndex, gameSteps),
|
||||||
|
storeGain: after[ownStore] - board[ownStore],
|
||||||
|
endsGame: gameSteps.some((step) => step.type === GAME_STEP_BOARD_CLEARED),
|
||||||
|
board: after,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame";
|
||||||
|
|
||||||
|
export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean {
|
||||||
|
switch (task) {
|
||||||
|
case "anyMove": return true;
|
||||||
|
case "extraTurn": return outcome.extraTurn;
|
||||||
|
case "evenCapture": return outcome.capture === "even";
|
||||||
|
case "emptyPitCapture": return outcome.capture === "emptyPit";
|
||||||
|
case "endGame": return outcome.endsGame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Mover = "you" | "bot";
|
||||||
|
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
// What happened in a move and why, in one or two sentences (lessons and hints).
|
||||||
|
export function explainMove(outcome: MoveOutcome, mover: Mover, t: Translate): string {
|
||||||
|
const who = mover === "you" ? "You" : "Bot";
|
||||||
|
const sentences: string[] = [];
|
||||||
|
if (outcome.capture === "even") {
|
||||||
|
sentences.push(t(`MoveExplainEven${who}`, { count: outcome.captured }));
|
||||||
|
} else if (outcome.capture === "emptyPit") {
|
||||||
|
sentences.push(t(`MoveExplainEmptyPit${who}`, { count: outcome.captured }));
|
||||||
|
} else if (outcome.landing === "ownStore") {
|
||||||
|
// A move that ends the game gives no turn to play.
|
||||||
|
if (!outcome.endsGame) sentences.push(t(`MoveExplainExtraTurn${who}`));
|
||||||
|
} else if (outcome.landing === "opponentPit") {
|
||||||
|
sentences.push(t(`MoveExplainOdd${who}`, { count: outcome.stonesBeforeLanding + 1 }));
|
||||||
|
} else if (outcome.stonesBeforeLanding === 0) {
|
||||||
|
sentences.push(t(`MoveExplainEmptyOpposite${who}`));
|
||||||
|
} else {
|
||||||
|
sentences.push(t(`MoveExplainOwnPit${who}`));
|
||||||
|
}
|
||||||
|
if (outcome.endsGame) sentences.push(t("MoveExplainGameOver"));
|
||||||
|
return sentences.join(" ");
|
||||||
|
}
|
||||||
83
packages/core/src/tutorial/Rules.ts
Normal file
83
packages/core/src/tutorial/Rules.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { LessonTask } from "./MoveOutcome";
|
||||||
|
|
||||||
|
export type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock";
|
||||||
|
|
||||||
|
// A rule shown on a board: the position, player 1 (bottom) to move, and the pit played, if any.
|
||||||
|
export interface RuleExample {
|
||||||
|
board: number[];
|
||||||
|
pitIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule taught on a prepared position (player 1, bottom, to move) with a task.
|
||||||
|
export interface Lesson {
|
||||||
|
board: number[];
|
||||||
|
task: LessonTask;
|
||||||
|
taskKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rule {
|
||||||
|
id: RuleId;
|
||||||
|
titleKey: string;
|
||||||
|
// At most three sentences.
|
||||||
|
textKeys: string[];
|
||||||
|
example?: RuleExample;
|
||||||
|
lesson?: Lesson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const START = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0];
|
||||||
|
const SOWING = [4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2];
|
||||||
|
const EVEN_CAPTURE = [4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1];
|
||||||
|
const EMPTY_PIT_CAPTURE = [2, 0, 4, 4, 4, 4, 3, 4, 4, 4, 4, 5, 4, 2];
|
||||||
|
const END_OF_GAME = [0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18];
|
||||||
|
|
||||||
|
const texts = (id: string, count: number) => Array.from({ length: count }, (_, i) => `Rule${id}Text${i + 1}`);
|
||||||
|
|
||||||
|
// The rules of the game in the order they are taught (docs/specs/tutorial). Every board here is checked against the
|
||||||
|
// engine by the tests; the boards after a move are never written by hand.
|
||||||
|
export const RULES: Rule[] = [
|
||||||
|
{
|
||||||
|
id: "board", titleKey: "RuleBoardTitle", textKeys: texts("Board", 3),
|
||||||
|
example: { board: START },
|
||||||
|
lesson: { board: START, task: "anyMove", taskKey: "LessonTaskAnyMove" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sowing", titleKey: "RuleSowingTitle", textKeys: texts("Sowing", 3),
|
||||||
|
example: { board: SOWING, pitIndex: 0 },
|
||||||
|
lesson: { board: SOWING, task: "anyMove", taskKey: "LessonTaskSowing" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "extraTurn", titleKey: "RuleExtraTurnTitle", textKeys: texts("ExtraTurn", 2),
|
||||||
|
example: { board: START, pitIndex: 3 },
|
||||||
|
lesson: { board: START, task: "extraTurn", taskKey: "LessonTaskExtraTurn" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evenCapture", titleKey: "RuleEvenCaptureTitle", textKeys: texts("EvenCapture", 2),
|
||||||
|
example: { board: EVEN_CAPTURE, pitIndex: 5 },
|
||||||
|
lesson: { board: EVEN_CAPTURE, task: "evenCapture", taskKey: "LessonTaskEvenCapture" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "emptyPitCapture", titleKey: "RuleEmptyPitCaptureTitle", textKeys: texts("EmptyPitCapture", 2),
|
||||||
|
example: { board: EMPTY_PIT_CAPTURE, pitIndex: 0 },
|
||||||
|
lesson: { board: EMPTY_PIT_CAPTURE, task: "emptyPitCapture", taskKey: "LessonTaskEmptyPitCapture" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "endOfGame", titleKey: "RuleEndOfGameTitle", textKeys: texts("EndOfGame", 3),
|
||||||
|
example: { board: END_OF_GAME, pitIndex: 5 },
|
||||||
|
lesson: { board: END_OF_GAME, task: "endGame", taskKey: "LessonTaskEndGame" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clock", titleKey: "RuleClockTitle", textKeys: texts("Clock", 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LESSON_RULES: Rule[] = RULES.filter((rule) => rule.lesson);
|
||||||
|
|
||||||
|
export function getRule(id: string | undefined): Rule | undefined {
|
||||||
|
return RULES.find((rule) => rule.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lesson after the given one, or undefined after the last.
|
||||||
|
export function getNextLessonRule(id: RuleId): Rule | undefined {
|
||||||
|
const index = LESSON_RULES.findIndex((rule) => rule.id === id);
|
||||||
|
return index >= 0 ? LESSON_RULES[index + 1] : undefined;
|
||||||
|
}
|
||||||
45
packages/core/src/tutorial/TutorialStorage.ts
Normal file
45
packages/core/src/tutorial/TutorialStorage.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Storage } from "../storage/storage";
|
||||||
|
import { RuleId } from "./Rules";
|
||||||
|
|
||||||
|
// What the tutorial remembers on the device (docs/specs/tutorial, R2.6, R3.4, R4).
|
||||||
|
const COMPLETED_LESSONS_KEY = "tutorialCompletedLessons";
|
||||||
|
const BANNER_HIDDEN_KEY = "tutorialBannerHidden";
|
||||||
|
const HINTS_KEY = "botGameHints";
|
||||||
|
const GAME_PLAYED_KEY = "gamePlayed";
|
||||||
|
|
||||||
|
export function getCompletedLessons(storage: Storage): RuleId[] {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(storage.getString(COMPLETED_LESSONS_KEY) ?? "[]");
|
||||||
|
return Array.isArray(saved) ? saved : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeLesson(storage: Storage, id: RuleId): void {
|
||||||
|
const completed = getCompletedLessons(storage);
|
||||||
|
if (!completed.includes(id)) storage.setString(COMPLETED_LESSONS_KEY, JSON.stringify([...completed, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideTutorialBanner(storage: Storage): void {
|
||||||
|
storage.setString(BANNER_HIDDEN_KEY, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markGamePlayed(storage: Storage): void {
|
||||||
|
storage.setString(GAME_PLAYED_KEY, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// For newcomers only: nobody played a game on this device, opened the tutorial or dismissed the banner (R4).
|
||||||
|
export function shouldShowTutorialBanner(storage: Storage): boolean {
|
||||||
|
return storage.getString(BANNER_HIDDEN_KEY) !== "true" && storage.getString(GAME_PLAYED_KEY) !== "true"
|
||||||
|
&& getCompletedLessons(storage).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hints in games against the bot; off until turned on.
|
||||||
|
export function getHintsEnabled(storage: Storage): boolean {
|
||||||
|
return storage.getString(HINTS_KEY) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHintsEnabled(storage: Storage, enabled: boolean): void {
|
||||||
|
storage.setString(HINTS_KEY, String(enabled));
|
||||||
|
}
|
||||||
3
packages/core/src/tutorial/index.ts
Normal file
3
packages/core/src/tutorial/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './MoveOutcome'
|
||||||
|
export * from './Rules'
|
||||||
|
export * from './TutorialStorage'
|
||||||
@ -59,7 +59,7 @@ describe("describeMove", () => {
|
|||||||
const game = newGame();
|
const game = newGame();
|
||||||
game.moveByPlayerPit(P1, 3); // 4 stones from the 4th pit: pits 4, 5, 6 and the store
|
game.moveByPlayerPit(P1, 3); // 4 stones from the 4th pit: pits 4, 5, 6 and the store
|
||||||
expect(describeMove(movesOf(game), 1)).toEqual({
|
expect(describeMove(movesOf(game), 1)).toEqual({
|
||||||
number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, storeGain: 1, gameOver: false,
|
number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, captured: 0, storeGain: 1, gameOver: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ describe("describeMove", () => {
|
|||||||
game.turnPlayerId = P1;
|
game.turnPlayerId = P1;
|
||||||
game.history[0].boardSnapshot = game.board.getStoneArray();
|
game.history[0].boardSnapshot = game.board.getStoneArray();
|
||||||
game.moveByPlayerPit(P1, 0);
|
game.moveByPlayerPit(P1, 0);
|
||||||
expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, storeGain: 6 });
|
expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, captureKind: "emptyPit", captured: 5, storeGain: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks only the last move of a finished game as game over, and store gains add up to the final stores", () => {
|
it("marks only the last move of a finished game as game over, and store gains add up to the final stores", () => {
|
||||||
|
|||||||
200
packages/core/tests/tutorial/Tutorial.test.ts
Normal file
200
packages/core/tests/tutorial/Tutorial.test.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { CommonMancalaGame } from "mancala.js";
|
||||||
|
import { en, tr } from "../../src/localization";
|
||||||
|
import {
|
||||||
|
completeLesson,
|
||||||
|
explainMove,
|
||||||
|
fulfilsTask,
|
||||||
|
getCompletedLessons,
|
||||||
|
getHintsEnabled,
|
||||||
|
getMoveOutcome,
|
||||||
|
getNextLessonRule,
|
||||||
|
hideTutorialBanner,
|
||||||
|
LESSON_RULES,
|
||||||
|
markGamePlayed,
|
||||||
|
MoveOutcome,
|
||||||
|
RULES,
|
||||||
|
setHintsEnabled,
|
||||||
|
shouldShowTutorialBanner,
|
||||||
|
} from "../../src/tutorial";
|
||||||
|
import { describeMove, PlayedMove } from "../../src/history";
|
||||||
|
|
||||||
|
const player1Pits = (board: number[]) => [0, 1, 2, 3, 4, 5].filter((index) => board[index] > 0);
|
||||||
|
|
||||||
|
const translator = (texts: Record<string, string>) => (key: string, options?: Record<string, unknown>) => {
|
||||||
|
const text = texts[key];
|
||||||
|
if (text === undefined) throw new Error(`Missing text ${key}`);
|
||||||
|
return text.replace(/{{(\w+)}}/g, (_, name) => String(options?.[name]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryStorage = () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getString: (key: string) => values.get(key),
|
||||||
|
setString: (key: string, value: string) => { values.set(key, value); },
|
||||||
|
deleteString: (key: string) => { values.delete(key); },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("lessons", () => {
|
||||||
|
it("teach every rule but the clock, in order", () => {
|
||||||
|
expect(LESSON_RULES.map((rule) => rule.id))
|
||||||
|
.toEqual(["board", "sowing", "extraTurn", "evenCapture", "emptyPitCapture", "endOfGame"]);
|
||||||
|
expect(getNextLessonRule("sowing")?.id).toBe("extraTurn");
|
||||||
|
expect(getNextLessonRule("endOfGame")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(LESSON_RULES.map((rule) => [rule.id, rule.lesson!] as const))("%s can be done and failed", (_, lesson) => {
|
||||||
|
expect(lesson.board.reduce((a, b) => a + b, 0)).toBe(48);
|
||||||
|
const pits = player1Pits(lesson.board);
|
||||||
|
const results = pits.map((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit)));
|
||||||
|
expect(results).toContain(true);
|
||||||
|
if (lesson.task !== "anyMove" && pits.length > 1) expect(results).toContain(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are done by the moves the spec names", () => {
|
||||||
|
const fulfilling = (id: string) => {
|
||||||
|
const lesson = LESSON_RULES.find((rule) => rule.id === id)!.lesson!;
|
||||||
|
return player1Pits(lesson.board).filter((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit)));
|
||||||
|
};
|
||||||
|
expect(fulfilling("extraTurn")).toEqual([3]);
|
||||||
|
expect(fulfilling("evenCapture")).toEqual([5]);
|
||||||
|
expect(fulfilling("emptyPitCapture")).toEqual([0]);
|
||||||
|
expect(fulfilling("endOfGame")).toEqual([5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rule examples", () => {
|
||||||
|
const example = (id: string) => {
|
||||||
|
const rule = RULES.find((r) => r.id === id)!;
|
||||||
|
return getMoveOutcome(rule.example!.board, rule.example!.pitIndex!);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("show what their rule is about", () => {
|
||||||
|
const sowing = example("sowing");
|
||||||
|
expect(sowing.path).toEqual([0, 1, 2, 3]);
|
||||||
|
expect(example("extraTurn").extraTurn).toBe(true);
|
||||||
|
expect(example("evenCapture")).toMatchObject({ capture: "even", captured: 4, storeGain: 5 });
|
||||||
|
expect(example("emptyPitCapture")).toMatchObject({ capture: "emptyPit", captured: 5, storeGain: 6 });
|
||||||
|
expect(example("endOfGame")).toMatchObject({ endsGame: true, extraTurn: true, storeGain: 10 });
|
||||||
|
expect(example("endOfGame").board).toEqual([0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a single stone to the next pit", () => {
|
||||||
|
const single = getMoveOutcome([4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2], 2);
|
||||||
|
expect(single.path).toEqual([3]);
|
||||||
|
expect(single).toMatchObject({ landing: "ownPit", stonesBeforeLanding: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a title and texts in both languages", () => {
|
||||||
|
for (const rule of RULES) {
|
||||||
|
for (const key of [rule.titleKey, ...rule.textKeys, ...(rule.lesson ? [rule.lesson.taskKey] : [])]) {
|
||||||
|
expect(en).toHaveProperty(key);
|
||||||
|
expect(tr).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
expect(rule.textKeys.length).toBeLessThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMoveOutcome", () => {
|
||||||
|
it("matches the engine in random games, for both players", () => {
|
||||||
|
let seed = 7;
|
||||||
|
const random = (n: number) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
|
||||||
|
for (let gameNumber = 0; gameNumber < 300; gameNumber++) {
|
||||||
|
const game = new CommonMancalaGame("g", "p1", "p2");
|
||||||
|
game.turnPlayerId = random(2) ? "p1" : "p2";
|
||||||
|
const moves: PlayedMove[] = [];
|
||||||
|
const isEnded = () => (game.state as string) === "ended";
|
||||||
|
while (!isEnded()) {
|
||||||
|
const before = game.board.getStoneArray();
|
||||||
|
const player1 = game.turnPlayerId === "p1";
|
||||||
|
const first = player1 ? 0 : 7;
|
||||||
|
const pits = [0, 1, 2, 3, 4, 5].filter((pit) => before[first + pit] > 0);
|
||||||
|
const pit = pits[random(pits.length)];
|
||||||
|
const outcome = getMoveOutcome(before, first + pit);
|
||||||
|
const mover = game.turnPlayerId;
|
||||||
|
game.moveByPlayerPit(mover, pit);
|
||||||
|
expect(outcome.board).toEqual(game.board.getStoneArray());
|
||||||
|
if (!isEnded()) expect(outcome.extraTurn).toBe(game.turnPlayerId === mover);
|
||||||
|
expect(outcome.endsGame).toBe(isEnded());
|
||||||
|
moves.push({ playerId: mover, moveIndex: first + pit, boardSnapshot: game.board.getStoneArray(),
|
||||||
|
gameSteps: game.history[game.history.length - 1].gameSteps });
|
||||||
|
const description = describeMove(moves, moves.length);
|
||||||
|
expect(description.captured).toBe(outcome.captured);
|
||||||
|
expect(description.captureKind).toBe(outcome.capture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses stores and empty pits", () => {
|
||||||
|
expect(() => getMoveOutcome([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0], 6)).toThrow();
|
||||||
|
expect(() => getMoveOutcome([0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0], 0)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explainMove", () => {
|
||||||
|
const outcome = (board: number[], pit: number): MoveOutcome => getMoveOutcome(board, pit);
|
||||||
|
const start = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0];
|
||||||
|
|
||||||
|
it("says why in Turkish and English", () => {
|
||||||
|
const t = translator(tr);
|
||||||
|
expect(explainMove(outcome(start, 3), "you", t)).toBe("Son taşın hazinene düştü: bir kez daha oynuyorsun.");
|
||||||
|
expect(explainMove(outcome(start, 4), "you", t))
|
||||||
|
.toBe("Son taşın rakibin kuyusunda 5 taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti.");
|
||||||
|
expect(explainMove(outcome(start, 0), "you", t)).toBe("Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti.");
|
||||||
|
expect(explainMove(outcome([4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1], 5), "you", translator(en)))
|
||||||
|
.toBe("Your last stone made an opponent's pit even: you took 4 stones.");
|
||||||
|
expect(explainMove(outcome([0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18], 5), "you", translator(en)))
|
||||||
|
.toBe("One side is empty, so the game is over.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains the bot's moves from the player's side", () => {
|
||||||
|
// The bot is player 2: pit 7 with 2 stones ends in its empty pit 8, opposite player 1's pit 4.
|
||||||
|
const board = [4, 4, 4, 4, 3, 4, 0, 2, 0, 4, 4, 4, 4, 9];
|
||||||
|
expect(explainMove(outcome(board, 7), "bot", translator(tr)))
|
||||||
|
.toBe("Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki 3 taşını aldı.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has every sentence in both languages", () => {
|
||||||
|
for (const texts of [en, tr]) {
|
||||||
|
for (const who of ["You", "Bot"]) {
|
||||||
|
for (const kind of ["ExtraTurn", "Even", "EmptyPit", "Odd", "OwnPit", "EmptyOpposite"]) {
|
||||||
|
expect(texts).toHaveProperty(`MoveExplain${kind}${who}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tutorial storage", () => {
|
||||||
|
it("remembers completed lessons once each", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
expect(getCompletedLessons(storage)).toEqual([]);
|
||||||
|
completeLesson(storage, "sowing");
|
||||||
|
completeLesson(storage, "sowing");
|
||||||
|
completeLesson(storage, "board");
|
||||||
|
expect(getCompletedLessons(storage)).toEqual(["sowing", "board"]);
|
||||||
|
storage.setString("tutorialCompletedLessons", "not json");
|
||||||
|
expect(getCompletedLessons(storage)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner to newcomers only", () => {
|
||||||
|
const dismissed = memoryStorage();
|
||||||
|
expect(shouldShowTutorialBanner(dismissed)).toBe(true);
|
||||||
|
hideTutorialBanner(dismissed);
|
||||||
|
expect(shouldShowTutorialBanner(dismissed)).toBe(false);
|
||||||
|
const played = memoryStorage();
|
||||||
|
markGamePlayed(played);
|
||||||
|
expect(shouldShowTutorialBanner(played)).toBe(false);
|
||||||
|
const learning = memoryStorage();
|
||||||
|
completeLesson(learning, "board");
|
||||||
|
expect(shouldShowTutorialBanner(learning)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps hints off until turned on", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
expect(getHintsEnabled(storage)).toBe(false);
|
||||||
|
setHintsEnabled(storage, true);
|
||||||
|
expect(getHintsEnabled(storage)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user