Skip to content

engine: rest surface emits located cast (NPCs + live monsters) — closes #1639#1662

Merged
100yenadmin merged 1 commit into
mainfrom
cast-presence-1639
Jul 22, 2026
Merged

engine: rest surface emits located cast (NPCs + live monsters) — closes #1639#1662
100yenadmin merged 1 commit into
mainfrom
cast-presence-1639

Conversation

@100yenadmin

Copy link
Copy Markdown
Member

Closes #1639.

Root cause

The rest/walk surface's stage.tokens (built by viewer/server.py _scene_stage, the sole
builder of that block) projected only the party + present NPCs. Resident live monsters
(spawn_monster, kind=monster) surfaced only in combat — so the walked world rendered
empty of its cast: Keeper Maera was emitted (the #1363 NPC path), but the Goblin Boss in the
throne hall and the crypt goblins were invisible (adventure_walk RED: actor_missing).

Note on the file path: the issue/dispatch pointed at servers/engine/server.py, but stage.tokens
is projected in viewer/server.py _scene_stage (the /combat-surface route lives in the viewer,
which reads the engine-owned snapshot). The fix is there. _living_hostiles /
_seed_stage_cells_on_arrival remain the engine-side references for the alive/placement idiom.

The additive projection

Live monsters at the party's current location are now emitted after the party + NPCs:

  • hp>0 gate via the SAME _is_dead_or_downed predicate the party/NPC branches use — a dead or
    downed (0 HP, not-yet-dead) foe never re-stands in the walked scene (combat handles the fallen).
  • id-sorted deterministic order; excludes anyone already in party.
  • Placement: the engine-authoritative Character.stage_cell when set (walk-in / seeded),
    else the authored foes spawn bucket (scene_grid.py), then the shared anchor/party fallback —
    all filtered off blocking props/walls. Never mints a cell (pure read).
  • rest_role: "foe" (team: "foe" via _combat_team) — distinct from "npc", so the client's
    click-to-talk path (rest_role === "npc", screen-combat.jsx) never opens a parley on a
    monster. No client changes; the client renders tokens generically.

Combat-inertness (provably inert)

_scene_stage returns {"mode":"combat","tokens":[]} before any rest projection when combat is
active — the monster addition lives entirely in the rest branch and cannot double-emit onto the
tactical board. Test test_combat_mode_carries_no_monster_tokens pins this with a live monster
present + combat active. The surface gains no non-stage top-level key
(test_monster_present_surface_is_still_stage_only_new_key).

Serializer discipline / sole-writer

Pure viewer projection — the engine stays sole writer, no new model field, positionAuthority
stays "derived", and _scene_stage never mutates the snapshot
(test_projection_does_not_mutate_the_snapshot). Engine-side round-trip test proves a snapshot
carrying an NPC + a live monster (with stage_cells) is byte-identical on load→save, and an
unplaced monster omits stage_cell from the dump (test_cast_presence_snapshot_roundtrip.py).

Tests (red-first)

  • viewer/tests/test_scene_at_rest_stage.py — +10 tests (SceneAtRestLiveMonsterTests): live
    monster emitted, uses foes bucket unwalked, renders at stage_cell when set, dead excluded,
    downed(0 HP) excluded, other-location excluded, derived-hint/rest_role, combat-inert,
    stage-only-key, no-mutation, deterministic. Verified RED (4 emission tests KeyError: mon_boss)
    with the fix reverted.
  • servers/engine/tests/test_cast_presence_snapshot_roundtrip.py — +2 tests (byte-identity + omit-when-None).
  • Full viewer/tests suite: 875 passed, 8 skipped (no regressions). Engine test_living_stage
    • round-trip: 15 passed.

Live curl evidence

Seeded adventure_demo_v1 (qa/seed_adventure_demo.py) into throwaway state dirs, served the
viewer on scratch ports, GET /combat-surface:

throne_hall:  Goblin Boss  | kind=monster | cell=(10,5) | team=foe | rest_role=foe
tavern_snug:  Keeper Maera | kind=npc     | cell=(5,3)  | team=ally | rest_role=npc

Owner engine (:8766), owner state dir, :8866/:8972, and qa/state/adv_live3 (a live eval) were not
touched — scratch ports :8123/:8124 + /tmp seed dirs only.

#1639

The rest/walk surface's stage.tokens projected only the party + present NPCs;
resident live monsters (spawn_monster, kind=monster) surfaced ONLY in combat, so
the walked world rendered empty of its cast — the Goblin Boss on his throne and
the crypt goblins were invisible (adventure_walk RED: actor_missing).

Additive, combat-inert, hp>0-gated projection in viewer/server.py _scene_stage
(the sole builder of stage.tokens): live monsters at the party's current location
are emitted after the party + NPCs, on their engine-seeded stage_cell (else the
authored foes spawn bucket, then the shared anchor/party fallback). rest_role
'foe' (team 'foe') keeps them off the client's click-to-talk path. Pure viewer
projection — engine stays sole writer, no new model field, snapshots unchanged.
Combat mode still returns empty stage.tokens (no double-paint on the tactical
board).
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cb69059b-d7ec-41d2-bf34-85751e1bcb27

📥 Commits

Reviewing files that changed from the base of the PR and between 22e0280 and 82da7a0.

📒 Files selected for processing (3)
  • servers/engine/tests/test_cast_presence_snapshot_roundtrip.py
  • viewer/server.py
  • viewer/tests/test_scene_at_rest_stage.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cast-presence-1639

Comment @coderabbitai help to get the list of available commands.

@100yenadmin
100yenadmin enabled auto-merge (squash) July 22, 2026 08:17
@evaos-code-review-bot

evaos-code-review-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

evaOS review status: closed or merged before review

PR: #1662 - engine: rest surface emits located cast (NPCs + live monsters) — closes #1639
Head: 82da7a070bc3f6fafa8dedbd01d5d059ba837c3f
Updated: 2026-07-22T08:24:57.687Z

evaOS review stopped because the PR closed or merged before this queued head could be reviewed.

Automation note: agents should wait for this comment to reach completed, stale_head, closed_or_merged_before_review, skipped, or failed before treating evaOS review as settled for this head. provider_deferred means evaOS still intends to retry.

PR URL: #1662

Details: state=closed

@100yenadmin
100yenadmin merged commit 899b066 into main Jul 22, 2026
25 of 30 checks passed
@100yenadmin
100yenadmin deleted the cast-presence-1639 branch July 22, 2026 08:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82da7a070b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread viewer/server.py
# target. Placement: the engine-authoritative stage_cell when set (walk-in / seeded), else the
# `foes` spawn bucket, then the shared anchor/party fallback (all inside _emit).
for k, cid in enumerate(present_monsters):
_emit(cid, foe_cells, k, fb, "foe")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep rest foes out of the walk selection pool

When a resident monster is emitted as a rest-stage token, the existing rest board treats every non-rest_role === "npc" token as selectable and then posts walk_to_cell for the selected id on the next empty-cell click; _resolve_walk_to forwards that id directly to engine.walk_to, which only rejects different locations, not non-party actors. In a room with a visible boss, clicking the boss and then a cell will therefore move the hostile and persist its stage_cell instead of just showing it as inert cast presence, so these foe tokens need to be non-selectable or the walk resolver must reject non-party ids.

Useful? React with 👍 / 👎.

Comment thread viewer/server.py
# cast — the boss on his throne, not just the party). Foes fall back to the `foes` spawn bucket
# the generators author (scene_grid.py) when they carry no seeded stage_cell; drop any that sit
# on a blocking prop/wall, same discipline as the party/npc buckets.
foe_cells = [c for c in _cells(spawns.get("foes")) if c not in blocked]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align foe fallback cells with rest walk occupancy

For monsters that do not yet have stage_cell, this paints them onto the generic spawns.foes bucket, but the engine paths and combat seeding do not treat that derived cell as occupied: rest_blocked_cells only reads character stage_cell plus per-id npc:* anchors and skips the generic party/foes/npcs lists, and start_combat(seed_from_stage=True) likewise ignores combatants without stage_cell. In the new unwalked-monster scenario, the rest board can show a boss standing on a cell that /move can route through and that the tactical fight will not seed from, so either avoid presenting unplaced foes as occupied cells or make the engine own/block the same cell.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

Comment thread viewer/server.py
# target. Placement: the engine-authoritative stage_cell when set (walk-in / seeded), else the
# `foes` spawn bucket, then the shared anchor/party fallback (all inside _emit).
for k, cid in enumerate(present_monsters):
_emit(cid, foe_cells, k, fb, "foe")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hiding earlier actors when foe slots collide

Because the new monster tokens are emitted after party and NPC tokens, any foe whose stage_cell overlaps an earlier actor, or whose overflow fallback lands on an occupied party/NPC cell, wins in the existing rest board's single-token at[x,y] map and hides the active PC or NPC from the grid. This can happen in rooms with more live monsters than authored foe/anchor slots or with stale overlapping stage cells, so the projection should de-conflict placements before emitting foe tokens instead of relying on the client to render stacks it cannot represent.

Useful? React with 👍 / 👎.

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.

Walked world renders empty of its cast: rest-surface tokens exclude NPCs + spawned monsters (first A-G weakest-link finding)

1 participant