Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
@ -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ı",
|
||||||
};
|
};
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
53
packages/core/src/models/Series.ts
Normal file
53
packages/core/src/models/Series.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
30
packages/core/tests/models/Series.test.ts
Normal file
30
packages/core/tests/models/Series.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user