Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
12 changed files with 595 additions and 10 deletions
Showing only changes of commit 3a46833e86 - Show all commits

View File

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

View File

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

View File

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

View File

@ -10,3 +10,4 @@ export * from './viewmodel/index'
export * from './service/index'
export * from './history/index'
export * from './clock/index'
export * from './tutorial/index'

View File

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

View File

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

View File

@ -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, unknown>) => string;
// What happened in a move and why, in one or two sentences (lessons and hints).
export function explainMove(outcome: MoveOutcome, mover: Mover, t: Translate): string {
const who = mover === "you" ? "You" : "Bot";
const sentences: string[] = [];
if (outcome.capture === "even") {
sentences.push(t(`MoveExplainEven${who}`, { count: outcome.captured }));
} else if (outcome.capture === "emptyPit") {
sentences.push(t(`MoveExplainEmptyPit${who}`, { count: outcome.captured }));
} else if (outcome.landing === "ownStore") {
sentences.push(t(`MoveExplainExtraTurn${who}`));
} else if (outcome.landing === "opponentPit") {
sentences.push(t(`MoveExplainOdd${who}`, { count: outcome.stonesBeforeLanding + 1 }));
} else if (outcome.stonesBeforeLanding === 0) {
sentences.push(t(`MoveExplainEmptyOpposite${who}`));
} else {
sentences.push(t(`MoveExplainOwnPit${who}`));
}
if (outcome.endsGame) sentences.push(t("MoveExplainGameOver"));
return sentences.join(" ");
}

View File

@ -0,0 +1,83 @@
import { LessonTask } from "./MoveOutcome";
export type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock";
// A rule shown on a board: the position, player 1 (bottom) to move, and the pit played, if any.
export interface RuleExample {
board: number[];
pitIndex?: number;
}
// The rule taught on a prepared position (player 1, bottom, to move) with a task.
export interface Lesson {
board: number[];
task: LessonTask;
taskKey: string;
}
export interface Rule {
id: RuleId;
titleKey: string;
// At most three sentences.
textKeys: string[];
example?: RuleExample;
lesson?: Lesson;
}
const START = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0];
const SOWING = [4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2];
const EVEN_CAPTURE = [4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1];
const EMPTY_PIT_CAPTURE = [2, 0, 4, 4, 4, 4, 3, 4, 4, 4, 4, 5, 4, 2];
const END_OF_GAME = [0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18];
const texts = (id: string, count: number) => Array.from({ length: count }, (_, i) => `Rule${id}Text${i + 1}`);
// The rules of the game in the order they are taught (docs/specs/tutorial). Every board here is checked against the
// engine by the tests; the boards after a move are never written by hand.
export const RULES: Rule[] = [
{
id: "board", titleKey: "RuleBoardTitle", textKeys: texts("Board", 3),
example: { board: START },
lesson: { board: START, task: "anyMove", taskKey: "LessonTaskAnyMove" },
},
{
id: "sowing", titleKey: "RuleSowingTitle", textKeys: texts("Sowing", 3),
example: { board: SOWING, pitIndex: 0 },
lesson: { board: SOWING, task: "anyMove", taskKey: "LessonTaskSowing" },
},
{
id: "extraTurn", titleKey: "RuleExtraTurnTitle", textKeys: texts("ExtraTurn", 2),
example: { board: START, pitIndex: 3 },
lesson: { board: START, task: "extraTurn", taskKey: "LessonTaskExtraTurn" },
},
{
id: "evenCapture", titleKey: "RuleEvenCaptureTitle", textKeys: texts("EvenCapture", 2),
example: { board: EVEN_CAPTURE, pitIndex: 5 },
lesson: { board: EVEN_CAPTURE, task: "evenCapture", taskKey: "LessonTaskEvenCapture" },
},
{
id: "emptyPitCapture", titleKey: "RuleEmptyPitCaptureTitle", textKeys: texts("EmptyPitCapture", 2),
example: { board: EMPTY_PIT_CAPTURE, pitIndex: 0 },
lesson: { board: EMPTY_PIT_CAPTURE, task: "emptyPitCapture", taskKey: "LessonTaskEmptyPitCapture" },
},
{
id: "endOfGame", titleKey: "RuleEndOfGameTitle", textKeys: texts("EndOfGame", 3),
example: { board: END_OF_GAME, pitIndex: 5 },
lesson: { board: END_OF_GAME, task: "endGame", taskKey: "LessonTaskEndGame" },
},
{
id: "clock", titleKey: "RuleClockTitle", textKeys: texts("Clock", 2),
},
];
export const LESSON_RULES: Rule[] = RULES.filter((rule) => rule.lesson);
export function getRule(id: string | undefined): Rule | undefined {
return RULES.find((rule) => rule.id === id);
}
// The lesson after the given one, or undefined after the last.
export function getNextLessonRule(id: RuleId): Rule | undefined {
const index = LESSON_RULES.findIndex((rule) => rule.id === id);
return index >= 0 ? LESSON_RULES[index + 1] : undefined;
}

