feature/better-normalization #1

Merged
LordAndradus merged 21 commits from feature/better-normalization into main 2026-07-04 02:21:16 +00:00
Owner

Getting workflow into main so version bumps will auto-update instances of the plugin

Getting workflow into main so version bumps will auto-update instances of the plugin
Adversarial live-Steam verification (226 filenames) surfaced 17 real false SSOT
maps in the prior confidence logic; all fixed:

meetsConfidence (delicate core):
- Screen the CANDIDATE's extra tokens too, not just the query's — rejects a base
  name grabbing a different specific entry: Devil May Cry->DMC5, Sid Meiers
  Civilization->Civ VII, Crash and Burn->...Racing, Fire and Ice->Beyond:...,
  Wolf Among Us->...2, Command and Conquer Red Alert->...3.
- Require >= 2 query tokens: single-token titles are too generic to fuzzy-claim
  (Okami->Ookami Okami clone).
- Remove the prefix ("Path B") acceptance entirely — proven unable to tell a
  unique game's edition subtitle from a franchise entry (Tomb Raider: Anniversary,
  Silent Hill f, Tales of Berseria Remastered; an edition word IS often a distinct
  game). Tradeoff: short filename -> subtitled store title (e.g. "Boobs saga" ->
  "BOOBS SAGA: ... Edition") no longer auto-maps — use [steamid=] tag or rename.

name-normalizer:
- foldToAscii (fullwidth, Unicode Roman numerals, accents via canonical NFD so
  fractions/superscripts are NOT expanded to digits, & -> and), applied
  symmetrically in toAlphaLower + confidenceTokens.
- Guarded destructive strips: junk brackets/parens (keeps a bare year), Build/r####
  version tags, curated scene groups ONLY after a '-' separator (Portal.Reloaded
  keeps its real title word).
