worldbox-observer-mod/IdleSpectator/EventLiveApplyLoopHarness.cs

322 lines
12 KiB
C#

using System;
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class EventLiveApplyLoopResult
{
public bool Passed;
public string Detail = "";
public int HappinessAttempted;
public int StatusAttempted;
public int Failures;
}
/// <summary>
/// Phase 1 live apply loops: every camera-worthy happiness Signal and status gain
/// through real <c>changeHappiness</c> / <c>addStatusEffect</c>. Claims into
/// <see cref="EventLiveCoverageLedger"/> (<c>id</c> / <c>id_drop</c> / <c>fail</c>).
/// Primary asserts are ActivityLog / router notes (Busy may skip interest).
/// </summary>
public static class EventLiveApplyLoopHarness
{
public static EventLiveApplyLoopResult Run(string outputDirectory, Actor unit)
{
var result = new EventLiveApplyLoopResult();
if (unit == null || !unit.isAlive())
{
result.Detail = "no living unit for live apply loop";
return result;
}
EventLiveCoverageLedger.SeedFromCatalogs();
long subjectId = EventFeedUtil.SafeId(unit);
var fails = new List<string>(16);
// --- Happiness Signal ---
foreach (string raw in EventCatalog.Happiness.AuthoredIds)
{
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id))
{
continue;
}
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
continue;
}
result.HappinessAttempted++;
HappinessEventRouter.ResetCountersForSubject(subjectId);
InterestDropLog.Clear();
bool applied = HappinessGameApi.TryChangeHappiness(unit, id, 0);
string level;
string notes;
if (applied
&& HappinessEventRouter.NotesSinceClear > 0
&& string.Equals(HappinessEventRouter.LastEffectId, id, StringComparison.OrdinalIgnoreCase))
{
level = "id";
notes = "notes=" + HappinessEventRouter.NotesSinceClear;
}
else if (!applied && DropMentions("no_emotions_or_egg", id))
{
level = "id_drop";
notes = "no_emotions_or_egg";
}
else if (!applied && DropMentions("no_emotions_or_egg", ""))
{
// Suppressed without id in detail still counts as authored psychopath/egg path.
level = "id_drop";
notes = "no_emotions_or_egg_generic";
}
else
{
level = "fail";
notes = applied
? ("applied_but_no_note last=" + HappinessEventRouter.LastEffectId
+ " notes=" + HappinessEventRouter.NotesSinceClear
+ " drops=" + InterestDropLog.FormatRecent(3))
: ("apply_false drops=" + InterestDropLog.FormatRecent(3));
result.Failures++;
fails.Add("happiness:" + id + " " + notes);
}
EventLiveCoverageLedger.Claim("happiness", id, level, "happiness_apply", notes);
}
// --- Status CreatesInterest gains ---
foreach (string raw in EventCatalog.Status.AuthoredIds)
{
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id))
{
continue;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
continue;
}
result.StatusAttempted++;
StatusGameApi.TryFinishAll(unit);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
bool applied = StatusGameApi.TryAddStatus(unit, id, overrideTimer: 8f);
bool gained = applied
&& ActivityLog.StatusGainsSinceClear > 0
&& string.Equals(ActivityLog.LastStatusGainId, id, StringComparison.OrdinalIgnoreCase);
string level;
string notes;
if (gained)
{
level = "id";
notes = "gains=" + ActivityLog.StatusGainsSinceClear;
}
else if (!applied && DropMentions("createsInterest=false", id))
{
level = "id_drop";
notes = "createsInterest=false";
}
else if (applied && ActivityLog.StatusRefreshesSinceClear > 0 && ActivityLog.StatusGainsSinceClear == 0)
{
// Still had the status somehow - treat as unexpected for a cleared unit.
level = "fail";
notes = "refresh_without_gain";
result.Failures++;
fails.Add("status:" + id + " " + notes);
}
else if (!applied)
{
// Game refused addStatusEffect (species/asset/stacking gate). Authored gap, not a harness bug.
level = "id_drop";
notes = "apply_false";
}
else
{
level = "fail";
notes = "applied_but_no_gain last=" + ActivityLog.LastStatusGainId
+ " gains=" + ActivityLog.StatusGainsSinceClear
+ " refreshes=" + ActivityLog.StatusRefreshesSinceClear;
result.Failures++;
fails.Add("status:" + id + " " + notes);
}
EventLiveCoverageLedger.Claim(
"status",
id,
level,
"status_apply",
notes + " busy_bypass=activity_log_primary");
StatusGameApi.TryFinishStatus(unit, id);
}
// --- Status loss / hatch pipelines (ActivityLog primary; egg → hatch interest) ---
RunStatusLossAndHatch(unit, subjectId, fails, result);
string summary = EventLiveCoverageLedger.Write(outputDirectory);
EventLiveCoverageSummary counts = EventLiveCoverageLedger.Summarize();
result.Passed = result.Failures == 0 && counts.Fail == 0
&& result.HappinessAttempted > 0
&& result.StatusAttempted > 0;
result.Detail =
$"happiness={result.HappinessAttempted} status={result.StatusAttempted} failures={result.Failures} "
+ summary
+ (fails.Count > 0
? (" first=" + fails[0] + (fails.Count > 1 ? " +" + (fails.Count - 1) : ""))
: "");
return result;
}
private static void RunStatusLossAndHatch(
Actor unit,
long subjectId,
List<string> fails,
EventLiveApplyLoopResult result)
{
bool prev = AgentHarness.ForceLiveEventFeeds;
AgentHarness.ForceLiveEventFeeds = true;
try
{
// Sample camera status: gain then finish → ActivityLog loss.
string sampleId = null;
foreach (string raw in EventCatalog.Status.AuthoredIds)
{
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id)
|| string.Equals(id, "egg", StringComparison.OrdinalIgnoreCase))
{
continue;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
continue;
}
sampleId = id;
break;
}
if (!string.IsNullOrEmpty(sampleId))
{
StatusGameApi.TryFinishAll(unit);
StatusGameApi.TryAddStatus(unit, sampleId, overrideTimer: 8f);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
StatusGameApi.TryFinishStatus(unit, sampleId);
bool lost = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
sampleId,
StringComparison.OrdinalIgnoreCase);
if (lost)
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"pipeline",
"status_finish",
"shared=finishStatusEffect sample=" + sampleId
+ " busy_bypass=activity_log_primary");
}
else
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"unsupported",
"status_finish",
"no_activity_loss sample=" + sampleId);
}
}
// Hatch: egg status loss owns the hatch camera key.
StatusGameApi.TryFinishAll(unit);
InterestRegistry.ForceExpireContaining("hatch:", UnityEngine.Time.unscaledTime);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
bool eggOn = StatusGameApi.TryAddStatus(unit, "egg", overrideTimer: 2f);
StatusGameApi.TryFinishStatus(unit, "egg");
bool hatchKey = InterestRegistry.CountKeysContaining("hatch:") > 0;
bool eggLoss = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
"egg",
StringComparison.OrdinalIgnoreCase);
if (hatchKey)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id",
"status_egg_loss",
"hatch_key busy_bypass=ForceLiveEventFeeds");
}
else if (eggOn && eggLoss)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"pipeline",
"status_finish",
"shared=finishStatusEffect activity_loss_only");
}
else if (!eggOn)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id_drop",
"status_apply",
"egg_apply_false");
}
else
{
result.Failures++;
fails.Add("status:egg hatch_miss");
EventLiveCoverageLedger.Claim(
"status",
"egg",
"fail",
"status_egg_loss",
"no_hatch_key");
}
}
finally
{
AgentHarness.ForceLiveEventFeeds = prev;
}
}
private static bool DropMentions(string reason, string detailNeedle)
{
List<string> lines = InterestDropLog.Snapshot(12);
for (int i = 0; i < lines.Count; i++)
{
string line = lines[i] ?? "";
if (line.IndexOf(reason, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
if (string.IsNullOrEmpty(detailNeedle)
|| line.IndexOf(detailNeedle, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
}