Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,5 +378,34 @@ public void DisplayNames_MatchTheMs1NameTables()
// Fallback for roles outside the named roster stays the enum name.
Assert.AreEqual(UnitRole.Unit.ToString(), CommandCardPresenter.UnitDisplayName(FactionId.Alliance, UnitRole.Unit));
}

// ----------------------------------------------------------------
// Field reserve line (21.2, #86)
// ----------------------------------------------------------------

[Test]
public void FormatFieldReserveAE_GroupsThousandsGermanStyle()
{
// The report's own example from sprint package 21.2.
Assert.AreEqual("6.420 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(6420, 9000));
Assert.AreEqual("0 / 15.000 AE", CommandCardPresenter.FormatFieldReserveAE(0, 15000));
Assert.AreEqual("12.345.678 / 12.345.678 AE", CommandCardPresenter.FormatFieldReserveAE(12345678, 12345678));
}

[Test]
public void FormatFieldReserveAE_SmallValuesStayUngrouped()
{
Assert.AreEqual("642 / 900 AE", CommandCardPresenter.FormatFieldReserveAE(642, 900));
Assert.AreEqual("1 / 1 AE", CommandCardPresenter.FormatFieldReserveAE(1, 1));
}

[Test]
public void FormatFieldReserveAE_NonPositiveValuesRenderAsZero()
{
// Reserve values are never negative in the sim; a hostile input
// must still render sanely instead of producing "-6.420".
Assert.AreEqual("0 / 0 AE", CommandCardPresenter.FormatFieldReserveAE(0, 0));
Assert.AreEqual("0 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(-5, 9000));
}
}
}
71 changes: 71 additions & 0 deletions Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using NUnit.Framework;
using Nova.Gameplay;

namespace Nova.Gameplay.Tests
{
/// <summary>
/// Contract tests for the field-marker staging rule of
/// <see cref="FieldCrystalStages"/> (21.2, #86): ceiling of the reserve
/// fraction times the shard count — full reserve lights every shard, any
/// reserve above zero keeps at least one, exactly 0 AE lights none, and
/// the result never leaves [0, shardCount].
/// </summary>
[TestFixture]
public class FieldCrystalStagesTests
{
[Test]
public void VisibleShards_FullReserve_ShowsEveryShard()
{
Assert.AreEqual(7, FieldCrystalStages.VisibleShards(9000, 9000, 7));
Assert.AreEqual(7, FieldCrystalStages.VisibleShards(15000, 15000, 7));
}

[Test]
public void VisibleShards_ZeroRemaining_ShowsNone()
{
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7));
}

[Test]
public void VisibleShards_StageBoundaries_RoundUp()
{
// shardCount 4 over 8.000 AE: stage k holds while the reserve is
// in ((k-1)/4, k/4] of the initial reserve — the boundary value
// itself still shows the HIGHER stage's lower edge exactly.
Assert.AreEqual(4, FieldCrystalStages.VisibleShards(8000, 8000, 4));
Assert.AreEqual(4, FieldCrystalStages.VisibleShards(6001, 8000, 4));
Assert.AreEqual(3, FieldCrystalStages.VisibleShards(6000, 8000, 4));
Assert.AreEqual(3, FieldCrystalStages.VisibleShards(4001, 8000, 4));
Assert.AreEqual(2, FieldCrystalStages.VisibleShards(4000, 8000, 4));
Assert.AreEqual(1, FieldCrystalStages.VisibleShards(2000, 8000, 4));
Assert.AreEqual(1, FieldCrystalStages.VisibleShards(1, 8000, 4), "any reserve above zero keeps one shard");
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 8000, 4), "0 AE means none — the stump is the view's business");
}

