mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-11 03:46:52 -05:00
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
|
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
|
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
|
import { db } from "~/db/sql";
|
|
import type {
|
|
ScannerMatch,
|
|
ScannerMatchPlayer,
|
|
} from "~/features/scanner/core/scanner-match";
|
|
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
|
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
|
import * as Matches from "./core/Matches";
|
|
import type { IngestableGame } from "./core/Scoreboards";
|
|
import * as ScannerIngestRepository from "./ScannerIngestRepository.server";
|
|
|
|
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
|
|
const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80];
|
|
const PLAYED_AT = Date.UTC(2026, 7, 1, 18, 0, 0);
|
|
/** enough teams for the bracket winner to play more than one match */
|
|
const TOURNAMENT_TEAM_COUNT = 4;
|
|
|
|
describe("addOrMergeMatches", () => {
|
|
test("inserts a fresh match with hash, hints and playedAt", async () => {
|
|
const user = await UserFactory.create();
|
|
const { match: groupMatch } = await setupSendouqMatch();
|
|
|
|
const result = await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: user.id,
|
|
submitterUserId: user.id,
|
|
matches: [testMatch()],
|
|
context: { type: "sendouq", groupMatchId: groupMatch.id },
|
|
});
|
|
|
|
expect(result.insertedCount).toBe(1);
|
|
expect(result.mergedCount).toBe(0);
|
|
expect(result.effectiveMatches).toHaveLength(1);
|
|
|
|
const rows = await fetchIngestedMatches();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].id).toBe(result.effectiveMatches[0].id);
|
|
expect(rows[0].povUserId).toBe(user.id);
|
|
expect(rows[0].submitterUserId).toBe(user.id);
|
|
expect(rows[0].playedAt).toBe(Math.floor(PLAYED_AT / 1000));
|
|
expect(rows[0].matchHash).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
|
|
expect(rows[0].tournamentIdHint).toBeNull();
|
|
expect(rows[0].data).toEqual(Matches.canonicalMatch(testMatch()));
|
|
});
|
|
|
|
test("identical resend is a no-op that backfills missing hints", async () => {
|
|
const user = await UserFactory.create();
|
|
const { match: groupMatch } = await setupSendouqMatch();
|
|
|
|
const first = await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: user.id,
|
|
submitterUserId: user.id,
|
|
matches: [testMatch()],
|
|
context: null,
|
|
});
|
|
expect((await fetchIngestedMatches())[0].groupMatchIdHint).toBeNull();
|
|
|
|
const second = await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: user.id,
|
|
submitterUserId: user.id,
|
|
matches: [testMatch()],
|
|
context: { type: "sendouq", groupMatchId: groupMatch.id },
|
|
});
|
|
|
|
expect(second.insertedCount).toBe(0);
|
|
expect(second.mergedCount).toBe(0);
|
|
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
|
|
|
|
const rows = await fetchIngestedMatches();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
|
|
});
|
|
|
|
test("a fuller re-send of the same game merges into the stored partial", async () => {
|
|
const user = await UserFactory.create();
|
|
const partial = testMatch({
|
|
playedAt: PLAYED_AT + 5 * 60 * 1000,
|
|
mode: null,
|
|
matchScores: null,
|
|
teams: [{ players: [] }, { players: [] }],
|
|
winner: null,
|
|
});
|
|
|
|
const first = await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: user.id,
|
|
submitterUserId: user.id,
|
|
matches: [partial],
|
|
context: null,
|
|
});
|
|
expect(first.insertedCount).toBe(1);
|
|
const storedHash = (await fetchIngestedMatches())[0].matchHash;
|
|
|
|
const second = await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: user.id,
|
|
submitterUserId: user.id,
|
|
matches: [testMatch()],
|
|
context: null,
|
|
});
|
|
|
|
expect(second.insertedCount).toBe(0);
|
|
expect(second.mergedCount).toBe(1);
|
|
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
|
|
expect(second.effectiveMatches[0].data.mode).toBe("SZ");
|
|
|
|
const rows = await fetchIngestedMatches();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].data.mode).toBe("SZ");
|
|
expect(rows[0].data.winner).toBe(0);
|
|
expect(rows[0].data.teams[0].players.map((p) => p.name)).toEqual(
|
|
NAMES.slice(0, 4),
|
|
);
|
|
expect(rows[0].playedAt).toBe(Math.floor(partial.playedAt! / 1000));
|
|
expect(rows[0].matchHash).not.toBe(storedHash);
|
|
});
|
|
});
|
|
|
|
describe("addLinks", () => {
|
|
test("creates link rows for group match maps", async () => {
|
|
const user = await UserFactory.create();
|
|
const { maps } = await setupSendouqMatch();
|
|
|
|
const { effectiveMatches } =
|
|
await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: null,
|
|
submitterUserId: user.id,
|
|
matches: [
|
|
testMatch(),
|
|
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
|
|
],
|
|
context: null,
|
|
});
|
|
|
|
const linkedCount = await ScannerIngestRepository.addLinks({
|
|
links: effectiveMatches.map((effective, i) => ({
|
|
ingestedMatchId: effective.id,
|
|
match: effective.data,
|
|
game: sendouqGame(maps[i]),
|
|
})),
|
|
povUserId: null,
|
|
});
|
|
|
|
expect(linkedCount).toBe(2);
|
|
const links = await fetchLinks();
|
|
expect(links).toHaveLength(2);
|
|
expect(links.map((link) => link.ingestedMatchId)).toEqual(
|
|
effectiveMatches.map((effective) => effective.id),
|
|
);
|
|
expect(links.map((link) => link.groupMatchMapId)).toEqual(
|
|
maps.slice(0, 2).map((map) => map.id),
|
|
);
|
|
expect(
|
|
links.every((link) => link.tournamentMatchGameResultId === null),
|
|
).toBe(true);
|
|
expect(await fetchReportedWeapons()).toHaveLength(0);
|
|
});
|
|
|
|
test("re-sends are no-ops and only newly created links are counted", async () => {
|
|
const user = await UserFactory.create();
|
|
const { maps } = await setupSendouqMatch();
|
|
|
|
const { effectiveMatches } =
|
|
await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: null,
|
|
submitterUserId: user.id,
|
|
matches: [
|
|
testMatch(),
|
|
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
|
|
],
|
|
context: null,
|
|
});
|
|
const links = effectiveMatches.map((effective, i) => ({
|
|
ingestedMatchId: effective.id,
|
|
match: effective.data,
|
|
game: sendouqGame(maps[i]),
|
|
}));
|
|
|
|
await ScannerIngestRepository.addLinks({
|
|
links: [links[0]],
|
|
povUserId: null,
|
|
});
|
|
const secondCount = await ScannerIngestRepository.addLinks({
|
|
links,
|
|
povUserId: null,
|
|
});
|
|
|
|
expect(secondCount).toBe(1);
|
|
expect(await fetchLinks()).toHaveLength(2);
|
|
});
|
|
|
|
test("reports the POV player's weapon once", async () => {
|
|
const povUser = await UserFactory.create();
|
|
const { match: groupMatch, maps } = await setupSendouqMatch();
|
|
|
|
const { effectiveMatches } =
|
|
await ScannerIngestRepository.addOrMergeMatches({
|
|
povUserId: povUser.id,
|
|
submitterUserId: povUser.id,
|
|
matches: [testMatch({ pov: { team: 0, index: 0 } })],
|
|
context: null,
|
|
});
|
|
const links = [
|
|
{
|
|
ingestedMatchId: effectiveMatches[0].id,
|
|
match: effectiveMatches[0].data,
|
|
game: sendouqGame(maps[0]),
|
|
},
|
|
];
|
|
|
|
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
|
|
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
|
|
|
|
const reportedWeapons = await fetchReportedWeapons();
|
|
expect(reportedWeapons).toHaveLength(1);
|
|
expect(reportedWeapons[0].groupMatchId).toBe(groupMatch.id);
|
|
expect(reportedWeapons[0].tournamentMatchId).toBeNull();
|
|
expect(reportedWeapons[0].mapIndex).toBe(maps[0].index);
|
|
expect(reportedWeapons[0].userId).toBe(povUser.id);
|
|
expect(reportedWeapons[0].weaponSplId).toBe(WEAPONS[0]);
|
|
});
|
|
});
|
|
|
|
describe("gamesInTournamentMatch", () => {
|
|
test("returns the match's own games only, leaving the rest of the tournament out", async () => {
|
|
const users = await UserFactory.createMany(TOURNAMENT_TEAM_COUNT);
|
|
const tournament = await TournamentFactory.createPlayed(
|
|
{ authorId: users[0]!.id, minMembersPerTeam: 1 },
|
|
{
|
|
teamRosters: users.map((user) => [user.id]),
|
|
playedOut: 0,
|
|
},
|
|
);
|
|
// the bracket winner plays every round on the same map list, so its
|
|
// earlier round's games are the ones a live send could wrongly take
|
|
const [firstMatch, ...laterMatches] = tournament.matches;
|
|
const winnerUserId = users.find(
|
|
(user) =>
|
|
tournament.teams.find((team) => team.id === firstMatch!.winnerTeamId)
|
|
?.memberUserIds[0] === user.id,
|
|
)!.id;
|
|
|
|
const games = await ScannerIngestRepository.gamesInTournamentMatch(
|
|
firstMatch!.id,
|
|
);
|
|
|
|
expect(games.length).toBeGreaterThan(0);
|
|
expect(
|
|
games.every(
|
|
(game) =>
|
|
game.target.type === "tournament" &&
|
|
game.target.tournamentMatchId === firstMatch!.id,
|
|
),
|
|
).toBe(true);
|
|
|
|
// the tournament-wide list is what the walk would otherwise see
|
|
const allGames =
|
|
await ScannerIngestRepository.gamesPlayedByUserInTournament({
|
|
userId: winnerUserId,
|
|
tournamentId: tournament.id,
|
|
});
|
|
expect(allGames.length).toBeGreaterThan(games.length);
|
|
expect(
|
|
allGames.some(
|
|
(game) =>
|
|
game.target.type === "tournament" &&
|
|
laterMatches.some(
|
|
(match) =>
|
|
game.target.type === "tournament" &&
|
|
game.target.tournamentMatchId === match.id,
|
|
),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
function player(name: string, weaponId: MainWeaponId): ScannerMatchPlayer {
|
|
return {
|
|
name,
|
|
weaponId,
|
|
paint: 1000,
|
|
ka: 10,
|
|
d: 5,
|
|
s: 2,
|
|
};
|
|
}
|
|
|
|
function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
|
|
return {
|
|
startsAt: 100,
|
|
endsAt: 400,
|
|
playedAt: PLAYED_AT,
|
|
lobby: "PRIVATE",
|
|
mode: "SZ",
|
|
stage: 0,
|
|
matchScores: [100, 52],
|
|
replayCode: null,
|
|
cast: false,
|
|
objective: null,
|
|
playerStatus: null,
|
|
teams: [
|
|
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
|
|
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
|
|
],
|
|
winner: 0,
|
|
pov: null,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
async function setupSendouqMatch() {
|
|
const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2);
|
|
const match = await SQMatchFactory.create({
|
|
alphaUserIds: users.slice(0, FULL_GROUP_SIZE).map((user) => user.id),
|
|
bravoUserIds: users.slice(FULL_GROUP_SIZE).map((user) => user.id),
|
|
});
|
|
|
|
const maps = await db
|
|
.selectFrom("GroupMatchMap")
|
|
.selectAll()
|
|
.where("matchId", "=", match.id)
|
|
.orderBy("index", "asc")
|
|
.execute();
|
|
|
|
return { match, maps };
|
|
}
|
|
|
|
function fetchIngestedMatches() {
|
|
return db
|
|
.selectFrom("IngestedMatch")
|
|
.selectAll()
|
|
.orderBy("id", "asc")
|
|
.execute();
|
|
}
|
|
|
|
function fetchLinks() {
|
|
return db
|
|
.selectFrom("IngestedMatchLink")
|
|
.selectAll()
|
|
.orderBy("id", "asc")
|
|
.execute();
|
|
}
|
|
|
|
function fetchReportedWeapons() {
|
|
return db.selectFrom("ReportedWeapon").selectAll().execute();
|
|
}
|
|
|
|
function sendouqGame(map: {
|
|
id: number;
|
|
matchId: number;
|
|
index: number;
|
|
mode: IngestableGame["mode"];
|
|
stageId: IngestableGame["stageId"];
|
|
}): IngestableGame {
|
|
return {
|
|
target: {
|
|
type: "sendouq",
|
|
groupMatchMapId: map.id,
|
|
groupMatchId: map.matchId,
|
|
},
|
|
mapIndex: map.index,
|
|
mode: map.mode,
|
|
stageId: map.stageId,
|
|
winnerInGameNames: [],
|
|
loserInGameNames: [],
|
|
playedAt: Math.floor(PLAYED_AT / 1000),
|
|
linkedPlayerNames: null,
|
|
};
|
|
}
|