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
6 changed files with 84 additions and 13 deletions
Showing only changes of commit d1918fed10 - Show all commits

View File

@ -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 (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{BOT_LEVELS.map((level) => (
<Pressable key={level} accessibilityRole="button" onPress={() => {
setLastBotLevel(context.storage, level);
onChoose(level);
}}>
<CircularPanel
color={level === lastLevel ? theme.boardColor : theme.background}
style={{ borderWidth: 1, borderColor: theme.boardColor }}
children={<Text style={{ color: theme.textColor }}>{t(getBotLevelTextKey(level))}</Text>} />
</Pressable>
))}
</View>
);
};
export default BotLevelPicker;

View File

@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GameScreenProps } from '../types'; import { GameScreenProps } from '../types';
import { useState } from 'react'; 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 { MancalaGame, Pit } from 'mancala.js';
import { v4 } from 'uuid'; 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'; 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 = () => { const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return;
context.storage.deleteString('lastGameId'); 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) => { const onPitSelect = (index: number, pit: Pit) => {
@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
onLeaveGameClick(); onLeaveGameClick();
} }
}}> }}>
<CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}</Text>} /> <CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}</Text>} />
</Pressable> </Pressable>
<Pressable onPress={() => { <Pressable onPress={() => {
navigation.push("Help", { context }) navigation.push("Help", { context })
@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
const showBoardView = game && boardViewModel && userKey && true; const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; 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 = { const topLocatedUser = {
id: topLocatedUserId, id: topLocatedUserId,
name: "Anonymous", name: topBotName ?? "Anonymous",
isOnline: isUserOnline(topLocatedUserId), isOnline: isUserOnline(topLocatedUserId),
isAnonymous: true isAnonymous: !topBotName
}; };
const bottomLocatedUser = { const bottomLocatedUser = {
id: bottomLocatedUserId, id: bottomLocatedUserId,
name: "Anonymous", name: bottomBotName ?? "Anonymous",
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
isAnonymous: true isAnonymous: !bottomBotName
}; };
const currentUser = isSpectator ? { const currentUser = isSpectator ? {
id: "2", id: "2",

View File

@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { HomeScreenProps } from '../types'; import { HomeScreenProps } from '../types';
import CircularPanel from '../components/CircularPanel'; import CircularPanel from '../components/CircularPanel';
import BotLevelPicker from '../components/BotLevelPicker';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import helpSvg from '../svg/help'; import helpSvg from '../svg/help';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
const { context } = route.params; const { context } = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined); const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined);
const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
const textColorOnAppBar = getColorByBrightness( const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor, context.themeManager.theme.appBarBgColor,
@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
<Pressable onPress={() => navigation.replace('Loby', { context })}> <Pressable onPress={() => navigation.replace('Loby', { context })}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} />
</Pressable> </Pressable>
<View style={{ height: 20 }} />
{choosingBotLevel ? (
<View style={{ alignItems: 'center', gap: 10, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('ChooseBotLevel')}</Text>
<BotLevelPicker context={context} onChoose={(botLevel) => navigation.replace('Loby', { context, botLevel })} />
</View>
) : (
<Pressable onPress={() => setChoosingBotLevel(true)}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('PlayWithBot')}</Text>} />
</Pressable>
)}
{lastGameId && ( {lastGameId && (
<> <>
<View style={{ height: 20 }} /> <View style={{ height: 20 }} />

View File

@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { LobyScreenProps } from '../types'; import { LobyScreenProps } from '../types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { CommonMancalaGame } from 'mancala.js'; 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 CircularPanel from '../components/CircularPanel';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
@ -12,7 +13,13 @@ import helpSvg from '../svg/help';
export default function LobyScreen({ navigation, route }: LobyScreenProps) { 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 { t } = useTranslation();
const onGameStart = async (message: Object) => { const onGameStart = async (message: Object) => {
@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.sendMessage("new_game", {}); let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => { return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
} }
}, []); }, []);
@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}> <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{t('SearchingOpponent') + " " + t('PleaseWait') + "..."}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}</Text>} />
{botOffered && (
<View style={{ alignItems: 'center', gap: 10, marginTop: 20, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('NoOpponentYet')}</Text>
<BotLevelPicker context={context} onChoose={startBotGame} />
</View>
)}
<View style={{height: 10}}></View> <View style={{height: 10}}></View>
<Pressable onPress={onCancelNewGameClick}> <Pressable onPress={onCancelNewGameClick}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} />

View File

@ -1,9 +1,11 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BotLevel } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
export type RootStackParamList = { export type RootStackParamList = {
Home: { context: Context }, 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 } Game: { context: Context, gameId: string, userKey: string }
Help: { context: Context } Help: { context: Context }
}; };

View File

@ -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. `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 - [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. 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`, - [ ] 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). `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items).