- confidenceTokens: apostrophe-join (Jerez's->jerezs) + guarded leading-"The" drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
The "your title is a prefix of a longer store title" shape is ambiguous on the
storefront alone — "Boobs saga" -> "BOOBS SAGA: Prepare To Hentai Edition" (same
game) is string-identical to "Tomb Raider" -> "Tomb Raider: Anniversary" (a
DIFFERENT game). Add corroboratedPrefix(): accept that shape ONLY for the appid
an INDEPENDENT web-search resolved for the query, gated on an edition-word suffix
and no sequel/DLC. Web-search returns the MAIN game for a franchise (Tomb Raider
-> the 2013 reboot GOTY, never the 2007 Anniversary) and the unique game for a
one-off, so the franchise sibling never reaches the check.

Verified live against the plugin's SearXNG: Boobs saga -> 726360 (claim);
Tomb Raider -> 203160 main game (claim, correct); Silent Hill -> SH2 (reject,
sequel); Tales of -> Arise / Crash and Burn -> Racing (reject, no edition word);
Sonic / Metro -> single-token (reject); Half-Life -> Alyx (reject).

Applied ONLY in getBestMatch step 3 (web/explicit-resolved appid), never on raw
storesearch siblings, so meetsConfidence stays strict on the storefront path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
Adversarial review (Codex) found the bracket junk regex matched as a SUBSTRING:
"(Goggles)" was stripped because "GOG" is inside it (same for crack/codex/rune...).
- \b-wrap the bracket junk alternation so groups/keywords match as whole words.
- Split scene groups: HARD (SKIDROW, FitGirl, GOG, ...) strip even inside brackets;
  SOFT (CODEX, RELOADED, RUNE, PROPHET, Chronos — also real words) strip ONLY after
  a trailing "-" separator, so "Runescape (Prophet)" / "… (Codex)" keep their text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
Claude adversarial workflow (4 dimensions) confirmed two real wrong-SSOT claims:

1. Censorship-marker strip over-reached. `/\b(Uncensored|Adult|NSFW|...)\b.*$/`
   deleted the marker AND every word after it, so a MID-title marker collapsed a
   distinctive title to a generic word: "Love Adult Adventure" -> "Love", which then
   exact-matched the unrelated Steam game "LOVE" and claimed SSOT. Critical for the
   *unfiltered* (adult) plugin whose titles legitimately contain Adult/Uncensored.
   Fix: strip only a TRAILING run of marker/edition/junk words (CENSOR_TRAILING);
   a real word after the marker cancels the strip. Trailing tags still strip
   ("Adorable Witch Uncensored" -> "Adorable Witch").

2. foldToAscii "&"->"and" collided distinct titles in the EXACT-alpha gate:
   toAlphaLower("s&box")="sandbox"==toAlphaLower("Sandbox") -> a library "Sandbox"
   claimed Facepunch's s&box (590830). REGRESSION introduced by routing toAlphaLower
   through foldToAscii. Fix: drop "&"->"and" from foldToAscii; the fuzzy path still
   treats them as equivalent because "and" is an EDITION_WORD.

Also (Codex): \b-word-boundary the bracket junk regex + hard/soft scene-group split
(prev commit) so "(Goggles)"/"(Prophet)" keep their text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
The "-1" no-match sentinel is a sticky mapping — once Steam-Unfiltered "owns" a
game (even as a failure), getBestMatch isn't re-run, so a transient web-search
outage (SearXNG engines rate-limited/CAPTCHA'd) got baked in permanently. Now the
web-search client distinguishes an OUTAGE from a genuine miss and the game is left
UNMAPPED to retry.

websearch-client.ts:
- searchWith/providers return {results, ok} — ok = the engine actually responded
  (vs blocked/CAPTCHA/errored). SearXNG: 0 results + non-empty unresponsive_engines
  = not ok. DDG: 0 result URLs after both endpoints = soft-blocked = not ok.
- findSteamAppId throws WebSearchUnavailableError when NO appid was found AND any
  preferred engine was unavailable (the weak fallback's miss isn't authoritative
  when the reliable engine is down). A genuine miss (all engines responded, nothing
  found) still returns undefined.

service:
- getBestMatch step 3 catches WebSearchUnavailableError -> throws NotFoundException
  (no sentinel) so the game stays unmapped and re-discovers next scan/recache.
- search() catches it -> shows storefront candidates only (never errors the remap UI).

Verified live against the currently-rate-limited SearXNG: findSteamAppId now throws
WebSearchUnavailableError instead of returning undefined.

BACKLOG: Mojeek needs its own result parser (plain anchors, not DDG uddg redirects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
- Extract the pure matching predicates (meetsConfidence, corroboratedPrefix,
  confidenceTokens, tokenMatch, isSequelToken + the word sets) out of the provider
  service into matching.ts so they can be UNIT-TESTED standalone (the service imports
  GameVault backend entities and can't load in a test). Behavior-preserving
  (Codex-verified); service rewired to import them.
- Add corroboratedAcronym: "Rustil ELC" -> "Rustil: Eternal Labyrinth Castle" (ELC =
  the initials of the trailing words). Wired into getBestMatch STEP 3 ONLY (the
  web-search-resolved appid), never raw storesearch siblings. Guards: 3-5 uppercase
  acronym, NOT a roman numeral of any length / edition / DLC word; a >= 5-char
  mixed-case distinctive prefix must positionally match; the acronym letters must
  equal the initials of the candidate's remaining words, consuming the ENTIRE
  remainder.
- Unit tests (node's built-in node:test via tsx, zero new deps): matching.test.ts,
  run with `npm test`.
- Adversarially verified: Codex (2 findings fixed — roman >xii gap, distinctive-prefix
  floor 4->5) + a 4-vector probe workflow (0 confirmed mis-maps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
CAPTCHA solver rejected (arms race / heavy deps); SteamDB-via-Byparr considered &
shelved (IP-ban risk + circumvents a deliberate protection + Chromium sidecar).
Default stays the polite path: throttled web-search + [steamid=] tag. Reliability
upgrades = Mojeek (needs its own parser) or Brave API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
buildRecoveredMetadata left metadata.title unset (the old "keep the filename via
merge" idea), so a delisted game — which has no storefront name to fall back on —
showed the mapped Title as N/A. Scrape the game's real title from the archived store
page (apphub_AppName, then og:title/<title> with " on Steam" + "Save N% on" stripped)
in WaybackMediaClient, carry it on RecoveredMedia.title, and set it in
buildRecoveredMetadata. A title-only recovery now also caches.

Verified live: findMedia(1605010) -> title "Adorable Witch".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
Bundled static binary via child_process — no key, no service (unlike SearXNG/Byparr);
defeats fingerprint blocking, not IP-volume; pair with throttle + multi-engine. Prototype
+ measure before committing to a binary-in-dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChjiJZ7fLwNZBFZziKUkEG
Replaces the sandbox-only copy: deploys dist/ into the stack's shared
./plugins with a staged directory swap, reloads both backend instances,
optional --fresh wipe of the disposable spicy DB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
Three new acceptance paths, each verified live against Steam:
- corroboratedAcronym gains a minLetters tier: >=4-letter acronyms may match
  raw storefront candidates (YBFON -> 'One Night ~Young Bride for One
  Night~'); 3-letter stays web-corroborated-only. DLC/demo/soundtrack words
  can never supply acronym initials.
- corroboratedSubtitle + uniqueExpansionAppId: single-brand subtitle
  expansions ('Menherarium' -> 'Menherarium: Deadly Dice') accepted only when
  web-search resolved the same appid AND the storefront census is unambiguous
  (at-cap pages refused, sequel/year shadows count as franchise evidence).
- confidenceTokens folds multi-char roman numerals (II..XX) to digits so
  'Mount & Blade 2 - Bannerlord' == 'Mount & Blade II: Bannerlord';
  single-letter romans excluded (Mega Man X != Mega Man 10), and
  confidentStorefrontClaim vetoes fold-only matches when a year-disambiguated
  reboot sibling exists (MW2 2009 must not claim MW2 2022).
- foldToAscii range extended through precomposed XI/XII (U+216A/B) — 'Final
  Fantasy XI' no longer collapses to 'FINAL FANTASY'.

Every finding from the adversarial review pass is fixed and pinned with
mutation-killing fixtures (25 matching tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
An engine that fails to respond (CAPTCHA/429/timeout) is skipped instead of
re-poked — re-poking a soft-blocked engine prolongs the block. Public engines
(DDG/Brave) cool down 1h; self-hosted SearXNG 5min (skipping your own box
just diverts traffic to the engines the breaker protects). A cooling engine
still counts as UNAVAILABLE so misses stay non-authoritative, and an
all-cooling chain fails fast with zero network. 0 disables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
getBestMatch gains step 2.5 (storefront acronym, gated behind the explicit
[steamid=] pin) and the subtitle+census disjunct at step 3; step 2 goes
form-aware via confidentStorefrontClaim. search() hoists acronym expansions
and skips web-search when one fired. parseNumber treats blank env vars as
unset (review finding: blank must not silently disable safety features).
Adds WEBSEARCH_COOLDOWN_MS/_SEARXNG_COOLDOWN_MS and the AUTO_UPDATE/
UPDATE_* env surface consumed by the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
The P0 (docs/auto-update-design.md), e2e-tested in the two-instance stack:
- verify chain: HTTPS-only/no-redirects -> ed25519 signature over exact
  manifest bytes (pinned pubkeys) -> monotonic semver floor (replay guard) ->
  bundle URL by convention -> SHA-256 before extraction -> sanitized untar
  with the marker DISARMED from the first byte -> syntax pre-flight (failures
  poison that version) -> atomic disarmed swap under a cross-instance lock
- at most ONE armed marker ever exists on disk; janitor repairs every crash
  window; defused .rollback dir = two-rename SMB recovery
- stage-only: disk updates, running code applies at next natural restart
  (AUTO_RESTART is deliberately v2); fully inert when AUTO_UPDATE=false;
  hard-disabled on the dev-mode plugin volume
- release.ts: init-keys (scrypt+AES-GCM key envelope, outside the repo tree),
  pack (deterministic tarball), prepare (sign + commit manifest), verify
  (CI proves its rebuild matches the SIGNED manifest — CI holds no keys)
- .forgejo/workflows/release.yml: gate -> boot-gate (real backend container)
  -> publish (bundle first, manifest LAST) on protected v* tags, zero
  configured secrets, dedicated repo-scoped 'ludonexus' runner

All dependency-free: node:crypto ed25519, hand-rolled ustar, no new deps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
- docs/auto-update-design.md: DP1-DP5 decisions, loader ground truth, e2e
  validation record, accepted risks, build order
- docs/forgejo-ci-setup.md: dedicated-runner setup runbook (unit, config,
  socket, verification, troubleshooting, security posture)
- README: websearch env table, release + self-update usage, runner checklist
- BACKLOG: P0 locked->built; preservation-cache item captured

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
npm version <patch|minor|major> --no-git-tag-version now bumps package.json
and plugin-version.ts in lockstep; npm run release = build + test + sign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
npm run release now refuses to re-cut a used version: if the current
version's signed manifest already exists under release/, it patch-bumps
both version files first. A deliberate npm version minor/major still wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
auto-bump -> build -> test -> sign -> commit -> tag -> push. The passphrase
prompt is the release decision; there is no sequence to forget at 2AM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
Non-blocking: prints 'nothing ships until npm run ship' whenever a plain
push contains commits no release tag covers. Enable per clone with:
git config core.hooksPath .githooks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
New machines: clone + npm install = pre-push guard active. Guarded with
'|| exit 0' so environments without git config access don't break installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DL9ooECq5YjPm98QhdDow
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
LordAndradus/ludonexus-plugin-steamstore!1
No description provided.