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
|
||||
`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.
|
||||
- [ ] 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
|
||||
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.
|
||||
|
||||
@ -176,4 +176,22 @@ export const en = {
|
||||
LessonDone: "Done",
|
||||
AllLessons: "All lessons",
|
||||
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ı",
|
||||
AllLessons: "Tüm dersler",
|
||||
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 { GameClock } from "./GameClock";
|
||||
import { GameClock, Seat } from "./GameClock";
|
||||
import { Series } from "./Series";
|
||||
import { UserConnectionInfo } from "./UserConnectionInfo";
|
||||
|
||||
export interface Game {
|
||||
@ -14,6 +15,10 @@ export interface Game {
|
||||
clock?: GameClock;
|
||||
// Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock.
|
||||
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 {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { GameStep } from "mancala.js";
|
||||
import { GameClock } from "./GameClock";
|
||||
import { Series } from "./Series";
|
||||
|
||||
export interface PlayerSummary {
|
||||
userId: string;
|
||||
@ -57,6 +58,8 @@ export interface GameRecord extends FinishedGameSummary {
|
||||
moves: MoveRecord[];
|
||||
// Distinct users who watched the game.
|
||||
totalSpectators: number;
|
||||
// Undefined for games saved before rematches.
|
||||
seriesId?: string;
|
||||
}
|
||||
|
||||
// GET /game/:id for a game that has ended.
|
||||
@ -74,4 +77,8 @@ export interface GameEndMessage {
|
||||
leftUserId?: string;
|
||||
// The stopped clocks.
|
||||
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 './GameClock'
|
||||
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"
|
||||
// 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"
|
||||
|
||||
// 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