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.

This commit is contained in:
DazedAnon 2026-07-15 18:44:30 -05:00
parent 1d2e830013
commit 8427079188
6 changed files with 314 additions and 13 deletions

View file

@ -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)

View file

@ -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();

View file

@ -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
};
}
/// <summary>Long "Name (species)" must leave room for the level chip (no overlap).</summary>
private static List<HarnessCommand> DossierNametagLayout()
{
return new List<HarnessCommand>
{
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<HarnessCommand> SettingsFull()
{
return new List<HarnessCommand>

View file

@ -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;
/// <summary>Hard cap so long "Name (species)" lines cannot paint under the level chip.</summary>
private const float NameMaxW = 210f;
private const float PadX = 4f;
/// <summary>Inset from the canvas top-right corner (grows left via pivot).</summary>
private const float ScreenInset = 12f;
@ -759,10 +762,36 @@ public static class WatchCaption
return _current != null ? (_current.TaskText ?? "") : "";
}
/// <summary>Harness: text currently shown on the nametag (may be ellipsis-truncated).</summary>
public static string ShownNameChipText =>
_nameText != null && _nameText.gameObject.activeSelf ? (_nameText.text ?? "") : "";
/// <summary>Harness: text currently shown on the nametag task chip.</summary>
public static string ShownTaskChipText =>
_taskText != null && _taskText.gameObject.activeSelf ? (_taskText.text ?? "") : "";
/// <summary>Harness: force a long nametag headline and relayout (layout overlap tests).</summary>
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);
}
/// <summary>
/// Size the name chip to the visible text and ellipsis-truncate when over <see cref="NameMaxW"/>.
/// Prevents Overflow paint from landing under the level badge (long "Name (species)" lines).
/// </summary>
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))

View file

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

153
scripts/worldbox-ctl.sh Executable file
View file

@ -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 <<EOF
Usage: $0 {status|start|stop|restart}
Canonical WorldBox control for IdleSpectator.
Always invoke with unrestricted / all permissions (never from the Cursor sandbox).
EOF
}
main() {
local op="${1:-}"
case "$op" in
status) cmd_status ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
-h|--help|help|"") usage; exit 2 ;;
*)
echo "Unknown op: $op" >&2
usage
exit 2
;;
esac
}
main "$@"