diff --git a/apps/mobile/src/components/BotLevelPicker.tsx b/apps/mobile/src/components/BotLevelPicker.tsx
new file mode 100644
index 0000000..6cbda7a
--- /dev/null
+++ b/apps/mobile/src/components/BotLevelPicker.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { FunctionComponent } from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from '@mancala/core';
+import { Context } from '../context/context';
+import CircularPanel from './CircularPanel';
+
+// The four bot levels; the level chosen last is highlighted and remembered on the device.
+const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
+ const { t } = useTranslation();
+ const theme = context.themeManager.theme;
+ const lastLevel = getLastBotLevel(context.storage);
+ return (
+
+ {BOT_LEVELS.map((level) => (
+ {
+ setLastBotLevel(context.storage, level);
+ onChoose(level);
+ }}>
+ {t(getBotLevelTextKey(level))}} />
+
+ ))}
+
+ );
+};
+
+export default BotLevelPicker;
diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx
index d05709e..404b432 100644
--- a/apps/mobile/src/screens/GameScreen.tsx
+++ b/apps/mobile/src/screens/GameScreen.tsx
@@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native'
import { useTranslation } from 'react-i18next';
import { GameScreenProps } from '../types';
import { useState } from 'react';
-import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } from "@mancala/core";
+import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core";
import { MancalaGame, Pit } from 'mancala.js';
import { v4 } from 'uuid';
import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move, channel_on_game_end } from '@mancala/core';
@@ -159,10 +159,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
});
};
+ // In a game against the bot, a new game is another one at the same level.
const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return;
context.storage.deleteString('lastGameId');
- navigation.replace("Loby", { context })
+ const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
+ navigation.replace("Loby", { context, botLevel: opponentBotLevel })
};
const onPitSelect = (index: number, pit: Pit) => {
@@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
onLeaveGameClick();
}
}}>
- {isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}} />
+ {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} />
{
navigation.push("Help", { context })
@@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
+ // Players have no names on mobile yet (legacy users), except the bot.
+ const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t);
+ const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t);
const topLocatedUser = {
id: topLocatedUserId,
- name: "Anonymous",
+ name: topBotName ?? "Anonymous",
isOnline: isUserOnline(topLocatedUserId),
- isAnonymous: true
+ isAnonymous: !topBotName
};
const bottomLocatedUser = {
id: bottomLocatedUserId,
- name: "Anonymous",
+ name: bottomBotName ?? "Anonymous",
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
- isAnonymous: true
+ isAnonymous: !bottomBotName
};
const currentUser = isSpectator ? {
id: "2",
diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx
index aa7757b..0bbf6b4 100644
--- a/apps/mobile/src/screens/HomeScreen.tsx
+++ b/apps/mobile/src/screens/HomeScreen.tsx
@@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { HomeScreenProps } from '../types';
import CircularPanel from '../components/CircularPanel';
+import BotLevelPicker from '../components/BotLevelPicker';
import { SvgXml } from 'react-native-svg';
import helpSvg from '../svg/help';
import { getColorByBrightness } from "@mancala/core";
@@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
const { context } = route.params;
const { t } = useTranslation();
const [lastGameId, setLastGameId] = React.useState(undefined);
+ const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
@@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
navigation.replace('Loby', { context })}>
{t('NewGame')}} />
+
+ {choosingBotLevel ? (
+
+ {t('ChooseBotLevel')}
+ navigation.replace('Loby', { context, botLevel })} />
+
+ ) : (
+ setChoosingBotLevel(true)}>
+ {t('PlayWithBot')}} />
+
+ )}
{lastGameId && (
<>
diff --git a/apps/mobile/src/screens/LobyScreen.tsx b/apps/mobile/src/screens/LobyScreen.tsx
index 7c5bf78..76648d4 100644
--- a/apps/mobile/src/screens/LobyScreen.tsx
+++ b/apps/mobile/src/screens/LobyScreen.tsx
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { LobyScreenProps } from '../types';
import { useEffect } from 'react';
import { CommonMancalaGame } from 'mancala.js';
-import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core';
+import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core';
+import BotLevelPicker from '../components/BotLevelPicker';
import CircularPanel from '../components/CircularPanel';
import { getColorByBrightness } from "@mancala/core";
import { SvgXml } from 'react-native-svg';
@@ -12,7 +13,13 @@ import helpSvg from '../svg/help';
export default function LobyScreen({ navigation, route }: LobyScreenProps) {
- const { context } = route.params;
+ const { context, botLevel } = route.params;
+ // Shown when no opponent has been found for a while.
+ const [botOffered, setBotOffered] = React.useState(false);
+
+ const startBotGame = (level: BotLevel) => {
+ context.rtmt.sendMessage(channel_new_bot_game, { level });
+ }
const { t } = useTranslation();
const onGameStart = async (message: Object) => {
@@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
- context.rtmt.sendMessage("new_game", {});
+ let botOfferTimer: ReturnType | undefined;
+ if (botLevel) {
+ startBotGame(botLevel);
+ } else {
+ context.rtmt.sendMessage(channel_new_game, {});
+ botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
+ }
return () => {
+ if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
}
}, []);
@@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
return (
- {t('SearchingOpponent') + " " + t('PleaseWait') + "..."}} />
+ {(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}} />
+ {botOffered && (
+
+ {t('NoOpponentYet')}
+
+
+ )}
{t('Cancel')}} />
diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts
index 4b2db02..190597a 100644
--- a/apps/mobile/src/types/index.ts
+++ b/apps/mobile/src/types/index.ts
@@ -1,9 +1,11 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
+import { BotLevel } from '@mancala/core';
import { Context } from '../context/context';
export type RootStackParamList = {
Home: { context: Context },
- Loby: { context: Context },
+ // botLevel: start a game against the bot at once instead of searching for an opponent.
+ Loby: { context: Context, botLevel?: BotLevel },
Game: { context: Context, gameId: string, userKey: string }
Help: { context: Context }
};
diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md
index dc3a7d3..3459e5f 100644
--- a/docs/specs/game-bot/tasks.md
+++ b/docs/specs/game-bot/tasks.md
@@ -16,6 +16,6 @@ Work through the tasks in order; each ends with its tests passing and a commit.
`getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests.
- [x] 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.
-- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle.
+- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle.
- [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`,
`AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items).