Notable updates to RealmForge, newest first.
Bug: Master Vendrek's Demonstration's mode 1 (deal 1 damage to each opp unit) worked, but mode 2 (deal 4 damage to target unit) gave the choice prompt and then did nothing — the chosen branch's DealDamage effect silently resolved with an empty target list. By extension, any future modal spell with a target-requiring branch would fail the same way.
Root cause: the phase-rs translator was emitting Effect::ChooseOneOf with a branches field. The engine HAS that variant, but collect_target_slots (which walks an ability's effect tree at cast time to collect target choices the player must make) does NOT walk into the branches array. So mode 2's target slot was never created, never picked, and at resolution time the DealDamage effect had nothing to damage.
The engine's canonical modal-spell shape is different. Per crates/engine/src/database/forge/translate.rs:171: outer ability has effect=Unimplemented{name:'modal_placeholder'} as a sentinel, plus modal: ModalChoice { min_choices, max_choices, mode_count, mode_descriptions, ... } and mode_abilities: AbilityDefinition[]. collect_target_slots WALKS mode_abilities and collects per-mode target slots up-front, so each mode's branch effect gets exactly the targets it needs at resolution.
Fix in scripts/ability_translator.cjs's tryModalChooseOne: replace the ChooseOneOf emission with the modal-placeholder + ModalChoice + mode_abilities shape. Verified via curl that Master Vendrek's now serializes correctly: outer effect type 'Unimplemented', modal.mode_count=2, mode_abilities[0] is DamageAll, mode_abilities[1] is DealDamage. New card-data.json hash deployed.
If you still see multi-ability cards drop their second ability, that's likely a different category of bug — flag specifics and I'll investigate.
Long world names like ALDENCROFT were trailing into the ASPECTS REALMFORGE logo at the bottom of the card frame because the auto-fit logic was too optimistic about glyph width with tracking-widest (0.1em letter-spacing).
Three small tweaks in CardPreview.tsx, scoped to the worldRect region only:
• Padding 0 3% → 0 1% (recovers ~4% of usable width, mostly on the left).
• Glyph-width estimate bumped from 0.55 + 0.1 = 0.65 to 0.62 + 0.1 = 0.72 em-per-char so the loop picks a smaller font when needed.
• Floor lowered 6px → 5px so very long names still squeeze in instead of bleeding past the region.
Oracle and Flavor regions kept their original 3% padding — only the world-name region was tightened.
patternTapForMana had if (cardType !== "ASPECT") return null; at the top, rejecting {T}: {A}{A} shorthand on UNIT cards (and FIELD/ACTION/ANSWER/etc.). Activated mana abilities are valid on any card type — think MTG mana-dorks like Llanowar Elves. Gate removed. The handler's regexes only match sentences that LITERALLY start with {T}:, so non-mana sentences fall through cleanly without being mis-claimed.
Three follow-ups on /cards/propose after the polish pass:
{T}: {A}{A} shorthand. patternTapForMana now accepts the no-Add-verb form and groups repeated symbols. {T}: {A} → addAspectToPool A:1. {T}: {A}{A}{A} → addAspectToPool A:3. Multi-letter {T}: {A}{B} emits one ability per letter. Existing {T}: Add {X} and {T}: Add {X} or {Y} patterns unaffected. (Was: parser flagged 'No ability patterns matched' for the shorthand even though it's the conventional way authors write basic-land tap costs.)worldName or createdAt. Added both: worldName comes from the selected world; createdAt is set to new Date() for preview-time (server stamps the real value at submit). Card frames now read 'AELINDRA · 2026' (or whatever world+year applies).Files: src/lib/cards/abilityParser.ts (shorthand branch), src/components/cards/CardProposeForm.tsx (preview fields + Link import + schema-edit anchor).
Section reorder on /worlds/[slug]: Magic Systems now appears immediately after Tech Eras, before Cyclical Events. Pure block move; nothing else changed. (User asked to either move the section or make all sections drag-reorderable; the latter is queued as a follow-up since it needs a schema field, a drag library, and an edit-mode toggle.)
World detail page (Technology stat row) was reading planet.techLevel directly. If the world owner never explicitly set a top-level Tech Level on the world but DID add tech eras with their own techLevel values, the row showed 'Unknown' even though the world clearly has a defined tech state.
Fix: when planet.techLevel is null and the world has at least one tech era, the page now derives the displayed tech level from the most recent era (highest startYear, BigInt-aware sort; falls back to the first era when none have a startYear set). The world's explicit techLevel still wins when it's set.
Touched: src/app/worlds/[slug]/page.tsx (line ~178 — added eraTechLevelFallback derivation, no other behavior changes).
Context: the in-house Aspects v2 ability DSL parser (src/lib/cards/abilityParser.ts) was leaving every one of the 24 approved Aspects-set cards in the admin Ability Queue marked unresolved (0 clean / 1 partial / 23 unresolved at start). Aspects v2 engine cards need clean DSL to mechanically execute; partial parses still let the UI show what was authored.
Added 12 new keyword EffectKinds to abilityDSL.ts so bare-keyword lines compile to typed Abilities: trample, menace, haste, firstStrike, doubleStrike, prowess, lifelink, reach, defender, indestructible, hexproof, deathtouch. Also added two continuous global-prohibition kinds (noLifeGain, damageCantBePrevented) and three structural kinds (bouncePermanent for the broader return-to-hand target shape, discardThenDraw for Tersha's ETB, exileFromDiscard for Pale-Sister/Liturgy field abilities).
Wired matching cases into abilityResolver.ts so each new EffectKind logs a tagged keyword:* entry and degrades gracefully — no engine crashes if persisted DSL references a kind the engine helpers haven't fully implemented yet.
Added pattern handlers in abilityParser.ts:
• patternKeywordList — single bare keyword line + comma-separated lists (Flight, haste).
• patternNoLifeGain / patternDamageCantBePrevented — global prohibition continuous abilities.
• patternConditionalFirstStrike — "This unit has first strike during your turn" (the during-your-turn qualifier is dropped at the DSL level).
• patternTapForMana — basic and multi-mode {T}: Add {X}. and {T}: Add {X} or {Y}. with reminder-paren tolerance.
• patternEntersTappedUnless — aspect-side ETB-tapped condition.
• patternNamedDealDamage / patternDealDamageEachOppUnit / patternDealDamageAnyTarget / patternIfKickedDamage — covers Cinder-Tap, Pulse-Lance Strike, Veil-Lash Pulse base + kicker, and Master Vendrek's Demonstration's two modes.
• patternBounceTarget — Thal Hush-Rite's return-to-hand.
• patternEtbDamageOppUnit — Auroral Skywyrm's ETB damage line.
• patternOpponentCastsCheapDamage — Geyser-Coil Sentinel's spell-cast trigger.
• patternOpponentDrawsTrigger — Stitched-Smile's draw trigger (continuous noOp stub until onOpponentDraw lands).
• patternDiscardThenDraw — Tersha's ETB.
• patternEtbExileFromDiscard / patternExileEachOpponentDiscard — Pale-Sister Lantern's ETB and tap-and-sac.
• patternAlternateCostKickerCurly — Kicker {F}{F}{F}{F} cost form (in addition to the existing numeric Kicker N).
• patternActivatedBuffSelf / patternActivatedAddMarkSelf / patternActivatedCreateToken / patternActivatedGeneric — activated-ability wrappers that strip the curly-brace cost prefix and delegate to parseInlineTail.
Other changes: splitSentences now rejoins "Choose one —" header lines with their bullet-led mode lines into a single "Choose one — <A> or <B>" sentence so patternChooseMode matches without modification. patternAddAspect's "to your Pool" suffix is now optional so it can match as the LHS of an "X, then Y" composition (Liturgy of the Kindler).
Coverage result on the 24 approved Aspects cards (after re-stamping abilitiesDSL):
• 10 CLEAN: Geyser-Coil Sentinel, Stitched-Smile Cinderkin, Pale-Sister Lantern, Master Vendrek's Demonstration, Thal Hush-Rite, Cinder-Tap, Pulse-Lance Strike, Veil-Lash Pulse, Singing Range Outcrop, Spire-Cliff Tide-Channel.
• 13 PARTIAL: Auroral Skywyrm (Warp skipped), Emberheart Caerit Champion (Valiant skipped), Pyre-Tendon Vrekkar (Start your engines! / Max speed skipped), Sandsearer Mercenary (attack-with-Drekarin trigger skipped), Solven, the Kindler (Pyre-channeling skipped), Solvinar-Spine Caerit (count-driven ETB damage skipped), Tersha Chordbreaker (attack trigger skipped), Vendal, Stone-Captain (damage modifier skipped), Liturgy of the Kindler (exile-top-of-deck skipped), Cliffroost Hamlet (multi-subtype buff activated skipped), Pillar of the Wandering Stone (becomes-a-unit skipped), Slow-Chord Pillar (next-time damage doubling skipped), Tideglass-Pyre Verge (activate-only-if rider partial-stripped).
• 1 UNRESOLVED: Chordbane Drekarin (damage equal to count of cast non-units — too dynamic for a static dealDamage param).
Skipped clauses are documented in the parser comments. Partial parses still produce typed Abilities for the clauses that did parse — the engine fires those, the rest survive on rawText.
Validation: 69/69 self-test cases pass (was 48/48; 21 new test entries cover the new patterns). Typecheck clean. Build deployed via safe_build_restart.sh — no manifest race.
Systematic pass over every non-aspect card. Goal: "as complete as phase-rs can express." 5 new patterns added on top of the first-pass translator. Every untranslatable mechanic documented against engine source.
Coverage delta vs first pass:
• Stitched-Smile Cinderkin: conditional first strike during your turn — added as StaticDefinition Continuous with AddKeyword + DuringYourTurn condition. (Templated from rancor/bonesplitter snapshots; StaticCondition::DuringYourTurn defined at types/ability.rs:2728.)
• Pyre-Tendon Vrekkar: "{2}: This unit gets +1/+0 until end of turn." — activated Pump with AbilityCost::Mana. (Templated from bonecrusher_giant; Effect::Pump at types/ability.rs:3480.)
• Sandsearer Mercenary: "{1}{R}: Put a +1/+1 mark on this unit." — activated PutCounter (P1P1). Activation gating ("if opp lost life this turn / once each turn") skipped — RequiresCondition shape isn't documented in any snapshot for our specific predicates.
• Auroral Skywyrm: "When this unit enters, it deals 1 damage to target unit an opponent controls." — ChangesZone ETB trigger with valid_card SelfRef + destination Battlefield, execute=DealDamage to Typed{Creature, controller=Opponent}. (Templated from goblin_chainwhirler snapshot.)
• Solvinar-Spine Caerit: "Players cannot gain life." — StaticMode::CantGainLife unit variant (defined types/statics.rs:378), serializes as bare string. Wired through dump_aspects_cards.cjs line 474 by adding static_abilities field to the export entry.
Final coverage matrix (18 non-aspect cards):
• ✓ Fully translated (12): Cinder-Tap, Pulse-Lance Strike, Veil-Lash Pulse (base), Thal Hush-Rite, Master Vendrek's Demonstration (modal), Stitched-Smile (FS-during-turn + opp-draws trigger), Auroral Skywyrm (Flying/Haste + ETB damage), Vendal Stone-Captain (Trample), Pyre-Tendon Vrekkar (Menace + activated Pump), Tersha Chordbreaker (Haste), Emberheart Caerit Champion (Haste/Prowess), Geyser-Coil Sentinel (opp-casts-cheap trigger).
• ⚠ Partial (1): Sandsearer Mercenary — activated +1/+1 baked, attack-trigger and activation gating skipped.
• ✗ Skipped with documented reason (5): Solvinar-Spine ETB damage (data-model conflict — every aspect is Basic for mana to work, counting non-basics = 0); Solven (Pyre-channeling custom + {F}×8 cost is unpayable, ManaColor is WUBRG only); Pale-Sister Lantern (discard-pile semantics + {F} costs); Liturgy of the Kindler (aspect tokens custom + {F} costs); Chordbane Drekarin ("damage equal to non-units cast this turn" — QuantityRef shape uncertain in this context).
Untranslatable mechanics (all need Aspects v2's clean-room engine):
• Warp, Pyre-channeling, Valiant, Start Your Engines / Max-speed, aspect tokens — no engine primitive.
• {F} mana symbol — phase-rs's ManaColor enum is WUBRG only (types/mana.rs:9). Any {F} shard in a Mana cost fails serde deserialize and would break DB load. Affects Solven's 8-F token cost, Pale-Sister/Liturgy F-tap-sac, Veil-Lash kicker.
• "non-basic aspects" semantic — current data model marks every aspect Basic so synthesize_basic_land_mana auto-attaches the tap ability. Until we have a non-Basic aspect category, Solvinar-Spine's count is 0.
• Continuous "damage cannot be prevented" static — engine only models this as one-shot Effect::AddRestriction (per questing_beast); no StaticMode variant.
• Subtype-source damage amplifier (Vendal's "+1 dmg from Cinder sources") — replacement-static shape with subtype filter, no comparable snapshot.
• Activation gating like "only if opp lost life this turn / once each turn" — RequiresCondition exists but ParsedCondition shape isn't documented for these specific predicates.
• discard-pile-as-graveyard semantic equivalence — risky to encode without verifying our data model treats them identically.
Verification: curl-fetched served card-data.json post-redeploy. 24 cards loaded (no card lost). Pre-v2 cards (Cinder-Tap, Pulse-Lance, Vendrek's modal, Thal Hush-Rite, Geyser-Coil, Veil-Lash, keyword-only cards) byte-stable. New patterns emit fields whose shapes byte-match canonical engine snapshots. New card-data hash: originis-802401c8b002.
Files touched:
• scripts/ability_translator.cjs — 5 new tryFooBar patterns + static_abilities returned in public API.
• scripts/dump_aspects_cards.cjs (line 474) — wired translator.static_abilities into export entry.
• public/aspects-app/{card-data,aspects-cards,card-data-meta}.json — refreshed via re-dump + mirror.
Headline takeaway: phase-rs is now expressing as much of the Aspects ruleset as its enums physically support. The remaining gap is the v2 engine's job — that's exactly what the 2026-05-06 clean-room rebuild is for.
End-to-end chrome rebrand of the /aspects-app/ SPA so it presents as RealmForge, not phase.rs.
Branding:
• Browser tab title: "RealmForge — Aspects" (was "phase.rs").
• PWA manifest name/short_name: "RealmForge — Aspects" / "RealmForge". start_url corrected to /aspects-app/. Manifest icons re-pointed to /aspects-app/icons/.
• Hero + splash logo: full RealmForge logo (circular sigil over REALMFORGE wordmark, 1605×751) replaces the phase.rs cards-and-gear graphic. Sourced from the canonical brand asset.
• Favicon set rebuilt from the square sigil — favicon-16x16, favicon-32x32, favicon.ico (32×32 PNG bytes), apple-touch-icon (180), icon-192, icon-512. Resized via System.Drawing locally because the server has no ImageMagick/cwebp.
Nav links (top-left of MenuPage):
• Removed: GitHub link and Sponsor link (entire <a> blocks plus their now-unused SVG icon components).
• Added: Home link (/realmforge.net/), placed BEFORE Discord, with house icon, full-page nav (no target=_blank), aria-label "Home — return to RealmForge".
• Discord URL: https://discord.gg/JeSvDHP5qU (was discord.gg/dUZwhYHUyk). Defined in DiscordBadge.tsx, not inline.
• Ko-fi URL: https://ko-fi.com/cyberfortress (was ko-fi.com/phasers).
• Final order: Home → Discord → Ko-fi.
Multiplayer (Play Online → lobby):
• Direct-connection / dedicated-server mode is now HIDDEN. Server-mode UI no longer renders.
• P2P is always selected; the implicit mode-switch effect (flipping to server when serverAddress was set) is disabled.
• "Pick server" chip removed from the lobby chrome — no escape hatch back to server mode.
• Underlying services (openPhaseSocket, multiplayerSession, ServerPicker) NOT deleted — just unreferenced from UI. Easy to bring back when the upstream phase-server becomes Aspects-aware.
• Why: only functional decks are custom Aspects cards; phase-server has no knowledge of these, so direct-connect mode would be unplayable.
Files touched:
• vendor/phase-rs/client/index.html (title)
• vendor/phase-rs/client/public/manifest.json (rewritten)
• vendor/phase-rs/client/public/{logo.png, favicon-16x16.png, favicon-32x32.png, favicon.ico}
• vendor/phase-rs/client/public/icons/{apple-touch-icon,icon-192,icon-512}.png
• vendor/phase-rs/client/src/pages/MenuPage.tsx (nav links + new HomeIcon)
• vendor/phase-rs/client/src/components/chrome/DiscordBadge.tsx (Discord URL constant)
• vendor/phase-rs/client/src/components/menu/MenuLogo.tsx (logo src)
• vendor/phase-rs/client/src/components/splash/SplashScreen.tsx (logo src)
• vendor/phase-rs/client/src/pages/MultiplayerPage.tsx (forced p2p mode)
• vendor/phase-rs/client/src/components/lobby/LobbyView.tsx (Pick-server chip removed, info note rewritten)
Known holdovers (deliberately left for user decision):
• Page-footer MIT attribution at MenuShell.tsx:71 still reads "Aspects — built on phase-rs (MIT). github.com/phase-rs/phase" — left intact as a license credit, not a nav-style GitHub link.
• Hero halo geometry is square (256px); the wordmark portion of the full logo will sit centered with whitespace above/below. Visual decision deferred to user.
Deployed via safe_phase_rs_redeploy.sh. Verified: served logo.png is 1605×751, served manifest.json reads RealmForge, all favicon paths return 200, https://realmforge.net/aspects-app/ tab title reads "RealmForge — Aspects".
Background: phase-rs's WASM card-database load path doesn't run synthesis or oracle-text parsing. Every non-aspect card was loading vanilla — name + stats only — so spells fizzled, units had no keywords, and triggered abilities never fired.
Built a conservative oracle-text → engine-shape translator (scripts/ability_translator.cjs) wired into dump_aspects_cards.cjs. Translates the patterns we can match safely and skips anything ambiguous rather than fabricating wrong rules.
What it now translates:
• Standalone keywords on units: Flying (incl. our "Flight"), Haste, Trample, Menace, First Strike, Double Strike, Vigilance, Lifelink, Reach, Defender, Indestructible, Hexproof, Prowess, Deathtouch, Flash.
• Direct-damage spells matching "<Name> deals N damage to any target": Cinder-Tap (2), Pulse-Lance Strike (3), Veil-Lash Pulse (2 base — kicker upgrade NOT translated).
• Bounce: "Return target non-aspect permanent to its owner's hand" (Thal Hush-Rite) — encoded as Effect::Bounce with TargetFilter::Typed{[Permanent, Non(Land)]}.
Currently NOT translated (left as vanilla until Aspects v2 engine ships, since machine-translating these wrong is worse than translating them not at all):
• Triggered abilities: Stitched-Smile (opp draws), Sandsearer (attack), Geyser-Coil (opp casts cheap), Tersha (ETB + attack), Auroral Skywyrm (ETB damage).
• Modal spells: Master Vendrek's Demonstration ("Choose one —").
• Kicker / alt costs: Veil-Lash Pulse upgrade, Auroral's Warp.
• Self-counting damage: Solvinar-Spine, Chordbane Drekarin.
• Custom Aspects mechanics: Pyre-channeling (Solven), Start Your Engines (Pyre-Tendon), Valiant (Emberheart), aspect tokens, F mana costs as activation costs.
• Activated abilities with sac cost: Pale-Sister Lantern, Liturgy of the Kindler, Solven token-creation.
• Static prohibitions: Solvinar-Spine "Players cannot gain life" / "Damage cannot be prevented", Stitched-Smile conditional first strike.
Net result: 9 of 18 non-aspect cards now have at least partial engine support (4 spells + 5 unit keyword sets), up from 0. The remaining 9 still cast and deal combat damage normally, but their text-driven rules don't fire. Hard-refresh the SPA + start a new game to pick up the changes.
Diagnosed via screenshot: aspects on the field but no mana available to cast spells. Three layered bugs:
1) The phase-rs engine reads /aspects-app/card-data.json (NOT aspects-cards.json or scryfall-data.json), but card-data.json was stale — frozen on 2026-05-06, missing all 6 saga/transform replacement cards and still containing the 6 cards we replaced.
2) The WASM card-database load path (CardDatabase::from_json_str → from_export_entries) does NOT run synthesize_all. Synthesis only runs on the MTGJSON load path. So even though aspects had Basic Land — Mountain typing in the JSON, the engine never auto-attached the {T}: Add {R} ability — every aspect ended up vanilla with empty abilities.
3) Cliffroost Hamlet was being typed as Wastes (engine has no ManaColor::F variant), even though the card also produces {D} (Mountain).
Fixes:
• dump_aspects_cards.cjs: detection now scans EVERY {T}: Add {X} line + name suffix + legacy prose at once, prefers non-F so dual-producers like Cliffroost get Mountain.
• dump_aspects_cards.cjs: for ASPECT cards, pre-bakes the activated {T}: Add {color} mana ability into the abilities array (matching the runtime_card_export_fixture.json shape) so the engine sees the ability the moment it loads card-data.json.
• safe_phase_rs_redeploy.sh: mirrors aspects-cards.json onto public/aspects-app/card-data.json on every redeploy and updates the meta hash. The previous version had this copy and a 2026-05-06 rewrite silently dropped it — that is what caused card-data.json to drift.
All 6 aspects (Singing Range Outcrop, Cliffroost Hamlet, Tideglass-Pyre Verge, Spire-Cliff Tide-Channel, Slow-Chord Pillar, Pillar of the Wandering Stone) now serve with subtype Mountain or Island and a baked tap-for-mana ability.
The /cards/play page now renders the in-house Aspects engine directly. No more iframe, no more vendored Rust+WASM SPA, no more phasersoff plugin layer.
vendor/phase-rs/ (1.9 GB) — entire upstream phase.rs clone, source + WASM + distpublic/aspects-app/ (422 MB) — built phase-rs SPA assetssrc/app/cards/play/solo/play/ — old SoloPlaySurface iframe wrappersrc/app/cards/play/aspects-v2/ — temp scaffolding folder (the UI lives at /cards/play directly now)src/app/api/admin/plugin/{export,import,save,test-card-source,uninstall,upload}/ — phasersoff admin routessrc/app/api/plugins/[...path]/route.ts — file-system plugin streamersrc/app/api/aspects/card-data/route.ts — vanilla MTG ↔ phasersoff card-data merge route/aspects-app/*, /audio/*, /feeds/*, /battlefield/*, /preview-icons/*, /logo.*, /scryfall-data.json, /card-data*.json, /card-names.json, /coverage-*.json, /decks.json, /set-list.json, /assets/*, /icons/*, /favicon-*, /aspects-cards.json, /aspects-schema.json, /aspects-card-frames.json rewrites in next.config.tssrc/app/cards/play/page.tsx — auth gate + renders <Aspects />src/app/cards/play/Aspects.tsx — client component (~17KB). Renders opponent + stack + your battlefield + hand. Click cards in hand to cast / play; click ASPECT permanents to tap for mana; buttons for pass priority / declare attackers / declare blockers / concede.GET /cards/play → 307 to /login when unauth (auth gate works)GET /aspects-app/index.html → 404 (gone)GET /card-data.json → 404 (gone)GET /api/plugins/phasersoff/manifest.json → 404 (gone)POST /api/aspects/match/solo → 401 unauth (engine API still up)safe_build_restart.sh ran clean; service active.GET /api/cards/[id] per id; bulk fetch would be cleanerCard.artUrl nextAspectsSoloMatch.log)Engine ready to play. Open https://your-host/cards/play, log in, click "Start a match".
Adventure layout joined Transform and MDFC: CardLayout enum is now SINGLE | TRANSFORM | MDFC | ADVENTURE; dumper emits layout: adventure on the JSON pair so phase-rs CardLayout::Adventure picks them up. AspectCost cap raised from 3 to 7 slots in both POST and PATCH zod schemas to allow CMC 4-7 cards (Ojer Axonil, Nova Hellkite, etc.). First Originis deck conversion landed: 21 unique MTG cards translated to Originis voice via locked subtype mapping (Lizard - Drekarin, Goblin - Stoneborn, Human - Valari/Dren, God - Resonance-Embodied, Dragon - Skywyrm, etc.) and inserted as 24 Card rows including 2 transform pairs (Vendal of the Slow-Time Stoneworks; The Saga of Solven the Kindler) and 1 adventure pair (Geyser-Coil Tideglass).
InkFortress card model now supports two-faced cards end-to-end.\n\nSchema: new Card.layout enum (SINGLE | TRANSFORM | MDFC), Card.backFaceId self-relation, Card.faceGroupId shared between paired faces.\n\nPropose form: Layout dropdown + full Back-face panel (name, type, subtypes, aspect cost, power/endurance, abilities, flavor, art description, art upload).\n\nDumper: emits both faces as separate top-level entries in aspects-cards.json sharing one scryfall_oracle_id (= faceGroupId), tagged layout: transform / modal_dfc - phase-rs auto-pairs them into CardLayout::Transform via its oracle_id_index.\n\nSaga authoring: subtypes [Saga] on the front face + chapter-numbered ability text. Edit pages cross-link the two faces with a banner.
Reset Originis Aspects card pool to 120 hand-authored cards arranged as 2 sealed decks (48 ASPECT, 36 UNIT, 16 ACTION, 8 ANSWER, 8 FIELD, 4 SIGNATURE). Every card now carries a screenwriter-style art_description paragraph (full WHO/WHAT/WHEN/WHERE/HOW/WHY blocking) feeding originis_grinder.py. Local SDXL Juggernaut grinder rendered all 120 PNGs to NAS in a 2.8h pass with 0 failures. Dump pushed to vendor/phase-rs/data/aspects-cards.json + public/aspects-app/ + public/card-data.json; service restarted.
ASPECT cards were rendering with the synthesized {T}: Add {F}. line AND the redundant DB-original Add 1 aspect token of Chord to your pool line concatenated together (e.g. "Hall of Chord" showed both). Dumper now skips card.abilities.text for card.type === "ASPECT", since the synthesized line already covers the same effect.
WASM panic messages used to land only in the worker's own DevTools target, invisible to anyone debugging the main page. engine-worker.ts now drains take_last_panic_message() after every WASM call (and on every catch path) and posts a {type:"panic"} envelope to the main thread, where engine-worker-client.ts logs [ENGINE PANIC] <msg> via console.error. Cuts the observability gap that made the ETB-destroy STATE_LOST cascade hard to triage.
After parser-on-load went live, malformed parsed-ability shapes occasionally surfaced undefined string fields, which .replace() callers in the React render tree blew up on — unmounting the entire game tree mid-event ("destroy a blocker → crash → STATE_LOST"). Guarded ManaSymbol.tsx (renders ? chip on nil shard), keywordProps.ts:splitPascalCase, costLabel.ts default-branch fallback, and the prime culprit utils/description.ts:renderDescription (substitutes ~ self-reference; was throwing on undefined description from newly-structured triggers).
Renamed ManaColor::Colorless → F across the engine, added BasicLandType::F variant with mana_color() returning ManaColor::F, and added ("Wastes", ManaColor::F) to synthesize_basic_land_mana. Updates the dumper, scryfall service, and SPA aspect schema accordingly. ManaColor is now WUBRGF (6 colors), not WUBRG + Colorless. Also fixed mana_payment.rs:50 [u32; 5] → [u32; 6] to match the new color count (was causing submitAction-panic index-out-of-bounds reports).
For most of the day, no card was castable on /cards/play after the F-aspect rename. Root cause was the SPA fetching the engine card-data from /card-data.json (default __CARD_DATA_URL__), but the dumper only wrote to /aspects-app/aspects-cards.json — engine read a stale snapshot pre-F-rename, treating lands as inert non-mana shells. Fix copies the freshly dumped JSON to /public/card-data.json AND updated safe_phase_rs_redeploy.sh to keep the two paths in sync. Also dropped the obsolete F-workaround in the dumper (F lands now carry Basic supertype like every other aspect, since the engine has BasicLandType::F + ("Wastes", ManaColor::F) in synthesize_basic_land_mana), and made CardDatabase::from_export_entries run synthesize_all on each face after deserialization.
Added an inline-SVG coffee-cup button to the right of the user dropdown linking to https://ko-fi.com/cyberfortress in a new tab. Visible to logged-in users only (it lives inside the existing session ? branch).
A prisma db push --accept-data-loss during the Wave 2 deploy on 2026-04-30 wiped existing ChangelogEntry rows. The site stayed up (build hadn't swapped yet), but the public timeline lost everything before 2026-05-04. This entry and the surrounding backfill restore the lost week from memory + deploy script timestamps.
New glyph wallet on every user (balance, transaction history) plus seeded glyph packs with Stripe price IDs. Card pack opens, vote rewards, and trade fees all settle in glyphs; packs purchasable through Stripe.
New social rails on the home and books pages: "continue reading" from book reads, "your bookmarks", "your favorites", "follow author" buttons on every book/profile, and inbound DM threading. Gives the discovery surface real Wattpad-tier engagement.
New Quest models cover quest definitions, objectives, rewards, and per-user progress tracking. Replaces the earlier ad-hoc LoreQuest blurbs with something the engine can actually progress, complete, and reward against.
The Aspects card engine is now powered by phase-rs (Rust compiled to ~7.8MB WASM) vendored under /vendor/phase-rs/. The six aspect colors A-F map to MTG colors so the underlying rules engine can validate plays. /cards/play/solo and the SPA play surface are fully wired up.
WorldArtifact, Fauna, Flora, and CharacterClass create/edit forms all gained an image upload control. Backed by /api/planets/[slug]/upload-image; affected POST/PATCH endpoints accept imageUrl. No more authoring without art.
Stories now show a force-directed character relationship graph (walks chapter content for @-mentions). New StoryArc model groups chapters into arcs. Bookshelves let readers organise stories. ChapterComment (1-level threaded) and ChapterReaction (emoji allow-list) shipped on the chapter reading page with notification fan-out.
Factions, LoreQuests, CyclicalEvents, Interactive Maps (with editable SVG markers and regions), and a scrubbable per-world Timeline now live under /worlds/[slug]/. New Prisma models for each, mounted as sections on the world page.
Card ability text is now parsed into a structured DSL covering triggers (onPlay/onAttack/onTurnStart/...), effects (damage/heal/destroy/bounce/silence/draw/...), conditions, and targets. Match endpoint dispatches at every event hook. Cards that fail to parse get an "unresolved" advisory badge until rewritten.
Six card types (UNIT, ACTION, ANSWER, FIELD, TERRAIN, SIGNATURE) and six aspect slots A-F. New Card / CardSchema / CardVote / UserCardStats / CardInventoryItem / CardPack models. Players can propose cards at /cards/propose, vote at /cards/vote (3/day free, 100/day subs), open packs with transparent odds + pity, and browse per-world card pools.
Subscriber-only in-platform VTT. New Prisma models: Campaign, CampaignMember, CampaignSession, DiceRoll, CharacterSheet. Worldwright tier gets one active campaign; Loremaster tier can have six (one active). Play happens at /play/[campaignId] with scene panel, dice roller, initiative tracker, and party list.
Free accounts: max 2 stories, 5 chapters per story, 10,000 words per chapter, 5 storyboard panels, 5 characters, 15 lore entries, and 5 short notes per story. Limits return a 402 with requiresSubscription:true so the UI can prompt to upgrade. Subscribers remain unlimited.
StoryCharacter and StoryEntity now carry optional foreign keys to Planet (world) and Galaxy. Editors get a dropdown to attach an existing world/galaxy or create a new one inline. Creating a world is immediate; new galaxies require admin approval through the existing /admin/galaxies queue.