Skip to content

v0.79.0 — the generators reach the models you already chose

Two phases with one root cause between them: a capability existed and the surface that wanted it had no way to ask. Phase M gives genai a headless half so any platform surface can generate an image without rendering the module's panel. Phase G moves System 8 monster generation out of the platform and into the module that owns the rules.

The three larger features that were listed here as bullets — Retouch upsampling, background-token resize, and a second image-search provider — are not in this release. Each was written up as its own spec under docs/committed/ and is carried by v0.80.0.

Phase M - the generators reach the models the user already chose

The monster generators have an image problem and a naming problem, and both come from the same place: genai can generate an image from a prompt, and nothing outside genai can ask it to. So MonsterArtworkPicker auto-fetches art the only way it can - a Google Image search on "${name} ${type}" - and a level 7 orc skirmisher with Arcane Archer levels comes back as whatever the open web has under "orc humanoid". The generator knows far more about the monster than the query it is allowed to send.

The AI tab has the mirror of it. "Generate with AI" produces a stat block and no art at all, even though the user picked an image model minutes earlier in the artwork editor, and even though the prompt they typed is a better image prompt than anything the algorithm can derive.

M.1 comes first because everything after it wants the answer to "which model did the user last pick?" and today nothing records it. M.2 is the extension point the rest depends on.

  • [x] M.1 - A model chosen by hand becomes the default for its kind. Every model dropdown outside Settings is per-surface and forgets on close, so a user who has settled on Nano Banana Pro re-picks it in the artwork editor, in the encounter generator, and in the monster generator, every time - and Settings never hears about it. Choosing a model by hand records it as the default for that domain, image or language, and every dropdown of that domain opens on it.

    It writes into Phase I's own `imagePreference` rather than beside it, and adds the
    symmetric `languagePreference`. Two stores would have been two competing answers to
    "which image model?" - the one Settings shows and the one the generators use - and the
    failure mode is a user changing it in Settings and watching nothing change. There is one
    preference; this is a second way to set it. `chooseInitialModel` handles the case that
    makes it not a lookup: a remembered model disappears when a provider deprecates it, when
    its key is removed, or when it is disabled in Settings.
    
  • [x] M.2 - Modules can contribute an image generator, not just an artwork panel. registerArtworkSource hands the platform a component, which is right for the token artwork editor and useless everywhere else: the monster and encounter generators want an image for a monster they just rolled, with no UI of the module's own and no interaction. ImageGeneratorExtension is the headless half - listModels() and generate() - so any platform surface can ask. MODULE_API_VERSION 1.34.0.

    `generate()` is a function call rather than a platform POST to
    `/api/modules/genai/generate`, and that is the load-bearing decision: genai v0.6.0 moved
    generation direct-to-provider because returning image bytes as base64 through the server
    OOM-killed a 512 MB container. So the request carries `authFetch` and `getImageGenKey`
    and the module keeps deciding, per call, whose key it is spending.
    
    `ArtworkSourceProps.onPreferredImageModelChange` rides along, finishing M.1: I.3's
    `preferredImageModel` could only be set in Settings, which left a module's own dropdown a
    dead end - switch model, generate, come back tomorrow, get the old default. A host with
    no generator registered is exactly 1.33.0 behaviour.
    
  • [x] M.3 - Auto-artwork has three states, and the third one is the good one. The Auto switch becomes off / Search / Generate, defaulting to Generate whenever a module has registered a generator that offered models, and to Search when none has - which is exactly today's behaviour, and what every deployment without genai keeps. Generate is omitted rather than disabled where it is unavailable; a disabled option advertises a feature nothing in the modal can enable.

    The prompt is the difference. `buildMonsterArtPrompt` describes the monster the
    generator actually produced - level, ancestry, role, classes, and up to four powers or
    feats - where `searchGoogleImages(`${name} ${type}`)` sent two words. Search keeps the
    short query, because a search engine matches documents and a descriptive sentence
    matches nothing; the two modes want different strings, which is why both live in one
    file rather than one being reused for the other.
    
    A model dropdown sits beside the control. Changing it re-queries, the refresh button
    re-queries the selected model, and the pick is remembered as the user's default (M.1).
    
  • [x] M.4 - The encounter generator gets the same control. MonsterArtworkPicker is a near-copy of renderArtworkCell here, by a deliberate v0.25.0 decision, and the copy has been drifting since. M.3's hook and control are the pieces worth sharing, so the encounter generator uses them rather than growing its own third state. Its own searchGoogleImages call and its autoSelectArt boolean are gone, with a test asserting both stay gone.

    A group's subject is thinner than a monster's - no ancestry, no level - but the CR and the
    combat role are real, and size and type come from a monster the group already generated.
    The all-empty guard on re-acquisition stays: without it, opening the mode control would
    discard art the user had already curated in the drawers.
    
  • [x] M.5 - The algorithmic generator suggests a name worth keeping. Custom Monster (Level 7) is what a template can produce, and it is the one part of a generated stat block a GM always rewrites by hand. POST /api/ai/suggest-token-name sends the race, level, role, classes, powers and feats and gets back Grok-nar, Battle-Hardened Chief of the Iron-Skull Clan.

    The suggestion fills an editable field with a sparkle button beside it; it is never
    applied silently, and what the user types always wins at insert. The field is separate
    from the Configure tab's Monster Name, which is an *input* to generation - sharing one
    would have made a re-roll on the preview quietly change the next generation.
    
    Gated on the genai module being loaded, which is a product decision rather than a
    technical need (the LLM plumbing is the platform's) and is confined to
    `nameSuggestionAvailable` so it is one line to revisit. Also hidden on the in-browser
    path, where there is no server route to call. Temperature is 1.0, not the parsing
    routes' 0.1: a re-roll button that returns the same name twice is a broken button.
    
  • [x] M.6 - Generate with AI generates the image too. The AI tab gains an image-model dropdown beside the language model, and one click fires both: the stat block and the portrait, concurrently, from the one prompt the user already wrote. The image is started before the stat block and awaited after it - sequential would make the tab take as long as both, and the image is usually the slower half.

    Neither failure blocks the other. `generateAiArtwork` resolves to `null` rather than
    rejecting, because a stat block with no art is exactly what this tab produced before
    M.6.
    
    The image dropdown carries its own "No image" option instead of sitting beside a
    checkbox - two controls for one decision, and a greyed dropdown next to an unticked box
    reads as broken. Both dropdowns record the pick as the user's default (M.1), except
    Automatic, which is the absence of a choice and would clobber a real one. The language
    choice rides on `x-ai-model`, overriding the task preference for this request only.
    
  • [x] M.7 - Bump modules.hosted.json to @purple-cat-games/genai 0.9.0. Last, and only once the module is published, for the same reason as G.4: the pin names an exact version, so bumping it ahead of the publish points the host at a package that does not exist.

    Worth noting while doing it - **the pin is at 0.6.1, and 0.7.0 and 0.8.0 both shipped.**
    A deployed host has therefore never run the registered image operations (0.7.0) or the
    preferred-model picker (0.8.0), and I.4's "genai opens on the preferred model" is inert
    in production today for that reason rather than for anything wrong with either release.
    Until this lands, a deployed v0.79.0 runs a genai with no image generator registered,
    which M.2 was written to survive: the artwork controls offer Search and Off, and no
    Generate.
    

Phase G — Generate Monster asks the module

The System 8 monster generator does not live in the System 8 module. It lives here, in eight apps/web/src/utils/system8*.ts files and four System8*Editor.tsx components, and it is the larger of the two implementations — the module's own generateCharacter assigns an ability array and stops, because when it was written the module had no way to read System 8's powers and feats. Module v0.13.0 removed that reason: the content ships inside the package now.

So the app fetches /api/modules/system8/{powers,feats} in a useEffect, holds the rows in component state, picks the class, rolls the array, fills the power and feat slots and assembles the Character — all of it System 8 rules, none of it in System 8. The leakage guard has the count baselined; this is the ratchet.

Three requested behaviour changes ride along, and all three are reasons the split hurts: the role dropdown is labelled "class" in the one place that still shows the legacy favor table, the generator cannot be told which class to build toward, and it re-rolls the subclass on every power pick so a "Death Knight" comes out holding one power from each of Fighter's five subclasses. Every one of those is a rules decision, which is to say the module's.

  • [x] G.1 — The seed can carry what was asked for. Module API 1.32.0 adds MonsterSeed.name, .classPreference and .subclassPreference. M-2's seed has been "level + template + rngSeed" since it was written, which is enough to generate A monster and not the one the GM asked for — so every choice past the template necessarily stayed in the app. All three optional; absent is exactly 1.31.0 behaviour. classPreference is a string rather than an enum because class names are the system's vocabulary; only 'auto' and 'monster' are the platform's. Adds Template.castingStats and SystemClass.castingStats alongside — the plural of the long-standing spellcastingAbility, for systems where the class picks (a Witch keys off Int, Wis or Cha) — so the app can decide whether to show the Casting Stat control without knowing which roles are casters. Artillery Flavor needs no such field: it only ever narrowed the class roster, which is what Class Preference now does.

  • [x] G.2 — System 8 owns generation end to end (module v0.15.0, released separately). The roles, the ability array, the power and feat auto-fill, the derived stats and the Character assembly move into @purple-cat-games/system8, which fetches its own content rather than being handed it. Adds Class Preference and Subclass Preference, and drops the class→role misnaming. Bump modules.hosted.json once it is published.

  • [x] G.3 — Generate Monster becomes a form, and what moved is deleted. The System 8 branch of MonsterGenerator.tsx loses the Template/Manual toggle, the favor-weight sliders, the Monster Preset and Race Name, and gains Class Preference and Subclass Preference. It calls generateCharacter(seed) on the campaign's own module and renders the result; it decides nothing. Artillery Flavor goes too — it only ever narrowed the class roster, which is what Class Preference does now.

    Manual mode goes rather than moves. Create Token ▸ **New Monster** already opens a blank
    character sheet, which is a better hand-editor than four modal sub-editors reimplementing
    one against a second copy of the rules.
    
    The deletions are the same commit because they are what makes the rewrite compile: eight
    `system8*` utils, `seededRandom.ts`, four `System8*Editor` components and their tests —
    3,000-odd lines — with the `systemLiterals` guard baseline ratcheted down beside them.
    Size / Type / Alignment are hidden for System 8, where they were collected and discarded:
    size comes from the generated Large/Huge powers, and the system has no monster type or
    alignment.
    
    Found on the way: `hashCode` was **called and never defined**. Every System 8 generation
    with a non-numeric seed threw `hashCode is not defined`, and the missing identifier sat
    among `apps/web`'s ~200 known type errors where it looked like all the rest.
    
  • [x] G.4 — Bump modules.hosted.json to @purple-cat-games/system8 0.15.0. Last, and only once the module is published: the pin names an exact version, so bumping it ahead of the publish points the host at a package that does not exist. Until it lands, a deployed v0.79.0 runs the 0.14.1 module, which ignores the new seed fields and generates as it did before — the form collects preferences nothing acts on, which is the right way round for the window between the two releases.

    Two corrections made when it landed. The branch was cut before v0.78.1 and still pinned
    system8 **0.14.1**, so merging it would have walked the pin backwards past 0.14.3 — the
    Express-5 route-string fix v0.78.1 was released to pin — and 404'd every
    `/api/modules/system8` route in production for the third time. And **`modules.dev.json`
    pins `genai` separately**, at 0.6.1; M.7 in one file would have left the dev environment
    with no image generator while prod had one. Both files move together now.
    
    Both module branches also had to be merged forward before they could ship: `genai`
    `version/0.9.0` was cut from v0.8.0 and lacked v0.8.1's `vitest.setup.ts` (51 client
    tests failing under Node 26), and `version/system8-0.15.0` was cut from 0.14.1 and lacked
    the Express-5 fix it is the whole point of this task not to regress.