Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions servers/engine/tests/test_cast_presence_snapshot_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""#1639 CAST PRESENCE — engine-side serializer discipline for the cast the rest/walk surface now
projects.

The rest surface's #1639 addition (viewer/server.py `_scene_stage`) emits resident NPCs + live
monsters at the party's current location by READING each actor's engine-owned `location_id` +
`stage_cell`. This is a PURE viewer projection — the engine stays the sole writer and gains NO new
field. This test pins that invariant from the engine side: a campaign carrying an NPC and a live
monster (each anchored to a location with a seeded `stage_cell` — the exact shape the fixture and
the arrival machinery produce) round-trips through the store BYTE-IDENTICALLY, so a pre-existing
snapshot is never rewritten and the omit-when-None `stage_cell` serializer (models.py) holds.
"""

from __future__ import annotations

import pytest

import server
import store


@pytest.fixture
def cid(tmp_path, monkeypatch):
monkeypatch.setenv("WORLDOS_STATE_DIR", str(tmp_path))
cid = server.create_campaign("cast-presence-roundtrip")["id"]
loc = server.add_location(cid, "The Throne Hall")["id"]
# A party PC (no stage_cell), a resident NPC + a live monster, each anchored to the location
# with a seeded stage_cell — the cast the #1639 rest projection reads.
server.create_character(cid, "Aidan", kind="player", add_to_party=True)
npc = server.create_character(cid, "Keeper Maera", kind="npc", location_id=loc)["id"]
mon = server.spawn_monster(campaign_id=cid, name="Goblin Boss", count=1)["spawned"][0]["id"]
c = server._require(cid)
c.characters[npc].stage_cell = (5, 3)
c.characters[mon].location_id = loc
c.characters[mon].stage_cell = (10, 5)
server.save_campaign(c)
return cid


def _snapshot_bytes(cid: str) -> str:
return (store._campaign_dir(cid) / "snapshot.json").read_text()


def test_cast_snapshot_round_trips_byte_identical(cid):
"""load -> save of a snapshot carrying an NPC + a live monster (with stage_cells) rewrites the
SAME bytes — the serializer is stable, so no pure read/save churns the file."""
before = _snapshot_bytes(cid)
c = store.load_campaign(cid)
server.save_campaign(c)
after = _snapshot_bytes(cid)
assert after == before, "a pure load->save of the cast snapshot must be byte-identical"


def test_unwalked_monster_omits_stage_cell_from_the_dump(cid):
"""A monster with NO stage_cell (never placed) serializes WITHOUT a `stage_cell` key at all —
the omit-when-None discipline (models.py) — so an un-placed foe adds nothing to the wire and a
legacy snapshot round-trips unchanged."""
c = server._require(cid)
# Mint a second, unplaced monster (location-less, no stage_cell) — spawn_monster's default.
mon2 = server.spawn_monster(campaign_id=c.id, name="Goblin", count=1)["spawned"][0]["id"]
server.save_campaign(server._require(c.id))
dump = _snapshot_bytes(c.id)
reloaded = store.load_campaign(c.id)
assert reloaded.characters[mon2].stage_cell is None
# The un-placed monster's record carries no `stage_cell` field in the JSON.
assert '"stage_cell": null' not in dump
36 changes: 34 additions & 2 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3646,8 +3646,10 @@ def _scene_stage(snapshot: dict, combat_active: bool) -> dict:

Rest tokens = the party (``snapshot.party``) on the ``party`` spawn cells, plus present NPCs
(characters whose ``location_id`` == the current location and whose kind is npc/companion, and
who are NOT already party) on the ``npcs`` spawn cells. Cells fall back to ``zone_anchors``
values, then the party cells, when a bucket is short.
who are NOT already party) on the ``npcs`` spawn cells, plus resident LIVE monsters (#1639 —
kind ``monster`` at the current location with hp>0) on the ``foes`` spawn cells, so the walked
world shows its cast (the quest giver AND the boss), not just the party. Cells fall back to
``zone_anchors`` values, then the party cells, when a bucket is short.

NO DOUBLE-PAINT: during active combat the authoritative tokens live in the top-level
``tokens`` (the tactical board); ``stage.tokens`` is therefore EMPTY in combat mode so a
Expand Down Expand Up @@ -3696,6 +3698,11 @@ def _cells(raw: object) -> list[tuple[int, int]]:

party_cells = [c for c in _cells(spawns.get("party")) if c not in blocked]
npc_cells = [c for c in _cells(spawns.get("npcs")) if c not in blocked]
# #1639: live monsters resident at this location get placed too (the walked world must show its
# 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 👍 / 👎.

# Deterministic fallback pool: the zone anchors (id-sorted) then the party cells, so a bucket
# that ran short still lands its actors on a walkable in-world spot rather than off-board. Drop
# any anchor that lands on a blocking prop/wall — a fallback actor must not stand on a column.
Expand Down Expand Up @@ -3731,6 +3738,23 @@ def _cells(raw: object) -> list[tuple[int, int]]:
and not _is_dead_or_downed(ch)
)

# #1639 CAST PRESENCE: live monsters (kind=monster) resident at the current location, ALIVE
# (hp>0 — the SAME _is_dead_or_downed predicate the party/NPC branches use, so a cleared/dead
# foe never re-stands in the walked scene; combat handles the fallen). id-sorted for a
# deterministic order, and excludes anyone already in `party` (a charmed/allied monster the DM
# folded into the party keeps its party seat). Monsters surfaced ONLY in combat before this —
# so the walked world rendered empty of its cast (the Goblin Boss in the throne hall). ADDITIVE:
# a location with no resident live monster projects EXACTLY as before.
present_monsters = sorted(
cid
for cid, ch in chars.items()
if isinstance(cid, str) and isinstance(ch, dict)
and cid not in party_set
and _text(ch.get("kind")).lower() == "monster"
and _text(ch.get("location_id")) == loc_id and loc_id
and not _is_dead_or_downed(ch)
)

tokens: list[dict] = []

def _emit(cid: str, cells: list[tuple[int, int]], slot: int, fallback_slot: int, rest_role: str) -> None:
Expand Down Expand Up @@ -3784,6 +3808,14 @@ def _emit(cid: str, cells: list[tuple[int, int]], slot: int, fallback_slot: int,
for j, cid in enumerate(present_npcs):
_emit(cid, npc_cells, j, fb, "npc")
fb += 1
# #1639: emit resident live monsters after the party + NPCs. rest_role "foe" (team "foe" via
# _combat_team) — distinct from "npc" so the client's click-to-TALK path (rest_role == "npc")
# never opens a parley on a monster; a foe token renders in the walked scene but is not a talk
# 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 👍 / 👎.

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 👍 / 👎.

fb += 1

return {"mode": mode, "tokens": tokens}

Expand Down
114 changes: 114 additions & 0 deletions viewer/tests/test_scene_at_rest_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,119 @@ def test_stage_cell_token_stays_a_derived_hint(self):
self.assertEqual(by_id["pc_hero"]["positionAuthority"], "derived")


class SceneAtRestLiveMonsterTests(unittest.TestCase):
"""#1639 CAST PRESENCE: the rest/walk surface must show the resident LIVE monsters at the
party's current location (the Goblin Boss on his throne), not just the party — monsters
surfaced ONLY in combat before, so the walked world rendered empty of its cast. Additive,
combat-inert, hp>0-gated projection (the SAME dead/downed rule the party + NPC branches use)."""

@staticmethod
def _monster(loc: str, **over) -> dict:
m = {"id": over.pop("id", "mon_boss"), "name": over.pop("name", "Goblin Boss"),
"kind": "monster", "location_id": loc, "current_hp": 21}
m.update(over)
return m

def test_live_monster_and_npc_both_placed_not_party_only(self):
"""Red-first repro of #1639: a location holding a present NPC AND a live monster must emit
BOTH — before this change the monster was dropped and the surface was party+NPC only (and
the eval fixture's boss room, party-ONLY). team "foe" distinguishes the monster."""
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1")
by_id = {t["id"]: t for t in _surface(snap)["stage"]["tokens"]}
# the party PC and the present NPC still project (unchanged)...
self.assertIn("pc_hero", by_id)
self.assertIn("npc_keeper", by_id)
# ...AND the resident live monster now projects too — the #1639 fix.
self.assertIn("mon_boss", by_id)
self.assertEqual(by_id["mon_boss"]["team"], "foe")
self.assertEqual(by_id["mon_boss"]["kind"], "monster")

def test_live_monster_uses_foes_spawn_bucket_when_unwalked(self):
"""A monster carrying no seeded stage_cell lands on the `foes` spawn cell the generators
author (scene_grid.py) — the first foe cell in _tavern_scene_grid is (6, 2)."""
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1") # no stage_cell
by_id = {t["id"]: t for t in _surface(snap)["stage"]["tokens"]}
self.assertEqual((by_id["mon_boss"]["x"], by_id["mon_boss"]["y"]), (6, 2))

def test_live_monster_renders_at_stage_cell_when_set(self):
"""A seeded/walked monster (engine-authoritative stage_cell) renders WHERE IT STANDS, not
at its spawn cell — same W2 discipline as the party/NPC branches."""
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1", stage_cell=[10, 5])
by_id = {t["id"]: t for t in _surface(snap)["stage"]["tokens"]}
self.assertEqual((by_id["mon_boss"]["x"], by_id["mon_boss"]["y"]), (10, 5))

def test_dead_monster_is_never_placed(self):
"""A cleared foe (dead flag) never re-stands in the walked scene."""
snap = _rest_snapshot()
snap["characters"]["mon_slain"] = self._monster("loc1", id="mon_slain",
name="Slain Goblin", dead=True)
ids = {t["id"] for t in _surface(snap)["stage"]["tokens"]}
self.assertNotIn("mon_slain", ids)

def test_downed_monster_at_zero_hp_is_never_placed(self):
"""hp>0 gate: a monster at 0 HP that is not yet flagged `dead` is excluded too (the same
_is_dead_or_downed predicate the party/NPC branches apply)."""
snap = _rest_snapshot()
snap["characters"]["mon_downed"] = self._monster("loc1", id="mon_downed",
current_hp=0, dead=False)
ids = {t["id"] for t in _surface(snap)["stage"]["tokens"]}
self.assertNotIn("mon_downed", ids)

def test_monster_at_another_location_is_not_placed(self):
"""A live monster anchored at a DIFFERENT location does not leak onto this scene."""
snap = _rest_snapshot()
snap["characters"]["mon_far"] = self._monster("loc2", id="mon_far", name="Distant Ogre")
ids = {t["id"] for t in _surface(snap)["stage"]["tokens"]}
self.assertNotIn("mon_far", ids)

def test_monster_token_is_a_derived_hint(self):
"""The engine stays sole writer: a monster rest token is a derived render hint, idle-posed,
never authoritative — same discipline as every other rest token."""
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1", stage_cell=[10, 5])
by_id = {t["id"]: t for t in _surface(snap)["stage"]["tokens"]}
self.assertEqual(by_id["mon_boss"]["positionAuthority"], "derived")
self.assertEqual(by_id["mon_boss"]["pose"], "idle")
# rest_role "foe" — NOT "npc", so the client's click-to-TALK path never fires on a monster.
self.assertEqual(by_id["mon_boss"]["rest_role"], "foe")

def test_combat_mode_carries_no_monster_tokens(self):
"""Combat-inertness: with a live monster present but combat ACTIVE, the stage block is
empty (the authoritative tokens are the top-level combat board) — the #1639 addition
cannot double-emit onto the combat surface."""
snap = _combat_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1")
surface = _surface(snap)
self.assertEqual(surface["stage"]["mode"], "combat")
self.assertEqual(surface["stage"]["tokens"], [])
self.assertTrue(surface["tokens"]) # the combat board itself is unchanged

def test_monster_present_surface_is_still_stage_only_new_key(self):
"""Byte-identity holds with a monster present: the surface gains no NON-stage top-level
key, so every existing consumer of the combat surface is unaffected."""
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1")
surface = _surface(snap)
self.assertEqual(set(surface) - _LEGACY_KEYS, {"stage"})

def test_projection_does_not_mutate_the_snapshot(self):
"""The viewer is a pure reader: projecting a monster never writes back to the engine-owned
snapshot (no stage_cell is minted, no field added)."""
import copy
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1")
before = copy.deepcopy(snap)
_surface(snap)
self.assertEqual(snap, before)

def test_projection_is_deterministic_with_monsters(self):
snap = _rest_snapshot()
snap["characters"]["mon_boss"] = self._monster("loc1")
self.assertEqual(_surface(snap)["stage"], _surface(snap)["stage"])


if __name__ == "__main__":
unittest.main()
Loading