From 013998f8fd349ac47dfb4312e6a47b5c17082229 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 16:47:44 +0700 Subject: [PATCH] fix(coldopen): guard player-PC seating + raise Part-A poll window (no_actor reliability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISSUE 1 (no_actor): the .app drives scripts/play_party.sh, which pre-seeds companions but leaves the human's PC to the DM's cold-open turn — DM-stochastic. Forensic run g1 (veteran persona) minted can_act:true but the DM ended after start_world without ever calling create_character(kind=player): party=[], characters={NPCs only}, chat empty → viewer readiness=degraded / failure_bucket=no_actor ("no active player actor is seated"), an unplayable surface the persona can't escape. (A prior newbie run DID seat — stochastic miss.) Fix: (a) strengthen the cold-open prompt to make seating the PC the FIRST, mandatory tool action (create_character kind=player, add_to_party); (b) add a snapshot-backed seating GUARD after the cold open that mirrors viewer/server.py _action_actor (a party member whose record is kind=player) — if unseated, retry the cold open ONCE on a fresh session with a hard seat directive, then FAIL LOUD ("COLD-OPEN SEATED NO PC", exit 1) rather than hand a silent no_actor session. ISSUE 2 (Part-A poll too short): the #356 mint poll was 70 x 3s = 210s, shorter than a max-effort cold open (~280-400s, WORLDOS_COLDOPEN_TIMEOUT=400) → spurious FAIL on a slow-but-healthy mint. Raise to a 420s-default, env-overridable knob (WOS_APP_PART_A_DEADLINE); the poll loop now derives its iteration count from it. Tests: two static regressions in test_adversarial_release.py lock both fixes. bash -n + py_compile clean; guard validated against the real g1 (unseated) and g1-b (seated, PC Zevlor) snapshots. --- qa/ui_playtest_app.sh | 25 ++++++--- scripts/play_party.sh | 54 ++++++++++++++++++- .../engine/tests/test_adversarial_release.py | 29 ++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index 5024f695..127eda85 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -70,6 +70,12 @@ PART="$(worldos_env APP_PART "${WOS_APP_PART:-AB}")" KEEP_MINTED_BACKEND="${WOS_APP_KEEP_MINTED_BACKEND:-0}" SELECTED_PROVIDER="${WOS_APP_SELECTED_PROVIDER:-}" PLAYER_AGENT="${WOS_APP_PLAYER_AGENT:-claude}" +# Part-A cold-open mint deadline (seconds). The #356 banner spawns the DM cold open, whose +# --effort max world-build runs ~280–400s (qa/lib_beat_driver.sh WORLDOS_COLDOPEN_TIMEOUT=400); +# the old 210s poll (70 × 3s) was SHORTER than a max-effort cold open, so a slow-but-healthy +# mint timed out as a spurious FAIL. Give the poll a 420s budget (just past the cold-open +# deadline), env-overridable for fast inner loops. +PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}" if [ "$KEEP_MINTED_BACKEND" = "1" ] && [ "$PART" != "A" ]; then printf '[uipt-app] WOS_APP_KEEP_MINTED_BACKEND=1 requires WOS_APP_PART=A; refusing to mix kept native backend with part B.\n' >&2 exit 2 @@ -392,15 +398,18 @@ PY a_log "[A] raising WorldOS to front + CGEvent-clicking the RESUME → PLAY CTA (with retries)…" click_play_cta # first attempt (the poll re-clicks if the focus-race ate it) - # ASSERT the mint. Poll up to ~3.5 min for BOTH: (i) a NEW play-state run dir AND (ii) a NEW - # viewer (a DIFFERENT port than the launcher) whose /session-surface reports can_act:true. The - # DM cold-open mints the campaign; can_act flips once the move-sink is set AND the viewer binds - # the live campaign (auto-follow), which happens early in the cold-open turn. RE-CLICK every - # ~24s while nothing has minted: on a busy multi-app desktop another window can steal focus - # between the activate and the CGEvent, swallowing the click — a re-click recovers it. - a_log "[A] polling for a minted live session (new run dir + can_act:true on a new port)…" + # ASSERT the mint. Poll up to PART_A_DEADLINE (default 420s, env WOS_APP_PART_A_DEADLINE) for + # BOTH: (i) a NEW play-state run dir AND (ii) a NEW viewer (a DIFFERENT port than the launcher) + # whose /session-surface reports can_act:true. The DM cold-open mints the campaign; can_act flips + # once the move-sink is set AND the viewer binds the live campaign (auto-follow), which happens + # early in the cold-open turn — but the budget must outlast the max-effort cold open (~280–400s), + # so the old 210s poll was a spurious FAIL on a slow-but-healthy mint. RE-CLICK every ~24s while + # nothing has minted: on a busy multi-app desktop another window can steal focus between the + # activate and the CGEvent, swallowing the click — a re-click recovers it. + local part_a_polls=$(( PART_A_DEADLINE / 3 )); [ "$part_a_polls" -lt 1 ] && part_a_polls=1 + a_log "[A] polling for a minted live session (new run dir + can_act:true on a new port; deadline ${PART_A_DEADLINE}s / ${part_a_polls} polls)…" local minted_port="" minted_run="" can_act="false" - for i in $(seq 1 70); do + for i in $(seq 1 "$part_a_polls"); do # (i) a new play-state dir local now_dirs new_dir now_dirs="$(ls -1 "$ROOT/play-state" 2>/dev/null | sort || true)" diff --git a/scripts/play_party.sh b/scripts/play_party.sh index 0cc8e558..1c1a21f3 100755 --- a/scripts/play_party.sh +++ b/scripts/play_party.sh @@ -416,7 +416,7 @@ Begin a session in a living world for a single human player (who acts through th - This session's campaign ALREADY EXISTS: use campaign_id=$CAMPAIGN_ID for EVERY engine call. The world, party, and companions were pre-seeded for you. DO NOT call start_world — it would mint a NEW campaign id and ORPHAN the pre-seeded companions; DO NOT recruit or create companions yourself. - call get_state(\"$CAMPAIGN_ID\") FIRST to read the world bible (premise, era/chronology, tone, standing threads, seeded regions/factions) AND the existing party roster. The companions already present are: $COMP_NAME_LIST. They are SEPARATE people with their own agency — each is controlled by its OWN agent. You voice the WORLD and NPCs and resolve everyone's declared moves; you NEVER invent a companion's internal choice or speak for them beyond narrating the RESULT of what they declared. - start_session (for continuity and the recap) if get_state shows no active session. -- Create a level-3 player character for the HUMAN (generate_ability_scores + create_character, apply_srd_defaults, sensible skills/spells). Pick a fitting concept and tell the player who they are. This is the ONLY character you create. +- SEAT THE PLAYER CHARACTER FIRST — this is MANDATORY and comes BEFORE any narration, art, or scene-setting. Create a level-3 player character for the HUMAN (generate_ability_scores + create_character with kind=\"player\" and add_to_party=true, apply_srd_defaults, sensible skills/spells). Pick a fitting concept and tell the player who they are. This is the ONLY character you create. A cold open that ends with NO seated player PC (the party has no kind=\"player\" member) is a BROKEN session the player cannot play — never end this turn without the human's PC seated in the party. - Open a human-scale, personal scene grounded in the world's canon, with real quoted dialogue, that includes the human's PC AND their companions, and hand the player an open moment + a clear, real choice. CRITICAL — your FINAL output THIS turn MUST BE the opening SCENE itself, written as 2nd-person player-facing prose (addressed to \"you\"): where the player IS, what they see/hear/smell, who is present and a real quoted line from them, ending on a clear open moment + choice. The player reads ONLY your final reply text as the scene — so the opening prose MUST be IN it. Do your setup with the tools FIRST, then CLOSE the turn by writing the scene. NEVER end this turn on a tool call, and NEVER let your reply be a 3rd-person setup brief or game-system notation (e.g. \"COLD OPEN — ARRIVAL: (tiefling wizard, PC) walks toward…\") — that is your private scratchpad, not the player's scene. If you logged a setup note via log_event, you must STILL write the 2nd-person scene as your reply text. @@ -429,6 +429,58 @@ DMSG="$(clawdnd_dm_narration_or_fallback "$DMSG" "$STATE_DIR")" chatlog dm "$DMSG"; AGENT_TURNS=1 echo "[play-party] DM opened: ${DMSG:0:120}…" +# --- SEATING GUARD: the cold open MUST seat a player PC ---------------------------------- +# UNLIKE the companions (pre-seeded above), the human's PC is created by the DM live in the +# cold-open turn (play.sh-parity "the DM hands you a character" feel). That makes seating +# DM-STOCHASTIC: a forensic .app run (g1, veteran persona) built the world but its cold-open +# turn ended after start_world WITHOUT ever calling create_character(kind="player") — leaving +# party=[] and characters={NPCs only}. The viewer then reports readiness=degraded / +# failure_bucket="no_actor" ("no active player actor is seated"), an UNPLAYABLE surface the +# persona cannot escape (viewer/server.py `_action_actor` returns None when no party member is +# kind="player"). A prior newbie run DID seat (PC Rolan), so this is a stochastic miss, not a +# code break. Guard it the same way the DM TURN itself is guarded (one retry, then fail loud): +# read the snapshot for a seated player actor; if none, retry the cold open ONCE on a FRESH +# session with a hard seat-only directive; if STILL none, abort with a clear, non-silent error +# rather than hand the player a no_actor session. Mirrors viewer/server.py `_action_actor`: +# a seated PC = a party member whose character record is kind="player". +pc_seated() { # 0 = a player PC is seated in the party; 1 = none + local snap="$CAMP_DIR/snapshot.json" + [ -f "$snap" ] || return 1 # no snapshot at all -> definitely not seated + python3 - "$snap" <<'PY' +import json, sys +try: + snap = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +chars = snap.get("characters") if isinstance(snap.get("characters"), dict) else {} +party = snap.get("party") if isinstance(snap.get("party"), list) else [] +# Match the viewer's _action_actor: a party member whose record is kind="player". +seated = any( + isinstance(chars.get(cid), dict) and chars.get(cid, {}).get("kind") == "player" + for cid in party if isinstance(cid, str) +) +sys.exit(0 if seated else 1) +PY +} +if ! pc_seated; then + echo "[play-party] cold open seated NO player PC (party has no kind=\"player\" member) — retrying the cold open ONCE on a fresh session…" >&2 + # Fresh session id so the retry's first=1 --session-id can't collide with the consumed $DSID. + DSID="$(uuidgen 2>/dev/null || python3 -c 'import uuid;print(uuid.uuid4())')" + RESEAT_DMSG="$(turn dm "$DSID" 1 "Your previous cold-open turn for campaign $CAMPAIGN_ID did NOT seat the human's player character — the party still has no kind=\"player\" member, so the game is UNPLAYABLE. Fix this NOW, before anything else. + +- use campaign_id=$CAMPAIGN_ID for EVERY engine call. DO NOT call start_world (it would mint a NEW campaign id and ORPHAN the pre-seeded companions). The companions already present are: $COMP_NAME_LIST. +- SEAT THE PLAYER CHARACTER: generate_ability_scores + create_character with kind=\"player\" and add_to_party=true, apply_srd_defaults, sensible skills/spells. Pick a fitting concept and tell the player who they are. This is the ONLY character you create. The party MUST contain the human's kind=\"player\" PC when this turn ends. +- Then CLOSE the turn by writing the opening SCENE as 2nd-person player-facing prose addressed to \"you\" (where the player IS, what they see/hear/smell, who is present + a real quoted line), ending on a clear open moment + choice. NEVER end on a tool call or a 3rd-person setup brief.")" + RESEAT_DMSG="$(clawdnd_dm_narration_or_fallback "$RESEAT_DMSG" "$STATE_DIR")" + AGENT_TURNS=$((AGENT_TURNS + 1)) + if [ -n "$RESEAT_DMSG" ]; then DMSG="$RESEAT_DMSG"; chatlog dm "$DMSG"; echo "[play-party] reseat turn opened: ${DMSG:0:120}…"; fi + if ! pc_seated; then + echo "[play-party] COLD-OPEN SEATED NO PC: after a retry the party still has no kind=\"player\" member — aborting rather than hand the player a no_actor session (see $COMBINED)." >&2 + exit 1 + fi + echo "[play-party] reseat OK — a player PC is now seated in the party." +fi + # --- beat 0: each companion INTRODUCES in character, loading its PERSONA ----------------- # This is the fix for the inert-companion bug: COMP_PERSONAS is wired above but was never # read, so companions had a sheet but no voice / no agenda. Mirror qa/run_party.sh's beat-0 diff --git a/servers/engine/tests/test_adversarial_release.py b/servers/engine/tests/test_adversarial_release.py index 207ab586..9aa764f3 100644 --- a/servers/engine/tests/test_adversarial_release.py +++ b/servers/engine/tests/test_adversarial_release.py @@ -182,6 +182,35 @@ def test_issue50_play_party_prompt_uses_existing_campaign_not_start_world(): assert "DO NOT call start_world" in text and "campaign_id=$CAMPAIGN_ID" in text +def test_coldopen_play_party_guards_player_pc_seating(): + """Cold-open reliability: play_party.sh (the .app's path) creates the human PC live in a + DM-stochastic turn. A forensic run minted can_act:true but the DM never seated a PC → + viewer readiness=degraded / no_actor. Guard the miss: detect a seated player actor after + the cold open, retry once, and FAIL LOUD rather than silently hand a no_actor session.""" + text = (_ROOT / "scripts" / "play_party.sh").read_text(encoding="utf-8") + # (1) the prompt mandates seating the PC (kind="player", add_to_party) up front. + assert 'create_character with kind=\\"player\\" and add_to_party=true' in text + # (2) a snapshot-backed guard exists and matches the viewer's _action_actor notion of a + # seated PC (a party member whose record is kind="player"). + assert "pc_seated()" in text + assert 'get("kind") == "player"' in text + # (3) the guard retries the cold open ONCE, then aborts loudly on a still-unseated party. + assert "retrying the cold open ONCE" in text + assert "COLD-OPEN SEATED NO PC" in text and "exit 1" in text + + +def test_coldopen_part_a_poll_window_outlasts_max_effort_coldopen(): + """Cold-open reliability: the Part-A (#356) mint poll must outlast the max-effort cold open + (~280–400s). The old 210s window (70 × 3s) was a spurious FAIL; the deadline is now a + 420s-default, env-overridable knob.""" + text = (_ROOT / "qa" / "ui_playtest_app.sh").read_text(encoding="utf-8") + assert 'PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}"' in text + # the poll loop derives its iteration count from the deadline (no more hardcoded `seq 1 70`). + assert "part_a_polls=$(( PART_A_DEADLINE / 3 ))" in text + assert 'for i in $(seq 1 "$part_a_polls"); do' in text + assert "for i in $(seq 1 70); do" not in text + + def test_issue51_campaign_new_command_creates_one_campaign(): text = (_ROOT / "commands" / "campaign-new.md").read_text(encoding="utf-8") assert "create_campaign` to get a campaign id, then" not in text # the two-campaign instruction