Campaign VT slice 7 (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md), S7.3 (Monsters). Owner's bar: looks basically the same as VTank and behaves the same from the player's seat. The pre-S7.3 Monsters tab was a select-then-edit adaptation (one padded-text list + a separate expression/priority/damage-menu/equip editor row + 14 standalone toggles) forced by acdream's earlier single-column-only <list>. Campaign VT slice 1 Part B already closed that gap (the <list><column> markup extension, docs/plugin-ui-markup.md "Columns"), so this slice replaces the whole tab body with VTank's own one 23-column HudList grid (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Monsters"): 14 check columns (F B G I Y V A R S WC FC Cp DC Cs, each 20px = VVS's forced 13px check width + WPadding per the markup doc's PITCH convention), 7 text columns (Name 127/P 27/Dmg type 63/ Ex. Vuln 63/Weapon 87/Offhand 87/PetDmg 63, each pitch = fixedwidth+7), 2 icon columns (move up/down, DAT ids 0x060028FC/0x060028FD), 15 header <label>s carrying VTank's own decompiled tooltip text (PluginCore.cs:1693-1707 — corrects "Broadside of a Tumerok" to the decompile's actual "Broadside of a Barn"), and the txtNewMonster field + Add + Add Sel row at VTank's exact geometry (8,136,388,16 / 404,136,50,16 / 462,136,50,16). Selection is unused, matching VTank — every real cell is its own click target. Per-cell behavior ported from PluginCore.cs:7885-8105's `b(object,int, int)` handler: Name click deletes the row (guarded on the rule's own IsDefault rather than literal row 0, so the invariant holds even if a caller reorders DEFAULT); P cycles -1->0->1->2->3->4->-1; Dmg type cycles every eDamageElement 0..13; Ex. Vuln and PetDmg reproduce the decompile's own non-contiguous jumps (6->8->98 and 6->101->8->98 respectively — eDamageElement.cs); move up/down reorder but never displace DEFAULT (a deliberate symmetric guard — VTank's own case 21 keeps MoveUp off row 0 but case 22 has no matching MoveDown guard, which reads as an authoring oversight, not intended asymmetric behavior, so a literal port was not preserved here). Case 6's "A" column write is inverted in VTank's own obfuscated field, but that inversion is an artifact of VTank's internal storage, not the checkbox the player sees — our Attack flag is already declared un-inverted, so no inversion is needed for parity. Deviation: cases 18/19's Weapon/Offhand cycle VTank's own runtime list of owned weapon-TYPE ids via an opaque item-kind classifier the decompile alone doesn't resolve. MossTank has no "weapon type" abstraction — Weapon/Offhand are always concrete owned items — so these columns instead cycle the same registered weapon roster the Items tab already maintains (ordinal-sorted for a deterministic order), preserving the click-to-cycle-through-<AUTO> contract without inventing a type abstraction outside this slice's scope. MossTankPanel: the whole select-then-edit surface (SelectedMonsterRule selection, RefreshMonsterEditor's cached row formatting, Apply/Remove/ priority-stepper/damage-menu/equip-from-selection actions, HasMonsterFlag/ToggleMonsterFlag) is retired in favor of per-row column-array properties (14 flag columns, 7 text columns, 2 icon columns) and per-row mutation actions, all still writing through the unchanged CombatSettings.Rules/MonsterRule/MonsterRuleActions model. EnsureDefaultMonsterRule replaces RefreshMonsterEditor's DEFAULT invariant at the two remaining call sites (ctor init, profile reload, and the /vt refresh command). MonsterEquipmentText is kept as a read-only DEFAULT-rule readout for the (out-of-scope) Items tab label that already depended on it. Tests (every pin below shown to fail against a targeted mutation, reverted after confirming the failure — mutations: wrong priority-wrap boundary, dropped DEFAULT-delete guard, dropped DEFAULT-displacement guard on move, spliced Harm into the Ex.Vuln cycle, flag toggle targeting row 0 unconditionally, a wrong header tooltip string; the xml column-count contract's 205->202 pin was independently shown to fail against the pre-edit test file and the new markup): MossTankMarkupContractTests gained MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips (column type/order + the 15 header tooltips) and updated the 205->202 interactive-control count plus the tooltip-check's <column> skip (columns have no text/tooltip grammar). MossTankPanelTests replaced the three old select-then-edit tests with MonstersGridMutationsPersistAcrossSessions, ToggleMonsterFlagAtWritesOnlyTheTargetedRow, CycleMonsterPriorityAtWrapsExactlyNegativeOneThroughFour, MonsterDamageColumnsCycleInTheExactRetailOrder (all three cycles), DeleteMonsterRuleAtRemovesNonDefaultRowsButNeverDefault, MoveMonsterRuleAtReordersButNeverDisplacesDefault, AddMonsterRuleUsesTheDraftTextAndAddSelectedMonsterUsesTheWorldTarget, and MonsterWeaponColumnCyclesTheRegisteredRosterAndPersistsByName AcrossSessions, and updated MacroWieldsCasterEntersMagicBuffsThen WieldsWeaponFightsThenIdlePeace for the new weapon-cycle API. Full suite green: 651/651 MossTank tests (645 + 6 net), 109/109 App markup-filtered tests. Deviation not requiring a divergence-register row: this campaign ports VTank plugin UI behavior, not retail AC client behavior, so docs/architecture/retail-divergence-register.md's scope (acdream vs. retail) does not apply here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
424 lines
18 KiB
C#
424 lines
18 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class MossTankMarkupContractTests
|
|
{
|
|
/// <summary>
|
|
/// Fix round item 9: every element name whose interactive attributes
|
|
/// (onclick/onchange/onsubmit) this contract validates and requires a
|
|
/// real handler for — shared by
|
|
/// <see cref="EveryInteractiveControlDeclaresARealHandlerBinding"/> and
|
|
/// <see cref="TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips"/>.
|
|
/// <c>column</c> (Campaign VT slice 1 Part B's <c><list><column></c>)
|
|
/// joined this set here — mosstank.xml itself has no <c><column></c>
|
|
/// elements yet, so this is a zero-behavior-change addition against the
|
|
/// current file (see <see cref="InteractiveElementNames_IncludesColumn"/>
|
|
/// for the direct pin).
|
|
/// </summary>
|
|
private static readonly string[] InteractiveElementNames =
|
|
[
|
|
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
|
|
];
|
|
|
|
[Fact]
|
|
public void InteractiveElementNames_IncludesColumn()
|
|
{
|
|
Assert.Contains("column", InteractiveElementNames);
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"Options", "Profiles", "Vitals", "Monsters", "Items",
|
|
"Consumables", "Buffs", "Route", "Meta",
|
|
],
|
|
root.Elements("tab")
|
|
.Select(static tab => (string?)tab.Attribute("text")));
|
|
|
|
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
|
|
BindingFlags.Instance | BindingFlags.Public);
|
|
var byName = properties.ToDictionary(
|
|
static property => property.Name,
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
|
|
{
|
|
string value = attribute.Value;
|
|
if (!value.Contains('{', StringComparison.Ordinal))
|
|
continue;
|
|
Assert.Matches("^\\{[^{}]+\\}$", value);
|
|
string name = value[1..^1];
|
|
Assert.True(
|
|
byName.ContainsKey(name),
|
|
$"Markup binding {value} on <{attribute.Parent?.Name}> has no "
|
|
+ $"public MossTankPanel property.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryInteractiveBindingMatchesTheRetainedUiDelegateShape()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
|
|
BindingFlags.Instance | BindingFlags.Public);
|
|
var byName = properties.ToDictionary(
|
|
static property => property.Name,
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (XElement element in root.DescendantsAndSelf())
|
|
AssertElementBindingsMatchRetainedUiDelegateShape(element, byName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round item 9: <c><column></c>'s own <c>onchange</c> (a
|
|
/// <c>type="check"</c> column) and <c>onclick</c> (<c>type="icon"</c>,
|
|
/// or a <c>type="text"</c> column's fix-item-1 optional onclick) are
|
|
/// BOTH <c>Action<int></c> (the row index) — never the plain
|
|
/// <c>Action</c> every other element's <c>onclick</c> resolves to.
|
|
/// Extracted out of <see cref="EveryInteractiveBindingMatchesTheRetainedUiDelegateShape"/>
|
|
/// so <see cref="Column_OnchangeAndOnclick_MustBeActionOfInt"/> can drive
|
|
/// it directly against a synthetic <c><column></c> element —
|
|
/// mosstank.xml itself has none yet.
|
|
/// </summary>
|
|
private static void AssertElementBindingsMatchRetainedUiDelegateShape(
|
|
XElement element,
|
|
IReadOnlyDictionary<string, PropertyInfo> byName)
|
|
{
|
|
if (element.Name.LocalName == "column")
|
|
{
|
|
AssertBindingType(element, "onchange", typeof(Action<int>), byName);
|
|
AssertBindingType(element, "onclick", typeof(Action<int>), byName);
|
|
return;
|
|
}
|
|
|
|
AssertBindingType(element, "onclick", typeof(Action), byName);
|
|
AssertBindingType(
|
|
element,
|
|
"onsubmit",
|
|
typeof(Action<string>),
|
|
byName);
|
|
|
|
Type? changeType = element.Name.LocalName switch
|
|
{
|
|
"field" or "menu" => typeof(Action<string>),
|
|
"slider" => typeof(Action<float>),
|
|
"list" => typeof(Action<int>),
|
|
_ => null,
|
|
};
|
|
if (changeType is not null)
|
|
AssertBindingType(element, "onchange", changeType, byName);
|
|
}
|
|
|
|
private sealed class ColumnBindingProbe
|
|
{
|
|
public Action<int> RowAction { get; } = _ => { };
|
|
public Action PlainAction { get; } = () => { };
|
|
}
|
|
|
|
[Fact]
|
|
public void Column_OnchangeAndOnclick_MustBeActionOfInt()
|
|
{
|
|
var byName = typeof(ColumnBindingProbe)
|
|
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
|
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
|
|
|
|
// Correctly typed Action<int> — must not throw.
|
|
var goodColumn = new XElement(
|
|
"column",
|
|
new XAttribute("type", "check"),
|
|
new XAttribute("onchange", "{RowAction}"));
|
|
AssertElementBindingsMatchRetainedUiDelegateShape(goodColumn, byName);
|
|
|
|
// A column's onclick bound to a PLAIN Action (the shape every other
|
|
// element's onclick uses) must be rejected — proves the dispatch
|
|
// actually enforces Action<int> for <column> specifically, rather
|
|
// than silently accepting whatever the generic non-column path
|
|
// would have allowed.
|
|
var badColumn = new XElement(
|
|
"column",
|
|
new XAttribute("type", "icon"),
|
|
new XAttribute("onclick", "{PlainAction}"));
|
|
Assert.Throws<Xunit.Sdk.EqualException>(
|
|
() => AssertElementBindingsMatchRetainedUiDelegateShape(badColumn, byName));
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryInteractiveControlDeclaresARealHandlerBinding()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
|
|
|
|
XElement[] controls = root.Descendants()
|
|
.Where(element => interactive.Contains(element.Name.LocalName))
|
|
.ToArray();
|
|
// Round 3 item 10: +3 for the Route/Loot/Meta Delete buttons
|
|
// (Settings already had one from round 2 step 5). Campaign VT
|
|
// slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals
|
|
// control-for-control from VTank's own tabs (docs/research/
|
|
// vtank-kb/08-ui-views.md §1) and repositioned the Advanced
|
|
// Options/Loot Editor popups to VTank's exact 392x300/268x300
|
|
// geometry — net 194 -> 205. The Options tab gained four direct
|
|
// checkboxes for settings that already existed but were only
|
|
// reachable through the generic Advanced Options key-value editor
|
|
// (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback
|
|
// Debuffs if Blocked); the Profiles tab gained a real Meta-profile
|
|
// combo/CopyTo row (previously a stub label) and per-type name
|
|
// fields/New buttons, and lost the Loot Priority Boost toggle as a
|
|
// duplicate of the Options-tab control of the same name; the Loot
|
|
// Editor popup lost LootEditorNotice (a status label, not an
|
|
// action — dropped to fit VTank's 268x300 footprint without
|
|
// clipping, see the group's own comment).
|
|
// S7.3 replaced the whole Monsters tab body with VTank's own
|
|
// 23-column grid (docs/research/vtank-kb/08-ui-views.md §1 "Tab:
|
|
// Monsters"): the select-then-edit surface (1 list + 1 field + 8
|
|
// buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls)
|
|
// is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons
|
|
// (27) — net 205 -> 202.
|
|
Assert.Equal(202, controls.Length);
|
|
|
|
foreach (XElement control in controls)
|
|
{
|
|
XAttribute? handler = control.Attribute("onclick")
|
|
?? control.Attribute("onchange")
|
|
?? control.Attribute("onsubmit");
|
|
Assert.NotNull(handler);
|
|
Assert.NotEqual("false", (string?)control.Attribute("enabled"));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryVtankTabIsBackedByALivePanelSurface()
|
|
{
|
|
var panel = new MossTankPanel(new StubHost());
|
|
|
|
Assert.True(panel.OptionsTabEnabled);
|
|
Assert.True(panel.VitalsTabEnabled);
|
|
Assert.True(panel.MonstersTabEnabled);
|
|
Assert.True(panel.BuffsTabEnabled);
|
|
Assert.True(panel.ProfilesTabEnabled);
|
|
Assert.True(panel.ItemsTabEnabled);
|
|
Assert.True(panel.ConsumablesTabEnabled);
|
|
Assert.True(panel.RouteTabEnabled);
|
|
Assert.True(panel.MetaTabEnabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips()
|
|
{
|
|
// Campaign VT S7.3: pins the transcription against
|
|
// docs/research/vtank-kb/08-ui-views.md §1 "Tab: Monsters" (column
|
|
// type/order) and the decompile's own tooltip strings
|
|
// (refs/vtank/decompiled/uTank2/PluginCore.cs:1693-1707).
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
XElement monstersGroup = root.Elements("group")
|
|
.Single(static g => (string?)g.Attribute("visible") == "{MonstersVisible}");
|
|
|
|
XElement list = Assert.Single(monstersGroup.Elements("list"));
|
|
XElement[] columns = list.Elements("column").ToArray();
|
|
Assert.Equal(23, columns.Length);
|
|
|
|
string[] expectedTypes =
|
|
[
|
|
"check", "check", "check", "check", "check", "check", "check",
|
|
"check", "check", "check", "check", "check", "check", "check",
|
|
"text", "text", "text", "text", "text", "text", "text",
|
|
"icon", "icon",
|
|
];
|
|
Assert.Equal(expectedTypes, columns.Select(c => (string?)c.Attribute("type")));
|
|
|
|
// The 14 check columns' header letters carry VTank's own decompiled
|
|
// tooltip text on the <label> placed above them (columns themselves
|
|
// have no tooltip grammar — docs/plugin-ui-markup.md's "Columns").
|
|
Dictionary<string, string?> tooltipsByHeaderText = monstersGroup.Elements("label")
|
|
.ToDictionary(
|
|
static l => (string?)l.Attribute("text") ?? string.Empty,
|
|
static l => (string?)l.Attribute("tooltip"));
|
|
Assert.Equal("Fester", tooltipsByHeaderText["F"]);
|
|
Assert.Equal("Broadside of a Barn", tooltipsByHeaderText["B"]);
|
|
Assert.Equal("Gravity Well", tooltipsByHeaderText["G"]);
|
|
Assert.Equal("Imperil", tooltipsByHeaderText["I"]);
|
|
Assert.Equal("Yield", tooltipsByHeaderText["Y"]);
|
|
Assert.Equal("Vuln (Element)", tooltipsByHeaderText["V"]);
|
|
Assert.Equal("Attack", tooltipsByHeaderText["A"]);
|
|
Assert.Equal("Ring Spell", tooltipsByHeaderText["R"]);
|
|
Assert.Equal("Streak", tooltipsByHeaderText["S"]);
|
|
Assert.Equal("Weakening Curse", tooltipsByHeaderText["WC"]);
|
|
Assert.Equal("Festering Curse", tooltipsByHeaderText["FC"]);
|
|
Assert.Equal("Corruption", tooltipsByHeaderText["Cp"]);
|
|
Assert.Equal("Destructive Curse", tooltipsByHeaderText["DC"]);
|
|
Assert.Equal("Corrosion", tooltipsByHeaderText["Cs"]);
|
|
Assert.Equal("Priority", tooltipsByHeaderText["P"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
// Campaign VT slice 7 S7.1: 856 wide, matching VTank's own
|
|
// mainView.xml content (856x210, docs/research/vtank-kb/
|
|
// 08-ui-views.md §0). 350 tall (not 210) only because the
|
|
// Advanced Options / Loot Editor popups moved to VTank's own
|
|
// 392x300 / 268x300 popup geometry as separate in-panel groups —
|
|
// see mosstank.xml's own panel-level comment.
|
|
Assert.Equal(856f, Number(root, "w"));
|
|
Assert.Equal(350f, Number(root, "h"));
|
|
AssertWithinParent(root);
|
|
}
|
|
|
|
[Fact]
|
|
public void TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
string[] interactive = InteractiveElementNames;
|
|
HashSet<string> terse = new(
|
|
[
|
|
"+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A",
|
|
"R", "S", "W", "FC", "Cp", "DC", "Cs",
|
|
], StringComparer.Ordinal);
|
|
|
|
foreach (XElement element in root.Descendants()
|
|
.Where(element => interactive.Contains(
|
|
element.Name.LocalName,
|
|
StringComparer.Ordinal)))
|
|
{
|
|
// Campaign VT S7.3: <column> has no text/tooltip grammar at all
|
|
// (docs/plugin-ui-markup.md's "Columns" section — a column's
|
|
// attribute set is items/values/onclick/onchange/iconkind, never
|
|
// text or tooltip). Every column's "caption" is the header
|
|
// <label> placed above the <list> instead, which — being a
|
|
// plain label, not one of InteractiveElementNames — this loop
|
|
// never visits; the Monsters grid's own header labels carry
|
|
// real tooltip text for the abbreviated ones (see mosstank.xml).
|
|
if (element.Name.LocalName == "column")
|
|
continue;
|
|
string? text = (string?)element.Attribute("text");
|
|
if (!string.IsNullOrWhiteSpace(text) && !terse.Contains(text))
|
|
continue;
|
|
Assert.False(
|
|
string.IsNullOrWhiteSpace((string?)element.Attribute("tooltip")),
|
|
$"<{element.Name}> text='{text}' needs a tooltip.");
|
|
}
|
|
}
|
|
|
|
private static void AssertBindingType(
|
|
XElement element,
|
|
string attributeName,
|
|
Type expectedType,
|
|
IReadOnlyDictionary<string, PropertyInfo> properties)
|
|
{
|
|
string? expression = (string?)element.Attribute(attributeName);
|
|
if (expression is null)
|
|
return;
|
|
Assert.StartsWith("{", expression, StringComparison.Ordinal);
|
|
Assert.EndsWith("}", expression, StringComparison.Ordinal);
|
|
string name = expression[1..^1];
|
|
Assert.True(
|
|
properties.TryGetValue(name, out PropertyInfo? property),
|
|
$"Markup binding {expression} on <{element.Name}> has no public "
|
|
+ "MossTankPanel property.");
|
|
Assert.Equal(expectedType, property.PropertyType);
|
|
}
|
|
|
|
private static void AssertWithinParent(XElement parent)
|
|
{
|
|
float parentWidth = Number(parent, "w");
|
|
float parentHeight = Number(parent, "h");
|
|
foreach (XElement child in parent.Elements())
|
|
{
|
|
float width = Number(child, "w");
|
|
float height = Number(child, "h");
|
|
if (width > 0f)
|
|
{
|
|
Assert.True(
|
|
Number(child, "x") + width <= parentWidth,
|
|
$"<{child.Name}> crosses the right edge of <{parent.Name}>.");
|
|
}
|
|
if (height > 0f)
|
|
{
|
|
Assert.True(
|
|
Number(child, "y") + height <= parentHeight,
|
|
$"<{child.Name}> crosses the bottom edge of <{parent.Name}>.");
|
|
}
|
|
AssertWithinParent(child);
|
|
}
|
|
}
|
|
|
|
private static float Number(XElement element, string attribute) =>
|
|
float.TryParse(
|
|
(string?)element.Attribute(attribute),
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out float value)
|
|
? value
|
|
: 0f;
|
|
|
|
private sealed class StubHost : IPluginHost
|
|
{
|
|
public bool HasUi => false;
|
|
public IPluginLogger Log { get; } = new StubLogger();
|
|
public IGameState State { get; } = new StubState();
|
|
public IEvents Events { get; } = new StubEvents();
|
|
public ISelectionService Selection { get; } = new StubSelection();
|
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
|
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
|
|
}
|
|
|
|
private sealed class StubLogger : IPluginLogger
|
|
{
|
|
public void Info(string message) { }
|
|
public void Warn(string message) { }
|
|
public void Error(string message, Exception? exception = null) { }
|
|
}
|
|
|
|
private sealed class StubState : IGameState
|
|
{
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
|
}
|
|
|
|
private sealed class StubEvents : IEvents
|
|
{
|
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public event Action<double> Tick
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
}
|
|
|
|
private sealed class StubSelection : ISelectionService
|
|
{
|
|
public uint? SelectedObjectId => null;
|
|
public uint? PreviousObjectId => null;
|
|
public event Action<SelectionChangedEvent> Changed
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public bool Select(uint objectId) => false;
|
|
public bool Clear() => false;
|
|
}
|
|
}
|