-
Notifications
You must be signed in to change notification settings - Fork 0
engine: rest surface emits located cast (NPCs + live monsters) — closes #1639 #1662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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] | ||
| # 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. | ||
|
|
@@ -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: | ||
|
|
@@ -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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a resident monster is emitted as a rest-stage token, the existing rest board treats every non- Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because the new monster tokens are emitted after party and NPC tokens, any foe whose Useful? React with 👍 / 👎. |
||
| fb += 1 | ||
|
|
||
| return {"mode": mode, "tokens": tokens} | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For monsters that do not yet have
stage_cell, this paints them onto the genericspawns.foesbucket, but the engine paths and combat seeding do not treat that derived cell as occupied:rest_blocked_cellsonly reads characterstage_cellplus per-idnpc:*anchors and skips the genericparty/foes/npcslists, andstart_combat(seed_from_stage=True)likewise ignores combatants withoutstage_cell. In the new unwalked-monster scenario, the rest board can show a boss standing on a cell that/movecan 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 👍 / 👎.