New TeslaConsole.BattleTech namespace mirroring TeslaConsole.RedPlanet's mission layer: BTMission/BTFreeForAllMission (egg serializer; RP wire framing and ordinal bitmaps reused verbatim), BTParticipant/BTPlayer/BTCamera (BT participant fields: advancedDamage, experience, vehicleValue, patch, role), and the BTConfig/BTScenario/BTMap/BTVehicle/BTWeather/BTRole catalog loaded from BattleTech\BTConfig.xml (reconstructed from the Mac Console 4.10 ini BT tree). Free For All and No Return share one mission class - both send scenario=freeforall; the mode is the role assigned to each pilot. BTGoldenEggTests (7 tests, recovered-only) diff the generated egg field-by-field against two eggs captured from the original consoles (cavern.egg, TESTARN.EGG): order-independent per-section compare with font-rendered bitmap pixel rows excluded, ordinal art byte-exact vs TESTARN.EGG, EggFileMessage framing reassembly, role de-dup, No Return role semantics, and shipped-catalog contents. Invoker gains BTEggString/ BTEggFraming reflection cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
248 lines
11 KiB
C#
248 lines
11 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
using System.Xml;
|
||
using Xunit;
|
||
|
||
namespace TeslaConsole.DiffTests
|
||
{
|
||
/// <summary>
|
||
/// Golden-egg tests for the new TeslaConsole.BattleTech mission builder. BattleTech
|
||
/// is absent from the original exe, so like CatalogTests these run against the
|
||
/// recovered build only; the baselines are two eggs captured from the original
|
||
/// consoles (BattleTech\cavern.egg, BattleTech\TESTARN.EGG — see the port spec in
|
||
/// 410console/battletech-port/).
|
||
///
|
||
/// The comparison is per-section and order-independent: the pod parses the egg
|
||
/// INI-style, and the two golden eggs themselves disagree with each other on
|
||
/// participant field order. Pixel rows of the font-rendered name bitmaps are
|
||
/// excluded — the original consoles rendered them with a different font, and
|
||
/// cavern.egg's ordinal art is Mac-rendered too (the spec's known false
|
||
/// byte-match). TESTARN.EGG's ordinal art, however, is identical to the
|
||
/// RP-inherited rows, so that one IS compared byte-exactly.
|
||
/// </summary>
|
||
public class BTGoldenEggTests : IClassFixture<DifferentialFixture>
|
||
{
|
||
private readonly DifferentialFixture _fx;
|
||
|
||
public BTGoldenEggTests(DifferentialFixture fx) => _fx = fx;
|
||
|
||
// Mission args: mapKey, timeOfDayKey, weatherKey, temperature, lengthSeconds,
|
||
// then per pilot: podHost, name, vehicle, camo, patch, emblem, experience,
|
||
// dropzone, roleKey, roleModel (matches Invoker.BuildBTMission).
|
||
private static readonly string[] CavernArgs =
|
||
{
|
||
"cavern", "night", "clear", "27", "120",
|
||
"200.0.0.113", "cyd", "madcat", "Grey", "Red", "VGL", "veteran", "one", "Default", "dfltrole",
|
||
};
|
||
|
||
private static readonly string[] TestarnArgs =
|
||
{
|
||
"arena1", "day", "clear", "27", "600",
|
||
"200.0.0.113", "cyd", "madcat", "White", "Yellow", "VGL", "veteran", "one", "Default", "dfltrole",
|
||
};
|
||
|
||
private string Generate(string[] args)
|
||
{
|
||
string egg = _fx.Recovered.Run("BTEggString", args);
|
||
Assert.StartsWith("[mission]", egg); // not an "EXC:<Type>" result
|
||
return egg;
|
||
}
|
||
|
||
private static string ReadGolden(string fileName)
|
||
{
|
||
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "BattleTech", fileName);
|
||
Assert.True(File.Exists(path), "Golden egg not found: " + path);
|
||
// On disk the egg is the wire form: NUL-delimited ASCII.
|
||
return Encoding.ASCII.GetString(File.ReadAllBytes(path));
|
||
}
|
||
|
||
// ---- egg parsing ----
|
||
|
||
private static Dictionary<string, List<string>> ParseEgg(string text)
|
||
{
|
||
var sections = new Dictionary<string, List<string>>();
|
||
List<string> current = null;
|
||
foreach (var raw in text.Split(new[] { '\0', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
// TESTARN.EGG was captured with a stray \r before each NUL delimiter.
|
||
var line = raw.TrimEnd('\r');
|
||
if (line.Length == 0)
|
||
continue;
|
||
if (line.StartsWith("[", StringComparison.Ordinal))
|
||
{
|
||
string name = line.Trim('[', ']');
|
||
Assert.False(sections.ContainsKey(name), "Duplicate egg section: " + name);
|
||
current = new List<string>();
|
||
sections.Add(name, current);
|
||
}
|
||
else
|
||
{
|
||
Assert.NotNull(current); // a key=value before any section header
|
||
current.Add(line);
|
||
}
|
||
}
|
||
return sections;
|
||
}
|
||
|
||
/// <summary>Sections whose bitmap= lines are pixel rows, not references.</summary>
|
||
private static bool IsPixelSection(string name)
|
||
=> name.StartsWith("BitMap::", StringComparison.Ordinal) ||
|
||
name.StartsWith("Ordinal::BitMap::", StringComparison.Ordinal);
|
||
|
||
private static Dictionary<string, List<string>> DropPixelRows(Dictionary<string, List<string>> egg)
|
||
=> egg.ToDictionary(
|
||
s => s.Key,
|
||
s => IsPixelSection(s.Key)
|
||
? s.Value.Where(l => !l.StartsWith("bitmap=", StringComparison.Ordinal)).ToList()
|
||
: s.Value);
|
||
|
||
private static void AssertFieldsMatchGolden(string generated, string golden,
|
||
params string[] acceptedExtraGoldenSections)
|
||
{
|
||
var gen = DropPixelRows(ParseEgg(generated));
|
||
var gold = DropPixelRows(ParseEgg(golden));
|
||
|
||
var missing = gold.Keys.Except(gen.Keys).Except(acceptedExtraGoldenSections)
|
||
.OrderBy(s => s, StringComparer.Ordinal).ToArray();
|
||
var extra = gen.Keys.Except(gold.Keys)
|
||
.OrderBy(s => s, StringComparer.Ordinal).ToArray();
|
||
Assert.True(missing.Length == 0,
|
||
"Golden egg sections missing from the generated egg: " + string.Join(", ", missing));
|
||
Assert.True(extra.Length == 0,
|
||
"Generated egg sections absent from the golden egg: " + string.Join(", ", extra));
|
||
|
||
foreach (var name in gen.Keys)
|
||
{
|
||
var g = gen[name].OrderBy(s => s, StringComparer.Ordinal).ToArray();
|
||
var r = gold[name].OrderBy(s => s, StringComparer.Ordinal).ToArray();
|
||
Assert.True(g.SequenceEqual(r),
|
||
"[" + name + "] fields differ.\n golden: " + string.Join(" | ", r) +
|
||
"\n generated: " + string.Join(" | ", g));
|
||
}
|
||
}
|
||
|
||
// ---- golden-egg field equivalence ----
|
||
|
||
[Fact]
|
||
public void Cavern_Egg_Fields_Match_Golden()
|
||
=> AssertFieldsMatchGolden(Generate(CavernArgs), ReadGolden("cavern.egg"));
|
||
|
||
[Fact]
|
||
public void Testarn_Egg_Fields_Match_Golden()
|
||
{
|
||
// The original console emitted a [Role::*] block for every role in its
|
||
// catalog; ours emits only the roles the pilots reference, so TESTARN's
|
||
// unreferenced [Role::NoReturn] is an accepted difference (extra blocks
|
||
// are harmless to the pod's INI-style parser; missing referenced ones
|
||
// would not be).
|
||
AssertFieldsMatchGolden(Generate(TestarnArgs), ReadGolden("TESTARN.EGG"),
|
||
"Role::NoReturn");
|
||
}
|
||
|
||
[Fact]
|
||
public void Ordinal_Bitmap_Art_Matches_Testarn_Byte_Exactly()
|
||
{
|
||
// TESTARN.EGG's 1st–4th place art is identical to the RP ordinals the BT
|
||
// builder reuses, so here the pixel rows are compared exactly and in order.
|
||
// (cavern.egg is excluded on purpose: its ordinal art is Mac-font-rendered
|
||
// and legitimately differs — the spec's known false byte-match.)
|
||
var gen = ParseEgg(Generate(TestarnArgs));
|
||
var gold = ParseEgg(ReadGolden("TESTARN.EGG"));
|
||
for (int n = 1; n <= 4; n++)
|
||
{
|
||
string name = "Ordinal::BitMap::" + n;
|
||
Assert.True(gen.ContainsKey(name), "Generated egg lacks [" + name + "]");
|
||
Assert.Equal(gold[name], gen[name]);
|
||
}
|
||
}
|
||
|
||
// ---- wire framing ----
|
||
|
||
[Fact]
|
||
public void Egg_Wire_Framing_Chunks_And_Reassembles()
|
||
{
|
||
string result = _fx.Recovered.Run("BTEggFraming", CavernArgs);
|
||
var m = Regex.Match(result, @"^messages=(\d+);total=(\d+);framing=True;reassembled=True$");
|
||
Assert.True(m.Success, "Unexpected framing result: " + result);
|
||
|
||
int messages = int.Parse(m.Groups[1].Value);
|
||
int total = int.Parse(m.Groups[2].Value);
|
||
Assert.Equal((total + 999) / 1000, messages);
|
||
Assert.True(total > 5000, "Suspiciously small egg (" + total + " bytes)");
|
||
}
|
||
|
||
// ---- behavior pinned by the resolved spec questions ----
|
||
|
||
[Fact]
|
||
public void Role_Blocks_Are_Deduplicated_Across_Pilots()
|
||
{
|
||
var args = CavernArgs.Concat(new[]
|
||
{
|
||
"200.0.0.114", "kai", "loki", "Black", "Blue", "Davion", "novice", "two", "Default", "dfltrole",
|
||
}).ToArray();
|
||
string egg = Generate(args);
|
||
|
||
int roleRefs = Regex.Matches(egg, Regex.Escape("role=Role::Default\n")).Count;
|
||
int roleBlocks = Regex.Matches(egg, Regex.Escape("[Role::Default]\n")).Count;
|
||
Assert.Equal(2, roleRefs);
|
||
Assert.Equal(1, roleBlocks);
|
||
}
|
||
|
||
[Fact]
|
||
public void NoReturn_Mode_Sends_Freeforall_Scenario_With_NoReturn_Role()
|
||
{
|
||
// No Return is not a separate scenario on the wire: both golden eggs say
|
||
// scenario=freeforall, and the modes differ only by the pilots' role.
|
||
var args = (string[])CavernArgs.Clone();
|
||
args[13] = "NoReturn";
|
||
args[14] = "noretun";
|
||
var egg = ParseEgg(Generate(args));
|
||
|
||
Assert.Contains("scenario=freeforall", egg["mission"]);
|
||
Assert.Contains("role=Role::NoReturn", egg["200.0.0.113"]);
|
||
Assert.True(egg.ContainsKey("Role::NoReturn"), "Missing [Role::NoReturn] block");
|
||
Assert.Contains("model=noretun", egg["Role::NoReturn"]);
|
||
Assert.False(egg.ContainsKey("Role::Default"), "Unreferenced [Role::Default] emitted");
|
||
}
|
||
|
||
// ---- the shipped catalog ----
|
||
|
||
[Fact]
|
||
public void BTConfig_Catalog_Ships_With_Build_And_Has_Expected_Counts()
|
||
{
|
||
// Verifies the csproj Content include actually lands BTConfig.xml next to
|
||
// the exe (BTConfig loads it from there at runtime) and that the catalog
|
||
// carries the full Mac Console.ini BT tree.
|
||
string path = Path.Combine(Path.GetDirectoryName(AssemblyPaths.RecoveredExe),
|
||
"BattleTech", "BTConfig.xml");
|
||
Assert.True(File.Exists(path), "BTConfig.xml not copied next to the build: " + path);
|
||
|
||
var doc = new XmlDocument();
|
||
doc.Load(path);
|
||
XmlElement root = doc.DocumentElement;
|
||
Assert.Equal("BattleTech", root.Name);
|
||
|
||
Assert.Equal(8, root.SelectNodes("map").Count);
|
||
Assert.Equal(18, root.SelectNodes("vehicle").Count);
|
||
Assert.Equal(4, root.SelectNodes("time").Count);
|
||
Assert.Equal(3, root.SelectNodes("weather").Count);
|
||
foreach (XmlNode weather in root.SelectNodes("weather"))
|
||
Assert.Equal("27", weather.Attributes["temperature"].InnerText);
|
||
|
||
XmlNode ffa = root.SelectSingleNode("freeforall");
|
||
Assert.NotNull(ffa);
|
||
Assert.Equal(3, ffa.SelectNodes("experience").Count);
|
||
Assert.Equal(7, ffa.SelectNodes("color").Count);
|
||
Assert.Equal(8, ffa.SelectNodes("patch").Count);
|
||
Assert.Equal(7, ffa.SelectNodes("badge").Count);
|
||
Assert.Equal(2, ffa.SelectNodes("role").Count);
|
||
Assert.Equal("dfltrole", ffa.SelectSingleNode("role[@key='Default']").Attributes["model"].InnerText);
|
||
Assert.Equal("noretun", ffa.SelectSingleNode("role[@key='NoReturn']").Attributes["model"].InnerText);
|
||
}
|
||
}
|
||
}
|