using System; using System.IO; using System.Linq; using Ashfall.Core; using UnityEngine; namespace Ashfall.Runtime { // Explicit opt-in test runner. It never uses the normal world save. public sealed class SmokeHarness : MonoBehaviour { public static bool Enabled => Environment.GetCommandLineArgs().Contains("--smoke"); private GameSession session; private float start, scenarioStart = -1; private bool completed, captured; private V2 initialVehicle; private int errors; private static string Output { get { var a = Environment.GetCommandLineArgs(); int i = Array.IndexOf(a, "--smoke-output"); return i >= 0 && i + 1 < a.Length ? a[i + 1] : Application.persistentDataPath; } } private void Start() { session = GetComponent(); start = Time.realtimeSinceStartup; Application.logMessageReceived += Log; } private void Log(string message, string stack, LogType kind) { if (kind == LogType.Error || kind == LogType.Exception || kind == LogType.Assert) errors++; } public static PlayerInput InputFor(GameSession s) { var result = new PlayerInput(); var p = s.LocalPlayer; if (p == null || s.World == null) return result; var v = s.World.Vehicle("crawler"); result.aim = v.position + new V2(30, 10); if (p.stationId != "") { result.move = s.World.time < 18 ? new V2(0, 1) : new V2(); result.brake = s.World.time >= 18; result.fire = result.secondary = true; } else if (p.location.spaceId == v.spaceId) { int index = s.World.players.IndexOf(p); V2 target = index == 3 && p.location.floor == 0 ? new V2(6, -2) : new V2(0, index == 2 ? -1 : 0); V2 diff = target - p.location.position; result.move = diff.Length > .12f ? diff.Normalized : new V2(); } return result; } private void Update() { if (completed) return; if (Time.realtimeSinceStartup - start > 100) { Complete(false, "connection/scenario timeout"); return; } if (!session.Running || session.LocalPlayer == null) return; if (!session.IsAuthority) { if (session.World.time >= 29) Complete(session.World.players.Count(p => p.connected) == 4 && session.LocalPlayer.acknowledgedInput > 0 && errors == 0, "client received world and acknowledged inputs"); return; } var w = session.World; var sim = session.Simulation; var v = w.Vehicle("crawler"); if (scenarioStart < 0 && w.players.Count(p => p.connected) == 4) { scenarioStart = Time.realtimeSinceStartup; initialVehicle = v.position; // Controlled fixtures isolate network and rendering behavior from traversal already covered by unit tests. for (int i = 0; i < 4; i++) { var p = w.players[i]; p.location = WorldFactory.At(v.spaceId, i < 2 ? 1 : 0, new V2(i < 2 ? 0 : -1, 0)); if (i < 2) { var station = v.stations[i]; p.location = station.location.Copy(); var r = sim.Execute(p.id, new GameCommand { sequence = p.lastCommand + 1, kind = CommandKind.Interact, targetId = station.id }); if (!r.success) { Complete(false, r.message); return; } } } sim.CriticalChange?.Invoke(); } if (scenarioStart < 0) return; if (w.time > 20 && !captured) { captured = true; Directory.CreateDirectory(Output); if (SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.Null) CaptureCamera(Path.Combine(Output, "prototype-exterior.png")); } if (w.time < 32) return; bool success = w.players.Count(p => p.connected) == 4 && V2.Distance(initialVehicle, v.position) > 4 && v.cargo.Count("ammo") < 600 && w.players[2].location.floor == 0 && w.players[3].location.floor == 1 && w.players.Skip(1).All(p => p.IsWorm && p.acknowledgedInput > 0) && errors == 0; session.Save(); var restored = new SaveStore(session.SavePath, new UnityWorldCodec()).Load(); success &= restored.players.Count == 4 && restored.Vehicle("crawler").position.x == v.position.x && restored.players[3].location.floor == 1; Complete(success, $"players={w.players.Count}, distance={V2.Distance(initialVehicle, v.position):0.0}, ammo={v.cargo.Count("ammo")}, floors={string.Join(",", w.players.Select(p => p.location.floor))}, errors={errors}"); } private void Complete(bool success, string reason) { completed = true; Directory.CreateDirectory(Output); string role = session != null && session.IsAuthority ? "host" : session?.PlayerId ?? "unknown"; File.WriteAllText(Path.Combine(Output, "smoke-" + role + ".txt"), (success ? "PASS " : "FAIL ") + reason); Debug.Log("ASHFALL_SMOKE " + (success ? "PASS " : "FAIL ") + reason); exitCode = success ? 0 : 1; if (session != null && !session.IsAuthority && success) Invoke(nameof(BeginExit), 8); else BeginExit(); } private int exitCode; private void BeginExit() { session?.Stop(); GetComponent()?.ReleasePhysics(); // Let asynchronous scene unloads and transport shutdown drain before quitting. Invoke(nameof(Exit), .5f); } private void Exit() => Application.Quit(exitCode); private void CaptureCamera(string path) { var camera = GetComponent().Camera; var target = RenderTexture.GetTemporary(1280, 800, 24); var oldTarget = camera.targetTexture; var oldActive = RenderTexture.active; Texture2D pixels = null; try { camera.targetTexture = target; camera.Render(); RenderTexture.active = target; pixels = new Texture2D(1280, 800, TextureFormat.RGB24, false); pixels.ReadPixels(new Rect(0, 0, 1280, 800), 0, 0); pixels.Apply(); File.WriteAllBytes(path, pixels.EncodeToPNG()); } finally { camera.targetTexture = oldTarget; RenderTexture.active = oldActive; RenderTexture.ReleaseTemporary(target); if (pixels != null) Destroy(pixels); } } private void OnDestroy() => Application.logMessageReceived -= Log; } }