[Test]
public void VisibleShards_IsMonotonicallyNonIncreasing()
{
int previous = FieldCrystalStages.VisibleShards(9000, 9000, 7);
for (long remaining = 8999; remaining >= 0; remaining -= 97)
{
int stage = FieldCrystalStages.VisibleShards(remaining, 9000, 7);
Assert.LessOrEqual(stage, previous, $"stage must not rise as the reserve falls (remaining {remaining})");
Assert.GreaterOrEqual(stage, 0);
Assert.LessOrEqual(stage, 7);
previous = stage;
}
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7), "the walk ends at the exhausted stage");
}

[Test]
public void VisibleShards_Guards_ClampIntoRange()
{
// Over-reserve (or a layout/console slip) can never light more
// shards than the cluster has; degenerate inputs show nothing.
Assert.AreEqual(7, FieldCrystalStages.VisibleShards(20000, 9000, 7));
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 0, 7), "unknown initial reserve shows nothing rather than dividing by zero");
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(-5, 9000, 7));
Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 100, 0), "an empty cluster has no shards to light");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,66 @@ public void SelectionManager_RecallEmptyGroup_IsANoOp()
Assert.IsFalse(selection.HasControlGroup(5));
Assert.AreEqual(0, selection.RecallControlGroup(5, entities, playerId: 0));
}

// ------------------------------------------------------------------
// Sprint 21.2 (#86): field selection — UI-only, coupled both ways
// ------------------------------------------------------------------

[Test]
public void SelectionManager_SelectField_ClearsEntitySelection()
{
var entities = new EntityManager(10);
var selection = new SelectionManager();
EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5));
EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5));
selection.SelectSingle(u1);
selection.AddSingle(u2);

selection.SelectField(3);

Assert.AreEqual(0, selection.SelectedCount, "a field takes no entity orders — the entity selection goes");
Assert.AreEqual((ushort)3, selection.SelectedFieldId);
}

[Test]
public void SelectionManager_EntitySelection_ClearsSelectedField()
{
var entities = new EntityManager(10);
var selection = new SelectionManager();
EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5));
EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5));

selection.SelectField(2);
selection.SelectSingle(u1);
Assert.AreEqual((ushort)0, selection.SelectedFieldId, "SelectSingle replaces the field");
Assert.AreEqual(1, selection.SelectedCount);

selection.SelectField(2);
selection.AddSingle(u2);
Assert.AreEqual((ushort)0, selection.SelectedFieldId, "an additive entity pick ends the field selection too");
Assert.AreEqual(1, selection.SelectedCount);

selection.SelectField(2);
selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f);
Assert.AreEqual((ushort)0, selection.SelectedFieldId, "a box selection replaces the field");
Assert.AreEqual(2, selection.SelectedCount);
}

[Test]
public void SelectionManager_ClearSelection_ClearsFieldAndEntities()
{
var entities = new EntityManager(10);
var selection = new SelectionManager();
EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5));
selection.SelectSingle(u1);

selection.ClearSelection();
Assert.AreEqual(0, selection.SelectedCount);
Assert.AreEqual((ushort)0, selection.SelectedFieldId);

selection.SelectField(5);
selection.ClearSelection();
Assert.AreEqual((ushort)0, selection.SelectedFieldId, "the ingress rebind relies on ClearSelection dropping the field too");
}
}
}
124 changes: 124 additions & 0 deletions Assets/Tests/PlayMode/FieldReservePickTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Collections;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Nova.Gameplay;
using Nova.Gameplay.Match;

