Skip to content

fix: resolve player names for mob-initiated combat in world feed#300

Open
mokn wants to merge 549 commits intomainfrom
dev
Open

fix: resolve player names for mob-initiated combat in world feed#300
mokn wants to merge 549 commits intomainfrom
dev

Conversation

@mokn
Copy link
Copy Markdown
Collaborator

@mokn mokn commented Mar 26, 2026

Summary

  • When mobs initiate combat (attackers_are_mobs=true), the player wallet is in CombatEncounter.defenders, not attackers. The event feed was always reading attackers, causing ~37% of loot events to show "An adventurer" instead of the actual player name.
  • Backfill events now resolve both player and item names instead of hardcoding "An adventurer found an item!"
  • Added 12 tests for extractWalletHex and the mob-attack wallet resolution logic

Test plan

  • 142 indexer tests pass (12 new)
  • TypeScript compiles clean
  • Verify world feed on beta after Railway deploys from dev
  • Confirm player names appear for mob-initiated loot drops

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment Apr 17, 2026 8:35pm
ud Ready Ready Preview, Comment Apr 17, 2026 8:35pm
ud-api Ready Ready Preview, Comment Apr 17, 2026 8:35pm
ud-api-beta Ready Ready Preview, Comment Apr 17, 2026 8:35pm

Request Review

mokn and others added 30 commits April 14, 2026 09:23
Temper runs are ephemeral per-invocation JSON files that should never
be tracked. Earlier commit untracked one stale artifact; this prevents
the same from happening to future runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Bugbear, goblin, goblin-shaman, kobold, skeleton were using older static
GLBs. Lab's animated versions include the full battle sequence:
idle, walk, attack, hit_react, death — all 5 clips.

Run via: node tools/creature-lab/sync-to-client.mjs --apply
Verified: 12/12 in sync (carrion-crawler uses procedural draw, no GLB needed)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
feat(creatures): sync 5 monster GLBs with animated lab versions
- i18n: merge duplicate advancedClass keys in ui.json — second block was
  silently overwriting the UI text keys, causing raw key strings like
  ADVANCEDCLASS.CHOOSEYOURPATH to render on the class selection screen
- battle alignment: hide BattleWorldTicker and CurrentObjectiveHud during
  active combat so ticker height doesn't push the battle canvas down
- level-up animation: add lastSeenLevelRef watcher in GameBoard that fires
  LevelUpModal on any level increase, not only via BattleOutcomeModal close
- spells: export SPELL_CATALOG and reclassify "spell:" tokenURI items as
  ItemType.Spell regardless of on-chain type; fetchAllSpells now uses catalog
  for name and damage fallback when SpellStats table is empty

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The deploy-spell-items.ts script was using ITEM_TYPE_WEAPON = 0, which
caused spell items to be indexed in weaponTemplates instead of
spellTemplates on the client. Changed to ITEM_TYPE_SPELL = 2 so future
deploys create items with the correct type. Existing beta items are
handled client-side via the SPELL_CATALOG tokenURI reclassification.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…nd level watcher

- 15 tests for SPELL_CATALOG content, isSpellTokenURI, spellEffectNameFromURI,
  and advancedClass JSON key coexistence
- 10 tests for GameBoard level-up watcher logic (pure unit, no DOM)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The prior commit 938778cf switched this script to ItemType.Spell (2), but
the contract's ItemCreationSystem.createItem() has no Spell branch — items
created with that type write nothing to SpellStats, and CombatSystem's
_executeMagicAction() reverts with InvalidMagicItemType when cast. Revert
to ItemType.Weapon until the contract gains proper Spell support.

The client already categorizes these as spells via the "spell:<effectName>"
tokenURI prefix (see ItemsContext.tsx), so the UX is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
SPELL_CATALOG was missing the L15 class spells (warcry, judgment, volley,
backstab, regrowth, blight, meteor, mana_burn, smite), so any L15 spell
item deployed via deploy-spell-items.ts would fall back to "Spell #<id>"
with 0 damage in the UI. Values mirror windy_peaks/spells.json exactly.

