ab657fa578
Lap-1 Fabricator affordable (StartingOre 50->90); mine prompt shows IN the room; Return no longer false-completes on death (WasOnExpedition latch + soft timeout, never the 1-tick Returning edge); HUD keeps room/siege/ammo cues during combat steps; Welcome needs an explicit confirm; READY panel shows FINAL DEFENSE when goal-full; dev Force-Each-Launch hidden from public Settings. GoalProgress.Target 4->2 (~10-20 min demo). Onboarding tests updated to the new contracts (456/456). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
11 KiB
C#
224 lines
11 KiB
C#
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.Build);
|
|
Assert.AreEqual(OnboardingStepMath.Fabricator, 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 ReadyUp_AdvancesOnLocalReadyOrLaunch()
|
|
{
|
|
var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
|
|
var ready = Empty(); ready.LocalReady = true;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
|
|
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
|
|
}
|
|
|
|
[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 Rooms_AdvancesInRoomAfterMinimumDwell()
|
|
{
|
|
var s = Empty();
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
|
|
var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f;
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in
|
|
var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room
|
|
var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f;
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy
|
|
}
|
|
|
|
[Test]
|
|
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
|
|
{
|
|
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
|
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
|
var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward));
|
|
var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route));
|
|
}
|
|
|
|
[Test]
|
|
public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout()
|
|
{
|
|
var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true;
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition
|
|
var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step
|
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy
|
|
var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home
|
|
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds;
|
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge)
|
|
}
|
|
|
|
[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_OnlyTheRoomsStepPoints()
|
|
{
|
|
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
|
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
|
|
Assert.AreEqual(OnboardingStepMath.PointerNone, 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);
|
|
}
|
|
}
|
|
}
|