From 9522224bf9045c7aa8fee9258226afdc2215d100 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:46:50 +0300 Subject: [PATCH 1/9] docs(tutorial): add the spec Requirements, design and tasks for a rules page, interactive lessons and a practice game with hints against the Easy bot. --- docs/specs/tutorial/design.md | 145 ++++++++++++++++++++++++++++ docs/specs/tutorial/requirements.md | 83 ++++++++++++++++ docs/specs/tutorial/tasks.md | 16 +++ 3 files changed, 244 insertions(+) create mode 100644 docs/specs/tutorial/design.md create mode 100644 docs/specs/tutorial/requirements.md create mode 100644 docs/specs/tutorial/tasks.md diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md new file mode 100644 index 0000000..2c35773 --- /dev/null +++ b/docs/specs/tutorial/design.md @@ -0,0 +1,145 @@ +# 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": "End your move in your store" + opponentReply?: number; // board index the opponent plays after a fulfilled move, if the lesson needs one +} + +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[], player1ToMove: boolean, pit: number): MoveOutcome; +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" | "opponent" | "bot", t): string; +``` + +`getMoveOutcome` builds the position with `createGameFromBoard` (core history helpers), plays the pit and reads the +game steps, as `describeMove` does for the move list. `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. A scripted `opponentReply` is played after a short pause. +- `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" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with + `explainMove` of the last move. A "Hints" switch in the game page's toolbar, only in bot games. +- 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. diff --git a/docs/specs/tutorial/requirements.md b/docs/specs/tutorial/requirements.md new file mode 100644 index 0000000..eab42ad --- /dev/null +++ b/docs/specs/tutorial/requirements.md @@ -0,0 +1,83 @@ +# Tutorial: requirements + +Status: approved, being 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..0ca7ebe --- /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. +- [ ] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and + the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. +- [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the + game page. +- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and + reset, scripted opponent replies. +- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. +- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`. +- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle. +- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope + items). From 3a46833e86eb7a163b6505d5e1625cf4b4d0249b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:50:42 +0300 Subject: [PATCH 2/9] feat(core): add the rules, lessons and move explanations RULES defines every rule with an example and a lesson; getMoveOutcome works out a move's effect with the engine, explainMove says what happened, and describeMove now tells the kind of capture and how many stones it took. --- docs/specs/tutorial/design.md | 14 +- docs/specs/tutorial/tasks.md | 2 +- packages/core/src/history/MoveHistory.ts | 24 +++ packages/core/src/index.ts | 1 + packages/core/src/localization/en.ts | 61 ++++++ packages/core/src/localization/tr.ts | 61 ++++++ packages/core/src/tutorial/MoveOutcome.ts | 107 ++++++++++ packages/core/src/tutorial/Rules.ts | 83 ++++++++ packages/core/src/tutorial/TutorialStorage.ts | 45 ++++ packages/core/src/tutorial/index.ts | 3 + .../core/tests/history/MoveHistory.test.ts | 4 +- packages/core/tests/tutorial/Tutorial.test.ts | 200 ++++++++++++++++++ 12 files changed, 595 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/tutorial/MoveOutcome.ts create mode 100644 packages/core/src/tutorial/Rules.ts create mode 100644 packages/core/src/tutorial/TutorialStorage.ts create mode 100644 packages/core/src/tutorial/index.ts create mode 100644 packages/core/tests/tutorial/Tutorial.test.ts diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md index 2c35773..bd5da0c 100644 --- a/docs/specs/tutorial/design.md +++ b/docs/specs/tutorial/design.md @@ -51,8 +51,7 @@ type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | interface Lesson { board: number[]; // player 1 (bottom) to move task: LessonTask; - taskKey: string; // e.g. "LessonTaskExtraTurn": "End your move in your store" - opponentReply?: number; // board index the opponent plays after a fulfilled move, if the lesson needs one + taskKey: string; // e.g. "LessonTaskExtraTurn": "Find the pit whose last stone lands in your store" } interface Rule { @@ -75,15 +74,16 @@ interface MoveOutcome { endsGame: boolean; board: number[]; // after the move } -export function getMoveOutcome(board: number[], player1ToMove: boolean, pit: number): MoveOutcome; +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" | "opponent" | "bot", t): string; +export function explainMove(outcome: MoveOutcome, mover: "you" | "bot", t): string; ``` -`getMoveOutcome` builds the position with `createGameFromBoard` (core history helpers), plays the pit and reads the -game steps, as `describeMove` does for the move list. `describeMove` gains `capture` kind and `captured`, so the +`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 @@ -120,7 +120,7 @@ Components: 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. A scripted `opponentReply` is played after a short pause. + `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" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with diff --git a/docs/specs/tutorial/tasks.md b/docs/specs/tutorial/tasks.md index 0ca7ebe..5f87c40 100644 --- a/docs/specs/tutorial/tasks.md +++ b/docs/specs/tutorial/tasks.md @@ -3,7 +3,7 @@ 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. -- [ ] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and +- [x] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. - [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the game page. 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..ea4abb8 --- /dev/null +++ b/packages/core/src/tutorial/MoveOutcome.ts @@ -0,0 +1,107 @@ +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") { + 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..3b5db88 --- /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("Your last stone landed in your store: you play again. 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); + }); +}); From 8511e0b3f2ef36ca81b9eb0a6a8e156844255af7 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:53:18 +0300 Subject: [PATCH 3/9] feat(frontend): show the rules A How to play page with every rule and a before/after board computed by the engine, a help button in the header, and the rules in a side panel over the game page so a game can go on while a rule is looked up. --- apps/frontend/src/MancalaApp.tsx | 2 + apps/frontend/src/components/Icons.tsx | 4 ++ .../src/components/headerbar/AppHeader.tsx | 12 ++-- apps/frontend/src/components/home/Home.css | 14 ----- .../src/components/home/MiniBoard.css | 19 ++++++ .../src/components/home/MiniBoard.tsx | 15 +++-- .../src/components/rules/RuleBoard.tsx | 44 ++++++++++++++ .../src/components/rules/RulesPanel.tsx | 52 +++++++++++++++++ .../src/components/rules/RulesView.tsx | 58 +++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 6 +- apps/frontend/src/routes/RulesPage.tsx | 37 ++++++++++++ docs/specs/tutorial/tasks.md | 2 +- 12 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/src/components/home/MiniBoard.css create mode 100644 apps/frontend/src/components/rules/RuleBoard.tsx create mode 100644 apps/frontend/src/components/rules/RulesPanel.tsx create mode 100644 apps/frontend/src/components/rules/RulesView.tsx create mode 100644 apps/frontend/src/routes/RulesPage.tsx diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index bf2a1f8..a2faa37 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -14,6 +14,7 @@ import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; import PrivacyPage from './routes/PrivacyPage'; +import RulesPage from './routes/RulesPage'; import swal from 'sweetalert'; import Util from './util/Util'; import { server } from './const/config'; @@ -130,6 +131,7 @@ const MancalaApp: FunctionComponent = () => { }> + } /> } 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/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/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..cfd8a47 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -28,6 +28,7 @@ import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; import PlayerCard from '../components/game/PlayerCard'; import GameOverCard from '../components/game/GameOverCard'; +import RulesPanel from '../components/rules/RulesPanel'; import '../styles/game.css'; const GamePage: FunctionComponent<{ @@ -61,6 +62,8 @@ const GamePage: FunctionComponent<{ // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); + // The rules over the game; the game goes on behind them. + const [rulesOpen, setRulesOpen] = useState(false); // Set when the player closes the game over card to look at the board. const [gameOverClosed, setGameOverClosed] = useState(false); @@ -419,11 +422,12 @@ const GamePage: FunctionComponent<{ {showBoardView && renderGame()} + {rulesOpen && setRulesOpen(false)} />} ); function renderHeaderBar() { - return + return setRulesOpen(true)}> {renderNewGameBtn ? + )} + {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/RulesPage.tsx b/apps/frontend/src/routes/RulesPage.tsx index 0256cbc..752dbde 100644 --- a/apps/frontend/src/routes/RulesPage.tsx +++ b/apps/frontend/src/routes/RulesPage.tsx @@ -1,5 +1,6 @@ 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"; @@ -14,8 +15,11 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
-

{t("HowToPlay")}

- +
+

{t("HowToPlay")}

+ {t("Lessons")} +
+
+ + ); +}; + +export default PracticeGameCard; 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/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index d6e0690..cb7f733 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -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<{
+ {activeGame && authUser && ( = ({ context }) => { @@ -31,6 +32,7 @@ const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont ); })} + {t("HowToPlay")}