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
7 changed files with 79 additions and 7 deletions
Showing only changes of commit f8ce8f215f - Show all commits

View File

@ -165,6 +165,28 @@
pointer-events: none;
}
.hintchip {
position: absolute;
left: 50%;
top: -10px;
transform: translateX(-50%);
max-width: 120%;
padding: 2px 8px;
box-sizing: border-box;
border-radius: 10px;
background: var(--surface2);
color: var(--accent);
box-shadow: inset 0 0 0 1.5px var(--accent);
font-weight: 800;
font-size: 11px;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 3;
pointer-events: none;
}
@container (max-width: 600px) {
.board {
--pw: min(170px, calc((100cqw - 70px) / 2));

View File

@ -27,7 +27,9 @@ const BoardView: FunctionComponent<{
lastMoveIndex?: number;
// Board index where the shown move's last stone landed.
landIndex?: number;
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => {
// What the bottom player's moves would do, by board index (hints in games against the bot).
hints?: Record<number, string>;
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => {
const { t } = useTranslation();
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
const board = game.mancalaGame.board;
@ -45,6 +47,7 @@ const BoardView: FunctionComponent<{
preview: preview[index],
land: previewLand !== undefined ? index === previewLand : index === landIndex,
last: index === lastMoveIndex,
hint: hints?.[index],
});
const topPits = revert ? board.player1Pits : board.player2Pits;

View File

@ -11,6 +11,8 @@ export type PitMarks = {
land?: boolean;
// The last move was played from here.
last?: boolean;
// What playing this pit would do, in games against the bot with hints on, e.g. "Extra turn".
hint?: string;
};
export function getMarkClasses(marks: PitMarks): string {
@ -47,6 +49,7 @@ const PitView: FunctionComponent<{
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
{!!marks.hint && <span className="hintchip">{marks.hint}</span>}
</>
);
if (!onClick) {

View File

@ -9,7 +9,7 @@ import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import AppHeader from '../components/headerbar/AppHeader';
import { LeaveIcon, PlusIcon } from '../components/Icons';
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
import { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
@ -64,6 +64,8 @@ const GamePage: FunctionComponent<{
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
// The rules over the game; the game goes on behind them.
const [rulesOpen, setRulesOpen] = useState(false);
// Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device.
const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage));
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
@ -278,6 +280,34 @@ const GamePage: FunctionComponent<{
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
const isReviewing = viewedMove !== undefined && viewedMove < moves.length;
// Hints are only for games against the bot, never between people (R3.5).
const hintsOn = !!opponentBotLevel && hintsEnabled;
const toggleHints = () => {
setHintsEnabled(context.storage, !hintsEnabled);
setHintsEnabledState(!hintsEnabled);
};
const getHints = (): Record<number, string> | undefined => {
if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined;
if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined;
const stones = mancalaGame.board.getStoneArray();
const first = isPlayer2 ? 7 : 0;
const hints: Record<number, string> = {};
for (let index = first; index < first + 6; index++) {
if (!stones[index]) continue;
const outcome = getMoveOutcome(stones, index);
if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured });
else if (outcome.extraTurn) hints[index] = t("HintExtraTurn");
}
return hints;
};
// What the last move did and why, under the board (R3.3).
const getLastMoveExplanation = (): string | undefined => {
if (!hintsOn || moves.length === 0 || isReviewing) return undefined;
const last = moves[moves.length - 1];
const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex);
return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t);
};
const shownBoardViewModel = isReviewing && mancalaGame
? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme)
: boardViewModel;
@ -351,6 +381,7 @@ const GamePage: FunctionComponent<{
const renderGame = () => {
const board = mancalaGame!.board;
const lastMoveExplanation = getLastMoveExplanation();
const [topStore, bottomStore] = isPlayer2
? [board.player1BankIndex(), board.player2BankIndex()]
: [board.player2BankIndex(), board.player1BankIndex()];
@ -373,6 +404,11 @@ const GamePage: FunctionComponent<{
{!isGameOver && <span className="dot" />}
{getTurnText()}
</div>
{opponentBotLevel && (
<button className={"btn hints-toggle " + (hintsOn ? "pri" : "ghost")} aria-pressed={hintsOn} onClick={toggleHints}>
{t("Hints")}
</button>
)}
</div>
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
<div className="a-board">
@ -391,7 +427,9 @@ const GamePage: FunctionComponent<{
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} />
revert={isPlayer2}
hints={getHints()} />
{lastMoveExplanation && <p className="hint-line" aria-live="polite">{lastMoveExplanation}</p>}
{isGameOver && !gameOverClosed && (
<GameOverCard
title={resultTitle ?? t("GameEnded")}

View File

@ -10,7 +10,7 @@
}
.a-opp { grid-area: opp; min-width: 0; }
.a-turn { grid-area: turn; min-width: 0; }
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.a-me { grid-area: me; justify-self: end; min-width: 0; }
.a-board { grid-area: board; position: relative; min-width: 0; }
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
@ -100,6 +100,11 @@
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
/* Hints in games against the bot */
.hints-toggle { min-height: 36px; padding: 0 14px; font-size: 14px; }
.hint-line { margin: 14px 0 0; text-align: center; color: var(--muted); font-weight: 600; line-height: 1.45; }
/* Review banner, above the board */
.review {

View File

@ -123,8 +123,9 @@ Components:
`explainMove` and "Try again", which resets the board.
- `TutorialBanner` on the home page for newcomers (R4).
- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map
(pit → "Extra turn" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with
`explainMove` of the last move. A "Hints" switch in the game page's toolbar, only in bot games.
(pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below
the board a line with `explainMove` of the last move. A "Hints" switch next to the turn indicator, only in bot
games; hidden while looking back at earlier moves.
- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`.
## Mobile app

View File

@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
reset, scripted opponent replies.
- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
- [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
items).