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
5 changed files with 315 additions and 3 deletions
Showing only changes of commit 9beb8ba7d4 - Show all commits

View File

@ -15,6 +15,8 @@ import { Theme } from '@mancala/core';
import LobyPage from './routes/LobyPage'; import LobyPage from './routes/LobyPage';
import PrivacyPage from './routes/PrivacyPage'; import PrivacyPage from './routes/PrivacyPage';
import RulesPage from './routes/RulesPage'; import RulesPage from './routes/RulesPage';
import LearnPage from './routes/LearnPage';
import LessonPage from './routes/LessonPage';
import swal from 'sweetalert'; import swal from 'sweetalert';
import Util from './util/Util'; import Util from './util/Util';
import { server } from './const/config'; import { server } from './const/config';
@ -132,6 +134,8 @@ const MancalaApp: FunctionComponent = () => {
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}> <Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
</Route> </Route>
<Route path="rules" element={<RulesPage context={context} theme={theme} />} /> <Route path="rules" element={<RulesPage context={context} theme={theme} />} />
<Route path="learn" element={<LearnPage context={context} theme={theme} />} />
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter>} </BrowserRouter>}

View File

@ -0,0 +1,94 @@
import * as React from "react";
import { FunctionComponent, useEffect } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getCompletedLessons, hideTutorialBanner, LESSON_RULES, Theme } from "@mancala/core";
import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer";
import { Context } from "../context/context";
// The lessons in order, the ones done marked (docs/specs/tutorial, R2.6).
const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
const { t } = useTranslation();
useEffect(() => hideTutorialBanner(context.storage), []);
const completed = getCompletedLessons(context.storage);
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap learn">
<h1>{t("LearnMangala")}</h1>
<ol className="lessons">
{LESSON_RULES.map((rule, index) => {
const done = completed.includes(rule.id);
return (
<li key={rule.id}>
<Link className={"lesson-link panel" + (done ? " done" : "")} to={`/learn/${rule.id}`}>
<span className="num">{done ? "✓" : index + 1}</span>
<span className="name">{t(rule.titleKey)}</span>
{done && <span className="pill">{t("LessonDone")}</span>}
</Link>
</li>
);
})}
</ol>
<Link className="rules-link" to="/rules">{t("HowToPlay")}</Link>
</main>
<style jsx>{`
.learn {
max-width: 760px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
h1 {
margin: 0;
font-size: 30px;
letter-spacing: -.02em;
}
.lessons {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.learn :global(.lesson-link) {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
color: var(--ink);
text-decoration: none;
font-weight: 700;
}
.learn :global(.lesson-link:hover) {
background: var(--surface2);
}
.learn :global(.num) {
width: 32px;
height: 32px;
flex: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface2);
}
.learn :global(.done .num) {
background: var(--accent);
color: var(--onAccent);
}
.learn :global(.name) {
flex: 1;
}
.learn :global(.rules-link) {
color: var(--muted);
font-weight: 700;
}
`}</style>
</PageContainer>
);
};
export default LearnPage;

View File

