Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions hasura/functions/match/setup_maps.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ BEGIN

SELECT array_agg(map_id) INTO _map_pool FROM _map_pool WHERE map_pool_id = _map_pool_id;

_map_pool_count = array_length(_map_pool, 1);
_map_pool_count = COALESCE(array_length(_map_pool, 1), 0);

IF _map_pool_count = 0 THEN
RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'Match requires at least one map selected';
RETURN;
END IF;

IF _best_of > _map_pool_count THEN
Expand Down
4 changes: 4 additions & 0 deletions hasura/triggers/matches.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,10 @@ BEGIN
IF _match_options.map_veto = true AND _match_map_count < _match_options.best_of THEN
NEW.status = 'Veto';
END IF;

IF NEW.status = 'Live' AND _match_map_count = 0 THEN
RAISE EXCEPTION 'Match has no maps to play' USING ERRCODE = '22000';
END IF;
END IF;

IF(OLD.status = 'Finished' AND NEW.status = 'Canceled') THEN
Expand Down
233 changes: 211 additions & 22 deletions src/matches/clips/clips.service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,6 +126,32 @@ export class ClipsService {
});
}

// Most kills any single round held for this player. Mirrors the round-window
// logic in buildPresetSpec so the auto-clip min-kills gate agrees with the
// best_round preset it ends up rendering.
private static playerBestRoundKillCount(
myKills: ReadonlyArray<{ tick: number }>,
rounds: ReadonlyArray<{
start_tick: number;
freeze_end_tick?: number;
end_tick: number;
}>,
): number {
let best = 0;
for (const r of rounds) {
const lo =
typeof r.freeze_end_tick === "number" && r.freeze_end_tick > 0
? r.freeze_end_tick
: r.start_tick;
let count = 0;
for (const k of myKills) {
if (k.tick >= lo && k.tick <= r.end_tick) count++;
}
if (count > best) best = count;
}
return best;
}

