feat(core): add bot levels, the bot game channel and bot names

getPlayerDisplayName shows the bot as e.g. "Bot · Medium" from its id.
This commit is contained in:
Halit Aksoy 2026-10-16 14:20:31 +03:00
parent 4a018c7b53
commit 32d1ec98e7
8 changed files with 125 additions and 3 deletions

View File

@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
`onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in
`Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests.
- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
- [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, - [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`,
`getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests.
- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot
game, bot names everywhere players are shown. game, bot names everywhere players are shown.

View File

@ -106,4 +106,13 @@ export const en = {
StoreLabel: "{{name}}'s store, {{count}} stones", StoreLabel: "{{name}}'s store, {{count}} stones",
OpenGame: "Open the game", OpenGame: "Open the game",
Score: "Score", Score: "Score",
PlayWithBot: "Play with bot",
NoOpponentYet: "No opponent yet. Play with the bot?",
ChooseBotLevel: "Choose how strong the bot is",
Bot: "Bot",
BotLevelEasy: "Easy",
BotLevelMedium: "Medium",
BotLevelHard: "Hard",
BotLevelMaster: "Master",
PlayAgain: "Play again",
} }

View File

@ -106,4 +106,13 @@ export const tr = {
StoreLabel: "{{name}} hazinesi, {{count}} taş", StoreLabel: "{{name}} hazinesi, {{count}} taş",
OpenGame: "Oyunu aç", OpenGame: "Oyunu aç",
Score: "Skor", Score: "Skor",
PlayWithBot: "Bota karşı oyna",
NoOpponentYet: "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?",
ChooseBotLevel: "Botun ne kadar güçlü olacağını seç",
Bot: "Bot",
BotLevelEasy: "Kolay",
BotLevelMedium: "Orta",
BotLevelHard: "Zor",
BotLevelMaster: "Usta",
PlayAgain: "Tekrar oyna",
}; };

View File

@ -0,0 +1,42 @@
// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:<level>`.
export type BotLevel = "easy" | "medium" | "hard" | "master";
// Weakest first.
export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"];
// How long the lobby searches for an opponent before it offers the bot.
export const BOT_OFFER_DELAY_MS = 10_000;
const BOT_ID_PREFIX = "bot:";
export function isBotId(userId: string | undefined): boolean {
return userId?.startsWith(BOT_ID_PREFIX) ?? false;
}
// The level of a bot id, or undefined if it is not one.
export function getBotLevel(userId: string | undefined): BotLevel | undefined {
if (!userId || !isBotId(userId)) return undefined;
const level = userId.slice(BOT_ID_PREFIX.length);
return (BOT_LEVELS as string[]).includes(level) ? level as BotLevel : undefined;
}
export function isBotLevel(value: unknown): value is BotLevel {
return typeof value === "string" && (BOT_LEVELS as string[]).includes(value);
}
// Translation key of a level's name, e.g. BotLevelMedium.
export function getBotLevelTextKey(level: BotLevel): string {
return `BotLevel${level.charAt(0).toUpperCase()}${level.slice(1)}`;
}
const LAST_BOT_LEVEL_KEY = "botLevel";
// The level chosen last on this device; Medium at first.
export function getLastBotLevel(storage: { getString(key: string): string | undefined }): BotLevel {
const saved = storage.getString(LAST_BOT_LEVEL_KEY);
return isBotLevel(saved) ? saved : "medium";
}
export function setLastBotLevel(storage: { setString(key: string, value: string): void }, level: BotLevel): void {
storage.setString(LAST_BOT_LEVEL_KEY, level);
}

View File

@ -6,3 +6,4 @@ export * from './UserConnectionInfo'
export * from './AuthUser' export * from './AuthUser'
export * from './GameHistory' export * from './GameHistory'
export * from './GameClock' export * from './GameClock'
export * from './Bot'

View File

