First-run onboarding: contextual coach-marks + How-to-Play card + dev replay toggle

Teaches the deep, interlocking loop — especially the inverted win condition
(you win by CLEARING EXPEDITIONS, not by surviving base sieges; DR-042/DR-043).

- OnboardingSystem: client-only observe-only PresentationSystemGroup overlay
  (own UIDocument @ sortingOrder 60), soft-gated 10-beat coach-mark sequence
  with a world-space ▶ pointer; never mutates sim / never destroys a ghost.
- OnboardingStepMath: pure, unit-tested step machine (snapshot + IsSatisfied +
  scheme-aware prompts + pointer kinds + persisted-mask helpers).
- HowToPlayPanel: tabbed reference card (Controls / The Loop / Build / Threats /
  Win-Lose), reachable from the main menu and the pause overlay.
- Per-client client-local state in GameSettings (TutorialHints + OnboardingMask
  bitmask, additive) — a Join client keeps its own; a host save-wipe never
  re-teaches. Settings toggle + menu "Replay Tutorial".
- Dev "Force Each Launch" toggle (GameSettings.ForceOnboardingEachLaunch):
  SettingsService.Boot wipes the mask + forces hints on in-memory every launch
  so the tutorial always replays fresh.
- HudSystem suppresses its own location hint while onboarding is active
  (single prompt voice), via OnboardingState + [UpdateAfter(OnboardingSystem)].

Validated green: 20/20 EditMode; Play smoke confirmed overlay render, clean
U+25B6 pointer glyph, no system sort-cycle, and the force-wipe end-to-end.

Docs: DR-043 + session log; reusable lesson archived in the build-gotchas note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luis Gonzalez
2026-06-29 14:18:22 -07:00
parent 3bb9999173
commit 29e90a5008
20 changed files with 1058 additions and 9 deletions
@@ -0,0 +1,211 @@
using NUnit.Framework;
using ProjectM.Client;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
/// the scheme-aware prompts, and the pointer kinds.
/// </summary>
public class OnboardingStepTests
{
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
// ---- mask helpers (resume point + dormant detection) ----
[Test]
public void FirstIncomplete_EmptyMask_IsWelcome()
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
[Test]
public void FirstIncomplete_SkipsCompletedPrefix()
{
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
}
[Test]
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
{
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
int full = (1 << OnboardingStepMath.StepCount) - 1;
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
}
// ---- per-step completion rules ----
[Test]
public void Welcome_AdvancesOnTimer()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
}
[Test]
public void Move_AdvancesAfterThreshold()
{
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
s.MoveDistance = OnboardingStepMath.MoveThreshold;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
}
[Test]
public void Mine_AdvancesOnlyWhenLedgerOreRises()
{
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
s.OreNow = 51;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
}
[Test]
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
{
var s = Empty(); s.TurretCount = 0;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
}
[Test]
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
{
var none = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
var built = Empty(); built.FabricatorCount = 1;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
}
[Test]
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
{
var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
var onExp = Empty(); onExp.OnExpedition = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
}
[Test]
public void Clear_AdvancesOnClearedObjective()
{
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
}
[Test]
public void Return_AdvancesOnLeavingExpedition()
{
var s = Empty(); s.OnExpedition = true;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
s.OnExpedition = false;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
}
[Test]
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
{
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
}
[Test]
public void Done_LingersThenCompletes()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
s.StepElapsed = OnboardingStepMath.DoneSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
}
// ---- prompts (scheme-aware, never empty) ----
[Test]
public void Prompts_NonEmptyForEveryStep()
{
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
{
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
}
}
[Test]
public void Prompts_AreSchemeAware()
{
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
}
// ---- pointer kinds ----
[Test]
public void PointerKinds_MatchSpatialStepsOnly()
{
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
}
}
/// <summary>
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
/// </summary>
public class OnboardingSettingsTests
{
[Test]
public void Defaults_TutorialOn_MaskEmpty()
{
var d = GameSettings.Defaults();
Assert.AreEqual(1, d.TutorialHints);
Assert.AreEqual(0, d.OnboardingMask);
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
}
[Test]
public void Clamped_NormalizesHints_PreservesMask()
{
var s = GameSettings.Defaults();
s.TutorialHints = 5; // out of the 0/1 range
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
var c = s.Clamped();
Assert.AreEqual(1, c.TutorialHints);
Assert.AreEqual(0x55, c.OnboardingMask);
}
[Test]
public void Defaults_ForceEachLaunch_Off()
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
[Test]
public void Clamped_NormalizesForceEachLaunchToBool()
{
var s = GameSettings.Defaults();
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
s.ForceOnboardingEachLaunch = 0;
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b7226d166f601d43add545e1532c3e1