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
25 changes: 17 additions & 8 deletions qa/ui_playtest_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate WOS_APP_PART_A_DEADLINE before using it in arithmetic.

A non-numeric or non-positive override can collapse part_a_polls to 0, and the clamp then turns Part A into a single poll. That reintroduces the same spurious FAIL mode this change is fixing.

Suggested fix
 PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}"
+case "$PART_A_DEADLINE" in
+  ''|*[!0-9]*)
+    printf '[uipt-app] WOS_APP_PART_A_DEADLINE must be a positive integer (got %s)\n' "$PART_A_DEADLINE" >&2
+    exit 2
+    ;;
+esac
+[ "$PART_A_DEADLINE" -ge 1 ] || {
+  printf '[uipt-app] WOS_APP_PART_A_DEADLINE must be >= 1 (got %s)\n' "$PART_A_DEADLINE" >&2
+  exit 2
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}"
PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}"
case "$PART_A_DEADLINE" in
''|*[!0-9]*)
printf '[uipt-app] WOS_APP_PART_A_DEADLINE must be a positive integer (got %s)\n' "$PART_A_DEADLINE" >&2
exit 2
;;
esac
[ "$PART_A_DEADLINE" -ge 1 ] || {
printf '[uipt-app] WOS_APP_PART_A_DEADLINE must be >= 1 (got %s)\n' "$PART_A_DEADLINE" >&2
exit 2
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qa/ui_playtest_app.sh` at line 78, PART_A_DEADLINE is taken directly from
WOS_APP_PART_A_DEADLINE and used in arithmetic to compute part_a_polls, which
can become 0 if the env var is non-numeric or non-positive; validate and
sanitize the override by parsing it as an integer (e.g., use a safe integer
parse/regex) and ensure it is >0 before using it, otherwise fall back to the
default 420; then use the validated/normalized PART_A_DEADLINE value in the
existing part_a_polls calculation and subsequent clamp so Part A cannot collapse
to a single poll.

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
Expand Down Expand Up @@ -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)"
Expand Down
54 changes: 53 additions & 1 deletion scripts/play_party.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Name> (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.
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions servers/engine/tests/test_adversarial_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Comment on lines +203 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace the ambiguous unicode punctuation in the docstring.

Ruff is already flagging the en dash and multiplication sign here (RUF002), so this adds avoidable lint noise.

Suggested fix
-    """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
+    """Cold-open reliability: the Part-A (`#356`) mint poll must outlast the max-effort cold open
+    (~280-400s). The old 210s window (70 x 3s) was a spurious FAIL; the deadline is now a
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""Cold-open reliability: the Part-A (#356) mint poll must outlast the max-effort cold open
(~280400s). The old 210s window (70 × 3s) was a spurious FAIL; the deadline is now a
420s-default, env-overridable knob."""
"""Cold-open reliability: the Part-A (`#356`) mint poll must outlast the max-effort cold open
(~280-400s). The old 210s window (70 x 3s) was a spurious FAIL; the deadline is now a
420s-default, env-overridable knob."""
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 204-204: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


[warning] 204-204: Docstring contains ambiguous × (MULTIPLICATION SIGN). Did you mean x (LATIN SMALL LETTER X)?

(RUF002)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@servers/engine/tests/test_adversarial_release.py` around lines 203 - 205, The
module docstring contains ambiguous Unicode punctuation (an en dash and a
multiplication sign) causing RUF002; replace the en dash in "280–400s" with an
ASCII hyphen ("280-400s") and replace the multiplication sign in "70 × 3s" with
an ASCII "x" or plain asterisk (e.g., "70 x 3s" or "70 * 3s") so the docstring
in test_adversarial_release.py uses only ASCII punctuation.

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
Expand Down
Loading