@ -1,5 +1,7 @@
export const channel_new_game = "new_game" export const channel_new_game = "new_game"
export const channel_cancel_new_game = "cancel_new_game" export const channel_cancel_new_game = "cancel_new_game"
// client -> server: { level: BotLevel }, starts a game against the bot; ends a search for an opponent.
export const channel_new_bot_game = "new_bot_game"
export const channel_on_game_start = "on_game_start" export const channel_on_game_start = "on_game_start"
export const channel_game_move = "game_move" export const channel_game_move = "game_move"
export const channel_on_game_update = "on_game_update" export const channel_on_game_update = "on_game_update"

View File

@ -1,11 +1,15 @@
import { getBotLevel, getBotLevelTextKey } from "../models/Bot";
const GUEST_USERNAME_PREFIX = "guest-"; const GUEST_USERNAME_PREFIX = "guest-";
// Name to show for a player, or undefined if the server sent none (legacy userKey players). // Name to show for a player, or undefined if the server sent none (legacy userKey players).
// Guests show as e.g. "Guest 4F2A" instead of their generated username. // Guests show as e.g. "Guest 4F2A" instead of their generated username, the bot as e.g. "Bot · Medium".
export function getPlayerDisplayName( export function getPlayerDisplayName(
player: { name?: string, isGuest?: boolean } | undefined, player: { userId?: string, name?: string, isGuest?: boolean } | undefined,
t: (key: string) => string t: (key: string) => string
): string | undefined { ): string | undefined {
const botLevel = getBotLevel(player?.userId);
if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`;
if (!player?.name) return undefined; if (!player?.name) return undefined;
if (player.isGuest) { if (player.isGuest) {
const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name;

View File

@ -0,0 +1,55 @@
import { BOT_LEVELS, getBotLevel, getBotLevelTextKey, getLastBotLevel, isBotId, isBotLevel, setLastBotLevel } from "../../src/models";
import { en, tr } from "../../src/localization";
import { getPlayerDisplayName } from "../../src/util";
describe("bot ids", () => {
it("recognises bot ids and their levels", () => {
expect(isBotId("bot:medium")).toBe(true);
expect(isBotId("kc:alice")).toBe(false);
expect(isBotId(undefined)).toBe(false);
expect(getBotLevel("bot:master")).toBe("master");
expect(getBotLevel("bot:impossible")).toBeUndefined();
expect(getBotLevel("kc:alice")).toBeUndefined();
});
it("checks levels", () => {
expect(isBotLevel("hard")).toBe(true);
expect(isBotLevel("impossible")).toBe(false);
expect(isBotLevel(3)).toBe(false);
});
it("has a name for every level in both languages", () => {
for (const level of BOT_LEVELS) {
const key = getBotLevelTextKey(level) as keyof typeof en;
expect(en[key]).toBeTruthy();
expect(tr[key as keyof typeof tr]).toBeTruthy();
}
});
});
describe("last bot level", () => {
it("is Medium until one is chosen, then the chosen one", () => {
const values = new Map<string, string>();
const storage = { getString: (key: string) => values.get(key), setString: (key: string, value: string) => { values.set(key, value); } };
expect(getLastBotLevel(storage)).toBe("medium");
setLastBotLevel(storage, "master");
expect(getLastBotLevel(storage)).toBe("master");
values.set("botLevel", "impossible");
expect(getLastBotLevel(storage)).toBe("medium");
});
});
describe("getPlayerDisplayName", () => {
const t = (key: string) => (tr as Record<string, string>)[key] ?? key;
it("shows the bot with its level", () => {
expect(getPlayerDisplayName({ userId: "bot:medium", name: "Bot", isGuest: false }, t)).toBe("Bot · Orta");
expect(getPlayerDisplayName({ userId: "bot:easy" }, t)).toBe("Bot · Kolay");
});
it("still shows people and guests as before", () => {
expect(getPlayerDisplayName({ userId: "kc:alice", name: "alice", isGuest: false }, t)).toBe("alice");
expect(getPlayerDisplayName({ userId: "kc:g", name: "guest-4f2a9b", isGuest: true }, t)).toBe("Misafir 4F2A");
expect(getPlayerDisplayName({ userId: "0b9f6d1e" }, t)).toBeUndefined();
});
});