Render community-mod cards, relics, and potions#496
Open
ptrlrd wants to merge 15 commits into
Open
Conversation
Foundation for resolving modded entity art as a third fallback after main and beta on the run and live pages. data/mods.json is a vetted directory of mods whose image repos are named by the in-game entity id (lowercased), matching the official convention, so a modded id maps straight to a raw URL (<raw_base>/<paths[type]>/<id>.<ext>). /api/mods serves the directory. Seeded with WatcherMod (lamali292/WatcherMod), whose relics/cards/potions follow the convention (relic DAMARU -> relics/damaru.png, verified 200 on raw GitHub). Frontend wiring (resolve unresolved ids to the mod URL, with a placeholder as the final fallback) is the next step, since it touches several entity-image sites across the live and run components and is best done with one shared resolver.
The run and live pages fall back to modded art after main and beta by fetching a mod repo's id-named images. tools/ingest_mod.py takes a mod key from data/mods.json plus a source (a loose image folder or a Godot .pck unpacked statically with GDRE), normalizes each per-type folder to id-named webp, and stages it under extraction/mods/<key>/<type>/<id>.webp ready for CDN upload. It reads the per-type subpaths and source extension from data/mods.json so the input matches the layout the frontend already expects, ignores Godot .import sidecars, and lowercases ids to match the in-game id convention. Verified on WatcherMod: 92 cards and 9 relics staged.
The run and live pages render an entity from its catalog entry after
falling back main -> beta -> mod. This adds tools/parse_mod.py, which
turns a mod's C# card/relic/potion classes plus its localization JSON
into the same per-language catalog shape the site already renders for
base entities, written to data-mod/<key>/<lang>/.
It reuses the base-game parser machinery: description_resolver bakes the
SmartFormat tokens ({Damage:diff()}, {IfUpgraded:show:...}, [gold] tags)
into final text exactly as for base cards, and the 33-field card schema
and enums come from card_parser. The mod-specific part is reading
mechanics from the fluent builder calls (WithDamage, WithBlock, WithVar,
WithKeyword, WithCostUpgradeBy, WithCalculatedDamage, HasEnergyCostX)
rather than the decompiled game's DynamicVar declarations, and namespacing
ids with the mod prefix (Eruption -> WATCHER-ERUPTION) so they never
collide with base ids.
The mod's repo layout, id prefix, color, and languages come from its
data/mods.json entry. Verified against WatcherMod: 90 cards, 8 relics,
4 potions across 7 languages, with cost, type, rarity, damage/block,
custom vars, keywords, X-cost, and upgrade descriptions all resolving
correctly. One potion (Bottled Miracle) shows its count as the runtime
"X" because that number lives in an effect method rather than a builder
call; everything else bakes its numbers in.
Add a data-mod loader and a GET /api/mods/{key}/{entity} endpoint so the
run and live pages can resolve modded card/relic/potion metadata as the
third source after stable and beta.
load_mod_entities reads data-mod/<key>/<lang>/<entity>.json with a
per-language fallback to English and no stable fallback (modded entities
exist only in the mod tree), keyed in an lru_cache by (key, lang, entity).
The endpoint validates the entity type and mod key, returns the same
per-entity shape the stable /api/<entity> endpoints return, and 404s on an
unknown mod or entity.
Base and beta cards get full card images from the game engine; mods have no engine, so the site's CardRender component is the renderer. This adds the pipeline that turns the data-mod catalog into the same full card images the rest of the site already serves. - frontend/app/internal/mod-render: a bake-only render surface (gated behind ENABLE_MOD_RENDER, 404 otherwise) that draws one card with CardRender on an opaque backdrop, so headless Chrome can screenshot it with no site chrome bleeding in. - tools/bake_mod_cards.py: drives Chrome against that route for every card (base and upgraded), keys out the backdrop to transparency, and writes extraction/mods/<key>/cards-full/<id>.webp. - tools/upload_mod_assets.py: syncs the baked cards and the ingested portraits to R2 at the prefixes the frontend resolves (cards-full/mods/ and mods/<key>/...), mirroring the beta image push. Dry run by default. Verified end to end on WatcherMod: cards render with the correct frame, cost orb, name, portrait, and baked description, and the upgraded variants show the green cost/value changes. The baked tree is staged under extraction (gitignored); the upload step ships it at deploy time.
Wire the third fallback so modded cards/relics/potions render after stable
and beta, on the two pages where modded runs actually show up.
- image-url.ts: a mod id-prefix registry (setModPrefixes) populated from
/api/mods, plus modPrefixForId. fullCardUrl routes a modded id (one
carrying a registered prefix, e.g. WATCHER-ERUPTION) to the baked
cards-full/mods/ tree, so every existing call site resolves a modded
card image with no per-site branching.
- The run page detects which mods a run references (by id prefix), registers
their prefixes, and merges each mod's catalog into the card/relic/potion
maps after the beta merge, gap-fill only: precedence main > beta > mod.
- The live page loads its catalogs in one pass and gap-fills the mod
catalogs the same way (it had no beta/mod merge before), so the many
modded live players resolve their entities.
- The /api/mods/{key}/{entity} endpoint returns a bare list to match the
stable /api/<entity> shape the client already consumes.
Modded entities resolve their name and art; until the baked images are
uploaded, the existing onError chains fall back to the portrait then blank.
This was referenced Jun 16, 2026
Three issues in the baked card exports: - Color: the Watcher mod themes its cards purple (the C# sets hue 0.8 and a violet energy color), but the catalog defaulted them to colorless, so they framed grey. The frame system only ships five pre-tinted colors, so map the mod to necrobinder, the closest purple with real frame and orb assets. Set via the mod's color in data/mods.json and reparsed. - Clipped energy orb: the cost orb overhangs the card's top-left corner, so a window sized flush to the card cut it off. The bake route now centers the card and the window carries a margin; chroma_key crops back to the true bounds, orb included. - Dev-mode logo: baking against the dev server painted the Next.js dev indicator into the corner of every card. Bake against a production build instead; the tool docstring now says so.
The 1:1 card renders come from the game engine (GENERATING_CARD_RENDERS.md), so the CSS reconstruction approach is dropped in favor of a headless dockerized render pipeline that runs the game with the mod loaded. Added: - tools/render-exporter: a BaseLib mod that, when STS2_RENDER_OUT is set, renders every card in ModelDb.AllCards (filtered to a mod's id prefix) with the game's own NCard renderer and saves PNGs. A typed port of the compendium payload's card export; inert during normal play. Because the content mod registers its cards in AllCards, modded cards render 1:1 with the engine. - tools/render-container: a multi-stage image (steamcmd installs the native Linux build; Xvfb + Mesa llvmpipe for software GL since Godot --headless draws nothing) that stages BaseLib + the content mod + the exporter, runs the game, waits for the _all_done sentinel, converts PNG to webp, and syncs to cards-full/mods/<modid>/. convert-cards.sh is verbatim from the doc. Changed: - fullCardUrl routes a modded id (by prefix -> mod key) to cards-full/mods/<key>/<id>.webp; setMods registers the prefix->key map from /api/mods. The run and live pages call setMods. - upload_mod_assets.py now handles only the portrait art; the container ships the full renders. Removed: the CSS reconstruction (tools/bake_mod_cards.py, the mod-render route). The data-mod catalog, /api/mods serving, and the metadata gap-fill stay; they provide the card name/cost/description text that complements the engine image. The full container run (steamcmd login, native launch, render, R2 sync) can only be validated on a machine with the game license, a display, and the R2 keys; the mod source, Dockerfile, and scripts are written and lint-checked.
2fe960c to
2785664
Compare
Keep credentials off the command line: docker compose auto-loads .env from the render-container dir, so STEAM_USER / STEAM_PASS and the run knobs live there. - .env.example documents every var; .env is gitignored. - run.sh requires .env and drops the inline-cred requirement (inline env still overrides for one-offs). - README Run section uses the .env flow. - Also replaced stray em-dashes in the render-container/exporter files.
Verified by building the mod-compile stage in the container. Fixes: - Book.StS2.RefLib version *-* so it resolves the 0.107 prerelease ref build (plain * pulled the stale 0.103 stable and missed 0.107 APIs). - Fully-qualify System.Environment (Godot also defines Environment). - using MegaCrit.Sts2.Core.Entities.UI for ModelVisibility. - AnimatedSprite2D.Stop() instead of the Godot 3 Playing property. - ModelDb.AllCards.Count() (it is IEnumerable, so Count is a LINQ method) plus using System.Linq.
…t it
steamcmd ran as the non-root steam user but installed to /game under root-owned
/, which it could not create ("Disk write failure"). Pre-create /game owned by
steam in the image, add a game-data volume so the ~3GB install persists and the
volume inherits steam ownership, and have run.sh rebuild the image each run so
Dockerfile changes take effect.
The game errored into NGame.TryErrorInit and never loaded mods/ModelDb because SteamAPI_Init could not dlopen ~/.steam/sdk64/steamclient.so. Symlink steamcmd's steamclient.so into the SDK paths and export SteamAppId/SteamGameId so init bootstraps from the cached steamcmd login.
The steam-config named volume mounted ~/.steam root-owned, so the non-root steam user could not mkdir ~/.steam/sdk64 for the steamclient.so symlink and the entrypoint aborted under set -e right after locating the game binary. ~/.steam does not need to persist (the login sentry is in ~/Steam), so it now lives in the steam-owned home and is rebuilt each run.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Render community-mod cards, relics, and potions on the run and live pages, as a third source after main and beta.
Supersedes #493, #494, #495.
How modded cards get their image
The 1:1 card images are produced by the game engine, the same way base/beta cards are (per
GENERATING_CARD_RENDERS.md), not by a CSS reconstruction. This MR adds a headless render pipeline that runs the game with the mod loaded:tools/render-exporter/- a BaseLib mod (typed port of the compendium card renderer). WhenSTS2_RENDER_OUTis set it renders every card inModelDb.AllCards(filtered to a mod's id prefix) with the game's ownNCardrenderer and saves PNGs. Inert during normal play. Modded cards are inAllCardswhen the mod is loaded, so they render 1:1.tools/render-container/- a multi-stage image: steamcmd installs the native Linux build, the game runs under Xvfb + Mesa llvmpipe (Godot--headlessdraws nothing), the exporter renders to a volume, then PNG->webp and an R2 sync tocards-full/mods/<modid>/.Catalog + resolution (the text + wiring)
tools/parse_mod.pyturns the mod's C# classes + localization into the site's 33-field catalog shape atdata-mod/<key>/<lang>/, reusingdescription_resolverso descriptions bake identically. This supplies the name/cost/description text that complements the engine image.GET /api/mods/{key}/{entity}(data-modloader).fullCardUrlroutes a modded id (by prefix -> mod key, from/api/mods) tocards-full/mods/<key>/<id>.webp; the run and live pages gap-fill the mod catalog so names/art resolve (precedence main > beta > mod).tools/ingest_mod.py+upload_mod_assets.pyship the portrait art (catalogimage_url).Verified vs not
tools/render-container/README.md.Notes
data-mod/is committed likedata-beta/.