diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6d688c6..432a5fc 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -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 `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. -- [ ] 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. - [ ] 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. diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 58ed73e..22ef873 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -106,4 +106,13 @@ export const en = { StoreLabel: "{{name}}'s store, {{count}} stones", OpenGame: "Open the game", 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", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 153a1b5..d6c07c0 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -106,4 +106,13 @@ export const tr = { StoreLabel: "{{name}} hazinesi, {{count}} taş", OpenGame: "Oyunu aç", 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", }; \ No newline at end of file diff --git a/packages/core/src/models/Bot.ts b/packages/core/src/models/Bot.ts new file mode 100644 index 0000000..90254e7 --- /dev/null +++ b/packages/core/src/models/Bot.ts @@ -0,0 +1,42 @@ +// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:`. +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); +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 408e5bb..9745bb5 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -6,3 +6,4 @@ export * from './UserConnectionInfo' export * from './AuthUser' export * from './GameHistory' export * from './GameClock' +export * from './Bot' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index e3b532d..ecb9e34 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -1,5 +1,7 @@ export const channel_new_game = "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_game_move = "game_move" export const channel_on_game_update = "on_game_update" diff --git a/packages/core/src/util/PlayerUtil.ts b/packages/core/src/util/PlayerUtil.ts index 3aafc5d..1c169d6 100644 --- a/packages/core/src/util/PlayerUtil.ts +++ b/packages/core/src/util/PlayerUtil.ts @@ -1,11 +1,15 @@ +import { getBotLevel, getBotLevelTextKey } from "../models/Bot"; + const GUEST_USERNAME_PREFIX = "guest-"; // 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( - player: { name?: string, isGuest?: boolean } | undefined, + player: { userId?: string, name?: string, isGuest?: boolean } | undefined, t: (key: string) => string ): string | undefined { + const botLevel = getBotLevel(player?.userId); + if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`; if (!player?.name) return undefined; if (player.isGuest) { const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; diff --git a/packages/core/tests/models/Bot.test.ts b/packages/core/tests/models/Bot.test.ts new file mode 100644 index 0000000..e711d97 --- /dev/null +++ b/packages/core/tests/models/Bot.test.ts @@ -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(); + 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)[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(); + }); +});