@ -0,0 +1,201 @@
import * as React from "react";
import { FunctionComponent, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { CommonMancalaGame } from "mancala.js";
import {
BoardViewModel, completeLesson, explainMove, fulfilsTask, Game, getMoveOutcome, getNextLessonRule, getRule,
hideTutorialBanner, LESSON_RULES, MoveOutcome, PitAnimator, Theme,
} from "@mancala/core";
import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer";
import BoardView from "../components/board/BoardView";
import { Context } from "../context/context";
const YOU = "you";
const OPPONENT = "opponent";
// A game showing the lesson's board, with the real rules, the player at the bottom to move.
function createLessonGame(board: number[]): Game {
const mancalaGame = new CommonMancalaGame("lesson", YOU, OPPONENT);
board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
mancalaGame.turnPlayerId = YOU;
mancalaGame.state = "playing";
return {
id: "lesson",
mancalaGame,
gameUsersConnectionInfo: {
user1ConnectionInfo: { userId: YOU, isOnline: true },
user2ConnectionInfo: { userId: OPPONENT, isOnline: true },
},
};
}
type Phase = "playing" | "animating" | "done";
// One rule taught on a prepared board (docs/specs/tutorial, R2). Everything runs in the browser on the TypeScript
// engine; nothing is sent to the server.
const LessonPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { ruleId } = useParams();
const rule = getRule(ruleId);
const lesson = rule?.lesson;
const [game, setGame] = useState<Game | undefined>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
const [phase, setPhase] = useState<Phase>("playing");
const [outcome, setOutcome] = useState<MoveOutcome | undefined>(undefined);
const [playedIndex, setPlayedIndex] = useState<number | undefined>(undefined);
const animator = useRef<PitAnimator | undefined>(undefined);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const start = () => {
if (!lesson || !animator.current) return;
if (timer.current) clearTimeout(timer.current);
const lessonGame = createLessonGame(lesson.board);
setGame(lessonGame);
setOutcome(undefined);
setPhase("playing");
animator.current.setNewGame(lessonGame);
};
useEffect(() => {
hideTutorialBanner(context.storage);
animator.current = new PitAnimator(context.themeManager, (model) => {
model.id = v4();
setBoardViewModel(model);
});
start();
return () => {
if (timer.current) clearTimeout(timer.current);
animator.current?.dispose();
};
}, [ruleId]);
if (!rule || !lesson) {
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap"><Link to="/learn">{t("AllLessons")}</Link></main>
</PageContainer>
);
}
const onPitSelect = (index: number) => {
if (phase !== "playing" || !game || !(lesson.board[index] > 0) || index > 5) return;
const moveOutcome = getMoveOutcome(lesson.board, index);
const played = createLessonGame(lesson.board);
played.mancalaGame.moveByPlayerPit(YOU, index);
setGame(played);
setPlayedIndex(index);
setPhase("animating");
animator.current?.setUpdatedGame(played);
const steps = played.mancalaGame.history[0].gameSteps.length;
timer.current = setTimeout(() => {
setOutcome(moveOutcome);
setPhase("done");
if (fulfilsTask(lesson.task, moveOutcome)) completeLesson(context.storage, rule.id);
}, (steps + 1) * animator.current!.animationInterval);
};
const fulfilled = outcome !== undefined && fulfilsTask(lesson.task, outcome);
const next = getNextLessonRule(rule.id);
const number = LESSON_RULES.findIndex((r) => r.id === rule.id) + 1;
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap lesson">
<div className="lhead">
<Link to="/learn" className="crumb">{t("LessonNumber", { number, count: LESSON_RULES.length })}</Link>
<h1>{t(rule.titleKey)}</h1>
{rule.textKeys.map((key) => <p key={key}>{t(key)}</p>)}
</div>
<div className={"task panel" + (fulfilled ? " ok" : "")} aria-live="polite">
{phase !== "done" && <strong>{t(lesson.taskKey)}</strong>}
{phase === "done" && outcome && (
<>
{fulfilled && <strong>{t("WellDone")}</strong>}
<span>{explainMove(outcome, "you", t)}</span>
<div className="actions">
{fulfilled && next && (
<button className="btn pri" onClick={() => navigate(`/learn/${next.id}`)}>{t("NextLesson")}</button>
)}
{fulfilled && !next && (
<button className="btn pri" onClick={() => navigate("/learn")}>{t("AllLessons")}</button>
)}
<button className={"btn " + (fulfilled ? "ghost" : "pri")} onClick={start}>{t("TryAgain")}</button>
</div>
</>
)}
</div>
{game && boardViewModel && (
<div className="lboard">
<BoardView
game={game}
boardViewModel={boardViewModel}
context={context}
revert={false}
topName={t("Opponent")}
bottomName={t("You")}
onPitSelect={(index) => onPitSelect(index)}
canPlay={phase === "playing"}
lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} />
</div>
)}
</main>
<style jsx>{`
.lesson {
max-width: 980px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
.lhead {
display: flex;
flex-direction: column;
gap: 8px;
}
.lesson :global(.crumb) {
color: var(--muted);
font-weight: 700;
font-size: 14px;
text-decoration: none;
}
h1 {
margin: 0;
font-size: 28px;
letter-spacing: -.02em;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.task {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
}
.task.ok {
box-shadow: inset 0 0 0 2px var(--accent);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.lboard {
position: relative;
min-width: 0;
flex-shrink: 0;
}
`}</style>
</PageContainer>
);
};
export default LessonPage;

View File

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Theme } from "@mancala/core"; import { Theme } from "@mancala/core";
import AppHeader from "../components/headerbar/AppHeader"; import AppHeader from "../components/headerbar/AppHeader";
@ -14,8 +15,11 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
<PageContainer> <PageContainer>
<AppHeader context={context} /> <AppHeader context={context} />
<main className="wrap rules-page"> <main className="wrap rules-page">
<h1>{t("HowToPlay")}</h1> <div className="rtitle">
<RulesView /> <h1>{t("HowToPlay")}</h1>
<Link className="btn pri" to="/learn">{t("Lessons")}</Link>
</div>
<RulesView showLessons />
</main> </main>
<style jsx>{` <style jsx>{`
.rules-page { .rules-page {
@ -24,6 +28,15 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
box-sizing: border-box; box-sizing: border-box;
align-self: center; align-self: center;
} }
.rtitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.rules-page :global(.rtitle .btn) {
text-decoration: none;
}
h1 { h1 {
margin: 0; margin: 0;
font-size: 30px; font-size: 30px;

View File

@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests.
- [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the - [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
game page. game page.
- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and - [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
reset, scripted opponent replies. reset, scripted opponent replies.
- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. - [ ] 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`. - [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.