Merge branch 'feature/tutorial' into develop

This commit is contained in:
Halit Aksoy 2026-10-16 16:22:02 +03:00
commit 47b75b5456
42 changed files with 1671 additions and 34 deletions

View File

@ -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
View File

@ -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`.

View File

@ -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>}

View File

@ -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>
);

View 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;

View File

@ -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));

View File

@ -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;

View File

@ -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) {

View File

@ -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>

View File

@ -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; }
} }

View File

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

View File

@ -1,22 +1,27 @@
import * as React from "react"; import * 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>
); );

View 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;

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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}

View 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;

View 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;

View File

@ -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}`)
} }

View 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;

View File

@ -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 {

View File

@ -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>
); );

View 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;

View File

@ -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>

View File

@ -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>

View 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>
);
}

View File

@ -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'>;

View 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.

View 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.

View 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).

View File

@ -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.

View File

@ -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'

View File

@ -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}}",
} }

View File

@ -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}}",
}; };

View 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(" ");
}

View 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;
}

View 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));
}

View File

@ -0,0 +1,3 @@
export * from './MoveOutcome'
export * from './Rules'
export * from './TutorialStorage'

View File

@ -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", () => {

View 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);
});
});