Weapon report fixes

This commit is contained in:
Kalle
2026-09-29 18:25:00 +03:00
parent 3be15888a9
commit b5d3e4a533
8 changed files with 307 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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,

View 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();
});
}