View File

@ -0,0 +1,45 @@
import { Storage } from "../storage/storage";
import { RuleId } from "./Rules";
// What the tutorial remembers on the device (docs/specs/tutorial, R2.6, R3.4, R4).
const COMPLETED_LESSONS_KEY = "tutorialCompletedLessons";
const BANNER_HIDDEN_KEY = "tutorialBannerHidden";
const HINTS_KEY = "botGameHints";
const GAME_PLAYED_KEY = "gamePlayed";
export function getCompletedLessons(storage: Storage): RuleId[] {
try {
const saved = JSON.parse(storage.getString(COMPLETED_LESSONS_KEY) ?? "[]");
return Array.isArray(saved) ? saved : [];
} catch {
return [];
}
}
export function completeLesson(storage: Storage, id: RuleId): void {
const completed = getCompletedLessons(storage);
if (!completed.includes(id)) storage.setString(COMPLETED_LESSONS_KEY, JSON.stringify([...completed, id]));
}
export function hideTutorialBanner(storage: Storage): void {
storage.setString(BANNER_HIDDEN_KEY, "true");
}
export function markGamePlayed(storage: Storage): void {
storage.setString(GAME_PLAYED_KEY, "true");
}
// For newcomers only: nobody played a game on this device, opened the tutorial or dismissed the banner (R4).
export function shouldShowTutorialBanner(storage: Storage): boolean {
return storage.getString(BANNER_HIDDEN_KEY) !== "true" && storage.getString(GAME_PLAYED_KEY) !== "true"
&& getCompletedLessons(storage).length === 0;
}
// Hints in games against the bot; off until turned on.
export function getHintsEnabled(storage: Storage): boolean {
return storage.getString(HINTS_KEY) === "true";
}
export function setHintsEnabled(storage: Storage, enabled: boolean): void {
storage.setString(HINTS_KEY, String(enabled));
}

View File

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

View File

