using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using Ashfall.Core; namespace Ashfall.Specs { internal sealed class JsonCodec : IWorldCodec { private readonly JsonSerializerOptions options = new JsonSerializerOptions { IncludeFields = true, IgnoreReadOnlyProperties = true }; public string Encode(WorldState world) => JsonSerializer.Serialize(world, options); public WorldState Decode(string json) => JsonSerializer.Deserialize(json, options); } internal static class Program { private static int passed, failed; private static readonly List failures = new List(); private static void Main(string[] args) { Check("catalog stable IDs and complete target counts", Catalog); Check("projection and moving-space round trips", ProjectionRoundTrips); Check("new brood player starts as a worm", WormStart); Check("worm metamorphosis is gated and transactional", WormEvolution); Check("brood form survives species changes and save", WormPersistence); Check("all races may drive either vehicle family", SharedDriving); Check("station claim race permits only one occupant", StationRace); Check("driver and gunner have exclusive independent groups", WeaponOwnership); Check("disconnect brakes vehicle and releases firing", Disconnect); Check("stale input stops firing and driving", StaleInput); Check("moving interiors preserve passenger local position", MovingInterior); Check("moving and airborne exits rejected", ExitSafety); Check("blocked exit leaves the player in original space", BlockedExit); Check("portal destination is revalidated at commit", PortalCommit); Check("walk into stairs changes floor without interaction", Stairs); Check("different floors cannot be damaged by planar overlap", FloorCombat); Check("building and vehicle hulls block exterior fire", ExteriorOcclusion); Check("worm bite and burrowing work from initial form", WormCombat); Check("mixed modules rejected without consuming inventory", ModuleCompatibility); Check("module replacement is idempotent and recovers old part", ModuleTransaction); Check("inventory transfers conserve count and reject negative amounts", InventoryTransactions); Check("production survives full output storage without duplicate output", Production); Check("offline world and disconnected production pause", OfflinePause); Check("research requires proximity, power and correct species", Research); Check("stairs create a playable upper floor, max four floors", Building); Check("building cannot enclose a player away from all exits", EscapeRoutes); Check("save backup recovers corruption without losing worm state", Backups); Check("invalid snapshots never overwrite good saves", InvalidSave); Check("null nested save data is rejected as corruption", NullSaveData); Check("restored seat falls back if another player occupies it", RestoreSeat); Check("fifth connected player is refused", FourPlayers); Check("malformed input never contaminates world coordinates", BadInput); Check("four-player 15-minute simulation preserves invariants", Soak); Console.WriteLine($"\n{passed} passed; {failed} failed"); foreach (string failure in failures) Console.WriteLine(failure); Environment.ExitCode = failed == 0 ? 0 : 1; } private static void Check(string name, Action test) { try { test(); passed++; Console.WriteLine("PASS " + name); } catch (Exception ex) { failed++; failures.Add(name + ": " + ex.Message); Console.WriteLine("FAIL " + name + " — " + ex.Message); } } private static void Assert(bool condition, string message = "assertion failed") { if (!condition) throw new Exception(message); } private static void ExteriorOcclusion() { var s = Sim(); Assert(s.Blocked(WorldFactory.At("world", 0, new V2(-32, 16)), new V2(-8, 16))); var v = s.World.Vehicle("crawler"); var start = WorldFactory.At("world", 0, v.position + new V2(-10, 0)); Assert(s.Blocked(start, v.position + new V2(10, 0))); Assert(!s.Blocked(start, v.position + new V2(10, 0), v.id)); } private static void NullSaveData() { var world = WorldFactory.Create(); world.vehicles[0].stations.Add(null); bool rejected = false; try { SaveStore.Validate(world); } catch (InvalidDataException) { rejected = true; } Assert(rejected, "bad nested data must participate in save recovery"); } private static void Close(float a, float b, float tolerance = .001f) => Assert(Math.Abs(a - b) <= tolerance, $"expected {b}, got {a}"); private static GameSimulation Sim() => new GameSimulation(WorldFactory.Create(), ContentCatalog.CreateDefault()); private static PlayerState Add(GameSimulation s, string id = "a", Species species = Species.Human) => s.Connect(id, id, species); private static ActionResult Do(GameSimulation s, PlayerState p, CommandKind kind, string target = "", string value = "", string slot = "", int amount = 1) => s.Execute(p.id, new GameCommand { sequence = p.lastCommand + 1, kind = kind, targetId = target, value = value, slot = slot, amount = amount }); private static void At(PlayerState p, string space, int floor, float x, float y) => p.location = WorldFactory.At(space, floor, new V2(x, y)); private static void Step(GameSimulation s, int count) { for (int i = 0; i < count; i++) s.Tick(GameSimulation.TickSeconds); } private static void Seat(GameSimulation s, PlayerState p, string id) { var seat = s.FindStation(id); p.location = seat.location.Copy(); Assert(Do(s, p, CommandKind.Interact, id).success); } private static void Catalog() { var c = ContentCatalog.CreateDefault(); c.Validate(); foreach (var pair in new[] { ("hero", 8), ("weapon", 12), ("gene", 24), ("facility", 40), ("turret", 24), ("vehicle", 16), ("region", 6), ("boss", 6) }) Assert(c.entries.Count(e => e.category == pair.Item1) == pair.Item2, pair.Item1); Assert(c.entries.Any(e => e.id == "form.worm")); } private static void ProjectionRoundTrips() { for (int heading = 0; heading < 360; heading += 15) { V2 local = new V2(4, -3), origin = new V2(120, -73); V2 world = Projection.InteriorToWorld(local, origin, heading); V2 roundtrip = Projection.WorldToInterior(world, origin, heading); Close(roundtrip.x, local.x); Close(roundtrip.y, local.y); V2 screen = Projection.ToScreen(world, 6), back = Projection.ToGround(screen, 6); Close(world.x, back.x); Close(world.y, back.y); } } private static void WormStart() { var s = Sim(); var p = Add(s, species: Species.Brood); Assert(p.IsWorm); Close(p.maxHealth, 160); Assert(s.Content.entries.Count(e => e.category == "hero") == 8); } private static void WormEvolution() { var s = Sim(); var p = Add(s, species: Species.Brood); Assert(!Do(s, p, CommandKind.Evolve, "base.molt").success); At(p, "base", 0, 4, -4); Assert(!Do(s, p, CommandKind.Evolve, "base.molt").success); p.inventory.Add("biomass", 8); p.inventory.Add("sample", 1); long seq = p.lastCommand + 1; var cmd = new GameCommand { sequence = seq, kind = CommandKind.Evolve, targetId = "base.molt" }; Assert(s.Execute(p.id, cmd).success); Assert(!p.IsWorm); Close(p.maxHealth, 250); Assert(!s.Execute(p.id, cmd).success); Assert(p.inventory.Count("biomass") == 0 && p.inventory.Count("sample") == 0); } private static void WormPersistence() { var s = Sim(); var p = Add(s, species: Species.Brood); At(p, "base", 0, 4, -4); Assert(Do(s, p, CommandKind.SwitchSpecies, value: "Human").success); Assert(Do(s, p, CommandKind.SwitchSpecies, value: "Brood").success); Assert(p.IsWorm); var codec = new JsonCodec(); var restored = codec.Decode(codec.Encode(s.World)); Assert(restored.Player("a").IsWorm); p.broodForm = BroodForm.Adult; restored = codec.Decode(codec.Encode(s.World)); Assert(!restored.Player("a").IsWorm); } private static void SharedDriving() { foreach (var species in new[] { Species.Human, Species.Brood }) foreach (var id in new[] { "crawler", "beast" }) { var s = Sim(); var p = Add(s, species: species); Seat(s, p, id + ".driver"); Assert(p.stationId == id + ".driver"); } } private static void StationRace() { var s = Sim(); var a = Add(s); var b = Add(s, "b"); Seat(s, a, "crawler.driver"); b.location = a.location.Copy(); Assert(!Do(s, b, CommandKind.Interact, "crawler.driver").success); } private static void WeaponOwnership() { var s = Sim(); var a = Add(s); var b = Add(s, "b", Species.Brood); var v = s.World.Vehicle("crawler"); Seat(s, a, "crawler.driver"); Assert(s.CanControlGroup(a, v, 0)); Seat(s, b, "crawler.gunner"); Assert(!s.CanControlGroup(a, v, 0)); Assert(s.CanControlGroup(a, v, 1)); Assert(s.CanControlGroup(b, v, 0)); Assert(!s.CanControlGroup(b, v, 1)); Do(s, b, CommandKind.LeaveStation); Assert(s.CanControlGroup(a, v, 0)); } private static void Disconnect() { var s = Sim(); var p = Add(s); Seat(s, p, "crawler.driver"); var v = s.World.Vehicle("crawler"); v.speed = 8; v.cruise = true; p.input.fire = true; s.Disconnect(p.id); Close(v.speed, 0); Assert(!v.cruise && v.stations[0].playerId == "" && !p.input.fire); } private static void StaleInput() { var s = Sim(); var p = Add(s); s.SetInput(p.id, new PlayerInput { sequence = 1, fire = true, move = new V2(1, 0), aim = new V2(30, -9) }); Step(s, 20); Assert(!p.input.fire && p.input.move.Length == 0); } private static void MovingInterior() { var s = Sim(); var a = Add(s); var b = Add(s, "b", Species.Brood); Seat(s, a, "crawler.driver"); At(b, "crawler.inside", 0, 2, 1); var v = s.World.Vehicle("crawler"); V2 start = v.position; for (int i = 0; i < 60; i++) { s.SetInput(a.id, new PlayerInput { sequence = i + 1, move = new V2(0, 1) }); s.Tick(GameSimulation.TickSeconds); } Assert(V2.Distance(v.position, start) > 1); Close(b.location.position.x, 2); Close(b.location.position.y, 1); Assert(b.location.floor == 0); } private static void ExitSafety() { var s = Sim(); var p = Add(s); var portal = s.World.portals.Find(x => x.id == "crawler.inside.exit"); p.location = portal.from.Copy(); var v = s.World.Vehicle("crawler"); v.speed = 3; Assert(!s.ValidatePortal(p, portal).success); v.speed = 0; v.flightMode = FlightMode.Low; Assert(!s.ValidatePortal(p, portal).success); } private static void BlockedExit() { var s = Sim(); var p = Add(s); var portal = s.World.portals.Find(x => x.id == "crawler.inside.exit"); p.location = portal.from.Copy(); V2 dest = s.PortalDestination(portal).position; s.World.Space("world").floors[0].walls.Add(new WallState { bounds = new Box2(dest, new V2(3, 3)) }); Assert(!s.ValidatePortal(p, portal).success); Step(s, 60); Assert(p.location.spaceId == "crawler.inside"); } private static void PortalCommit() { var s = Sim(); var p = Add(s); var driver = Add(s, "b"); Seat(s, driver, "crawler.driver"); var portal = s.World.portals.Find(x => x.id == "crawler.inside.exit"); p.location = portal.from.Copy(); p.portalReadyAt = 0; s.Tick(GameSimulation.TickSeconds); Assert(p.transitionId != ""); s.World.Vehicle("crawler").speed = 4; Step(s, 10); Assert(p.location.spaceId == "crawler.inside"); } private static void Stairs() { var s = Sim(); var p = Add(s); var stair = s.World.portals.First(x => x.stairs && x.from.spaceId == "lab"); p.location = stair.from.Copy(); p.portalReadyAt = 0; Step(s, 30); Assert(p.location.spaceId == "lab" && p.location.floor == 1); Assert(p.transitionId == ""); } private static void FloorCombat() { var s = Sim(); var p = Add(s); At(p, "lab", 0, 0, 0); s.World.enemies.Clear(); var upper = new EnemyState { id = "upper", location = WorldFactory.At("lab", 1, new V2(3, 0)) }; s.World.enemies.Add(upper); s.SetInput(p.id, new PlayerInput { sequence = 1, fire = true, aim = new V2(3, 0) }); Step(s, 2); Close(upper.health, 100); s.World.Space("lab").floors[0].walls.Add(new WallState { bounds = new Box2(new V2(1.5f, 0), new V2(.3f, 4)) }); upper.location.floor = 0; Step(s, 10); Close(upper.health, 100); } private static void WormCombat() { var s = Sim(); var p = Add(s, species: Species.Brood); At(p, "world", 0, 0, -20); s.World.enemies.Clear(); var e = new EnemyState { id = "bite", location = WorldFactory.At("world", 0, new V2(1, -20)) }; s.World.enemies.Add(e); s.SetInput(p.id, new PlayerInput { sequence = 1, fire = true, aim = e.location.position }); Step(s, 1); Assert(e.health < 100); Assert(Do(s, p, CommandKind.Ability).success); float health = p.health; Step(s, 60); Close(p.health, health); At(p, "lab", 0, 0, 0); p.abilityReadyAt = 0; Assert(!Do(s, p, CommandKind.Ability).success); } private static void ModuleCompatibility() { var s = Sim(); var p = Add(s, species: Species.Brood); At(p, "crawler.inside", 0, -4, 2); var v = s.World.Vehicle("crawler"); v.cargo.Add("module.biological.light", 1); int before = v.cargo.Total; Assert(!Do(s, p, CommandKind.InstallModule, "crawler", "biological.light", "primary").success); Assert(before == v.cargo.Total); var tooBig = s.Content.Module("mechanical.heavy"); s.World.research.Add("mechanical.weapons"); Assert(!s.ValidateModule(v, v.hardpoints[1], tooBig).success); } private static void ModuleTransaction() { var s = Sim(); var p = Add(s, species: Species.Brood); At(p, "crawler.inside", 0, -4, 2); s.World.research.Add("mechanical.weapons"); var v = s.World.Vehicle("crawler"); var cmd = new GameCommand { sequence = 1, kind = CommandKind.InstallModule, targetId = "crawler", value = "mechanical.heavy", slot = "primary" }; int old = v.cargo.Count("module.mechanical.light"); Assert(s.Execute(p.id, cmd).success); Assert(v.cargo.Count("module.mechanical.heavy") == 0 && v.cargo.Count("module.mechanical.light") == old + 1); Assert(!s.Execute(p.id, cmd).success); Assert(v.cargo.Count("module.mechanical.light") == old + 1); } private static void InventoryTransactions() { var a = new Inventory { capacity = 20 }; var b = new Inventory { capacity = 5 }; a.Add("ore", 10); Assert(!Inventory.Transfer(a, b, "ore", -1)); Assert(!Inventory.Transfer(a, b, "ore", 6)); Assert(Inventory.Transfer(a, b, "ore", 5)); Assert(a.Count("ore") + b.Count("ore") == 10); } private static void Production() { var s = Sim(); var p = Add(s); At(p, "base", 0, -5, 2); Assert(Do(s, p, CommandKind.Craft, "base.workshop", "ammo").success); var inv = s.World.warehouse; int afterCost = inv.Count("metal"); inv.capacity = inv.Total; Step(s, 120); var f = s.World.facilities.Find(x => x.id == "base.workshop"); Assert(f.jobs.Count == 1 && f.jobs[0].remaining == 0); inv.capacity += 100; Step(s, 2); Assert(f.jobs.Count == 0 && inv.Count("ammo") == 60 && inv.Count("metal") == afterCost); Step(s, 20); Assert(inv.Count("ammo") == 60); } private static void OfflinePause() { var s = Sim(); var p = Add(s); s.Disconnect(p.id); double t = s.World.time; Step(s, 100); Assert(s.World.time == t); } private static void Research() { var s = Sim(); var p = Add(s); Assert(!Do(s, p, CommandKind.Research, "lab.research").success); At(p, "lab", 1, -4, 2); Assert(!Do(s, p, CommandKind.Research, "lab.research").success); At(p, "lab", 0, -5, 2); Assert(Do(s, p, CommandKind.Interact, "lab.power").success); At(p, "lab", 1, -4, 2); Assert(Do(s, p, CommandKind.Research, "lab.research").success); Assert(s.World.research.Contains("mechanical.weapons")); } private static void Building() { var s = Sim(); var p = Add(s); At(p, "base", 0, 0, -4); var cmd = new GameCommand { sequence = 1, kind = CommandKind.Build, piece = PieceKind.Stairs, floor = 0, position = new V2(0, -2) }; Assert(s.Execute(p.id, cmd).success); Assert(s.CanStand(WorldFactory.At("base", 1, new V2(0, 2)))); At(p, "base", 0, 0, -2); p.portalReadyAt = 0; Step(s, 30); Assert(p.location.floor == 1); cmd.sequence = 2; cmd.floor = 4; Assert(!s.Execute(p.id, cmd).success); } private static void EscapeRoutes() { var s = Sim(); var p = Add(s); At(p, "base", 0, 0, 0); for (int x = -2; x <= 2; x++) for (int y = -2; y <= 2; y++) if ((Math.Abs(x) == 2 || Math.Abs(y) == 2) && !(x == 0 && y == -2)) s.World.pieces.Add(new BuildPiece { id = s.World.NewId("wall"), kind = PieceKind.Wall, location = WorldFactory.At("base", 0, new V2(x, y)), rotation = Math.Abs(x) == 2 ? 1 : 0 }); var result = s.Execute(p.id, new GameCommand { sequence = 1, kind = CommandKind.Build, piece = PieceKind.Wall, position = new V2(0, -2) }); Assert(!result.success, "enclosure was permitted"); } private static void Backups() { string dir = Path.Combine(Path.GetTempPath(), "ashfall-spec-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { string path = Path.Combine(dir, "world.sav"); var store = new SaveStore(path, new JsonCodec()); var s = Sim(); var p = Add(s, species: Species.Brood); store.Save(s.World); p.broodForm = BroodForm.Adult; s.World.tick = 1; store.Save(s.World); s.World.tick = 2; store.Save(s.World); File.WriteAllText(path, "corrupted"); var recovered = store.Load(); Assert(recovered.tick == 1 && recovered.Player("a").broodForm == BroodForm.Adult); Assert(store.LastLoadedPath.EndsWith(".bak1")); recovered.tick = 3; store.Save(recovered); Assert(store.Load().tick == 3); Assert(File.Exists(path + ".bak2")); } finally { RemoveTestDirectory(dir); } } private static void InvalidSave() { string dir = Path.Combine(Path.GetTempPath(), "ashfall-spec-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { string path = Path.Combine(dir, "world.sav"); var store = new SaveStore(path, new JsonCodec()); var s = Sim(); store.Save(s.World); string before = File.ReadAllText(path); s.World.warehouse.items[0].amount = -1; bool rejected = false; try { store.Save(s.World); } catch (InvalidDataException) { rejected = true; } Assert(rejected && before == File.ReadAllText(path)); } finally { RemoveTestDirectory(dir); } } private static void RestoreSeat() { var s = Sim(); var a = Add(s); Seat(s, a, "crawler.driver"); s.PrepareRestoredWorld(); var b = Add(s, "b"); Seat(s, b, "crawler.driver"); a = s.Connect("a", "a", Species.Human); Assert(a != null && a.stationId == "" && a.location.spaceId == "crawler.inside"); } private static void RemoveTestDirectory(string directory) { string full = Path.GetFullPath(directory); string root = Path.GetFullPath(Path.GetTempPath()); if (!full.StartsWith(Path.Combine(root, "ashfall-spec-"), StringComparison.OrdinalIgnoreCase) || Path.GetDirectoryName(full) != root.TrimEnd(Path.DirectorySeparatorChar)) throw new IOException("Refusing to remove an unexpected test path."); Directory.Delete(full, true); } private static void FourPlayers() { var s = Sim(); for (int i = 0; i < 4; i++) Assert(Add(s, "p" + i) != null); Assert(Add(s, "fifth") == null); } private static void BadInput() { var s = Sim(); var p = Add(s); Assert(!s.SetInput(p.id, new PlayerInput { sequence = 1, move = new V2(float.NaN, 0) })); Step(s, 2); Assert(p.location.position.Finite); } private static void Soak() { var s = Sim(); var players = Enumerable.Range(0, 4).Select(i => Add(s, "p" + i, i % 2 == 0 ? Species.Human : Species.Brood)).ToArray(); Seat(s, players[0], "crawler.driver"); Seat(s, players[1], "crawler.gunner"); At(players[2], "crawler.inside", 0, 0, 0); At(players[3], "lab", 1, 0, 0); for (int i = 0; i < 30 * 60 * 15; i++) { s.SetInput(players[0].id, new PlayerInput { sequence = i + 1, move = new V2((i / 240) % 2 == 0 ? .1f : -.1f, .4f), aim = new V2(80, -20) }); s.SetInput(players[1].id, new PlayerInput { sequence = i + 1, fire = i % 3 == 0, aim = new V2(60, -20) }); s.Tick(GameSimulation.TickSeconds); } Assert(s.World.tick == 27000); Assert(players.All(p => p.location.position.Finite)); Assert(players[2].location.spaceId == "crawler.inside" && players[3].location.floor == 1); SaveStore.Validate(s.World); } } }