Skip to content
Merged
25 changes: 25 additions & 0 deletions patch-notes/2026-08-13-video-clears-and-build-goals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
title: Merry Christmas
date: 2026-08-13
summary: Add video links for Stygian recommendations, build goals for simmed characters, alter Stygian algorithm (read full notes)
---

## Stygian clear videos

Inside /tools/stygian, added a button to display clears using the teams suggested - of course some teams will not have clears.

## Stygian algorithm improvement (?)

Inside /tools/stigian, added a dropdown to select different algorithms for recommending Stygian teams.

Usage Rate: the previous algorithm, used usage rate to rank teams and solutions

Video Clears C0R0: algorithm that only suggests teams with C0R0 recorded clears (restrictive due to sample size)

Hybrid (New): combines usage rate and video clears - prioritizes solutions with video clears while also using usage rate.

Testing using Hybrid by default to see how effective it ends up being. Feedback appreciated.

## Build goals

Inside /characters/{name}, added stat goals for most characters implemented in gcsim. Updated automatically as more characters get implemented.
28 changes: 28 additions & 0 deletions src/lib/app/race-abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Reject when `signal` aborts without cancelling `promise`. */
export function raceAbort<T>(
promise: Promise<T>,
signal?: AbortSignal,
): Promise<T> {
if (!signal) return promise;
if (signal.aborted) {
return Promise.reject(
signal.reason ?? new DOMException("Aborted", "AbortError"),
);
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(err) => {
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
}
108 changes: 108 additions & 0 deletions src/lib/app/stygian-cheap-clears.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Client fetch + cache for experimental Stygian cost-capped clears.
* Roster-keyed: owned teams × Fearless clears with cost ≤ maxCost, by time.
*/

import type {
CharacterOwned,
StygianCheapClearRow,
StygianCheapClearsPayload,
StygianClearDifficulty,
} from "$lib/definitions";
import {
STYGIAN_CHEAP_CLEARS_DEFAULT_MAX_COST,
STYGIAN_CHEAP_CLEARS_DIFFICULTY,
} from "$lib/definitions";
import { raceAbort } from "$lib/app/race-abort";
import { ownedNameIds } from "$lib/utils";

const API_URL = "/api/stygian-cheap-clears";
const FETCH_TIMEOUT_MS = 15_000;

type CacheEntry = {
rows: StygianCheapClearRow[];
};

const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<StygianCheapClearRow[]>>();

function rosterKey(characters: string[]): string {
return JSON.stringify([...characters].sort());
}

function cacheKey(
characters: string[],
stygianVersion: number,
enemyIds: number[],
difficulty: StygianClearDifficulty,
maxCost: number,
): string {
const enemies = [...enemyIds].sort((a, b) => a - b).join(",");
return `${stygianVersion}:${difficulty}:c${maxCost}:${enemies}:${rosterKey(characters)}`;
}
Comment on lines +33 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Type the difficulty option as StygianClearDifficulty.

opts.difficulty and the cacheKey parameter use string. The default value comes from STYGIAN_CHEAP_CLEARS_DIFFICULTY, and the server rejects any other value with a 400 through requireStygianClearDifficulty. Use the exported union so an invalid difficulty fails at compile time instead of at request time.

♻️ Proposed refactor
 import type {
   CharacterOwned,
   StygianCheapClearRow,
   StygianCheapClearsPayload,
+  StygianClearDifficulty,
 } from "$lib/definitions";
 function cacheKey(
   characters: string[],
   stygianVersion: number,
   enemyIds: number[],
-  difficulty: string,
+  difficulty: StygianClearDifficulty,
   maxCost: number,
 ): string {
 export async function ensureCheapClears(opts: {
   owned: CharacterOwned[];
   stygianVersion: number;
   enemyIds: number[];
-  difficulty?: string;
+  difficulty?: StygianClearDifficulty;
   maxCost?: number;
   signal?: AbortSignal;
 }): Promise<StygianCheapClearRow[]> {

Also applies to: 72-82

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/app/stygian-cheap-clears.ts` around lines 31 - 40, Use the exported
StygianClearDifficulty union for the difficulty parameter in cacheKey and the
difficulty option type around the opts handling at the referenced section,
including the STYGIAN_CHEAP_CLEARS_DIFFICULTY default. Preserve the existing
validation flow while ensuring unsupported difficulty values are rejected at
compile time.


/**
* Ensure cost-capped clear rows for this roster × board are cached.
* Returns the rows (empty if none).
*/
export async function ensureCheapClears(opts: {
owned: CharacterOwned[];
stygianVersion: number;
enemyIds: number[];
difficulty?: StygianClearDifficulty;
maxCost?: number;
signal?: AbortSignal;
}): Promise<StygianCheapClearRow[]> {
const characters = [...ownedNameIds(opts.owned)];
const difficulty = opts.difficulty ?? STYGIAN_CHEAP_CLEARS_DIFFICULTY;
const maxCost = opts.maxCost ?? STYGIAN_CHEAP_CLEARS_DEFAULT_MAX_COST;
const enemyIds = opts.enemyIds.filter((id) => Number.isFinite(id) && id > 0);
if (characters.length === 0 || enemyIds.length === 0) return [];

const key = cacheKey(
characters,
opts.stygianVersion,
enemyIds,
difficulty,
maxCost,
);
const hit = cache.get(key);
if (hit) return raceAbort(Promise.resolve(hit.rows), opts.signal);

let fetchPromise = inflight.get(key);
if (!fetchPromise) {
fetchPromise = (async () => {
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const res = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
characters,
stygianVersion: opts.stygianVersion,
enemyIds,
difficulty,
maxCost,
}),
signal: timeout,
});
if (!res.ok) {
throw new Error(`stygian-cheap-clears HTTP ${res.status}`);
}
const payload = (await res.json()) as StygianCheapClearsPayload;
const rows = payload.rows ?? [];
cache.set(key, { rows });
return rows;
})();
inflight.set(key, fetchPromise);
void fetchPromise
.finally(() => {
if (inflight.get(key) === fetchPromise) inflight.delete(key);
})
.then(
() => {},
() => {},
);
}

return raceAbort(fetchPromise, opts.signal);
}
159 changes: 159 additions & 0 deletions src/lib/app/stygian-clear-videos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Client fetch + cache for Stygian clear videos (team_key × enemy_id).
* Called after the solver for the visible solution's three seats.
*/

import type {
StygianClearVideo,
StygianClearVideoPair,
StygianClearVideosPayload,
} from "$lib/definitions";
import { raceAbort } from "$lib/app/race-abort";

const API_URL = "/api/stygian-clear-videos";
const FETCH_TIMEOUT_MS = 15_000;
/** Keep in sync with MAX_TEAM_ENEMY_PAIRS in request-validation. */
const MAX_PAIRS_PER_REQUEST = 12;

function pairKey(teamKey: string, enemyId: number): string {
return `${teamKey}|${enemyId}`;
}

/** Cached clears keyed by team_key|enemy_id (empty array = known miss). */
const cache = new Map<string, StygianClearVideo[]>();
const inflight = new Map<string, Promise<void>>();

export function clearVideosCacheKey(teamKey: string, enemyId: number): string {
return pairKey(teamKey, enemyId);
}

/** YouTube video id from a watch / youtu.be / Shorts URL, or null. */
function youtubeVideoId(videoUrl: string): string | null {
try {
const u = new URL(videoUrl);
const host = u.hostname.replace(/^www\./, "").toLowerCase();
let id: string | null = null;
if (host === "youtu.be") {
id = u.pathname.replace(/^\/+|\/+$/g, "").split("/")[0] || null;
} else if (host === "youtube.com" || host === "m.youtube.com") {
id = u.searchParams.get("v");
if (!id) {
const shorts = u.pathname.match(/^\/shorts\/([^/]+)/);
id = shorts?.[1] ?? null;
}
}
if (!id || !/^[\w-]{6,}$/.test(id)) return null;
return id;
} catch {
return null;
}
}

/**
* YouTube thumbnail URL, or null for non-YouTube / unparseable links.
* Prefers maxres (1280×720, true 16:9); callers should fall back on error —
* maxres is missing for some older videos.
*/
export function youtubeThumbnailUrl(videoUrl: string): string | null {
const id = youtubeVideoId(videoUrl);
if (!id) return null;
return `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`;
}

/** Reliable HQ fallback when maxres 404s (480×360, often letterboxed). */
export function youtubeThumbnailFallbackUrl(videoUrl: string): string | null {
const id = youtubeVideoId(videoUrl);
if (!id) return null;
return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
}

/** Clears already in cache for this pair (including empty). */
export function getClearVideosCached(
teamKey: string,
enemyId: number,
): StygianClearVideo[] | undefined {
return cache.get(pairKey(teamKey, enemyId));
}

/**
* Ensure clear videos for these pairs are cached. Only requests missing keys.
* Returns a map of pairKey → clears (empty arrays for misses).
*/
export async function ensureClearVideos(
pairs: StygianClearVideoPair[],
signal?: AbortSignal,
): Promise<Map<string, StygianClearVideo[]>> {
const unique = new Map<string, StygianClearVideoPair>();
for (const p of pairs) {
if (!p.team_key || !Number.isFinite(p.enemy_id) || p.enemy_id <= 0) continue;
unique.set(pairKey(p.team_key, p.enemy_id), p);
}

const missing: StygianClearVideoPair[] = [];
const waits: Promise<void>[] = [];
for (const [key, pair] of unique) {
if (cache.has(key)) continue;
const pending = inflight.get(key);
if (pending) {
waits.push(pending);
} else {
missing.push(pair);
}
}

for (let i = 0; i < missing.length; i += MAX_PAIRS_PER_REQUEST) {
const chunk = missing.slice(i, i + MAX_PAIRS_PER_REQUEST);
const fetchPromise = (async () => {
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const res = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pairs: chunk }),
signal: timeout,
});
if (!res.ok) {
throw new Error(`stygian-clear-videos HTTP ${res.status}`);
}
const payload = (await res.json()) as StygianClearVideosPayload;
const byPair = new Map<string, StygianClearVideo[]>();
for (const p of chunk) {
byPair.set(pairKey(p.team_key, p.enemy_id), []);
}
for (const row of payload.clears ?? []) {
const key = pairKey(row.team_key, row.enemy_id);
const list = byPair.get(key);
if (list) list.push(row);
else byPair.set(key, [row]);
}
for (const [key, list] of byPair) {
cache.set(key, list);
}
})();

for (const p of chunk) {
inflight.set(pairKey(p.team_key, p.enemy_id), fetchPromise);
}
void fetchPromise
.finally(() => {
for (const p of chunk) {
const key = pairKey(p.team_key, p.enemy_id);
if (inflight.get(key) === fetchPromise) inflight.delete(key);
}
})
.then(
() => {},
() => {},
);
waits.push(fetchPromise);
}

if (waits.length > 0) {
await raceAbort(Promise.all(waits), signal);
}

const out = new Map<string, StygianClearVideo[]>();
for (const key of unique.keys()) {
out.set(key, cache.get(key) ?? []);
}
return raceAbort(Promise.resolve(out), signal);
}
Loading