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