fix(backend): clean up spectators and finished games
- Spectators are removed from games when they disconnect; the lookup iterated an iterator with for...in and never found anything - Finished and left games are removed from memory, not only from the player mappings - The connection info is sent once when a game starts
This commit is contained in:
parent
16026fb6cb
commit
ed2a909f16
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
53
apps/backend/tests/game/FakeRtmt.ts
Normal file
53
apps/backend/tests/game/FakeRtmt.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/backend/tests/game/GameManager.test.ts
Normal file
74
apps/backend/tests/game/GameManager.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user