Tests now cover:
- all 18 deployed effect names (L10 + L15) are in the catalog
- unknown effect names return undefined (miss behavior for ?? fallbacks)
- L15 damage spot checks (Backstab 10-18, Meteor 8-16, Regrowth 0-0)
- L10 + L15 utility spells have 0 damage

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The level-up watcher's else-branch used to advance lastSeenLevelRef to
currentLevel on every render, including renders where a level increase
was observed but blocked by BattleOutcomeModal. That consumed the signal:
when the modal closed and the useEffect re-ran, currentLevel was no
longer > lastSeenLevel, so LevelUpModal never fired — the exact bug this
watcher was added to fix, just shifted one render later.

Fix: only advance the ref when we either fire the modal or observe a
non-increase. If the level went up but a modal is blocking, leave the
ref behind so the next re-run can still detect the gap.

Tests cover the end-to-end battle-block sequence: level rises while
BattleOutcomeModal is open (ref stays at 5), then the modal closes and
the watcher fires on the second pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Both the on-chain deploy script (deploy-spell-items.ts) and the client's
SPELL_CATALOG used to carry independent copies of the class-spell list.
Adding a new spell required keeping both in lockstep manually, and the
drift would only surface when a player saw "Spell #<id>" 0/0 damage in
the UI.

Move the list to packages/client/src/data/spellsManifest.ts and have
both consumers derive from it:
- ItemsContext.tsx re-exports SPELL_CATALOG built from the manifest
- deploy-spell-items.ts maps SPELLS_MANIFEST to its internal SpellDef
- ItemsContext.test.ts iterates the manifest to verify the catalog
  matches name + damage + minLevel for every entry

Drift is now structurally impossible: editing the manifest updates both
consumers in the same file change, and the test would fail at CI time if
any catalog entry diverged from its manifest source.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The prior watcher fix preserved lastSeenLevelRef whenever a modal
blocked the fire path. That worked for the watcher-only scenario, but
broke the common case where BattleOutcomeModal's close handler calls
onOpenLevelUpModal() externally: the watcher would leave the ref at
the pre-level-up value, and when the user dismissed LevelUpModal the
watcher re-ran with both flags off and fired the modal a SECOND time.

Fix: advance the ref under two conditions — either we fire the modal
ourselves, or we observe isLevelUpModalOpen=true (meaning another code
path already handled this level gain, so we should not re-fire after
dismissal). Only the BattleOutcomeModal-blocking case leaves the ref
behind now; LevelUpModal-already-open is treated as "handled."

New regression tests cover:
- rule 2: LevelUpModal already open advances the ref
- scenario A: battle close handler opens LevelUpModal → no re-fire on
  user dismissal (the double-fire path this commit fixes)
- scenario B: battle modal closes without firing LevelUpModal → watcher
  fires on the next pass (unchanged)
- edge: two-level gain in a single step fires exactly once

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Refreshing /game-board flashed through three visual states: dark
BootScreen → orange AppInner shell with a footer "Loading..." →
game. The CLS spike (0.02 → 0.311 observed in telemetry) landed
mid-hydration, which is what players experienced as the battle
screen "starting aligned then snapping down".

Root cause: the BootScreen was only the root-level React.lazy
Suspense fallback. Once the GameAppRoot chunk resolved it
unmounted even though MUD setup + wallet path were still in
flight.

Extract BootScreen to a shared component and gate AppInner's
render on /game-board behind `ready && isSynced`. The gate is
scoped to GAME_BOARD_PATH so Welcome and static pages still
paint immediately. Covered with pure-function unit tests so
the predicate stays in lockstep with App.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The previous gate released as soon as MUD `ready && isSynced` flipped
true, but those flags only reflect wallet path initialization. The
network snapshot that populates Zustand hadn't hydrated yet, so
GameBoard would paint with partial/empty data and then snap the layout
once the character sidebar mounted — the CLS spike visible in the
latest Cricket clip.

Adding `useGameStore((s) => s.hydrated)` to the gate holds the dark
"Rebuilding the world state..." screen until the snapshot has actually
landed. Refresh is now a single visual state.

