diff --git a/AGENTS.md b/AGENTS.md index 033a853..5bdd33d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TODO.md b/TODO.md index 7ab5374..04de548 100644 --- a/TODO.md +++ b/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`. diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index bf2a1f8..670b298 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -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 = () => { }> + } /> + } /> + } /> } diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 3f7e5c0..37f93d1 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => ( export const BotIcon = ({ size = 20 }: IconProps) => ( ); + +export const HelpIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/PracticeGameCard.tsx b/apps/frontend/src/components/PracticeGameCard.tsx new file mode 100644 index 0000000..eee85bd --- /dev/null +++ b/apps/frontend/src/components/PracticeGameCard.tsx @@ -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 ( +
+
+ {t("PracticeGame")} +

{t("PracticeGameText")}

+
+ + +
+ ); +}; + +export default PracticeGameCard; diff --git a/apps/frontend/src/components/board/BoardView.css b/apps/frontend/src/components/board/BoardView.css index 246ad18..e72f0d9 100644 --- a/apps/frontend/src/components/board/BoardView.css +++ b/apps/frontend/src/components/board/BoardView.css @@ -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)); diff --git a/apps/frontend/src/components/board/BoardView.tsx b/apps/frontend/src/components/board/BoardView.tsx index 172f965..d88bd51 100644 --- a/apps/frontend/src/components/board/BoardView.tsx +++ b/apps/frontend/src/components/board/BoardView.tsx @@ -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; +}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => { const { t } = useTranslation(); const [previewIndex, setPreviewIndex] = useState(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; diff --git a/apps/frontend/src/components/board/PitView.tsx b/apps/frontend/src/components/board/PitView.tsx index a55d968..4a389d1 100644 --- a/apps/frontend/src/components/board/PitView.tsx +++ b/apps/frontend/src/components/board/PitView.tsx @@ -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<{ {stoneCount} {!!marks.preview && {`+${marks.preview}`}} + {!!marks.hint && {marks.hint}} ); if (!onClick) { diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx index 236e159..bb22127 100644 --- a/apps/frontend/src/components/headerbar/AppHeader.tsx +++ b/apps/frontend/src/components/headerbar/AppHeader.tsx @@ -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 (
@@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = {t("Mancala")} + {onHelp + ? + : } {authUser && } {children}
diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css index 74fc073..3cdda22 100644 --- a/apps/frontend/src/components/home/Home.css +++ b/apps/frontend/src/components/home/Home.css @@ -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; } } diff --git a/apps/frontend/src/components/home/MiniBoard.css b/apps/frontend/src/components/home/MiniBoard.css new file mode 100644 index 0000000..17d5c87 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.css @@ -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; } +} diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx index b6e1332..8923dd3 100644 --- a/apps/frontend/src/components/home/MiniBoard.tsx +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -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) =>
{board[index] || ""}
; + const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : ""); + const pit = (index: number) =>
{board[index] || ""}
; return ( ); diff --git a/apps/frontend/src/components/home/TutorialBanner.tsx b/apps/frontend/src/components/home/TutorialBanner.tsx new file mode 100644 index 0000000..7d3d1da --- /dev/null +++ b/apps/frontend/src/components/home/TutorialBanner.tsx @@ -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 ( +
+ {t("NewToMangala")} +
+ + +
+ +
+ ); +}; + +export default TutorialBanner; diff --git a/apps/frontend/src/components/rules/RuleBoard.tsx b/apps/frontend/src/components/rules/RuleBoard.tsx new file mode 100644 index 0000000..5d925ef --- /dev/null +++ b/apps/frontend/src/components/rules/RuleBoard.tsx @@ -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
; + } + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( +
+ + + + +
+ ); +}; + +export default RuleBoard; diff --git a/apps/frontend/src/components/rules/RulesPanel.tsx b/apps/frontend/src/components/rules/RulesPanel.tsx new file mode 100644 index 0000000..566b0be --- /dev/null +++ b/apps/frontend/src/components/rules/RulesPanel.tsx @@ -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 ( +
+ + +
+ ); +}; + +export default RulesPanel; diff --git a/apps/frontend/src/components/rules/RulesView.tsx b/apps/frontend/src/components/rules/RulesView.tsx new file mode 100644 index 0000000..2ca7727 --- /dev/null +++ b/apps/frontend/src/components/rules/RulesView.tsx @@ -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 ( +
    + {RULES.map((rule) => ( +
  1. +

    {t(rule.titleKey)}

    + {rule.textKeys.map((key) =>

    {t(key)}

    )} + {rule.example && } + {showLessons && rule.lesson && ( + {t("TryIt")} + )} +
  2. + ))} + +
+ ); +}; + +export default RulesView; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index bd3f812..fbe538a 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -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(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(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 | 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 = {}; + 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 && } {getTurnText()} + {opponentBotLevel && ( + + )}
{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
@@ -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 &&

{lastMoveExplanation}

} {isGameOver && !gameOverClosed && ( + {rulesOpen && setRulesOpen(false)} />} ); function renderHeaderBar() { - return + return setRulesOpen(true)}> {renderNewGameBtn ?
+ {activeGame && authUser && ( = ({ context }) => { + const { t } = useTranslation(); + useEffect(() => hideTutorialBanner(context.storage), []); + const completed = getCompletedLessons(context.storage); + return ( + + +
+

{t("LearnMangala")}

+
    + {LESSON_RULES.map((rule, index) => { + const done = completed.includes(rule.id); + return ( +
  1. + + {done ? "✓" : index + 1} + {t(rule.titleKey)} + {done && {t("LessonDone")}} + +
  2. + ); + })} +
+ + {t("HowToPlay")} +
+ +
+ ); +}; + +export default LearnPage; diff --git a/apps/frontend/src/routes/LessonPage.tsx b/apps/frontend/src/routes/LessonPage.tsx new file mode 100644 index 0000000..dff950d --- /dev/null +++ b/apps/frontend/src/routes/LessonPage.tsx @@ -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(undefined); + const [boardViewModel, setBoardViewModel] = useState(undefined); + const [phase, setPhase] = useState("playing"); + const [outcome, setOutcome] = useState(undefined); + const [playedIndex, setPlayedIndex] = useState(undefined); + const animator = useRef(undefined); + const timer = useRef | 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 ( + + +
{t("AllLessons")}
+
+ ); + } + + 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 ( + + +
+
+ {t("LessonNumber", { number, count: LESSON_RULES.length })} +

{t(rule.titleKey)}

+ {rule.textKeys.map((key) =>

{t(key)}

)} +
+
+ {phase !== "done" && {t(lesson.taskKey)}} + {phase === "done" && outcome && ( + <> + {fulfilled && {t("WellDone")}} + {explainMove(outcome, "you", t)} +
+ {fulfilled && next && ( + + )} + {fulfilled && !next && ( + + )} + +
+ + )} +
+ {fulfilled && !next && } + {game && boardViewModel && ( +
+ onPitSelect(index)} + canPlay={phase === "playing"} + lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} /> +
+ )} +
+ +
+ ); +}; + +export default LessonPage; diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index 49371f3..f6fa335 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -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}`) } diff --git a/apps/frontend/src/routes/RulesPage.tsx b/apps/frontend/src/routes/RulesPage.tsx new file mode 100644 index 0000000..752dbde --- /dev/null +++ b/apps/frontend/src/routes/RulesPage.tsx @@ -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 ( + + +
+
+

{t("HowToPlay")}

+ {t("Lessons")} +
+ +
+ +
+ ); +}; + +export default RulesPage; diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css index 208b706..a22c48d 100644 --- a/apps/frontend/src/styles/game.css +++ b/apps/frontend/src/styles/game.css @@ -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 { diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c0776c4..b33957b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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 }} /> + ); diff --git a/apps/mobile/src/components/RuleBoard.tsx b/apps/mobile/src/components/RuleBoard.tsx new file mode 100644 index 0000000..6707f0a --- /dev/null +++ b/apps/mobile/src/components/RuleBoard.tsx @@ -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 ( + + {board[index] || ''} + + ); + }; + return ( + + {cell(13, true)} + + {TOP_PITS.map((index) => cell(index, false))} + {BOTTOM_PITS.map((index) => cell(index, false))} + + {cell(6, true)} + + ); +}; + +// 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 ; + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( + + + ↓ + + + ); +}; + +export default RuleBoard; diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx index 404b432..c979fc6 100644 --- a/apps/mobile/src/screens/GameScreen.tsx +++ b/apps/mobile/src/screens/GameScreen.tsx @@ -255,7 +255,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} /> { - navigation.push("Help", { context }) + navigation.push("Rules", { context }) }}> diff --git a/apps/mobile/src/screens/HelpScreen.tsx b/apps/mobile/src/screens/HelpScreen.tsx index efd6997..726f19d 100644 --- a/apps/mobile/src/screens/HelpScreen.tsx +++ b/apps/mobile/src/screens/HelpScreen.tsx @@ -31,6 +31,9 @@ export default function HelpScreen({ navigation, route }: HelpScreenProps) { alignContent: "center", justifyContent: "center" }}> + navigation.push("Rules", { context })}> + {t('HowToPlay')} + {t('WebApp')} diff --git a/apps/mobile/src/screens/RulesScreen.tsx b/apps/mobile/src/screens/RulesScreen.tsx new file mode 100644 index 0000000..c36402f --- /dev/null +++ b/apps/mobile/src/screens/RulesScreen.tsx @@ -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 ( + + {RULES.map((rule) => ( + + {t(rule.titleKey)} + {rule.textKeys.map((key) => ( + {t(key)} + ))} + {rule.example && } + + ))} + + ); +} diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 190597a..3e93b1b 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -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; @@ -16,4 +17,6 @@ export type LobyScreenProps = NativeStackScreenProps export type GameScreenProps = NativeStackScreenProps; -export type HelpScreenProps = NativeStackScreenProps; \ No newline at end of file +export type HelpScreenProps = NativeStackScreenProps; + +export type RulesScreenProps = NativeStackScreenProps; diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md new file mode 100644 index 0000000..c075457 --- /dev/null +++ b/docs/specs/tutorial/design.md @@ -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: `RuleTitle`, `RuleText1..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. diff --git a/docs/specs/tutorial/requirements.md b/docs/specs/tutorial/requirements.md new file mode 100644 index 0000000..db35ea0 --- /dev/null +++ b/docs/specs/tutorial/requirements.md @@ -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. diff --git a/docs/specs/tutorial/tasks.md b/docs/specs/tutorial/tasks.md new file mode 100644 index 0000000..9a5f06a --- /dev/null +++ b/docs/specs/tutorial/tasks.md @@ -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). diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts index fc1c55c..3af04c3 100644 --- a/packages/core/src/history/MoveHistory.ts +++ b/packages/core/src/history/MoveHistory.ts @@ -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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72c074e..2670e24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,3 +10,4 @@ export * from './viewmodel/index' export * from './service/index' export * from './history/index' export * from './clock/index' +export * from './tutorial/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 22ef873..dec8451 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -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}}", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index d6c07c0..fe64c7b 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -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}}", }; \ No newline at end of file diff --git a/packages/core/src/tutorial/MoveOutcome.ts b/packages/core/src/tutorial/MoveOutcome.ts new file mode 100644 index 0000000..d965e92 --- /dev/null +++ b/packages/core/src/tutorial/MoveOutcome.ts @@ -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; + +// 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(" "); +} diff --git a/packages/core/src/tutorial/Rules.ts b/packages/core/src/tutorial/Rules.ts new file mode 100644 index 0000000..936e052 --- /dev/null +++ b/packages/core/src/tutorial/Rules.ts @@ -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; +} diff --git a/packages/core/src/tutorial/TutorialStorage.ts b/packages/core/src/tutorial/TutorialStorage.ts new file mode 100644 index 0000000..4a944a1 --- /dev/null +++ b/packages/core/src/tutorial/TutorialStorage.ts @@ -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)); +} diff --git a/packages/core/src/tutorial/index.ts b/packages/core/src/tutorial/index.ts new file mode 100644 index 0000000..d5458f3 --- /dev/null +++ b/packages/core/src/tutorial/index.ts @@ -0,0 +1,3 @@ +export * from './MoveOutcome' +export * from './Rules' +export * from './TutorialStorage' diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts index 3865e79..e76fb28 100644 --- a/packages/core/tests/history/MoveHistory.test.ts +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -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", () => { diff --git a/packages/core/tests/tutorial/Tutorial.test.ts b/packages/core/tests/tutorial/Tutorial.test.ts new file mode 100644 index 0000000..5ab0fb3 --- /dev/null +++ b/packages/core/tests/tutorial/Tutorial.test.ts @@ -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) => (key: string, options?: Record) => { + 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(); + 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); + }); +});