Skip to content

Commit ad8bbab

Browse files
authored
feat: implement simplified saving of unranked matches and duels (#219)
* feat: implement simplified saving of unranked matches and duels
1 parent 830be30 commit ad8bbab

File tree

10 files changed

+405
-7
lines changed

10 files changed

+405
-7
lines changed

src/shared/room/domain/YgoRoom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export abstract class YgoRoom {
111111
ipAddress: client.socket.remoteAddress ?? null,
112112
}));
113113

114-
this._match.duelWinner(winner, 0, ips);
114+
this._match.duelWinner(winner, this.turn, ips);
115115
}
116116

117117
get matchPlayersHistory(): PlayerData[] {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { mock, MockProxy } from "jest-mock-extended";
2+
import { Logger } from "src/shared/logger/domain/Logger";
3+
import { UnrankedMatchRepository } from "../domain/UnrankedMatchRepository";
4+
import { UnrankedMatchSaver } from "./UnrankedMatchSaver";
5+
import { GameOverDomainEvent } from "src/shared/room/domain/match/domain/domain-events/GameOverDomainEvent";
6+
import { Team } from "src/shared/room/Team";
7+
import { PlayerMatchSummary } from "src/shared/player/domain/Player";
8+
9+
describe("UnrankedMatchSaver", () => {
10+
let logger: MockProxy<Logger>;
11+
let unrankedMatchRepository: MockProxy<UnrankedMatchRepository>;
12+
let unrankedMatchSaver: UnrankedMatchSaver;
13+
14+
beforeEach(() => {
15+
logger = mock<Logger>();
16+
logger.child.mockReturnValue(logger);
17+
unrankedMatchRepository = mock<UnrankedMatchRepository>();
18+
unrankedMatchSaver = new UnrankedMatchSaver(
19+
logger,
20+
unrankedMatchRepository
21+
);
22+
});
23+
24+
it("should NOT save if match is ranked", async () => {
25+
const event = new GameOverDomainEvent({
26+
ranked: true,
27+
players: [],
28+
bestOf: 1,
29+
date: new Date(),
30+
banListHash: 123,
31+
});
32+
33+
await unrankedMatchSaver.handle(event);
34+
35+
expect(unrankedMatchRepository.saveMatch).not.toHaveBeenCalled();
36+
expect(unrankedMatchRepository.saveDuel).not.toHaveBeenCalled();
37+
});
38+
39+
it("should save single match record and duels if match is unranked", async () => {
40+
const playerTeam0: PlayerMatchSummary = {
41+
id: null,
42+
name: "Player0",
43+
team: Team.PLAYER,
44+
winner: true,
45+
games: [
46+
{
47+
result: "winner",
48+
turns: 5,
49+
ipAddress: "127.0.0.1",
50+
},
51+
],
52+
score: 1,
53+
};
54+
55+
const playerTeam1: PlayerMatchSummary = {
56+
id: null,
57+
name: "Player1",
58+
team: Team.OPPONENT,
59+
winner: false,
60+
games: [
61+
{
62+
result: "loser",
63+
turns: 5,
64+
ipAddress: "127.0.0.2",
65+
},
66+
],
67+
score: 0,
68+
};
69+
70+
const event = new GameOverDomainEvent({
71+
ranked: false,
72+
players: [playerTeam0, playerTeam1],
73+
bestOf: 1,
74+
date: new Date(),
75+
banListHash: 123,
76+
});
77+
78+
await unrankedMatchSaver.handle(event);
79+
80+
expect(unrankedMatchRepository.saveMatch).toHaveBeenCalledTimes(1);
81+
expect(unrankedMatchRepository.saveDuel).toHaveBeenCalledTimes(1);
82+
83+
const capturedMatch = unrankedMatchRepository.saveMatch.mock.calls[0][0];
84+
expect(capturedMatch.team0Score).toBe(1);
85+
expect(capturedMatch.team1Score).toBe(0);
86+
expect(capturedMatch.winnerTeam).toBe(0);
87+
expect(capturedMatch.playerNames).toContain("Player0");
88+
expect(capturedMatch.opponentNames).toContain("Player1");
89+
90+
const capturedDuel = unrankedMatchRepository.saveDuel.mock.calls[0][0];
91+
expect(capturedDuel.team0Score).toBe(1);
92+
expect(capturedDuel.team1Score).toBe(0);
93+
expect(capturedDuel.winnerTeam).toBe(0);
94+
expect(capturedDuel.banListName).toBe("N/A");
95+
});
96+
97+
it("should NOT save if players are missing from one team", async () => {
98+
const playerTeam0: PlayerMatchSummary = {
99+
id: null,
100+
name: "Player0",
101+
team: Team.PLAYER,
102+
winner: true,
103+
games: [],
104+
score: 0,
105+
};
106+
107+
const event = new GameOverDomainEvent({
108+
ranked: false,
109+
players: [playerTeam0],
110+
bestOf: 1,
111+
date: new Date(),
112+
banListHash: 123,
113+
});
114+
115+
await unrankedMatchSaver.handle(event);
116+
117+
expect(unrankedMatchRepository.saveMatch).not.toHaveBeenCalled();
118+
});
119+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { randomUUID } from "node:crypto";
2+
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
3+
import { DomainEventSubscriber } from "src/shared/event-bus/EventBus";
4+
import { Logger } from "src/shared/logger/domain/Logger";
5+
import { GameOverDomainEvent } from "src/shared/room/domain/match/domain/domain-events/GameOverDomainEvent";
6+
import { config } from "src/config";
7+
import { UnrankedMatchRepository } from "../domain/UnrankedMatchRepository";
8+
import { UnrankedMatch } from "../domain/UnrankedMatch";
9+
import { UnrankedDuel } from "../domain/UnrankedDuel";
10+
import { Team } from "src/shared/room/Team";
11+
12+
export class UnrankedMatchSaver implements DomainEventSubscriber<GameOverDomainEvent> {
13+
static readonly ListenTo = GameOverDomainEvent.DOMAIN_EVENT;
14+
15+
constructor(
16+
private readonly logger: Logger,
17+
private readonly unrankedMatchRepository: UnrankedMatchRepository
18+
) {
19+
this.logger = logger.child({ file: "UnrankedMatchSaver" });
20+
}
21+
22+
async handle(event: GameOverDomainEvent): Promise<void> {
23+
if (event.data.ranked) {
24+
return;
25+
}
26+
27+
const team0Players = event.data.players.filter((p) => p.team === Team.PLAYER);
28+
const team1Players = event.data.players.filter((p) => p.team === Team.OPPONENT);
29+
30+
if (team0Players.length === 0 || team1Players.length === 0) {
31+
return;
32+
}
33+
34+
this.logger.info(
35+
`Processing unranked match: Team 0 (${team0Players.map(p => p.name).join(", ")}) vs Team 1 (${team1Players.map(p => p.name).join(", ")})`
36+
);
37+
38+
const gameId = randomUUID();
39+
const banList = BanListMemoryRepository.findByHash(event.data.banListHash);
40+
const banListName = banList?.name ?? "N/A";
41+
const banListHash = event.data.banListHash.toString();
42+
43+
// Use the first player of Team 0 as reference for scores and result
44+
const referencePlayer = team0Players[0];
45+
const matchId = randomUUID();
46+
47+
const unrankedMatch = UnrankedMatch.create({
48+
id: matchId,
49+
gameId: gameId,
50+
bestOf: event.data.bestOf,
51+
playerNames: team0Players.map((p) => p.name),
52+
opponentNames: team1Players.map((p) => p.name),
53+
date: event.data.date,
54+
banListName,
55+
banListHash,
56+
team0Score: referencePlayer.score,
57+
team1Score: team1Players[0].score, // Assuming simple 1v1 or team consensus
58+
winnerTeam: referencePlayer.winner ? 0 : 1,
59+
season: config.season,
60+
});
61+
62+
await this.unrankedMatchRepository.saveMatch(unrankedMatch);
63+
this.logger.info(`Unranked match saved: ${matchId}`);
64+
65+
for (const game of referencePlayer.games) {
66+
const unrankedDuel = UnrankedDuel.create({
67+
id: randomUUID(),
68+
gameId: gameId,
69+
date: event.data.date,
70+
banListName,
71+
banListHash,
72+
team0Score: referencePlayer.score, // Or should it be individual game result?
73+
team1Score: team1Players[0].score, // Match score is usually what's tracked
74+
winnerTeam: referencePlayer.winner ? 0 : 1,
75+
turns: game.turns,
76+
matchId: matchId,
77+
season: config.season,
78+
ipAddress: game.ipAddress,
79+
});
80+
81+
await this.unrankedMatchRepository.saveDuel(unrankedDuel);
82+
}
83+
}
84+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export class UnrankedDuel {
2+
readonly id: string;
3+
readonly gameId: string;
4+
readonly date: Date;
5+
readonly banListName: string;
6+
readonly banListHash: string;
7+
readonly team0Score: number;
8+
readonly team1Score: number;
9+
readonly winnerTeam: number;
10+
readonly turns: number;
11+
readonly matchId: string;
12+
readonly season: number;
13+
readonly ipAddress: string | null;
14+
15+
private constructor(data: {
16+
id: string;
17+
gameId: string;
18+
date: Date;
19+
banListName: string;
20+
banListHash: string;
21+
team0Score: number;
22+
team1Score: number;
23+
winnerTeam: number;
24+
turns: number;
25+
matchId: string;
26+
season: number;
27+
ipAddress: string | null;
28+
}) {
29+
this.id = data.id;
30+
this.gameId = data.gameId;
31+
this.date = data.date;
32+
this.banListName = data.banListName;
33+
this.banListHash = data.banListHash;
34+
this.team0Score = data.team0Score;
35+
this.team1Score = data.team1Score;
36+
this.winnerTeam = data.winnerTeam;
37+
this.turns = data.turns;
38+
this.matchId = data.matchId;
39+
this.season = data.season;
40+
this.ipAddress = data.ipAddress;
41+
}
42+
43+
static create(data: {
44+
id: string;
45+
gameId: string;
46+
date: Date;
47+
banListName: string;
48+
banListHash: string;
49+
team0Score: number;
50+
team1Score: number;
51+
winnerTeam: number;
52+
turns: number;
53+
matchId: string;
54+
season: number;
55+
ipAddress: string | null;
56+
}): UnrankedDuel {
57+
return new UnrankedDuel(data);
58+
}
59+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export class UnrankedMatch {
2+
readonly id: string;
3+
readonly gameId: string;
4+
readonly bestOf: number;
5+
readonly playerNames: string[];
6+
readonly opponentNames: string[];
7+
readonly date: Date;
8+
readonly banListName: string;
9+
readonly banListHash: string;
10+
readonly team0Score: number;
11+
readonly team1Score: number;
12+
readonly winnerTeam: number;
13+
readonly season: number;
14+
15+
private constructor({
16+
id,
17+
gameId,
18+
bestOf,
19+
playerNames,
20+
opponentNames,
21+
date,
22+
banListName,
23+
banListHash,
24+
team0Score,
25+
team1Score,
26+
winnerTeam,
27+
season,
28+
}: {
29+
id: string;
30+
gameId: string;
31+
bestOf: number;
32+
playerNames: string[];
33+
opponentNames: string[];
34+
date: Date;
35+
banListName: string;
36+
banListHash: string;
37+
team0Score: number;
38+
team1Score: number;
39+
winnerTeam: number;
40+
season: number;
41+
}) {
42+
this.id = id;
43+
this.gameId = gameId;
44+
this.bestOf = bestOf;
45+
this.playerNames = playerNames;
46+
this.opponentNames = opponentNames;
47+
this.date = date;
48+
this.banListName = banListName;
49+
this.banListHash = banListHash;
50+
this.team0Score = team0Score;
51+
this.team1Score = team1Score;
52+
this.winnerTeam = winnerTeam;
53+
this.season = season;
54+
}
55+
56+
static create(data: {
57+
id: string;
58+
gameId: string;
59+
bestOf: number;
60+
playerNames: string[];
61+
opponentNames: string[];
62+
date: Date;
63+
banListName: string;
64+
banListHash: string;
65+
team0Score: number;
66+
team1Score: number;
67+
winnerTeam: number;
68+
season: number;
69+
}): UnrankedMatch {
70+
return new UnrankedMatch(data);
71+
}
72+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { UnrankedDuel } from "./UnrankedDuel";
2+
import { UnrankedMatch } from "./UnrankedMatch";
3+
4+
export interface UnrankedMatchRepository {
5+
saveMatch(unrankedMatch: UnrankedMatch): Promise<void>;
6+
saveDuel(unrankedDuel: UnrankedDuel): Promise<void>;
7+
}

0 commit comments

Comments
 (0)