From 3a46833e86eb7a163b6505d5e1625cf4b4d0249b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:50:42 +0300 Subject: [PATCH] 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); + }); +});