mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Weapon report fixes
This commit is contained in:
@@ -557,6 +557,8 @@ export interface ReportedWeapon {
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
createdAt: Generated<number>;
|
||||
/** the scanner read this weapon was reported from, null when reported by hand */
|
||||
ingestedMatchId: number | null;
|
||||
}
|
||||
|
||||
export interface IngestedMatch {
|
||||
|
||||
@@ -186,9 +186,9 @@ describe("addLinks", () => {
|
||||
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();
|
||||
test("reports the POV player's weapon once, tagged with its scan and dated to the game", async () => {
|
||||
const { match: groupMatch, maps, users } = await setupSendouqMatch();
|
||||
const povUser = users[0]!;
|
||||
|
||||
const { effectiveMatches } =
|
||||
await ScannerIngestRepository.addOrMergeMatches({
|
||||
@@ -201,7 +201,9 @@ describe("addLinks", () => {
|
||||
{
|
||||
ingestedMatchId: effectiveMatches[0].id,
|
||||
match: effectiveMatches[0].data,
|
||||
game: sendouqGame(maps[0]),
|
||||
game: sendouqGame(maps[0], {
|
||||
winnerUserIds: users.slice(0, FULL_GROUP_SIZE).map((user) => user.id),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,6 +217,75 @@ describe("addLinks", () => {
|
||||
expect(reportedWeapons[0].mapIndex).toBe(maps[0].index);
|
||||
expect(reportedWeapons[0].userId).toBe(povUser.id);
|
||||
expect(reportedWeapons[0].weaponSplId).toBe(WEAPONS[0]);
|
||||
expect(reportedWeapons[0].ingestedMatchId).toBe(effectiveMatches[0].id);
|
||||
expect(reportedWeapons[0].createdAt).toBe(Math.floor(PLAYED_AT / 1000));
|
||||
});
|
||||
|
||||
test("reports no weapon for a sender outside the game's rosters", async () => {
|
||||
const outsider = await UserFactory.create();
|
||||
const { maps, users } = await setupSendouqMatch();
|
||||
|
||||
const { effectiveMatches } =
|
||||
await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId: outsider.id,
|
||||
submitterUserId: outsider.id,
|
||||
matches: [testMatch({ pov: { team: 0, index: 0 } })],
|
||||
context: null,
|
||||
});
|
||||
|
||||
const linkedCount = await ScannerIngestRepository.addLinks({
|
||||
links: [
|
||||
{
|
||||
ingestedMatchId: effectiveMatches[0].id,
|
||||
match: effectiveMatches[0].data,
|
||||
game: sendouqGame(maps[0], {
|
||||
winnerUserIds: users
|
||||
.slice(0, FULL_GROUP_SIZE)
|
||||
.map((user) => user.id),
|
||||
}),
|
||||
},
|
||||
],
|
||||
povUserId: outsider.id,
|
||||
});
|
||||
|
||||
expect(linkedCount).toBe(1);
|
||||
expect(await fetchReportedWeapons()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("deleting the ingested match deletes the weapon reported from it", async () => {
|
||||
const { maps, users } = await setupSendouqMatch();
|
||||
const povUser = users[0]!;
|
||||
|
||||
const { effectiveMatches } =
|
||||
await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId: povUser.id,
|
||||
submitterUserId: povUser.id,
|
||||
matches: [testMatch({ pov: { team: 0, index: 0 } })],
|
||||
context: null,
|
||||
});
|
||||
await ScannerIngestRepository.addLinks({
|
||||
links: [
|
||||
{
|
||||
ingestedMatchId: effectiveMatches[0].id,
|
||||
match: effectiveMatches[0].data,
|
||||
game: sendouqGame(maps[0], {
|
||||
winnerUserIds: users
|
||||
.slice(0, FULL_GROUP_SIZE)
|
||||
.map((user) => user.id),
|
||||
}),
|
||||
},
|
||||
],
|
||||
povUserId: povUser.id,
|
||||
});
|
||||
expect(await fetchReportedWeapons()).toHaveLength(1);
|
||||
|
||||
// biome-ignore lint/plugin: no production path deletes ingested matches yet
|
||||
await db
|
||||
.deleteFrom("IngestedMatch")
|
||||
.where("id", "=", effectiveMatches[0].id)
|
||||
.execute();
|
||||
|
||||
expect(await fetchReportedWeapons()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,7 +416,7 @@ async function setupSendouqMatch(options: { isConcluded?: boolean } = {}) {
|
||||
.orderBy("index", "asc")
|
||||
.execute();
|
||||
|
||||
return { match, maps };
|
||||
return { match, maps, users };
|
||||
}
|
||||
|
||||
function fetchIngestedMatches() {
|
||||
@@ -368,13 +439,16 @@ function fetchReportedWeapons() {
|
||||
return db.selectFrom("ReportedWeapon").selectAll().execute();
|
||||
}
|
||||
|
||||
function sendouqGame(map: {
|
||||
id: number;
|
||||
matchId: number;
|
||||
index: number;
|
||||
mode: IngestableGame["mode"];
|
||||
stageId: IngestableGame["stageId"];
|
||||
}): IngestableGame {
|
||||
function sendouqGame(
|
||||
map: {
|
||||
id: number;
|
||||
matchId: number;
|
||||
index: number;
|
||||
mode: IngestableGame["mode"];
|
||||
stageId: IngestableGame["stageId"];
|
||||
},
|
||||
rosters: Partial<Pick<IngestableGame, "winnerUserIds" | "loserUserIds">> = {},
|
||||
): IngestableGame {
|
||||
return {
|
||||
target: {
|
||||
type: "sendouq",
|
||||
@@ -388,7 +462,9 @@ function sendouqGame(map: {
|
||||
loserUserIds: [],
|
||||
winnerInGameNames: [],
|
||||
loserInGameNames: [],
|
||||
inGameNameByUserId: new Map(),
|
||||
playedAt: Math.floor(PLAYED_AT / 1000),
|
||||
linkedPlayerNames: null,
|
||||
...rosters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -448,7 +448,8 @@ export async function addOrMergeMatches({
|
||||
/**
|
||||
* Links ingested matches to their matched game results. A row links to at most one game (re-sends
|
||||
* are no-ops); a game collects links from many rows (each POV's scan). A known POV player's
|
||||
* weapon is reported as a regular ReportedWeapon unless they already have one for that game.
|
||||
* weapon (Scoreboards.povWeaponId) is reported as a ReportedWeapon tagged with its ingested match,
|
||||
* unless they already have one for that game.
|
||||
*
|
||||
* @returns count of newly created links
|
||||
*/
|
||||
@@ -782,6 +783,10 @@ async function tournamentGames({
|
||||
loserUserIds: loserRoster?.userIds ?? [],
|
||||
winnerInGameNames: winnerRoster?.inGameNames ?? [],
|
||||
loserInGameNames: loserRoster?.inGameNames ?? [],
|
||||
inGameNameByUserId: new Map([
|
||||
...(winnerRoster?.inGameNameByUserId ?? []),
|
||||
...(loserRoster?.inGameNameByUserId ?? []),
|
||||
]),
|
||||
playedAt: row.playedAt,
|
||||
linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null,
|
||||
};
|
||||
@@ -791,6 +796,7 @@ async function tournamentGames({
|
||||
interface Roster {
|
||||
userIds: number[];
|
||||
inGameNames: string[];
|
||||
inGameNameByUserId: Map<number, string>;
|
||||
}
|
||||
|
||||
async function teamRosters(teamIds: Array<number | null>) {
|
||||
@@ -814,18 +820,22 @@ async function teamRosters(teamIds: Array<number | null>) {
|
||||
|
||||
const result = new Map<number, Roster>();
|
||||
for (const member of members) {
|
||||
const roster = result.get(member.tournamentTeamId) ?? {
|
||||
userIds: [],
|
||||
inGameNames: [],
|
||||
};
|
||||
const roster = result.get(member.tournamentTeamId) ?? emptyRoster();
|
||||
roster.userIds.push(member.userId);
|
||||
if (member.inGameName) roster.inGameNames.push(member.inGameName);
|
||||
if (member.inGameName) {
|
||||
roster.inGameNames.push(member.inGameName);
|
||||
roster.inGameNameByUserId.set(member.userId, member.inGameName);
|
||||
}
|
||||
result.set(member.tournamentTeamId, roster);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function emptyRoster(): Roster {
|
||||
return { userIds: [], inGameNames: [], inGameNameByUserId: new Map() };
|
||||
}
|
||||
|
||||
async function sendouqGames({
|
||||
groupMatchId,
|
||||
userId,
|
||||
@@ -918,6 +928,10 @@ async function sendouqGames({
|
||||
loserUserIds: loserRoster?.userIds ?? [],
|
||||
winnerInGameNames: winnerRoster?.inGameNames ?? [],
|
||||
loserInGameNames: loserRoster?.inGameNames ?? [],
|
||||
inGameNameByUserId: new Map([
|
||||
...(winnerRoster?.inGameNameByUserId ?? []),
|
||||
...(loserRoster?.inGameNameByUserId ?? []),
|
||||
]),
|
||||
playedAt: row.playedAt,
|
||||
linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null,
|
||||
};
|
||||
@@ -937,12 +951,12 @@ async function groupRosters(groupIds: number[]) {
|
||||
|
||||
const result = new Map<number, Roster>();
|
||||
for (const member of members) {
|
||||
const roster = result.get(member.groupId) ?? {
|
||||
userIds: [],
|
||||
inGameNames: [],
|
||||
};
|
||||
const roster = result.get(member.groupId) ?? emptyRoster();
|
||||
roster.userIds.push(member.userId);
|
||||
if (member.inGameName) roster.inGameNames.push(member.inGameName);
|
||||
if (member.inGameName) {
|
||||
roster.inGameNames.push(member.inGameName);
|
||||
roster.inGameNameByUserId.set(member.userId, member.inGameName);
|
||||
}
|
||||
result.set(member.groupId, roster);
|
||||
}
|
||||
|
||||
@@ -981,12 +995,15 @@ async function linkedPlayerNamesByTarget(
|
||||
|
||||
async function reportPovWeapon(
|
||||
trx: Transaction<DB>,
|
||||
{ match, game }: { match: ScannerMatch; game: IngestableGame },
|
||||
{
|
||||
ingestedMatchId,
|
||||
match,
|
||||
game,
|
||||
}: { ingestedMatchId: number; match: ScannerMatch; game: IngestableGame },
|
||||
povUserId: number | null,
|
||||
) {
|
||||
if (povUserId === null || match.pov === null) return;
|
||||
const weaponSplId =
|
||||
match.teams[match.pov.team]?.players[match.pov.index]?.weaponId ?? null;
|
||||
if (povUserId === null) return;
|
||||
const weaponSplId = Scoreboards.povWeaponId({ match, game, povUserId });
|
||||
if (weaponSplId === null) return;
|
||||
|
||||
await trx
|
||||
@@ -1001,6 +1018,9 @@ async function reportPovWeapon(
|
||||
mapIndex: game.mapIndex,
|
||||
userId: povUserId,
|
||||
weaponSplId,
|
||||
ingestedMatchId,
|
||||
// season stats bucket by it, and a scan can arrive long after the game
|
||||
createdAt: game.playedAt,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
|
||||
@@ -34,6 +34,7 @@ function testGame(
|
||||
loserUserIds: [],
|
||||
winnerInGameNames: [],
|
||||
loserInGameNames: [],
|
||||
inGameNameByUserId: new Map(),
|
||||
playedAt: 1000,
|
||||
linkedPlayerNames: null,
|
||||
...rest,
|
||||
@@ -811,6 +812,94 @@ describe("recognizablePlayerNames", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scoreboards.povWeaponId", () => {
|
||||
const SENDER_ID = 1;
|
||||
const rosterGame = () =>
|
||||
testGame({
|
||||
winnerUserIds: [1, 2, 3, 4],
|
||||
loserUserIds: [5, 6, 7, 8],
|
||||
inGameNameByUserId: new Map(
|
||||
NAMES.map((name, i) => [i + 1, `${name}#1234`]),
|
||||
),
|
||||
});
|
||||
const weapons: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80];
|
||||
|
||||
test.each([
|
||||
{
|
||||
why: "the sender's own seat",
|
||||
povIndex: 0,
|
||||
povUserId: SENDER_ID,
|
||||
expected: 10,
|
||||
},
|
||||
{
|
||||
why: "a losing sender's own seat",
|
||||
povIndex: 4,
|
||||
povUserId: 5,
|
||||
expected: 50,
|
||||
},
|
||||
{
|
||||
why: "no POV seat",
|
||||
povIndex: null,
|
||||
povUserId: SENDER_ID,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "a sender in neither roster (caster)",
|
||||
povIndex: 0,
|
||||
povUserId: 99,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "a seat on the sender's opponents' side",
|
||||
povIndex: 4,
|
||||
povUserId: SENDER_ID,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "a teammate's seat (their recording)",
|
||||
povIndex: 1,
|
||||
povUserId: SENDER_ID,
|
||||
expected: null,
|
||||
},
|
||||
])("$why", ({ povIndex, povUserId, expected }) => {
|
||||
expect(
|
||||
Scoreboards.povWeaponId({
|
||||
match: testMatch({ povIndex, weapons }),
|
||||
game: rosterGame(),
|
||||
povUserId,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "unread", seatName: "" },
|
||||
{ why: "garbled", seatName: "???1" },
|
||||
])(
|
||||
"a seat whose name is $why can't contradict the sender",
|
||||
({ seatName }) => {
|
||||
const names = NAMES.map((name, i) => (i === 0 ? seatName : name));
|
||||
|
||||
expect(
|
||||
Scoreboards.povWeaponId({
|
||||
match: testMatch({ povIndex: 0, names, weapons }),
|
||||
game: rosterGame(),
|
||||
povUserId: SENDER_ID,
|
||||
}),
|
||||
).toBe(10);
|
||||
},
|
||||
);
|
||||
|
||||
test("returns null when the seat's weapon was not read", () => {
|
||||
expect(
|
||||
Scoreboards.povWeaponId({
|
||||
match: testMatch({ povIndex: 0, weapons: [null, ...weapons.slice(1)] }),
|
||||
game: rosterGame(),
|
||||
povUserId: SENDER_ID,
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContext", () => {
|
||||
/** A tournament's reported games as an ordered (mode, stageId) sequence. */
|
||||
function tournamentGames(
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface IngestableGame {
|
||||
winnerInGameNames: string[];
|
||||
/** known in-game names of the losing team's roster, the side fallback for reads without a POV seat */
|
||||
loserInGameNames: string[];
|
||||
/** both rosters' known in-game names by user id, tells whose seat a POV read marks */
|
||||
inGameNameByUserId: Map<number, string>;
|
||||
/** timestamp of the game's report: the chronological key and what a scan's play time is measured against */
|
||||
playedAt: number;
|
||||
/** winner-first row-order names of the game's earliest linked ingest that read enough names to recognize a re-detection, null when none; lets matching skip taken games yet recognize re-detections */
|
||||
@@ -180,6 +182,38 @@ export function matchedGames({
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The weapon the sender played in a linked game, read off the scan's POV seat. Null unless the
|
||||
* sender is in the roster on the seat's side and the seat's name isn't another roster member's:
|
||||
* a caster's or a teammate's recording marks someone else's seat.
|
||||
*/
|
||||
export function povWeaponId({
|
||||
match,
|
||||
game,
|
||||
povUserId,
|
||||
}: {
|
||||
match: ScannerMatch;
|
||||
game: IngestableGame;
|
||||
povUserId: number;
|
||||
}): MainWeaponId | null {
|
||||
const view = winnerFirstView(match);
|
||||
if (!view || view.povIndex === null) return null;
|
||||
if (povSideAgreement(view, game, povUserId) !== true) return null;
|
||||
|
||||
const seat = view.players[view.povIndex]!;
|
||||
const seatName = Matches.normalizeInGameName(seat.name);
|
||||
const seatUserIds = [...game.inGameNameByUserId]
|
||||
.filter(
|
||||
([, inGameName]) => Matches.normalizeInGameName(inGameName) === seatName,
|
||||
)
|
||||
.map(([userId]) => userId);
|
||||
if (seatName && seatUserIds.length > 0 && !seatUserIds.includes(povUserId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return seat.weaponId;
|
||||
}
|
||||
|
||||
export interface IngestedScoreboardPlayer {
|
||||
name: string;
|
||||
tournamentTeamId: number | null;
|
||||
|
||||
@@ -356,6 +356,17 @@ describe("SendouQ flow", () => {
|
||||
expect(scoreboard.data.players[4]!.userId).toBe(w.bravoUsers[0]!.id);
|
||||
});
|
||||
|
||||
test("Q13 a teammate's recording: the game links, but their weapon isn't credited to the sender", async () => {
|
||||
const w = await sendouqWorld();
|
||||
await w.conclude();
|
||||
|
||||
// the POV seat is Alpha1's, sent by their teammate Alpha2
|
||||
const res = await ingest(w.alphaUsers[1]!, [w.scanned(w.maps[0]!)]);
|
||||
|
||||
expect(res.linkedGamesCount).toBe(1);
|
||||
expect(await fetchReportedWeapons()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Q11 POV read misflagged as cast: the sender's seat still resolves and links their match", async () => {
|
||||
const w = await sendouqWorld();
|
||||
await w.conclude();
|
||||
@@ -597,6 +608,23 @@ describe("tournament flow", () => {
|
||||
const page = await tournamentMatchPage(w.tournamentId, finalMatch.id);
|
||||
expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test("T7 cast footage with a POV seat: the caster gets no weapon for a set they didn't play", async () => {
|
||||
const w = await tournamentWorld();
|
||||
const finalMatch = w.matches.at(-1)!;
|
||||
await w.cast(finalMatch.id);
|
||||
const caster = await createUser();
|
||||
await w.staff(caster);
|
||||
const [firstGame, secondGame] = await w.games(finalMatch.id);
|
||||
|
||||
const res = await ingest(caster, [
|
||||
w.scanned(firstGame!, { cast: true }),
|
||||
w.scanned(secondGame!, { cast: true, pov: { team: 0, index: 0 } }),
|
||||
]);
|
||||
|
||||
expect(res.linkedGamesCount).toBe(2);
|
||||
expect(await fetchReportedWeapons()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response contract & idempotency", () => {
|
||||
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
type WeaponTemplate,
|
||||
} from "./weapons";
|
||||
|
||||
/**
|
||||
* Below this the best icon is no read: every fixture's true icon scores >= 0.53, while a
|
||||
* kit without a template (new after a game patch) reads as its nearest sibling at up to 0.92,
|
||||
* so the floor only drops the clearest non-matches.
|
||||
*/
|
||||
const WEAPON_MIN_SCORE = 0.5;
|
||||
|
||||
/** Per-row ROI geometry; the replay detector closes these over its panel dx. */
|
||||
export interface RowRois {
|
||||
weapon(cy: number): Roi;
|
||||
@@ -92,7 +99,10 @@ export function* parseScoreboardRowSteps(
|
||||
return {
|
||||
player: {
|
||||
name: name?.name ?? "",
|
||||
weaponId: weapon ? toMainWeaponId(weapon.id) : null,
|
||||
weaponId:
|
||||
weapon && weapon.score >= WEAPON_MIN_SCORE
|
||||
? toMainWeaponId(weapon.id)
|
||||
: null,
|
||||
paint: paint?.value ?? null,
|
||||
ka: statValues[0] ?? null,
|
||||
d: statValues[1] ?? null,
|
||||
|
||||
20
migrations/20260929151035-reported-weapon-ingested-match.ts
Normal file
20
migrations/20260929151035-reported-weapon-ingested-match.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/** Tags weapons reported from a scanner read with the ingested match they came from */
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.schema
|
||||
.alterTable("ReportedWeapon")
|
||||
.addColumn("ingestedMatchId", "integer", (col) =>
|
||||
col.references("IngestedMatch.id").onDelete("cascade"),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
.createIndex("reported_weapon_ingested_match_id")
|
||||
.on("ReportedWeapon")
|
||||
.column("ingestedMatchId")
|
||||
.where(sql.ref("ingestedMatchId"), "is not", null)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user