Tests updated to cover the new parameter including the exact
ready+synced+unhydrated window this fix addresses.
The previous gate released once MUD+gameStore were ready, but GameBoard
is React.lazy so its chunk can still be downloading at that moment.
When Suspense fired inside AppRoutes, it painted the orange Grid shell
with a "Loading..." VStack — the exact flash visible in the latest
Cricket clip (frame 10).

Fix preloads the GameBoard module in parallel with MUD setup and gates
the BootScreen on the preload completing. By the time the gate
releases, React.lazy finds the module cached and resolves
synchronously, so the inner Suspense never fires.
…me-board

The AppInner gate holds the dark BootScreen through MUD setup, wallet sync,
gameStore hydration, AND the lazy GameBoard chunk preload — but when the gate
released, users still saw a single-frame flash of the orange app shell with
a "Loading..." VStack. Root cause: React.lazy has its own internal state
machine. Even when the GameBoard module is already cached (thanks to the
AppInner preload), React.lazy throws-to-Suspense on the first render-tick
encounter, which bounces through AppRoutes' Suspense fallback before
resolving from cache on the next microtask.

The preload alone can't beat React.lazy's throw — the only robust fix is to
make that fallback visually identical to the AppInner BootScreen for the
/game-board path. BootScreen is position:fixed with zIndex 9999, so it
covers the entire AppInner Grid regardless of where in the tree it renders.

Also adds the missing `useEffect` import (the pre-existing ExternalRedirect
component was using it without importing it).
…pply

The cinematic battle view (TileDetailsPanel SHOW_Z2 branch) was a plain
block layout with the canvas hardcoded to `calc(100% - 80px)` and the HUD
taking its natural height. Two problems fed each other:

1. HealthBar conditionally rendered its status-effects row only when at
   least one effect was active. When the first effect applied mid-fight,
   the HealthBar grew by one row and the HUD HStack grew with it.
2. The canvas Box had a fixed height that didn't respond to HUD growth,
   so either the HUD overflowed the container (clipped by the parent's
   overflow:hidden) or a vertical gap appeared — and the whole battle
   scene visually "snapped down" when the HealthBar tried to claim the
   extra row.

Fixes:

- HealthBar now always renders the badges row (with `minH="14px"`), so
  its total height is constant whether or not effects are active.
- The battle container is now a flex column: canvas is `flex="1"`,
  HUD HStack is `flex="none"`. The canvas fills whatever vertical space
  the HUD doesn't use, and since HUD height is now stable, nothing moves.

Adds 5 HealthBar tests covering the reserved row and the 3-badge cap.
Two bugs in one root cause plus a removal:

1) The orange app-shell block behind every Suspense "Loading..." and every
   small-content route was a Chakra Grid row assignment bug. Header returned
   a React Fragment with two siblings (an IS_BETA amber strip at #C87A2A and
   the nav Grid). Fragments flatten into the parent, so App.tsx's Grid saw
   5 children instead of 4, and its templateRows="auto 1fr auto" put the
   flex row on the BETA strip — which then stretched to fill the viewport
   on any route where content was shorter than 100vh. BootScreen's zIndex
   9999 overlay hid it on /game-board hard refresh, which is why only that
   single path looked correct.

   Fix: wrap Header's return in a single <Box> wrapper so it occupies one
   Grid cell, and change App.tsx templateRows to "auto auto 1fr auto" so
   the flex row lands on AppRoutes (the actual content).

2) RoutesFallback was pathname-gated: /game-board got BootScreen, every
   other route fell through to a naked VStack that exposed the shell. Now
   all non-game-board routes render a dark in-place loader (#12100E, matches
   body bg) so the Suspense window is visually seamless everywhere.

3) Remove the BattleWorldTicker from GameBoard. It was added in 8e31c8a
   and never explicitly removed, so it came back on every SHOW_Z2 build.

