Skip to content

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

Open
mokn wants to merge 560 commits into
mainfrom
dev
Open

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

Conversation

@mokn

@mokn mokn commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

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

vercel Bot commented Mar 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment Jul 27, 2026 4:19pm
ud Ready Ready Preview, Comment Jul 27, 2026 4:19pm
ud-api Ready Ready Preview, Comment Jul 27, 2026 4:19pm
ud-api-beta Ready Ready Preview, Comment Jul 27, 2026 4:19pm

Request Review

mokn and others added 30 commits April 14, 2026 11:21
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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