-
Notifications
You must be signed in to change notification settings - Fork 0
Add video links for Stygian recommendations, build goals for simmed characters, alter Stygian algorithm #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
194e466
stat goals
woopxwoop 68fb89b
update patch workflow
woopxwoop f45cc7f
workflow_dispatch in patch notes yml
woopxwoop 8984888
revert
woopxwoop 8b02109
stat goals
woopxwoop 39b7747
Merge branch 'stat-goals' of https://github.com/woopxwoop/lightkeeper…
woopxwoop c4ace8f
Merge pull request #44 from woopxwoop/stat-goals
woopxwoop 083b464
stygian clears
woopxwoop e33b09f
implement into stygian page
woopxwoop 8247d0a
fix md
woopxwoop d8040ef
cr
woopxwoop 1e1299c
Merge pull request #45 from woopxwoop/stygian-videos
woopxwoop 3b122e7
cr
woopxwoop bd762d2
cr
woopxwoop 437c4c5
Merge pull request #47 from woopxwoop/stygian-videos
woopxwoop File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }, | ||
| ); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)}`; | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.difficultyand thecacheKeyparameter usestring. The default value comes fromSTYGIAN_CHEAP_CLEARS_DIFFICULTY, and the server rejects any other value with a 400 throughrequireStygianClearDifficulty. 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";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