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 130 additions and 4 deletions
Showing only changes of commit ed2a909f16 - Show all commits

View File

@ -187,7 +187,6 @@ export class GameManager {
public startGame(game: BackendGame) { public startGame(game: BackendGame) {
this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, game); this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, game);
this.sendUserConnectionInfo(game); this.sendUserConnectionInfo(game);
this.sendUserConnectionInfo(game);
} }
public sendUserConnectionInfo(game: BackendGame) { public sendUserConnectionInfo(game: BackendGame) {
@ -199,6 +198,7 @@ export class GameManager {
if (game) { if (game) {
this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id); this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id);
this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id); this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id);
this.gameStore.remove(game.id);
} }
} }

View File

@ -38,8 +38,7 @@ export class GameStoreImpl implements GameStore {
getSpeactatingGameIdsByUser(userId: string): string[] { getSpeactatingGameIdsByUser(userId: string): string[] {
const speactatingGameIds = []; const speactatingGameIds = [];
for (const gameId in this.gameStore.keys()) { for (const game of this.gameStore.values()) {
const game = this.gameStore.get(gameId) as BackendGame;
const isSpectator = game.spectatorIds.find((value) => value === userId); const isSpectator = game.spectatorIds.find((value) => value === userId);
isSpectator && speactatingGameIds.push(game.id); isSpectator && speactatingGameIds.push(game.id);
} }

View File

@ -0,0 +1,53 @@
import { AuthUser } from "../../src/auth";
import { OnClientConnectionChange, OnMessage, RTMT } from "../../src/rtmt/rtmt";
export type SentMessage = { clientID: string, channel: string, message: any };
// In-memory RTMT: tests connect users, send messages as them and inspect what the server sent back.
export class FakeRtmt implements RTMT {
readonly sent: SentMessage[] = [];
private readonly listeners = new Map<string, OnMessage>();
private readonly online = new Map<string, AuthUser>();
private onConnectionChange?: OnClientConnectionChange;
connect(user: AuthUser) {
this.online.set(user.id, user);
this.onConnectionChange?.(user.id, true);
}
disconnect(userId: string) {
this.online.delete(userId);
this.onConnectionChange?.(userId, false);
}
receive(userId: string, channel: string, message: Object = {}) {
const listener = this.listeners.get(channel);
if (!listener) throw new Error(`No listener for ${channel}`);
listener(userId, message);
}
messagesTo(userId: string, channel: string): any[] {
return this.sent.filter((m) => m.clientID === userId && m.channel === channel).map((m) => m.message);
}
sendMessage(clientID: string, channel: string, message: Object) {
// Copy, like serialising over the wire would.
this.sent.push({ clientID, channel, message: JSON.parse(JSON.stringify(message)) });
}
listenMessage(channel: string, callback: OnMessage) {
this.listeners.set(channel, callback);
}
isClientOnline(clientID: string): boolean {
return this.online.has(clientID);
}
getUser(clientID: string): AuthUser | undefined {
return this.online.get(clientID);
}
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void {
this.onConnectionChange = onUserConnectionChange;
}
}

View File

@ -0,0 +1,74 @@
import {
channel_game_move,
channel_leave_game,
channel_listen_game_events,
channel_new_game,
channel_on_game_start,
channel_on_user_connection_change,
} from "@mancala/core";
import { GameManager } from "../../src/game/GameManager";
import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl";
import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl";
import { BackendGame } from "../../src/models/Game";
import { FakeRtmt } from "./FakeRtmt";
const ALICE = { id: "kc:alice", name: "alice", isGuest: false };
const BOB = { id: "kc:bob", name: "bob", isGuest: false };
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
describe("GameManager", () => {
let rtmt: FakeRtmt;
let gameStore: GameStoreImpl;
beforeEach(() => {
rtmt = new FakeRtmt();
gameStore = new GameStoreImpl();
new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }) });
[ALICE, BOB, CAROL].forEach((user) => rtmt.connect(user));
});
const startGame = (): BackendGame => {
rtmt.receive(ALICE.id, channel_new_game);
rtmt.receive(BOB.id, channel_new_game);
const game = gameStore.getGameByUser(ALICE.id);
if (!game) throw new Error("game did not start");
return game;
};
it("sends the connection info once when a game starts", () => {
startGame();
expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)).toHaveLength(1);
expect(rtmt.messagesTo(ALICE.id, channel_on_user_connection_change)).toHaveLength(1);
});
it("removes a game from memory when a player leaves", () => {
const game = startGame();
rtmt.receive(ALICE.id, channel_leave_game);
expect(gameStore.get(game.id)).toBeUndefined();
expect(gameStore.getGameByUser(BOB.id)).toBeUndefined();
});
it("removes a game from memory when it ends by the rules", () => {
const game = startGame();
// Alice has a single stone in her last pit, next to her store; playing it empties her side.
const aliceIsPlayer1 = game.mancalaGame.player1Id === ALICE.id;
const board = game.mancalaGame.board;
board.pits.forEach((pit) => (pit.stoneCount = 0));
board.pits[aliceIsPlayer1 ? 5 : 12].stoneCount = 1;
board.pits[aliceIsPlayer1 ? 7 : 0].stoneCount = 1;
game.mancalaGame.turnPlayerId = ALICE.id;
rtmt.receive(ALICE.id, channel_game_move, { index: 5 });
expect(game.mancalaGame.state).toBe("ended");
expect(gameStore.get(game.id)).toBeUndefined();
});
it("stops counting a spectator who disconnects", () => {
const game = startGame();
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
expect(game.spectatorIds).toEqual([CAROL.id]);
expect(gameStore.getSpeactatingGameIdsByUser(CAROL.id)).toEqual([game.id]);
rtmt.disconnect(CAROL.id);
expect(game.spectatorIds).toEqual([]);
});
});

View File

@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`, - [x] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`,
`TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`, `TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`,
`createGameAtMove` with unit tests; localized strings. `createGameAtMove` with unit tests; localized strings.
- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection - [x] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection
info once at game start. info once at game start.
- [ ] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository` - [ ] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository`
(save, get, recent for user, popular), integration tests. (save, get, recent for user, popular), integration tests.