namespace Nova.PlayMode.Tests
{
/// <summary>
/// Regression/diagnosis for the 21.2 play-observation finding "fields are
/// not clickable" (#86): drives the real click path of RtsDeviceInput
/// (private SelectSingle/TryPickUnit/TryPickField via reflection — test
/// assemblies may not reference Nova.Presentation.UI, the MainMenuTests
/// pattern) with the field centre projected through the real main camera,
/// and logs every stage's verdict so a failure names its stage.
/// Run headless-with-graphics and NEVER with -quit (see
/// GrayboxDemoProofTests' header for the invocation).
/// </summary>
public sealed class FieldReservePickTests
{
private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity";

[UnityTest]
public IEnumerator ClickOnStartField_SelectsTheField()
{
yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single);

var bootstrap = Object.FindAnyObjectByType<MatchBootstrap>();
Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap");
bootstrap.StartGrayboxMatch();
Assert.IsTrue(bootstrap.IsMatchReady, "match did not start");

// Two frames: let every Awake/Start and the first model rebuilds settle.
yield return null;
yield return null;

MonoBehaviour input = FindByTypeName("RtsDeviceInput");
Assert.NotNull(input, "scene contains no RtsDeviceInput");

// The serialized scene predates the 21.2 field: a missing YAML
// entry must materialise the C# default 2f — pin that assumption,
// it is exactly the kind of silent zero a scene upgrade swallows.
FieldInfo radiusField = input.GetType().GetField(
"_fieldPickRadiusWorld", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(radiusField, "RtsDeviceInput._fieldPickRadiusWorld missing");
float radius = (float)radiusField.GetValue(input);
Debug.Log($"[FieldPick] _fieldPickRadiusWorld = {radius}");
Assert.Greater(radius, 0.5f, "field pick radius deserialised as ~0 — old scene asset ate the default");

Camera camera = Camera.main;
Assert.NotNull(camera, "no main camera");

// Stage 1: the raw field probe at the field centre of the
// canonical start field (7,7) -> centre (7.5, 0, 7.5).
var world = new Vector3(7.5f, 0f, 7.5f);
object[] pickArgs = { world, (ushort)0 };
bool fieldHit = (bool)InvokePrivate(input, "TryPickField", pickArgs);
Debug.Log($"[FieldPick] TryPickField({world}) = {fieldHit}, id = {pickArgs[1]}");

// Stage 2: does a UNIT claim the same point first (the intended
// priority — but then the click reads as unit selection)?
object[] unitArgs = { world, true, Nova.Core.EntityId.Invalid };
bool unitHit = (bool)InvokePrivate(input, "TryPickUnit", unitArgs);
Debug.Log($"[FieldPick] TryPickUnit({world}, own) = {unitHit}, id = {unitArgs[2]}");

// Stage 3: the real click path with the camera-projected point.
Vector3 screen = camera.WorldToScreenPoint(world);
Debug.Log($"[FieldPick] field centre on screen = {screen} (screen {Screen.width}x{Screen.height})");
InvokePrivate(input, "SelectSingle", new object[] { new Vector2(screen.x, screen.y), false });

var selection = (SelectionManager)input.GetType().GetProperty("Selection").GetValue(input);
Debug.Log($"[FieldPick] after SelectSingle: SelectedFieldId = {selection.SelectedFieldId}, " +
$"SelectedCount = {selection.SelectedCount}");

Assert.IsTrue(fieldHit, "TryPickField rejected the field centre itself");
Assert.AreEqual((ushort)1, selection.SelectedFieldId,
"a click on the start field must select field #1 (21.2, #86)");
Assert.AreEqual(0, selection.SelectedCount, "a field selection owns no entities");

// The real play-observation failure (T-02): on a trackpad a
// "click" is a MICRO-DRAG past the 8 px threshold, which becomes
// a box — and the box held no entities, so the gesture read as
// "clear selection". A unit forgives the same gesture (the box
// catches it), a field did not. Reproduce that exact gesture:
// a small, unit-empty drag across the field must select it too.
selection.ClearSelection();
// A tight quadrant of +10 px around the field-centre pixel:
// provably unit-empty here (stage 2 found no own unit within
// the wider 1.5-cell pick radius), so the box exercises the
// empty-gesture path and nothing else.
InvokePrivate(input, "SelectBox", new object[]
{
new Vector2(screen.x, screen.y),
new Vector2(screen.x + 10f, screen.y + 10f),
false,
});
Debug.Log($"[FieldPick] after micro-drag SelectBox: SelectedFieldId = {selection.SelectedFieldId}, " +
$"SelectedCount = {selection.SelectedCount}");
Assert.AreEqual((ushort)1, selection.SelectedFieldId,
"a micro-drag over the start field (no units inside the box) must select field #1, not clear into nothing (21.2 play finding)");
}

private static object InvokePrivate(MonoBehaviour target, string method, object[] args)
{
MethodInfo info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(info, $"{target.GetType().Name}.{method} not found");
object result = info.Invoke(target, args);
return result;
}

private static MonoBehaviour FindByTypeName(string typeName)
{
MonoBehaviour[] all = Object.FindObjectsByType<MonoBehaviour>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < all.Length; i++)
{
if (all[i] != null && all[i].GetType().Name == typeName) return all[i];
}
return null;
}
}
}
2 changes: 2 additions & 0 deletions Assets/Tests/PlayMode/FieldReservePickTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Assets/_Project/Editor/BootstrapSceneGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera)
WireReference(card, "_runner", runner);
WireReference(card, "_input", input);
WireReference(card, "_buildMenu", menu);
WireReference(card, "_bootstrap", runner.GetComponent<MatchBootstrap>());

