Client-side component that manages an in-game contextual tips/walkthrough coach. It chooses one relevant tip each frame (or self-drives), latches signals, completes and persists tip progress to a sandboxed data file, and publishes static state (ActiveTip, Visible, segments) for a display component to render.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace FieldGuide.Tips;
/// <summary>
/// The contextual onboarding coach: it decides which single tip is relevant right now and shows it in
/// a small calm panel (<see cref="TipsDisplay"/>) until the player performs the action, then advances
/// straight to the next tip. It never fights the player's attention: it hides while a modal,
/// conversation or a dev/menu overlay is open.
///
/// SEAM MODEL. The kit owns the engine, catalog types and display; the game owns the words and the
/// wiring. Because a library may not reach into game state, the coach is DRIVEN by the consumer:
/// <list type="bullet">
/// <item>Each frame the consumer builds a neutral <see cref="TipContext"/> (vitals, quest count,
/// modal/overlay flags, ...) and calls <see cref="Tick(TipContext)"/>.</item>
/// <item>When the consumer observes a one-off behaviour (the player moved, cast a spell, aggroed an
/// NPC) it calls <see cref="Signal(TipSignal)"/> or one of the named wrappers. The coach latches it
/// and the matching <c>Ever*</c> field on the context reads true from then on.</item>
/// </list>
/// A few behaviours are ALSO auto-latched from the neutral context each tick (talking from
/// <see cref="TipContext.DialogueActive"/>, quest-accept from <see cref="TipContext.ActiveQuestCount"/>,
/// aggro from in-combat + enemies-nearby, overlay-opened from <see cref="TipContext.OverlayOpen"/>,
/// panel-closed from a modal/overlay closing), so a game that only pushes context still advances.
///
/// It is a plain <see cref="Component"/> (all logic + state) that lives beside its
/// <see cref="TipsDisplay"/> PanelComponent on one <see cref="ScreenPanel"/>; the display reads the
/// statics this component publishes. Everything is client-local, tips are a personal UX layer and
/// never touch networking or gameplay state.
///
/// Progress (completed tip ids + a "dismissed for good" flag) persists to
/// <c>FileSystem.Data</c> so returning players aren't re-taught.
/// </summary>
[Title( "Tips Coach" )]
[Category( "Field Guide Tips" )]
[Icon( "school" )]
public sealed class TipsCoach : Component
{
// ------------------------------------------------------------------
// Config surface (convar to disable, concmd to reset)
// ------------------------------------------------------------------
/// <summary>Master switch. Set <c>fg_tips 0</c> in the console to turn the coach off entirely.
/// Named <c>TipsEnabled</c> (not <c>Enabled</c>) so this static convar doesn't hide the inherited
/// instance <see cref="Component.Enabled"/>.</summary>
[ConVar( "fg_tips" )]
public static bool TipsEnabled { get; set; } = true;
/// <summary>Forget all tip progress and start the walkthrough over (also re-enables the coach).</summary>
[ConCmd( "fg_tips_reset" )]
public static void ResetProgress()
{
_completed.Clear();
_allDismissed = false;
TipsEnabled = true;
Save();
// Clear any live coach's runtime state so it restarts cleanly this session.
foreach ( var coach in LiveCoaches() )
coach.ResetRuntime();
Log.Info( "fg_tips: progress reset, the walkthrough will start over." );
}
/// <summary>Print the active device, then every tip in the merged catalog: id, source, completed state,
/// priority, prerequisites, and a live relevance verdict from the first live coach.</summary>
[ConCmd( "fg_tips_list" )]
public static void ListTips()
{
Load();
var coach = LiveCoaches().FirstOrDefault();
Log.Info( $"fg_tips: device={ActiveDevice}" + ( coach is null ? " (no live coach, relevance verdicts unavailable)" : "" ) );
var any = false;
foreach ( var def in TipsCatalog.Active )
{
any = true;
var source = TipsCatalog.SourceOf( def.Id );
var state = _completed.Contains( def.Id ) ? "done" : "todo";
var prereqs = def.PrerequisiteTipIds.Length > 0 ? string.Join( ",", def.PrerequisiteTipIds ) : "-";
var verdict = coach is not null ? coach.RelevanceVerdict( def ) : "n/a";
Log.Info( $"fg_tips: {def.Id} [{source}] {state} prio={def.Priority} prereqs={prereqs} relevance={verdict}" );
}
if ( !any )
Log.Info( "fg_tips: catalog is empty." );
}
/// <summary>Print the active input device and how the on-screen tip resolved for it: which text field
/// (Text or TextPad) drives the wording, the parsed chip segments, the hide-chip label, and whether a pad
/// label map / hide seam is wired. Closes the dogfood finding that a device-gated tip was indistinguishable
/// from a broken input bridge.</summary>
[ConCmd( "fg_tips_device" )]
public static void DeviceInfo()
{
Load();
var onPad = ActiveDevice == TipDevice.Gamepad;
Log.Info( $"fg_tips: device={ActiveDevice} usingController={Input.UsingController} " +
$"padLabelMap={( PadLabelFor is not null ? "set" : "none" )} " +
$"hideAll={( HideAll is not null ? "wired" : "unset" )} " +
$"hideLabel='{( onPad ? HidePadLabel : HideKeyLabel )}'" );
var def = ActiveTip;
if ( def is null )
{
Log.Info( "fg_tips: no active tip on screen." );
return;
}
var usesPad = onPad && !string.IsNullOrEmpty( def.TextPad );
var field = !onPad ? "Text" : ( usesPad ? "TextPad" : "Text (TextPad unset, fallback)" );
var segs = onPad ? ActivePadSegments : ActiveSegments;
var kinds = string.Join( " | ", segs.Select( s => $"{s.Kind}:'{s.Text}'" ) );
Log.Info( $"fg_tips: active='{def.Id}' field={field} segments=[{kinds}]" );
}
/// <summary>Force the given tip active on every live coach, for preview. It shows until it completes
/// (or you complete/reset it); a completed tip previews for the readable window then retires.</summary>
[ConCmd( "fg_tips_show" )]
public static void ShowTip( string id )
{
var found = false;
foreach ( var coach in LiveCoaches() )
found |= coach.ForceShow( id );
if ( !found )
Log.Warning( $"fg_tips: no live coach could show '{id}' (unknown id, or no coach in the scene)." );
}
/// <summary>Fire completion for the given tip id (the same path a world trigger uses). Proves a
/// trigger end to end from the console.</summary>
[ConCmd( "fg_tips_complete" )]
public static void CompleteTip( string id )
{
if ( string.IsNullOrEmpty( id ) )
{
Log.Warning( "fg_tips: fg_tips_complete needs a tip id." );
return;
}
Complete( id );
Log.Info( $"fg_tips: completed '{id}'." );
}
private static IEnumerable<TipsCoach> LiveCoaches()
=> Game.ActiveScene?.GetAllComponents<TipsCoach>() ?? Enumerable.Empty<TipsCoach>();
// ------------------------------------------------------------------
// Published display state (read by TipsDisplay)
// ------------------------------------------------------------------
/// <summary>The tip to render right now, or null when nothing should show.</summary>
public static TipDefinition ActiveTip { get; private set; }
/// <summary>True when the panel should be visible (an active tip, and not suppressed).</summary>
public static bool Visible { get; private set; }
/// <summary>The active tip's text split into keycap/plain segments (bold-key rendering). Never null. This
/// is the KEYBOARD/MOUSE list; the display renders it while <see cref="ActiveDevice"/> is
/// <see cref="TipDevice.KeyboardMouse"/>.</summary>
public static IReadOnlyList<TipSegment> ActiveSegments { get; private set; } = Array.Empty<TipSegment>();
/// <summary>The active tip's PAD-MODE variant, parsed the same way as <see cref="ActiveSegments"/> but from
/// <see cref="TipDefinition.TextPad"/> when set, else the same <see cref="TipDefinition.Text"/> (so a tip
/// with no pad-specific wording reads identically from either list). Published alongside
/// <see cref="ActiveSegments"/> everywhere it changes; the display picks between the two off
/// <see cref="ActiveDevice"/>. Never null.</summary>
public static IReadOnlyList<TipSegment> ActivePadSegments { get; private set; } = Array.Empty<TipSegment>();
// ------------------------------------------------------------------
// Device (build plan point 1): the active input device, backed by Input.UsingController
// ------------------------------------------------------------------
/// <summary>
/// The device the player last used, from <c>Input.UsingController</c> (the engine flaps it to the
/// last-used device; it is polled, there is no event). The display folds this into its BuildHash so a
/// device flip re-renders the active tip with the right wording and chips immediately. A library may read
/// <c>Input</c> directly (the coach already does for its input triggers), so this needs no bridge.
/// </summary>
public static TipDevice ActiveDevice => PreviewDevice ?? ( Input.UsingController ? TipDevice.Gamepad : TipDevice.KeyboardMouse );
/// <summary>
/// Pin <see cref="ActiveDevice"/> to one device regardless of what the player last touched. An AUTHORING
/// seam: the Tips Studio uses it so you can read a tip's pad wording, and its pad chips, without owning a
/// controller. Null (the default) is the live value from <c>Input.UsingController</c>, which is what a
/// shipped game always runs with. Whoever sets it puts it back; the Studio clears it when it closes.
/// </summary>
public static TipDevice? PreviewDevice { get; set; }
/// <summary>
/// Optional whole-catalog keycap remap for pad mode (build plan point 4), generalizing World Builder's
/// <c>Cap()</c>. Null (the default) leaves keycaps untouched. When set, the display passes each keyboard
/// keycap label through this map while the player is on a pad: return a controller label to swap the chip
/// (e.g. "RMB" to "LT"), return the label unchanged to pass it through, or return null/empty to skip a
/// chip that has no pad equivalent. It is the alternative to authoring <see cref="TipDefinition.TextPad"/>
/// on every tip: map the handful of labels once. Applied at render time via
/// <see cref="TipDeviceText.PadCap(string, System.Func{string, string})"/>.
/// </summary>
public static Func<string, string> PadLabelFor { get; set; }
/// <summary>
/// Optional consumer-set labels for a card "hide tips" chip: the keyboard key name (e.g. "Y") and the
/// gamepad button name (e.g. "B"). The display shows the one that matches <see cref="ActiveDevice"/>, next
/// to the built-in close affordance; a null/empty label hides that chip variant. The kit itself has no
/// keybind opinion (SEAM MODEL: the game owns the words), so the chip does not appear until a game sets a
/// label. Set once at bootstrap. Upstreamed from the World Builder vendored copy under the same names.
/// </summary>
public static string HideKeyLabel { get; set; }
/// <inheritdoc cref="HideKeyLabel"/>
public static string HidePadLabel { get; set; }
/// <summary>
/// What "hide tips" MEANS belongs to the game (SEAM MODEL): a library cannot reference the game's controls
/// or its persisted preferences. When set, the card's hide affordance (the close chip, and the optional
/// <see cref="HideKeyLabel"/>/<see cref="HidePadLabel"/> chip) invokes this instead of dismissing only the
/// current tip, so a game can route the button at a persisted "tips off" preference. Null (the default)
/// leaves the close affordance dismissing the current tip, the v0.1-v0.3 behaviour. Assign once at init.
/// Upstreamed from the World Builder vendored copy under the same name and semantics.
/// </summary>
public static Action HideAll { get; set; }
// ------------------------------------------------------------------
// Persistence
// ------------------------------------------------------------------
/// <summary>
/// The <see cref="FileSystem.Data"/> file tip progress persists to. <see cref="FileSystem.Data"/> is
/// already sandboxed per game (each published package gets its own data folder), so the default name
/// never collides across two games on one machine. Override it before <see cref="Ensure(Scene)"/>
/// only if you want an explicit namespace (e.g. one game hosting several distinct tip tracks). Set it
/// before the first <see cref="Ensure(Scene)"/> / <see cref="Signal(TipSignal)"/> call so the initial
/// load reads the right file.
/// </summary>
public static string SaveFileName { get; set; } = "fieldguide_tips.json";
// Static so progress survives the component being recreated across play sessions, and so the
// concmd can reach it without an instance.
private static readonly HashSet<string> _completed = new();
private static bool _allDismissed;
private static bool _loaded;
/// <summary>Serializable progress blob.</summary>
private sealed class TipsSave
{
public List<string> Completed { get; set; } = new();
public bool Dismissed { get; set; }
}
private static void Load()
{
if ( _loaded )
return;
_loaded = true;
try
{
if ( !FileSystem.Data.FileExists( SaveFileName ) )
return;
var data = Json.Deserialize<TipsSave>( FileSystem.Data.ReadAllText( SaveFileName ) );
if ( data is null )
return;
_completed.Clear();
foreach ( var id in data.Completed ?? new List<string>() )
_completed.Add( id );
_allDismissed = data.Dismissed;
}
catch ( Exception e )
{
Log.Warning( $"TipsCoach: could not read tip progress ({e.Message}); starting fresh." );
}
}
private static void Save()
{
try
{
var data = new TipsSave { Completed = _completed.ToList(), Dismissed = _allDismissed };
FileSystem.Data.WriteAllText( SaveFileName, Json.Serialize( data ) );
}
catch ( Exception e )
{
Log.Warning( $"TipsCoach: could not save tip progress ({e.Message})." );
}
}
/// <summary>
/// One-time forward migration for a game that used to persist tip progress under a different file
/// name (before adopting this kit, or before changing <see cref="SaveFileName"/>). If the current
/// <see cref="SaveFileName"/> does not exist yet but <paramref name="legacyFileName"/> does, its
/// contents are copied forward so returning players keep their completed tips. The legacy file is
/// left untouched. Idempotent and safe to call every bootstrap; call it before
/// <see cref="Ensure(Scene)"/> so the first load reads the migrated file.
/// </summary>
/// <param name="legacyFileName">The old <see cref="FileSystem.Data"/> file to copy forward, e.g.
/// <c>"my_game_tips.json"</c>.</param>
/// <returns>True when a migration was performed this call; false when there was nothing to migrate.</returns>
public static bool MigrateProgressFrom( string legacyFileName )
{
if ( string.IsNullOrEmpty( legacyFileName ) || legacyFileName == SaveFileName )
return false;
try
{
if ( FileSystem.Data.FileExists( SaveFileName ) || !FileSystem.Data.FileExists( legacyFileName ) )
return false;
FileSystem.Data.WriteAllText( SaveFileName, FileSystem.Data.ReadAllText( legacyFileName ) );
Log.Info( $"TipsCoach: migrated tip progress from {legacyFileName} to {SaveFileName}." );
return true;
}
catch ( Exception e )
{
Log.Warning( $"TipsCoach: could not migrate tip progress from {legacyFileName} ({e.Message}); starting fresh." );
return false;
}
}
// ------------------------------------------------------------------
// Runtime state
// ------------------------------------------------------------------
private const float MinShowSeconds = 0.75f; // every tip is readable at least this long before it can complete
private TipContext _ctx;
private string _activeId;
private TimeSince _activeSince;
// Edge tracker for the "a panel just closed" latch.
private bool _anyPanelOpenLast;
// Self-drive guard (v0.3). Tick sets this true every frame it runs; the coach's own OnUpdate reads it
// and skips its evaluation pass whenever a Tick drove the coach since the last OnUpdate, so a game that
// calls Tick every frame (the v0.2 pattern) is fully Tick-driven and byte-identical. It starts TRUE so
// the very first OnUpdate defers to a bootstrap Tick that may run later in the same frame; this makes
// the guard robust to intra-frame component ordering from frame one, at the cost of a pure self-drive
// game (one that never calls Tick) beginning on its second frame instead of its first. A tickless game
// leaves this false after that first frame, so OnUpdate self-drives every frame with a context
// synthesized from the TipsWorld seams.
private bool _tickedSinceSelfUpdate = true;
// Latches a declarative completion trigger that fired for the active tip (v0.3). Input-edge kinds
// (Input.Pressed / Input.Keyboard.Pressed) are true for a single frame, so a press inside the
// MinShowSeconds readable window would otherwise be lost; latching lets the tip retire once the
// window passes. Reset whenever a new tip becomes active. The v0.2 CompleteWhen predicate is NOT
// latched (it is evaluated live at the completion gate, exactly as before), so existing tips are
// unaffected.
private bool _completionSeen;
// Latched behaviours (mirrored into TipContext each Tick). Once a signal lands it stays for the session.
private readonly HashSet<TipSignal> _signals = new();
// Latched custom (string-keyed) behaviours, for games that drive the coach in their own vocabulary
// rather than the named TipSignal set. Read back through TipContext.Ever(string).
private readonly HashSet<string> _customSignals = new();
// ------------------------------------------------------------------
// Bootstrap
// ------------------------------------------------------------------
/// <summary>
/// Ensure a single coach exists in the scene, on its own <see cref="ScreenPanel"/> alongside the
/// <see cref="TipsDisplay"/> view. Idempotent, safe to call from a client bootstrap every load.
/// The consumer still has to drive it every frame with <see cref="Tick(TipContext)"/>.
/// </summary>
public static TipsCoach Ensure( Scene scene )
{
if ( scene is null )
return null;
var existing = scene.GetAllComponents<TipsCoach>().FirstOrDefault();
if ( existing is not null )
return existing;
var go = scene.CreateObject();
go.Name = "UI.Tips";
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 50; // above the HUD/plates, below modals and full-screen dev/menu overlays
var coach = go.Components.Create<TipsCoach>();
go.Components.Create<TipsDisplay>();
return coach;
}
protected override void OnEnabled()
{
Load();
}
// ------------------------------------------------------------------
// Signals (the consumer calls these when it observes a behaviour)
// ------------------------------------------------------------------
/// <summary>Latch an observed behaviour. Idempotent; once latched it stays for the session.</summary>
public void Signal( TipSignal signal ) => _signals.Add( signal );
/// <summary>
/// Latch an observed behaviour named by your own string key, for games whose vocabulary does not fit
/// the built-in <see cref="TipSignal"/> set (a builder placing a block, a racer landing a drift).
/// Idempotent; once latched it stays for the session and reads true from
/// <see cref="TipContext.Ever(string)"/> in your predicates. Choose stable keys, they are matched
/// verbatim.
/// </summary>
public void Signal( string signal )
{
if ( !string.IsNullOrEmpty( signal ) )
_customSignals.Add( signal );
}
/// <summary>The player drew a hostile's attention. Wire your game's "NPC aggro" event to this.</summary>
public void SignalNpcAggro() => Signal( TipSignal.Aggroed );
/// <summary>The player accepted / started a quest. Wire your game's "quest started" event to this.</summary>
public void SignalQuestStarted() => Signal( TipSignal.AcceptedQuest );
/// <summary>The player cast a spell. Wire your game's "spell cast" event to this.</summary>
public void SignalSpellCast() => Signal( TipSignal.CastSpell );
/// <summary>The player talked to / interacted with a character. Wire your game's "interaction" event to this.</summary>
public void SignalInteraction() => Signal( TipSignal.Talked );
// ------------------------------------------------------------------
// Per-frame drive (the consumer calls this every frame)
// ------------------------------------------------------------------
/// <summary>
/// Drive the coach for one frame with a freshly-built neutral context. Applies latches, decides
/// which single tip is relevant, completes the active one when its behaviour is observed, and
/// publishes <see cref="ActiveTip"/> / <see cref="Visible"/> / <see cref="ActiveSegments"/> for the
/// display.
///
/// OPTIONAL since v0.3: a game whose tips complete only on input, signals, timers or world triggers
/// need not call this at all; the coach self-drives from <see cref="OnUpdate"/>. Call it when your
/// tips read live game context (vitals, modal/overlay flags, custom flags and numbers). Calling it
/// every frame is the v0.2 pattern and keeps the coach fully consumer-driven, which is byte-identical
/// to prior behaviour.
/// </summary>
public void Tick( TipContext context )
{
_tickedSinceSelfUpdate = true;
_ctx = context;
Evaluate();
}
/// <summary>
/// The self-driving path (v0.3). On any frame the game did not call <see cref="Tick(TipContext)"/>,
/// the coach evaluates on its own, reading <see cref="Input"/> directly for declarative input triggers
/// and synthesizing a context from the <see cref="TipsWorld"/> seams. The guard makes a Tick-driven
/// game behave exactly as before: Tick sets a flag every frame it runs, and this pass skips whenever
/// that flag is set. It is cleared here so the next tickless frame self-drives.
/// </summary>
protected override void OnUpdate()
{
if ( _tickedSinceSelfUpdate )
{
_tickedSinceSelfUpdate = false;
return;
}
_ctx = SynthContext();
Evaluate();
}
/// <summary>
/// The single per-frame evaluation body shared by <see cref="Tick(TipContext)"/> and the self-driving
/// <see cref="OnUpdate"/>. Reads <see cref="_ctx"/> (already assigned by the caller).
/// </summary>
private void Evaluate()
{
ApplyLatches();
if ( !TipsEnabled || _allDismissed || !_ctx.HasPlayer )
{
ClearVisible();
return;
}
var suppressed = _ctx.ModalOpen || _ctx.DialogueActive || _ctx.OverlayOpen;
// Drive the currently-active tip, or pick the next eligible one.
if ( _activeId is not null )
{
var def = FindDef( _activeId );
if ( def is null )
{
_activeId = null;
}
else
{
// Latch a declarative completion trigger the moment it fires, so an input-edge press
// inside the readable window is not lost (edges are true for one frame only). The v0.2
// CompleteWhen predicate below stays LIVE at the gate, so existing tips are unchanged.
if ( def.Completion is not null && EvalTrigger( def.Completion ) )
_completionSeen = true;
if ( _activeSince > MinShowSeconds && ( _completed.Contains( def.Id ) || def.CompleteWhen( _ctx )
|| _completionSeen || ( def.MaxShowSeconds > 0f && _activeSince > def.MaxShowSeconds ) ) )
{
RetireActive( def );
}
}
}
if ( _activeId is null )
SelectNext();
PublishVisible( suppressed );
}
/// <summary>
/// Synthesize a neutral context for the self-driving path from the <see cref="TipsWorld"/> seams.
/// Only <see cref="TipContext.HasPlayer"/> is derived (from <see cref="TipsWorld.HasLocalPlayer"/>,
/// default true); the live game fields stay at their defaults because a game that self-drives has not
/// pushed them. Fail-inert: a throwing seam is treated as "player present" rather than crashing.
/// </summary>
private static TipContext SynthContext()
{
var has = true;
var seam = TipsWorld.HasLocalPlayer;
if ( seam is not null )
{
try { has = seam(); }
catch { has = true; }
}
return new TipContext { HasPlayer = has };
}
/// <summary>
/// Evaluate a declarative <see cref="TipTrigger"/> against live <see cref="Input"/>, the pushed (or
/// synthesized) <see cref="TipContext"/> and the active tip's timer, via the pure
/// <see cref="TipTriggerEval"/> core. Input / Key kinds read the edge
/// (<see cref="Input.Pressed(string)"/> / <see cref="Input.Keyboard"/>) so they must be evaluated in a
/// per-frame pass, which both callers (Tick, OnUpdate) are.
/// </summary>
private bool EvalTrigger( TipTrigger t )
=> TipTriggerEval.Evaluate( t, CurrentEnv() );
/// <summary>Bind the trigger evaluation environment to this coach's live reads.</summary>
private TipTriggerEnv CurrentEnv() => new()
{
ActionPressed = static a => Input.Pressed( a ),
KeyPressed = static k => Input.Keyboard.Pressed( k ),
SignalLatched = s => _customSignals.Contains( s ),
Flag = k => _ctx.Flag( k ),
Number = k => _ctx.Number( k ),
Elapsed = _activeSince,
AnalogMagnitude = static src => src == TipTriggerAnalogSource.AnalogLook
? Input.AnalogLook.AsVector3().Length
: Input.AnalogMove.Length,
};
/// <summary>
/// Auto-latch the behaviours that can be derived from the neutral context (belt-and-suspenders, as
/// the original coach derived these from replicated state so a non-host client still advanced), then
/// merge every latched signal into the context the tip predicates read.
/// </summary>
private void ApplyLatches()
{
if ( _ctx.DialogueActive )
_signals.Add( TipSignal.Talked );
if ( _ctx.ActiveQuestCount > 0 )
_signals.Add( TipSignal.AcceptedQuest );
if ( _ctx.InCombat && _ctx.EnemiesNearby )
_signals.Add( TipSignal.Aggroed );
if ( _ctx.OverlayOpen )
_signals.Add( TipSignal.OpenedOverlay );
// A panel closing (Q / toggle), from open last frame to closed now.
var anyPanelOpen = _ctx.ModalOpen || _ctx.OverlayOpen;
if ( _anyPanelOpenLast && !anyPanelOpen )
_signals.Add( TipSignal.ClosedPanel );
_anyPanelOpenLast = anyPanelOpen;
// Copy the latched flags into the context the tip predicates read.
_ctx.EverMoved = _signals.Contains( TipSignal.Moved );
_ctx.EverSprinted = _signals.Contains( TipSignal.Sprinted );
_ctx.EverJumped = _signals.Contains( TipSignal.Jumped );
_ctx.EverLightAttacked = _signals.Contains( TipSignal.LightAttacked );
_ctx.EverHeavyAttacked = _signals.Contains( TipSignal.HeavyAttacked );
_ctx.EverBlocked = _signals.Contains( TipSignal.Blocked );
_ctx.EverDodged = _signals.Contains( TipSignal.Dodged );
_ctx.EverAggroed = _signals.Contains( TipSignal.Aggroed );
_ctx.EverTalked = _signals.Contains( TipSignal.Talked );
_ctx.EverAcceptedQuest = _signals.Contains( TipSignal.AcceptedQuest );
_ctx.EverOpenedSheet = _signals.Contains( TipSignal.OpenedSheet );
_ctx.EverSpentPoint = _signals.Contains( TipSignal.SpentPoint );
_ctx.EverCastSpell = _signals.Contains( TipSignal.CastSpell );
_ctx.EverUsedPotion = _signals.Contains( TipSignal.UsedPotion );
_ctx.EverOpenedOverlay = _signals.Contains( TipSignal.OpenedOverlay );
_ctx.EverClosedPanel = _signals.Contains( TipSignal.ClosedPanel );
// Expose the latched custom signals so TipContext.Ever(string) predicates can read them.
_ctx.LatchedCustom = _customSignals;
}
/// <summary>Retire the active tip: mark it done, persist, and clear it, the next evaluation advances
/// straight to the next eligible tip (no completion flourish; the card's rise-in animation on the new
/// tip is transition enough).</summary>
private void RetireActive( TipDefinition def )
{
if ( _completed.Add( def.Id ) )
Save();
_activeId = null;
_completionSeen = false;
}
/// <summary>
/// Mark a tip complete from anywhere and persist it (v0.3). This is the seam a
/// <see cref="TipTriggerObject"/> world trigger, a cutscene, or any custom game logic uses to retire a
/// tip by id without building a context. Idempotent: completing an already-complete tip does nothing.
/// If the tip is the one on screen, it retires on the next evaluation, subject to the same
/// MinShowSeconds readable-window guard as every other completion path.
/// </summary>
public static void Complete( string tipId )
{
if ( string.IsNullOrEmpty( tipId ) )
return;
Load();
if ( _completed.Add( tipId ) )
Save();
}
/// <summary>Has this tip already been retired? Read by the Tips Studio's list so a tip that will never
/// show again is visibly done rather than mysteriously absent.</summary>
public static bool IsCompleted( string tipId )
{
if ( string.IsNullOrEmpty( tipId ) )
return false;
Load();
return _completed.Contains( tipId );
}
/// <summary>
/// Mark a tip NOT complete again and persist it: the undo of <see cref="Complete(string)"/>. The seam the
/// Tips Studio test-fire uses, because a tip you already retired this session would otherwise vanish the
/// instant you tried to look at it again. <c>fg_tips_reset</c> is the whole-walkthrough version.
/// </summary>
public static void Uncomplete( string tipId )
{
if ( string.IsNullOrEmpty( tipId ) )
return;
Load();
if ( _completed.Remove( tipId ) )
Save();
}
/// <summary>Take the current tip off the card WITHOUT marking it complete, so the coach picks the next
/// eligible one on its next evaluation. <see cref="DismissCurrent"/> is the player-facing affordance and
/// retires the tip for good; this is the dev-tool one, for pulling a preview back off the screen.</summary>
public void DropActive()
{
_activeId = null;
_completionSeen = false;
ClearVisible();
}
/// <summary>Pick the highest-priority tip that isn't done, whose prerequisites are done and whose trigger passes.</summary>
private void SelectNext()
{
TipDefinition best = null;
foreach ( var def in TipsCatalog.Active )
{
if ( _completed.Contains( def.Id ) )
continue;
if ( !PrereqsDone( def ) )
continue;
if ( !def.Trigger( _ctx ) )
continue;
// Declarative relevance NARROWS: a tip with a Relevance trigger must also pass it (v0.3).
// Null Relevance leaves the predicate Trigger as the only gate, so v0.2 tips are unchanged.
if ( def.Relevance is not null && !EvalTrigger( def.Relevance ) )
continue;
if ( best is null || def.Priority > best.Priority )
best = def;
}
if ( best is null )
{
_activeId = null;
return;
}
_activeId = best.Id;
_activeSince = 0f;
_completionSeen = false;
ActiveTip = best;
ActiveSegments = TipSegment.Parse( best.Text );
ActivePadSegments = TipSegment.Parse( TipDeviceText.PadTextOr( best.Text, best.TextPad ) );
}
private bool PrereqsDone( TipDefinition def )
{
foreach ( var id in def.PrerequisiteTipIds )
if ( !_completed.Contains( id ) )
return false;
return true;
}
/// <summary>
/// A one-word live verdict for why a tip is or is not showing right now, read against this coach's
/// last-evaluated context (whatever the game pushed with <see cref="Tick(TipContext)"/>, or the
/// self-driven synth context). Used by the <c>fg_tips_list</c> diagnostics so a withheld tip is
/// distinguishable from a broken bridge: "done" (completed), "active" (on screen now),
/// "prereqs-pending" (a prerequisite is not complete), "trigger-false" / "relevance-false" (a gate is
/// not met this frame), or "relevant" (eligible, waiting its turn by priority).
/// </summary>
public string RelevanceVerdict( TipDefinition def )
{
if ( def is null )
return "unknown";
if ( _completed.Contains( def.Id ) )
return "done";
if ( def.Id == _activeId )
return "active";
if ( !PrereqsDone( def ) )
return "prereqs-pending";
if ( !def.Trigger( _ctx ) )
return "trigger-false";
if ( def.Relevance is not null && !EvalTrigger( def.Relevance ) )
return "relevance-false";
return "relevant";
}
private static TipDefinition FindDef( string id )
{
foreach ( var def in TipsCatalog.Active )
if ( def.Id == id )
return def;
return null;
}
private void PublishVisible( bool suppressed )
{
if ( _activeId is not null )
{
var def = FindDef( _activeId );
ActiveTip = def;
ActiveSegments = def is not null ? TipSegment.Parse( def.Text ) : Array.Empty<TipSegment>();
ActivePadSegments = def is not null
? TipSegment.Parse( TipDeviceText.PadTextOr( def.Text, def.TextPad ) )
: Array.Empty<TipSegment>();
Visible = def is not null && !suppressed;
return;
}
ClearVisible();
}
private void ClearVisible()
{
Visible = false;
ActiveTip = null;
ActiveSegments = Array.Empty<TipSegment>();
ActivePadSegments = Array.Empty<TipSegment>();
}
/// <summary>Force a specific tip active for preview (the <c>fg_tips_show</c> seam). Returns false when
/// the id is not in the merged catalog. Bypasses relevance and prerequisites on purpose so a developer
/// can preview any tip; the next evaluation resumes normal completion/selection from there.</summary>
public bool ForceShow( string id )
{
var def = FindDef( id );
if ( def is null )
return false;
_activeId = def.Id;
_activeSince = 0f;
_completionSeen = false;
ActiveTip = def;
ActiveSegments = TipSegment.Parse( def.Text );
ActivePadSegments = TipSegment.Parse( TipDeviceText.PadTextOr( def.Text, def.TextPad ) );
Visible = true;
return true;
}
/// <summary>The × affordance: skip the current tip for good (marks it complete).</summary>
public void DismissCurrent()
{
if ( _activeId is null )
return;
_completed.Add( _activeId );
Save();
_activeId = null;
_completionSeen = false;
ClearVisible();
}
private void ResetRuntime()
{
_activeId = null;
_completionSeen = false;
_signals.Clear();
_customSignals.Clear();
_anyPanelOpenLast = false;
ClearVisible();
}
}