@ -59,7 +59,7 @@ describe("describeMove", () => {
const game = newGame();
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", () => {

View File

@ -0,0 +1,200 @@
import { CommonMancalaGame } from "mancala.js";
import { en, tr } from "../../src/localization";
import {
completeLesson,
explainMove,
fulfilsTask,
getCompletedLessons,
getHintsEnabled,
getMoveOutcome,
getNextLessonRule,
hideTutorialBanner,
LESSON_RULES,
markGamePlayed,
MoveOutcome,
RULES,
setHintsEnabled,
shouldShowTutorialBanner,
} from "../../src/tutorial";
import { describeMove, PlayedMove } from "../../src/history";
const player1Pits = (board: number[]) => [0, 1, 2, 3, 4, 5].filter((index) => board[index] > 0);
const translator = (texts: Record<string, string>) => (key: string, options?: Record<string, unknown>) => {
const text = texts[key];
if (text === undefined) throw new Error(`Missing text ${key}`);
return text.replace(/{{(\w+)}}/g, (_, name) => String(options?.[name]));
};
const memoryStorage = () => {
const values = new Map<string, string>();
return {
getString: (key: string) => values.get(key),
setString: (key: string, value: string) => { values.set(key, value); },
deleteString: (key: string) => { values.delete(key); },
};
};
describe("lessons", () => {
it("teach every rule but the clock, in order", () => {
expect(LESSON_RULES.map((rule) => rule.id))
.toEqual(["board", "sowing", "extraTurn", "evenCapture", "emptyPitCapture", "endOfGame"]);
expect(getNextLessonRule("sowing")?.id).toBe("extraTurn");
expect(getNextLessonRule("endOfGame")).toBeUndefined();
});
it.each(LESSON_RULES.map((rule) => [rule.id, rule.lesson!] as const))("%s can be done and failed", (_, lesson) => {
expect(lesson.board.reduce((a, b) => a + b, 0)).toBe(48);
const pits = player1Pits(lesson.board);
const results = pits.map((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit)));
expect(results).toContain(true);
if (lesson.task !== "anyMove" && pits.length > 1) expect(results).toContain(false);
});
it("are done by the moves the spec names", () => {
const fulfilling = (id: string) => {
const lesson = LESSON_RULES.find((rule) => rule.id === id)!.lesson!;
return player1Pits(lesson.board).filter((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit)));
};
expect(fulfilling("extraTurn")).toEqual([3]);
expect(fulfilling("evenCapture")).toEqual([5]);
expect(fulfilling("emptyPitCapture")).toEqual([0]);
expect(fulfilling("endOfGame")).toEqual([5]);
});
});
describe("rule examples", () => {
const example = (id: string) => {
const rule = RULES.find((r) => r.id === id)!;
return getMoveOutcome(rule.example!.board, rule.example!.pitIndex!);
};
it("show what their rule is about", () => {
const sowing = example("sowing");
expect(sowing.path).toEqual([0, 1, 2, 3]);
expect(example("extraTurn").extraTurn).toBe(true);
expect(example("evenCapture")).toMatchObject({ capture: "even", captured: 4, storeGain: 5 });
expect(example("emptyPitCapture")).toMatchObject({ capture: "emptyPit", captured: 5, storeGain: 6 });
expect(example("endOfGame")).toMatchObject({ endsGame: true, extraTurn: true, storeGain: 10 });
expect(example("endOfGame").board).toEqual([0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18]);
});
it("moves a single stone to the next pit", () => {
const single = getMoveOutcome([4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2], 2);
expect(single.path).toEqual([3]);
expect(single).toMatchObject({ landing: "ownPit", stonesBeforeLanding: 4 });
});
it("has a title and texts in both languages", () => {
for (const rule of RULES) {
for (const key of [rule.titleKey, ...rule.textKeys, ...(rule.lesson ? [rule.lesson.taskKey] : [])]) {
expect(en).toHaveProperty(key);
expect(tr).toHaveProperty(key);
}
expect(rule.textKeys.length).toBeLessThanOrEqual(3);
}
});
});
describe("getMoveOutcome", () => {
it("matches the engine in random games, for both players", () => {
let seed = 7;
const random = (n: number) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
for (let gameNumber = 0; gameNumber < 300; gameNumber++) {
const game = new CommonMancalaGame("g", "p1", "p2");
game.turnPlayerId = random(2) ? "p1" : "p2";
const moves: PlayedMove[] = [];
const isEnded = () => (game.state as string) === "ended";
while (!isEnded()) {
const before = game.board.getStoneArray();
const player1 = game.turnPlayerId === "p1";
const first = player1 ? 0 : 7;
const pits = [0, 1, 2, 3, 4, 5].filter((pit) => before[first + pit] > 0);
const pit = pits[random(pits.length)];
const outcome = getMoveOutcome(before, first + pit);
const mover = game.turnPlayerId;
game.moveByPlayerPit(mover, pit);
expect(outcome.board).toEqual(game.board.getStoneArray());
if (!isEnded()) expect(outcome.extraTurn).toBe(game.turnPlayerId === mover);
expect(outcome.endsGame).toBe(isEnded());
moves.push({ playerId: mover, moveIndex: first + pit, boardSnapshot: game.board.getStoneArray(),
gameSteps: game.history[game.history.length - 1].gameSteps });
const description = describeMove(moves, moves.length);
expect(description.captured).toBe(outcome.captured);
expect(description.captureKind).toBe(outcome.capture);
}
}
});
it("refuses stores and empty pits", () => {
expect(() => getMoveOutcome([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0], 6)).toThrow();
expect(() => getMoveOutcome([0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0], 0)).toThrow();
});
});
describe("explainMove", () => {
const outcome = (board: number[], pit: number): MoveOutcome => getMoveOutcome(board, pit);
const start = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0];
it("says why in Turkish and English", () => {
const t = translator(tr);
expect(explainMove(outcome(start, 3), "you", t)).toBe("Son taşın hazinene düştü: bir kez daha oynuyorsun.");
expect(explainMove(outcome(start, 4), "you", t))
.toBe("Son taşın rakibin kuyusunda 5 taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti.");
expect(explainMove(outcome(start, 0), "you", t)).toBe("Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti.");
expect(explainMove(outcome([4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1], 5), "you", translator(en)))
.toBe("Your last stone made an opponent's pit even: you took 4 stones.");
expect(explainMove(outcome([0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18], 5), "you", translator(en)))
.toBe("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);
});
});