Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -2,7 +2,7 @@
|
||||
|
||||
Work through the tasks in order; each ends with its checks passing and a commit. Tick a task when it is committed.
|
||||
|
||||
- [ ] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
|
||||
- [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
|
||||
with unit tests; new texts (en, tr).
|
||||
- [ ] 2. **Backend**: `GET /games/popular?kind=finished`; test.
|
||||
- [ ] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
|
||||
|
||||
@ -120,28 +120,34 @@ export class PitAnimator {
|
||||
if (gameStep.type === GAME_STEP_GAME_MOVE) {
|
||||
pitViewModel.stoneCount += 1;
|
||||
pitViewModel.pitColor = theme.pitGameMoveAnimateColor;
|
||||
pitViewModel.highlight = "sow";
|
||||
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) {
|
||||
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
|
||||
pitViewModel.highlight = "capture";
|
||||
pitViewModel.stoneCount = 0;
|
||||
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(
|
||||
gameStep.data.oppositeIndex
|
||||
);
|
||||
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
|
||||
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
|
||||
oppositePitViewModel.highlight = "capture";
|
||||
oppositePitViewModel.stoneCount = 0;
|
||||
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_BANK) {
|
||||
pitViewModel.pitColor = theme.pitLastStoneInBankPitAnimateColor;
|
||||
pitViewModel.highlight = "store";
|
||||
} else if (gameStep.type === GAME_STEP_BOARD_CLEARED) {
|
||||
for (const index of gameStep.data.pitIndexesThatHasStone) {
|
||||
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(index);
|
||||
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
|
||||
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
|
||||
oppositePitViewModel.highlight = "capture";
|
||||
oppositePitViewModel.stoneCount = 0;
|
||||
}
|
||||
} else if (gameStep.type === GAME_STEP_DOUBLE_STONE_IN_PIT) {
|
||||
const _index = this.mancalaGame.board.getPitIndexCircularly(index);
|
||||
const pitViewModel = boardViewModel.pits[_index];
|
||||
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
|
||||
pitViewModel.highlight = "capture";
|
||||
pitViewModel.stoneCount = 0;
|
||||
}
|
||||
pitViewModel.stoneColor = getColorByBrightness(
|
||||
|
||||
@ -64,6 +64,16 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr
|
||||
};
|
||||
}
|
||||
|
||||
// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them: a single stone goes
|
||||
// to the next pit; otherwise the first stone goes back into the played pit. Every pit and store is passed, the
|
||||
// opponent's store too. The last index is where the last stone lands. Empty for an empty pit or a store.
|
||||
export function getSowingPath(stones: number[], pitIndex: number): number[] {
|
||||
const stoneCount = stones[pitIndex];
|
||||
if (!stoneCount || referenceBoard.checkPitTypeIsBankByIndex(pitIndex)) return [];
|
||||
const start = stoneCount === 1 ? pitIndex + 1 : pitIndex;
|
||||
return Array.from({ length: stoneCount }, (_, step) => (start + step) % stones.length);
|
||||
}
|
||||
|
||||
// The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator,
|
||||
// whose last history entry is the move it animates). Only for display: the rebuilt game has no rules.
|
||||
export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: number): Game {
|
||||
|
||||
@ -72,4 +72,38 @@ export const en = {
|
||||
WatchFromStart: "Watch from start",
|
||||
Speed: "Speed",
|
||||
Duration: "Duration",
|
||||
You: "You",
|
||||
Opponent: "Opponent",
|
||||
StoreCount: "Store {{count}}",
|
||||
Online: "Online",
|
||||
Offline: "Offline",
|
||||
ChooseTheme: "Choose theme",
|
||||
ThemeWalnut: "Walnut theme",
|
||||
ThemeNight: "Night theme",
|
||||
ThemeJade: "Jade theme",
|
||||
Account: "Account",
|
||||
NewestFirst: "newest first",
|
||||
BeingPlayedNow: "Being played now",
|
||||
MostWatched: "Most watched",
|
||||
YourGames: "Your games",
|
||||
GameWithOpponent: "Your game with {{name}} is on",
|
||||
StoreScores: "Your store {{mine}}, opponent's {{theirs}}",
|
||||
Live: "Live",
|
||||
Close: "Close",
|
||||
Play: "Play",
|
||||
Pause: "Pause",
|
||||
FirstMove: "Go to start",
|
||||
PreviousMove: "Previous move",
|
||||
NextMove: "Next move",
|
||||
LastMove: "Go to end",
|
||||
MovePosition: "Move position",
|
||||
PlaybackControls: "Playback controls",
|
||||
PlaybackSpeed: "Playback speed",
|
||||
GameSummary: "Game summary",
|
||||
StartPosition: "4 stones in every pit",
|
||||
MoveCount: "{{count}} moves",
|
||||
PitLabel: "{{name}}, pit {{pit}}, {{count}} stones",
|
||||
StoreLabel: "{{name}}'s store, {{count}} stones",
|
||||
OpenGame: "Open the game",
|
||||
Score: "Score",
|
||||
}
|
||||
@ -72,4 +72,38 @@ export const tr = {
|
||||
WatchFromStart: "Baştan izle",
|
||||
Speed: "Hız",
|
||||
Duration: "Süre",
|
||||
You: "Sen",
|
||||
Opponent: "Rakip",
|
||||
StoreCount: "Hazine {{count}}",
|
||||
Online: "Çevrimiçi",
|
||||
Offline: "Çevrimdışı",
|
||||
ChooseTheme: "Tema seç",
|
||||
ThemeWalnut: "Ceviz tema",
|
||||
ThemeNight: "Gece tema",
|
||||
ThemeJade: "Yeşim tema",
|
||||
Account: "Hesap",
|
||||
NewestFirst: "en yeni üstte",
|
||||
BeingPlayedNow: "Şu an oynanıyor",
|
||||
MostWatched: "En çok izlenenler",
|
||||
YourGames: "Senin maçların",
|
||||
GameWithOpponent: "{{name}} ile oyunun sürüyor",
|
||||
StoreScores: "Hazinen {{mine}}, rakibin {{theirs}}",
|
||||
Live: "Canlı",
|
||||
Close: "Kapat",
|
||||
Play: "Oynat",
|
||||
Pause: "Duraklat",
|
||||
FirstMove: "Başa dön",
|
||||
PreviousMove: "Önceki hamle",
|
||||
NextMove: "Sonraki hamle",
|
||||
LastMove: "Sona git",
|
||||
MovePosition: "Hamle konumu",
|
||||
PlaybackControls: "Oynatma kontrolleri",
|
||||
PlaybackSpeed: "Oynatma hızı",
|
||||
GameSummary: "Oyun özeti",
|
||||
StartPosition: "Her kuyuda 4 taş",
|
||||
MoveCount: "{{count}} hamle",
|
||||
PitLabel: "{{name}}, {{pit}}. kuyu, {{count}} taş",
|
||||
StoreLabel: "{{name}} hazinesi, {{count}} taş",
|
||||
OpenGame: "Oyunu aç",
|
||||
Score: "Skor",
|
||||
};
|
||||
@ -14,10 +14,13 @@ export type ThemeManagerEvents = "themechange";
|
||||
export class ThemeManager extends TinyEmitter {
|
||||
private _theme: Theme;
|
||||
private _storage: any;
|
||||
constructor(storage: Storage) {
|
||||
private _themes: Theme[];
|
||||
// The first theme is the default.
|
||||
constructor(storage: Storage, themeList: Theme[] = themes) {
|
||||
super();
|
||||
this._storage = storage;
|
||||
this._theme = this.readFromLocalStorage() || lightTheme;
|
||||
this._themes = themeList;
|
||||
this._theme = this.readFromLocalStorage() || themeList[0];
|
||||
}
|
||||
|
||||
public get theme() {
|
||||
@ -36,12 +39,12 @@ export class ThemeManager extends TinyEmitter {
|
||||
|
||||
private readFromLocalStorage(): Theme | undefined {
|
||||
const themeID = this._storage.getString(THEME_ID);
|
||||
const theme = themes.find((eachTheme: Theme) => themeID === eachTheme.id);
|
||||
const theme = this._themes.find((eachTheme: Theme) => themeID === eachTheme.id);
|
||||
return theme;
|
||||
}
|
||||
|
||||
public get themes(): Theme[] {
|
||||
return themes;
|
||||
return this._themes;
|
||||
}
|
||||
|
||||
public on(event: ThemeManagerEvents, callback: (...value: any[]) => void): this {
|
||||
|
||||
@ -1,18 +1,25 @@
|
||||
// Why a pit is marked during a move animation: a stone was sown into it, stones were captured from it, the last stone
|
||||
// landed in a store, or the player picked it and the move is on its way to the server.
|
||||
export type PitHighlight = "sow" | "capture" | "store" | "selected";
|
||||
|
||||
export class PitViewModel {
|
||||
id: string;
|
||||
stoneCount: number;
|
||||
stoneColor: string;
|
||||
pitColor: string;
|
||||
highlight?: PitHighlight;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
stoneCount: number,
|
||||
stoneColor: string,
|
||||
pitColor: string
|
||||
pitColor: string,
|
||||
highlight?: PitHighlight
|
||||
) {
|
||||
this.id = id;
|
||||
this.stoneCount = stoneCount;
|
||||
this.stoneColor = stoneColor;
|
||||
this.pitColor = pitColor;
|
||||
this.highlight = highlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { CommonMancalaGame, MancalaGame } from "mancala.js";
|
||||
import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, PlayedMove } from "../../src/history";
|
||||
import { CommonMancalaGame, GAME_STEP_GAME_MOVE, MancalaGame } from "mancala.js";
|
||||
import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, getSowingPath, PlayedMove } from "../../src/history";
|
||||
import { Game } from "../../src/models";
|
||||
|
||||
const P1 = "player-1";
|
||||
@ -136,3 +136,46 @@ describe("createGameAtMove", () => {
|
||||
expect(start.history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSowingPath", () => {
|
||||
it("starts with the played pit, or the next pit for a single stone", () => {
|
||||
const stones = getInitialBoard();
|
||||
expect(getSowingPath(stones, 2)).toEqual([2, 3, 4, 5]);
|
||||
stones[4] = 1;
|
||||
expect(getSowingPath(stones, 4)).toEqual([5]);
|
||||
});
|
||||
|
||||
it("passes both stores and wraps around the board", () => {
|
||||
const stones = getInitialBoard();
|
||||
stones[12] = 4;
|
||||
expect(getSowingPath(stones, 12)).toEqual([12, 13, 0, 1]);
|
||||
stones[5] = 10;
|
||||
expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13, 0]);
|
||||
});
|
||||
|
||||
it("is empty for an empty pit or a store", () => {
|
||||
const stones = getInitialBoard();
|
||||
stones[3] = 0;
|
||||
expect(getSowingPath(stones, 3)).toEqual([]);
|
||||
expect(getSowingPath(stones, 6)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches the pits the engine sows into", () => {
|
||||
const random = seededRandom(7);
|
||||
for (let gameNumber = 0; gameNumber < 20; gameNumber++) {
|
||||
const game = newGame();
|
||||
while (game.state !== "ended") {
|
||||
const before = game.board.getStoneArray();
|
||||
const playable = [0, 1, 2, 3, 4, 5].filter((pit) =>
|
||||
before[game.getBoardIndexByPlayerId(game.turnPlayerId, pit)] > 0);
|
||||
const pit = playable[Math.floor(random() * playable.length)];
|
||||
const boardIndex = game.getBoardIndexByPlayerId(game.turnPlayerId, pit);
|
||||
game.moveByPlayerPit(game.turnPlayerId, pit);
|
||||
const sown = game.history[game.history.length - 1].gameSteps
|
||||
.filter((step) => step.type === GAME_STEP_GAME_MOVE)
|
||||
.map((step) => game.board.getPitIndexCircularly(step.index));
|
||||
expect(getSowingPath(before, boardIndex)).toEqual(sown);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user