diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index afb9eb1..76db354 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -17,10 +17,19 @@ Do not call `Application.Quit()` (can hang Steam on Linux). **Never start, relaunch, or kill WorldBox / Steam from the Cursor sandbox.** -- `scripts/harness-run.sh` may launch the game when it is not already running. -- `pkill`, Steam, and game process control also fail or misbehave under sandbox. -- Always run harness commands (and any WorldBox process control) with **unrestricted / `all` permissions** so they execute outside the sandbox. -- Prefer `--no-launch` only when WorldBox is already running *and* you still use unrestricted permissions for the runner itself (log tail, `.harness` I/O under the live mod path). +- Always run harness commands and WorldBox process control with **unrestricted / `all` permissions**. +- Canonical process control (do not invent ad-hoc kill/launch loops): + +```bash +./scripts/worldbox-ctl.sh status +./scripts/worldbox-ctl.sh start +./scripts/worldbox-ctl.sh stop +./scripts/worldbox-ctl.sh restart # after mod.json bump / NML recompile +``` + +- `scripts/harness-run.sh` may launch the game when it is not already running (same Steam app id). +- Prefer `--no-launch` only when `./scripts/worldbox-ctl.sh status` shows WorldBox is already up. +- **Never** `pkill -f` with a `worldbox` path in the agent shell command - it can match and kill the agent process itself. Use `worldbox-ctl.sh stop` (`killall worldbox`). ## WorldBox wiki (mechanics source of truth) @@ -38,18 +47,22 @@ Vanilla `Tooltip` / `TooltipLibrary` (`tooltips/tooltip_actor`, etc.) may be reu After any feature or bug fix in `IdleSpectator/`: 1. Bump `IdleSpectator/mod.json` version (NML recompiles on game load). -2. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior. -3. Day-to-day / PR gate: +2. If the game was already running, recompile with: + ```bash + ./scripts/worldbox-ctl.sh restart + ``` +3. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior. +4. Day-to-day / PR gate: ```bash ./scripts/harness-run.sh --repeat 3 critical_smoke ``` -4. After larger changes (director, discovery, settings, input, tips, chronicle), also run: +5. After larger changes (director, discovery, settings, input, tips, chronicle), also run: ```bash ./scripts/harness-run.sh regression ``` -5. If you added a focused scenario, run it directly too. -6. Iterate until **all repeats PASS**. Do not stop after one flaky pass. -7. Report: scenario name(s), PASS/FAIL counts, and any new asserts. +6. If you added a focused scenario, run it directly too. +7. Iterate until **all repeats PASS**. Do not stop after one flaky pass. +8. Report: scenario name(s), PASS/FAIL counts, and any new asserts. ### HUD / visual changes - screenshot gate (required) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 3d45a63..2fd64f4 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -500,6 +500,17 @@ public static class AgentHarness break; } + case "dossier_force_headline": + { + string headline = !string.IsNullOrEmpty(cmd.value) + ? cmd.value + : (!string.IsNullOrEmpty(cmd.label) ? cmd.label : "Zezqzo Tikzkek Tak (scorpion)"); + WatchCaption.ForceNametagHeadline(headline); + _cmdOk++; + Emit(cmd, true, detail: $"headline='{WatchCaption.LastHeadline}' shown='{WatchCaption.ShownNameChipText}'"); + break; + } + case "dossier_clear_job": { WatchCaption.ClearHarnessJobOverrides(); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index ff6cd9c..4693546 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -18,6 +18,9 @@ internal static class HarnessScenarios case "critical": case "critical_smoke": return CriticalSmoke(); + case "dossier_nametag": + case "nametag_layout": + return DossierNametagLayout(); case "settings_full": return SettingsFull(); case "ghost_guard": @@ -442,6 +445,13 @@ internal static class HarnessScenarios Step("c47", "assert", expect: "no_bad"), Step("c48", "assert", expect: "power_bar", value: "false"), + // Long nametag must not paint under the level badge. + Step("c49a", "dossier_force_headline", value: "Zezqzo Tikzkek Tak (scorpion)"), + Step("c49b", "assert", expect: "caption_layout_ok"), + Step("c49c", "assert", expect: "dossier_contains", value: "Zezqzo"), + Step("c49d", "screenshot", value: "hud-dossier-long-name.png"), + Step("c49e", "wait", wait: 0.35f), + Step("c50", "simulate_input"), Step("c51", "wait", wait: 0.25f), Step("c52", "assert", expect: "idle", value: "false"), @@ -452,6 +462,37 @@ internal static class HarnessScenarios }; } + /// Long "Name (species)" must leave room for the level chip (no overlap). + private static List DossierNametagLayout() + { + return new List + { + Step("dn0", "dismiss_windows"), + Step("dn1", "wait_world"), + Step("dn2", "set_setting", expect: "enabled", value: "true"), + Step("dn3", "set_setting", expect: "show_dossier_caption", value: "true"), + Step("dn4", "pick_unit", asset: "auto"), + Step("dn5", "spectator", value: "off"), + Step("dn6", "spectator", value: "on"), + Step("dn7", "focus", asset: "auto"), + Step("dn8", "wait", wait: 0.3f), + Step("dn10", "dossier_force_headline", value: "Zezqzo Tikzkek Tak (scorpion)"), + Step("dn11", "assert", expect: "dossier_contains", value: "Zezqzo"), + Step("dn12", "assert", expect: "caption_layout_ok"), + Step("dn13", "screenshot", value: "hud-dossier-long-name.png"), + Step("dn14", "wait", wait: 0.4f), + // Extremely long: must ellipsis rather than paint under level. + Step("dn20", "dossier_force_headline", + value: "Zezqzo Tikzkek Tak the Unbelievably Long-Named Elder (scorpion)"), + Step("dn21", "assert", expect: "caption_layout_ok"), + Step("dn22", "assert", expect: "dossier_contains", value: "Zezqzo"), + Step("dn23", "screenshot", value: "hud-dossier-long-name-ellipsis.png"), + Step("dn24", "wait", wait: 0.4f), + Step("dn90", "assert", expect: "no_bad"), + Step("dn99", "snapshot"), + }; + } + private static List SettingsFull() { return new List diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index a726ce0..46c5125 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -21,6 +21,9 @@ public static class WatchCaption private const float TraitIcon = 12f; private const float HistoryIcon = 10f; private const float HistoryColMinW = 118f; + private const float NameMinW = 28f; + /// Hard cap so long "Name (species)" lines cannot paint under the level chip. + private const float NameMaxW = 210f; private const float PadX = 4f; /// Inset from the canvas top-right corner (grows left via pivot). private const float ScreenInset = 12f; @@ -759,10 +762,36 @@ public static class WatchCaption return _current != null ? (_current.TaskText ?? "") : ""; } + /// Harness: text currently shown on the nametag (may be ellipsis-truncated). + public static string ShownNameChipText => + _nameText != null && _nameText.gameObject.activeSelf ? (_nameText.text ?? "") : ""; + /// Harness: text currently shown on the nametag task chip. public static string ShownTaskChipText => _taskText != null && _taskText.gameObject.activeSelf ? (_taskText.text ?? "") : ""; + /// Harness: force a long nametag headline and relayout (layout overlap tests). + public static void ForceNametagHeadline(string headline) + { + EnsureBuilt(); + string text = string.IsNullOrEmpty(headline) ? "Nobody" : headline.Trim(); + LastHeadline = text; + if (_nameText != null) + { + _nameText.text = text; + } + + if (_current != null) + { + _current.Headline = text; + } + + bool hasBody = _current != null && _current.UnitId != 0; + bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + Relayout(hasBody, CountActiveTraitSlots(), hasTask, hasReason, CountActiveHistorySlots()); + } + private static void BringHeaderFront() { // Draw nametag above vanilla avatar chrome (which can paint outside its host). @@ -1520,7 +1549,7 @@ public static class WatchCaption if (_nameText != null) { - float nameW = Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f); + float nameW = FitNameChipWidth(); PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH); x += nameW + 5f; } @@ -1583,7 +1612,7 @@ public static class WatchCaption float x = PadX + SpeciesSize + 3f; if (_nameText != null) { - x += Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f) + 5f; + x += FitNameChipWidth() + 5f; } if (_levelIcon != null && _levelIcon.gameObject.activeSelf) @@ -1680,6 +1709,60 @@ public static class WatchCaption rt.sizeDelta = new Vector2(width, height); } + /// + /// Size the name chip to the visible text and ellipsis-truncate when over . + /// Prevents Overflow paint from landing under the level badge (long "Name (species)" lines). + /// + private static float FitNameChipWidth() + { + if (_nameText == null) + { + return NameMinW; + } + + _nameText.resizeTextForBestFit = false; + _nameText.horizontalOverflow = HorizontalWrapMode.Overflow; + string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""); + if (string.IsNullOrEmpty(full)) + { + _nameText.text = ""; + return NameMinW; + } + + _nameText.text = full; + float natural = MeasureTextWidth(_nameText, 72f); + if (natural <= NameMaxW) + { + return Mathf.Clamp(natural, NameMinW, NameMaxW); + } + + const string ellipsis = "..."; + int lo = 0; + int hi = full.Length; + string best = ellipsis; + while (lo <= hi) + { + int mid = (lo + hi) / 2; + string candidate = mid <= 0 + ? ellipsis + : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis); + _nameText.text = candidate; + float w = MeasureTextWidth(_nameText, NameMaxW); + if (w <= NameMaxW) + { + best = candidate; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + _nameText.text = best; + return Mathf.Clamp(MeasureTextWidth(_nameText, NameMaxW), NameMinW, NameMaxW); + } + private static float MeasureTextWidth(Text text, float fallback) { if (text == null || string.IsNullOrEmpty(text.text)) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 2769992..c45dcce 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.16.6", + "version": "0.16.8", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/worldbox-ctl.sh b/scripts/worldbox-ctl.sh new file mode 100755 index 0000000..1f3462e --- /dev/null +++ b/scripts/worldbox-ctl.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Canonical WorldBox process control for IdleSpectator agents. +# Always run this script (or harness-run.sh) with unrestricted / `all` permissions. +# Never invent ad-hoc pkill -f patterns that include the worldbox path in the +# agent shell command line - that can kill the agent itself. +# +# Usage: +# ./scripts/worldbox-ctl.sh status +# ./scripts/worldbox-ctl.sh start +# ./scripts/worldbox-ctl.sh stop +# ./scripts/worldbox-ctl.sh restart # stop + start + wait for IdleSpectator ready +set -euo pipefail + +STEAM_APP_ID="${WORLDBOX_APP_ID:-1206560}" +LOG="${WORLDBOX_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}" +BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}" +LAUNCH_LOG="${WORLDBOX_LAUNCH_LOG:-/tmp/idle-spectator-harness-launch.log}" + +worldbox_pids() { + # Match the real game binary only (process name "worldbox"). + # Do NOT use pkill -f with a path string that also appears in the calling shell. + ps -C worldbox -o pid= 2>/dev/null | tr -s ' ' | sed '/^$/d' || true +} + +worldbox_running() { + local pids + pids="$(worldbox_pids)" + [[ -n "${pids// /}" ]] +} + +mod_ready() { + [[ -f "$LOG" ]] || return 1 + if rg -q "error CS" "$LOG" 2>/dev/null \ + && rg -q "Compile Mod IdleSpectator|IdleSpectator.*error CS|error CS.*IdleSpectator" "$LOG" 2>/dev/null; then + return 2 + fi + rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null +} + +cmd_status() { + if worldbox_running; then + echo "WorldBox running pids=$(worldbox_pids | tr '\n' ' ')" + if mod_ready; then + echo "IdleSpectator: ready (Harmony patches applied)" + else + echo "IdleSpectator: not ready yet (check Player.log)" + fi + return 0 + fi + echo "WorldBox not running" + return 1 +} + +cmd_stop() { + if ! worldbox_running; then + echo "WorldBox already stopped" + return 0 + fi + echo "Stopping WorldBox ..." + # killall by process name - safe for agent shells (no -f path match). + killall -q worldbox 2>/dev/null || true + local i + for i in $(seq 1 30); do + if ! worldbox_running; then + echo "WorldBox stopped" + return 0 + fi + sleep 0.5 + done + echo "WorldBox still running after killall; sending SIGKILL ..." >&2 + killall -q -9 worldbox 2>/dev/null || true + sleep 1 + if worldbox_running; then + echo "FAILED to stop WorldBox pids=$(worldbox_pids | tr '\n' ' ')" >&2 + return 1 + fi + echo "WorldBox stopped" +} + +cmd_start() { + if worldbox_running; then + echo "WorldBox already running" + return 0 + fi + echo "Starting WorldBox (steam app $STEAM_APP_ID) ..." + echo "===== WORLDBOX-CTL START $(date -Iseconds) =====" >>"$LAUNCH_LOG" 2>/dev/null || true + nohup steam -applaunch "$STEAM_APP_ID" >>"$LAUNCH_LOG" 2>&1 & + disown || true + + local deadline=$((SECONDS + BOOT_TIMEOUT_SEC)) + local saw=0 + while (( SECONDS < deadline )); do + if worldbox_running; then + saw=1 + local ready_rc=0 + mod_ready || ready_rc=$? + if (( ready_rc == 0 )); then + echo "WorldBox + IdleSpectator ready" + return 0 + fi + if (( ready_rc == 2 )); then + echo "IdleSpectator compile failed - see $LOG" >&2 + rg -n "error CS|Compile Mod IdleSpectator" "$LOG" 2>/dev/null | tail -n 40 >&2 || true + return 1 + fi + printf '.' + else + printf 'o' + fi + sleep 2 + done + echo >&2 + if (( saw == 0 )); then + echo "Timed out waiting for WorldBox process (${BOOT_TIMEOUT_SEC}s)" >&2 + else + echo "Timed out waiting for IdleSpectator ready (${BOOT_TIMEOUT_SEC}s)" >&2 + fi + tail -n 40 "$LOG" 2>/dev/null >&2 || true + return 1 +} + +cmd_restart() { + cmd_stop + sleep 2 + cmd_start +} + +usage() { + cat <&2 + usage + exit 2 + ;; + esac +} + +main "$@"