WireReference(input, "_commandCard", card);

Expand Down
21 changes: 21 additions & 0 deletions Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,27 @@ private struct FieldLayout
public ushort LocalFieldId => LocalPlayerLayout.FieldId;
public ushort EnemyFieldId => EnemyPlayerLayout.FieldId;

/// <summary>
/// The initial reserve of a canonical field (21.2, #86). It lives in
/// the canonical map layout, NOT in simulation state —
/// <c>AetheriumField</c> deliberately carries no InitialReserve field
/// because the Simulation/State/ layout is frozen. Returns false for
/// an unknown id.
/// </summary>
public bool TryGetFieldInitialReserve(ushort fieldId, out long reserveAE)
{
for (int i = 0; i < FieldLayouts.Length; i++)
{
if (FieldLayouts[i].Id == fieldId)
{
reserveAE = FieldLayouts[i].ReserveAE;
return true;
}
}
reserveAE = 0L;
return false;
}

/// <summary>Aetherium field cell of the human player (7, 7).</summary>
public Vector2Int LocalFieldCell => new Vector2Int(LocalPlayerLayout.FieldX, LocalPlayerLayout.FieldY);

Expand Down
37 changes: 37 additions & 0 deletions Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Text;
using Nova.Core;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
Expand Down Expand Up @@ -309,6 +310,42 @@ public static bool TryFindRepairBuilder(EntityManager entities, byte playerSlot,
return false;
}

/// <summary>
/// The field card's reserve line (21.2, #86): "6.420 / 9.000 AE" —
/// German thousands grouping, assembled digit by digit so the output
/// is identical under ANY ambient culture (a build on an en-US host
/// must not render "6,420").
/// </summary>
public static string FormatFieldReserveAE(long remainingAE, long initialReserveAE)
{
var builder = new StringBuilder(24);
AppendGroupedDe(builder, remainingAE);
builder.Append(" / ");
AppendGroupedDe(builder, initialReserveAE);
builder.Append(" AE");
return builder.ToString();
}

/// <summary>Decimal digits with the German '.' group separator; reserve values are never negative, so a non-positive input renders as "0".</summary>
private static void AppendGroupedDe(StringBuilder builder, long value)
{
if (value <= 0)
{
builder.Append('0');
return;
}

int digitCount = 1;
for (long rest = value; rest >= 10; rest /= 10) digitCount++;
for (int i = 0; i < digitCount; i++)
{
if (i > 0 && (digitCount - i) % 3 == 0) builder.Append('.');
long divisor = 1;
for (int d = 1; d < digitCount - i; d++) divisor *= 10;
builder.Append((char)('0' + (int)(value / divisor % 10)));
}
}

/// <summary>
/// German display names of the eight MS-1 unit roles per faction,
/// from the canonical MS-1 name tables of docs/gamedesign/Vehicles.md
Expand Down
Loading
Loading