From 842707918824b8bb3c7edcb44615bcdefe34b2fe Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 18:44:30 -0500 Subject: [PATCH] Update IdleSpectator to version 0.16.8. Introduce new functionality for managing long nametags in WatchCaption, ensuring they do not overlap with level badges. Add new harness scenarios for testing nametag layout and headline display. Revise existing harness commands for improved clarity and control. Increment version in mod.json to reflect these changes. --- .cursor/skills/idle-spectator-e2e/SKILL.md | 33 +++-- IdleSpectator/AgentHarness.cs | 11 ++ IdleSpectator/HarnessScenarios.cs | 41 ++++++ IdleSpectator/WatchCaption.cs | 87 +++++++++++- IdleSpectator/mod.json | 2 +- scripts/worldbox-ctl.sh | 153 +++++++++++++++++++++ 6 files changed, 314 insertions(+), 13 deletions(-) create mode 100755 scripts/worldbox-ctl.sh 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 "$@"