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 "$@"