From 94fc7fcbc703859c9ee402525c318711a703a6b7 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Sat, 17 Oct 2026 02:05:12 +0300 Subject: [PATCH] feat(core): add rematch channels, series types and texts The four rematch channels, Series and SeriesGame, the series and first seat of a game, getSeriesScore and the texts in English and Turkish. --- docs/specs/rematch/tasks.md | 2 +- packages/core/src/localization/en.ts | 18 ++++++++ packages/core/src/localization/tr.ts | 18 ++++++++ packages/core/src/models/Game.ts | 7 ++- packages/core/src/models/GameHistory.ts | 7 +++ packages/core/src/models/Series.ts | 53 +++++++++++++++++++++++ packages/core/src/models/index.ts | 1 + packages/core/src/rtmt/channel_names.ts | 9 ++++ packages/core/tests/models/Series.test.ts | 30 +++++++++++++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/models/Series.ts create mode 100644 packages/core/tests/models/Series.test.ts diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index 6e68bae..57db6f8 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -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. diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index dec8451..36c277c 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -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", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index fe64c7b..83d6bdc 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -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ı", }; \ No newline at end of file diff --git a/packages/core/src/models/Game.ts b/packages/core/src/models/Game.ts index bc496a6..45421d6 100644 --- a/packages/core/src/models/Game.ts +++ b/packages/core/src/models/Game.ts @@ -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 { diff --git a/packages/core/src/models/GameHistory.ts b/packages/core/src/models/GameHistory.ts index 4f15377..f8830b6 100644 --- a/packages/core/src/models/GameHistory.ts +++ b/packages/core/src/models/GameHistory.ts @@ -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"; } diff --git a/packages/core/src/models/Series.ts b/packages/core/src/models/Series.ts new file mode 100644 index 0000000..a052791 --- /dev/null +++ b/packages/core/src/models/Series.ts @@ -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 }; +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 9745bb5..ec0ae1c 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -7,3 +7,4 @@ export * from './AuthUser' export * from './GameHistory' export * from './GameClock' export * from './Bot' +export * from './Series' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index ecb9e34..e3559d8 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -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" diff --git a/packages/core/tests/models/Series.test.ts b/packages/core/tests/models/Series.test.ts new file mode 100644 index 0000000..2802655 --- /dev/null +++ b/packages/core/tests/models/Series.test.ts @@ -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); + } + }); +});