public async createClipRender(
userSteamId: string,
spec: ClipSpec,
Expand DownExpand Up@@ -1957,7 +1983,12 @@ export class ClipsService {

const kills = ClipsService.filterValidKills(
demo.kills as
| Array<{ tick?: number; killer?: string; victim?: string }>
| Array<{
tick: number;
killer?: string;
victim?: string;
weapon?: string;
}>
| undefined,
);
if (kills.length === 0) return 0;
Expand DownExpand Up@@ -2028,27 +2059,38 @@ export class ClipsService {
}
}

const autoClipFilter = await this.resolveAutoClipFilter();
const rounds =
(demo.round_ticks as Array<{
round: number;
start_tick: number;
freeze_end_tick?: number;
end_tick: number;
}>) ?? [];

const pendingObjects: Array<{
targetSteamId: string;
sessionToken: string;
spec: ClipSpec;
}> = [];
for (const targetSteamId of killers) {
const myKills = kills.filter((k) => k.killer === targetSteamId);
try {
const baseSpec = await this.buildPresetSpec(
const spec = await this.buildAutoClipSpecForTarget(
matchMapId,
targetSteamId,
"best_round",
{ resolution: "1080p", fps: 60 },
undefined,
nameByStId.get(targetSteamId),
matchMapDemoId,
myKills,
rounds,
autoClipFilter,
{ name: nameByStId.get(targetSteamId), defaultVisibility },
);
const spec: ClipSpec = {
...baseSpec,
destination: "library",
visibility: defaultVisibility as ClipSpec["visibility"],
};
if (!spec) {
this.logger.log(
`[auto-clips] demo ${matchMapDemoId} target ${targetSteamId} skipped: no kills met the auto-clip filter (min ${autoClipFilter.minKills}K${autoClipFilter.alwaysKnife ? " / knife on" : ""})`,
);
continue;
}
pendingObjects.push({
targetSteamId,
sessionToken: randomBytes(24).toString("hex"),
Expand DownExpand Up@@ -2158,6 +2200,7 @@ export class ClipsService {
"auto_clip_default_visibility",
"public",
);
const autoClipFilter = await this.resolveAutoClipFilter();

const { matches_by_pk: match } = await this.hasura.query({
matches_by_pk: {
Expand DownExpand Up@@ -2333,13 +2376,26 @@ export class ClipsService {
for (const demo of demos) {
const kills = ClipsService.filterValidKills(
demo?.kills as
| Array<{ tick?: number; killer?: string; victim?: string }>
| Array<{
tick: number;
killer?: string;
victim?: string;
weapon?: string;
}>
| undefined,
);
if (kills.length === 0) {
continue;
}

const rounds =
(demo?.round_ticks as Array<{
round: number;
start_tick: number;
freeze_end_tick?: number;
end_tick: number;
}>) ?? [];

const killers = new Set<string>();
for (const k of kills) {
if (k.killer && allKillers.has(k.killer)) {
Expand All@@ -2348,21 +2404,23 @@ export class ClipsService {
}

for (const targetSteamId of killers) {
const myKills = kills.filter((k) => k.killer === targetSteamId);
try {
const baseSpec = await this.buildPresetSpec(
const spec = await this.buildAutoClipSpecForTarget(
mapRowId,
targetSteamId,
"best_round",
{ resolution: "1080p", fps: 60 },
undefined,
nameByStId.get(targetSteamId),
String(demo.id),
myKills,
rounds,
autoClipFilter,
{ name: nameByStId.get(targetSteamId), defaultVisibility },
);
const spec: ClipSpec = {
...baseSpec,
destination: "library",
visibility: defaultVisibility as ClipSpec["visibility"],
};
if (!spec) {
this.logger.log(
`[auto-clips] match ${matchId} map ${mapRowId} demo ${demo.id} target ${targetSteamId} skipped: no kills met the auto-clip filter (min ${autoClipFilter.minKills}K${autoClipFilter.alwaysKnife ? " / knife on" : ""})`,
);
continue;
}
pendingObjects.push({
mapRowId,
matchMapDemoId: String(demo.id),
Expand DownExpand Up@@ -2734,6 +2792,30 @@ export class ClipsService {
return raw === "true" || raw === "1";
}

private async readIntSetting(name: string, fallback: number): Promise<number> {
const raw = await this.readSetting(name, String(fallback));
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}

// Operator-configured gate on which moments auto-generate clips, so a match
// doesn't always spawn a clip for every player. minKills is clamped to the
// 1..5 the UI offers (1 = any kill, today's default behaviour).
private async resolveAutoClipFilter(): Promise<{
minKills: number;
alwaysKnife: boolean;
}> {
const minKills = await this.readIntSetting("auto_clip_min_kills", 1);
const alwaysKnife = await this.readBoolSetting(
"auto_clip_always_include_knife",
false,
);
return {
minKills: Math.max(1, Math.min(5, minKills)),
alwaysKnife,
};
}

// Imported (non-5stack) matches only get auto highlights when the operator
// has explicitly opted in, separately from the main auto-highlights toggle.
private async importedAutoClipsAllowed(
Expand DownExpand Up@@ -3041,6 +3123,113 @@ export class ClipsService {
return result;
}

// One auto-clip reel per player: their best round first, then — when the
// operator opted in — their knife kills appended after it. Returns null when
// the player clears neither the min-kills gate nor has knife kills, so the
// caller skips them entirely.
private async buildAutoClipSpecForTarget(
matchMapId: string,
targetSteamId: string,
matchMapDemoId: string,
myKills: ReadonlyArray<{ tick: number; weapon?: string }>,
rounds: ReadonlyArray<{
start_tick: number;
freeze_end_tick?: number;
end_tick: number;
}>,
filter: { minKills: number; alwaysKnife: boolean },
opts: { name?: string; defaultVisibility: string },
): Promise<ClipSpec | null> {
const bestRoundKills =
filter.minKills <= 1
? myKills.length
: ClipsService.playerBestRoundKillCount(myKills, rounds);
const qualifiesBestRound = bestRoundKills >= filter.minKills;
const hasKnife =
filter.alwaysKnife &&
myKills.some((k) => (k.weapon ?? "").toLowerCase().includes("knife"));

if (!qualifiesBestRound && !hasKnife) return null;

const output = { resolution: "1080p", fps: 60 } as const;

const base = qualifiesBestRound
? await this.buildPresetSpec(
matchMapId,
targetSteamId,
"best_round",
output,
undefined,
opts.name,
matchMapDemoId,
)
: null;

let knifeSpec: ClipSpec | null = null;
if (hasKnife) {
try {
knifeSpec = await this.buildPresetSpec(
matchMapId,
targetSteamId,
"knife",
output,
undefined,
opts.name,
matchMapDemoId,
);
} catch {
knifeSpec = null;
}
}

let spec: ClipSpec | null = null;
if (base && knifeSpec) {
// Drop knife segments already covered by the best-round block so a knife
// kill inside that round doesn't play twice, then append what's left
// after it (best round first, knife kills last).
const KNIFE_MAX = 3;
// Skip knife kills already shown in the best-round block — if the kill
// tick lands inside a best-round segment the viewer already sees it, so
// there's no point appending it again.
const shownInBestRound = (tick: number) =>
base.segments.some(
(bs) => tick >= bs.start_tick && tick <= bs.end_tick,
);
const extraKnifeSegs = knifeSpec.segments
.filter((ks) => ks.kill_tick == null || !shownInBestRound(ks.kill_tick))
.slice(0, KNIFE_MAX);
if (extraKnifeSegs.length === 0) {
spec = base;
} else {
const knifeCount =
myKills.filter(
(k) =>
(k.weapon ?? "").toLowerCase().includes("knife") &&
extraKnifeSegs.some(
(s) => k.tick >= s.start_tick && k.tick <= s.end_tick,
),
).length || extraKnifeSegs.length;
const { round: _round, ...baseRest } = base;
spec = {
...baseRest,
segments: [...base.segments, ...extraKnifeSegs].slice(0, 20),
title: `${base.title} + ${knifeCount} Knife ${knifeCount === 1 ? "Kill" : "Kills"}`,
kills_count: (base.kills_count ?? 0) + knifeCount,
};
}
} else {
spec = base ?? knifeSpec;
}

if (!spec) return null;

return {
...spec,
destination: "library",
visibility: opts.defaultVisibility as ClipSpec["visibility"],
};
}

private validateSpec(spec: ClipSpec) {
if (!spec || typeof spec !== "object") throw new Error("spec required");
if (!spec.match_map_id) throw new Error("spec.match_map_id required");
Expand Down