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
9 changed files with 143 additions and 2 deletions
Showing only changes of commit 94fc7fcbc7 - Show all commits

View File

@ -9,7 +9,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on - [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
`new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests. `new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests.
- [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests. - [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests.
- [ ] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests. - [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
- [ ] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch - [ ] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
states, "Play again" as a rematch, the score in two columns. states, "Play again" as a rematch, the score in two columns.
- [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators. - [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.

View File

@ -176,4 +176,22 @@ export const en = {
LessonDone: "Done", LessonDone: "Done",
AllLessons: "All lessons", AllLessons: "All lessons",
LessonNumber: "Lesson {{number}} of {{count}}", LessonNumber: "Lesson {{number}} of {{count}}",
Rematch: "Rematch",
No: "No",
RematchWaiting: "Waiting for {{name}}…",
RematchRequested: "{{name}} wants a rematch",
RematchDeclined: "{{name}} declined the rematch",
RematchWithdrawn: "{{name}} withdrew the rematch",
RematchNoAnswer: "{{name}} did not answer",
RematchRequestExpired: "The rematch request expired",
RematchOpponentGone: "{{name}} is no longer here",
RematchClosed: "A rematch is no longer possible",
Series: "Series",
SeriesScoreText: "Series: {{first}} {{firstWins}} – {{secondWins}} {{second}}",
SeriesGameNumber: "Game {{number}}",
SeriesCurrentGame: "Now",
SeriesEarlierGames: "{{count}} earlier games",
MovedFirst: "Moved first",
EndedOnTime: "time",
EndedByLeaving: "left",
} }

View File

@ -176,4 +176,22 @@ export const tr = {
LessonDone: "Tamamlandı", LessonDone: "Tamamlandı",
AllLessons: "Tüm dersler", AllLessons: "Tüm dersler",
LessonNumber: "Ders {{number}} / {{count}}", LessonNumber: "Ders {{number}} / {{count}}",
Rematch: "Rövanş",
No: "Hayır",
RematchWaiting: "{{name}} bekleniyor…",
RematchRequested: "{{name}} rövanş istiyor",
RematchDeclined: "{{name}} rövanşı kabul etmedi",
RematchWithdrawn: "{{name}} rövanştan vazgeçti",
RematchNoAnswer: "{{name}} cevap vermedi",
RematchRequestExpired: "Rövanş isteğinin süresi doldu",
RematchOpponentGone: "{{name}} artık burada değil",
RematchClosed: "Rövanş artık mümkün değil",
Series: "Seri",
SeriesScoreText: "Seri: {{first}} {{firstWins}} – {{secondWins}} {{second}}",
SeriesGameNumber: "{{number}}. oyun",
SeriesCurrentGame: "Şimdi",
SeriesEarlierGames: "{{count}} önceki oyun",
MovedFirst: "İlk hamle",
EndedOnTime: "süre",
EndedByLeaving: "ayrıldı",
}; };

View File

@ -1,5 +1,6 @@
import { MancalaGame } from "mancala.js"; import { MancalaGame } from "mancala.js";
import { GameClock } from "./GameClock"; import { GameClock, Seat } from "./GameClock";
import { Series } from "./Series";
import { UserConnectionInfo } from "./UserConnectionInfo"; import { UserConnectionInfo } from "./UserConnectionInfo";
export interface Game { export interface Game {
@ -14,6 +15,10 @@ export interface Game {
clock?: GameClock; clock?: GameClock;
// Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock. // Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock.
serverTime?: number; serverTime?: number;
// The series and the games before this one (docs/specs/rematch); sent by servers with rematches.
series?: Series;
// The seat that moved first.
firstSeat?: Seat;
} }
export interface GameUsersConnectionInfo { export interface GameUsersConnectionInfo {

View File

@ -1,5 +1,6 @@
import { GameStep } from "mancala.js"; import { GameStep } from "mancala.js";
import { GameClock } from "./GameClock"; import { GameClock } from "./GameClock";
import { Series } from "./Series";
export interface PlayerSummary { export interface PlayerSummary {
userId: string; userId: string;
@ -57,6 +58,8 @@ export interface GameRecord extends FinishedGameSummary {
moves: MoveRecord[]; moves: MoveRecord[];
// Distinct users who watched the game. // Distinct users who watched the game.
totalSpectators: number; totalSpectators: number;
// Undefined for games saved before rematches.
seriesId?: string;
} }
// GET /game/:id for a game that has ended. // GET /game/:id for a game that has ended.
@ -74,4 +77,8 @@ export interface GameEndMessage {
leftUserId?: string; leftUserId?: string;
// The stopped clocks. // The stopped clocks.
clock?: GameClock; clock?: GameClock;
// The series with the game that ended.
series?: Series;
// "available" when the players can ask for a rematch, i.e. unless a player left.
rematch?: "available";
} }

View File

@ -0,0 +1,53 @@
import { EndReason, GameResult } from "./GameHistory";
import { Seat } from "./GameClock";
// Rematches and series (docs/specs/rematch).
// One finished game of a series. Seats, not user ids: the players keep their seats through a series.
export interface SeriesGame {
gameId: string;
firstSeat: Seat;
// Null when the game ended without a winner.
result: GameResult | null;
endReason: EndReason;
player1Store: number;
player2Store: number;
}
// A game and the rematches after it; `games` are the finished ones, oldest first.
export interface Series {
id: string;
games: SeriesGame[];
}
// on_rematch_request: the opponent asks for a rematch of the game that ended.
export interface RematchRequest {
gameId: string;
}
// declined: the opponent said no; withdrawn: the player who asked took it back; expired: no answer within a minute;
// offline: a player went offline; left: a player looked for another game; closed: a rematch is no longer possible.
export type RematchEndReason = "declined" | "withdrawn" | "expired" | "offline" | "left" | "closed";
// on_rematch_ended, to both players.
export interface RematchEnded {
gameId: string;
reason: RematchEndReason;
}
// After these, the series is over and only a new game is offered.
export function isSeriesOver(reason: RematchEndReason): boolean {
return reason === "declined" || reason === "left" || reason === "closed";
}
export interface SeriesScore {
// Games won by the seat and by the other seat; draws and games without a winner count for neither.
wins: number;
losses: number;
}
export function getSeriesScore(games: SeriesGame[], seat: Seat): SeriesScore {
const wins = games.filter((game) => game.result === seat).length;
const losses = games.filter((game) => game.result !== null && game.result !== "draw" && game.result !== seat).length;
return { wins, losses };
}

View File

@ -7,3 +7,4 @@ export * from './AuthUser'
export * from './GameHistory' export * from './GameHistory'
export * from './GameClock' export * from './GameClock'
export * from './Bot' export * from './Bot'
export * from './Series'

View File

@ -29,3 +29,12 @@ export const channel_unsubscribe_live_games = "unsubscribe_live_games"
export const channel_on_live_games = "on_live_games" export const channel_on_live_games = "on_live_games"
// server -> client: { gameId }, when new_game is sent while the user has a game in progress. // server -> client: { gameId }, when new_game is sent while the user has a game in progress.
export const channel_on_new_game_rejected = "on_new_game_rejected" export const channel_on_new_game_rejected = "on_new_game_rejected"
// Rematches (docs/specs/rematch). client -> server: rematch { gameId } asks for a rematch of an ended game or accepts
// the opponent's request; decline_rematch { gameId } says no or withdraws one's own request.
// server -> client: on_rematch_request (RematchRequest) when the opponent asks; on_rematch_ended (RematchEnded) to both
// players when a request or the chance of a rematch ends. The next game arrives as on_game_start.
export const channel_rematch = "rematch"
export const channel_decline_rematch = "decline_rematch"
export const channel_on_rematch_request = "on_rematch_request"
export const channel_on_rematch_ended = "on_rematch_ended"

View File

@ -0,0 +1,30 @@
import { getSeriesScore, isSeriesOver, SeriesGame } from "../../src/models";
import { en, tr } from "../../src/localization";
const game = (result: SeriesGame["result"]): SeriesGame =>
({ gameId: "g", firstSeat: "player1", result, endReason: "completed", player1Store: 24, player2Store: 24 });
describe("series", () => {
it("counts the wins of each seat, leaving out draws and games without a winner", () => {
const games = [game("player1"), game("player2"), game("draw"), game("player1"), game(null)];
expect(getSeriesScore(games, "player1")).toEqual({ wins: 2, losses: 1 });
expect(getSeriesScore(games, "player2")).toEqual({ wins: 1, losses: 2 });
expect(getSeriesScore([], "player1")).toEqual({ wins: 0, losses: 0 });
});
it("knows which endings of a request end the series", () => {
expect(["declined", "left", "closed"].every((reason) => isSeriesOver(reason as any))).toBe(true);
expect(["withdrawn", "expired", "offline"].some((reason) => isSeriesOver(reason as any))).toBe(false);
});
it("has every rematch text in English and Turkish", () => {
const keys = ["Rematch", "No", "RematchWaiting", "RematchRequested", "RematchDeclined", "RematchWithdrawn",
"RematchNoAnswer", "RematchRequestExpired", "RematchOpponentGone", "RematchClosed", "Series",
"SeriesScoreText", "SeriesGameNumber", "SeriesCurrentGame", "SeriesEarlierGames", "MovedFirst", "EndedOnTime",
"EndedByLeaving"];
for (const key of keys) {
expect(en).toHaveProperty(key);
expect(tr).toHaveProperty(key);
}
});
});