Tests: 6 new Routes.test.tsx cases cover the dark fallback on /marketplace,
/leaderboard, /characters/:id, /guild, the BootScreen path on /game-board,
and a static assertion pinning APP_GRID_TEMPLATE_ROWS = "auto auto 1fr auto".
Existing BootScreen.test.ts still 10/10.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
BattleSceneCanvas set state.monsterAnim.startTime to absolute
performance.now() in triggerAttack, but forwarded the RAF-relative
`elapsed` (now - canvasMountStart) into renderMonster/computeAnimParams.
dt = relative - absolute was a huge negative number. Math.min(1, dt/dur)
only clamps the upper bound, so t stayed hugely negative; the 'hit'
branch of computeAnimParams then set translateX = -12 * easeOutCubic(t/0.15),
a huge POSITIVE value. The monster was translated thousands of pixels
off-canvas for the full 300-500ms hit reaction window — reading as the
arena flashing black on every player hit. Carrion Crawler and other
ASCII-only creatures hit this path; GLB monsters route hit/death through
the GLB clip on a different time base and are unaffected.

Fix:
- BattleSceneCanvas: pass `now` (absolute) instead of `elapsed` to
  renderMonster so it matches anim.startTime's base. `elapsed` inside
  renderMonster is used only for dt-vs-startTime and for phase-invariant
  sin(elapsed * k) bob/sway/breath/light terms — time-base swap is safe.
- computeAnimParams: clamp t to [0, 1] (was Math.min only). Any future
  caller that mismatches time bases now fails safe as pre-animation idle
  instead of flinging the sprite off-screen.

Adds four regression tests in MonsterAsciiRenderer.test.ts covering the
'hit' and 'death' windows under both matched and mismatched time bases.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
ZoneTransitionSystem required CharacterZoneCompletion[prevZone]=true
for any zone beyond Dark Cave. That flag is only set inside
_checkZoneCompletion on level-up, so any L10 character who hit max
before that code deployed had the flag stuck at false — permanently
locked out of Windy Peaks despite clearing the real bar.

The flag is redundant with ZoneMapConfig.minLevel (both trip at
maxLevel of the prior zone). Level is now the sole entry gate.
CharacterZoneCompletion is still maintained for the Conqueror badge
and leaderboard rank, it just no longer blocks transitions.

Drops the PrerequisiteZoneIncomplete error and its references.
Updates the existing test_transition_revertsIfPrereqIncomplete to a
positive regression (test_transition_succeedsAtMinLevelWithoutDarkCaveCompletion)
that locks in the fix, and syncs the test setUp with the current
prod minLevel (10, not 11).

Fork tests can't verify the new behavior until ZoneTransitionSystem
is redeployed — the new regression test will pass post-deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
mud deploy upgraded ZoneTransitionSystem (prereq gate removed) on beta
world 0xDc34AC3b06fa0ed899696A72B7706369864E5678.

EnsureAccess re-ran cleanly after FOUNDRY_PROFILE=script forge clean
(InterfaceNotSupported on first attempt was the stale-artifact pattern).

Regression test test_transition_succeedsAtMinLevelWithoutDarkCaveCompletion
now passes against the live beta fork. Legacy L10 characters can enter
Windy Peaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three separate bugs blocked the Z2 onboarding experience after zone
transition. On-chain state was correct — chains initialized, Fragment IX
auto-advanced, step config in place — but nothing rendered.

1. MapContext applied zone-origin offset to PositionV2 coords, which are
   already zone-relative. In WP (zone 2), spawn (0,0) became display
   (0,-100), so the dragon never landed on any tile. Branch on posIsV2
   and skip toDisplayPosition when V2 data is present.

2. CurrentObjectiveHud, FragmentChainProgress, and useNpcFlavor built
   FragmentChainProgress composite keys with type.toString() (decimal).
   encodeCompositeKey hex-pads strings, so decimal "10" encoded as
   0x0000...0010 = fragment 16, not 0x0000...000a = fragment 10. Every
   fragment past IX silently missed its row. Pass type.toString(16).

3. FragmentEchoTile was exported from FragmentEchoOverlay but never
   rendered anywhere — the pulsing echo icon over the trigger tile was
   dead code. Render it inside MapPanel's tile loop.

Add keys.test.ts regression covering the hex-vs-decimal encoding so this
specific bug can't come back silently.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant