using System; using System.Collections.Generic; using System.IO; using System.Numerics; using UnityEditor; using UnityEngine; using VS.Sim; namespace VS.EditorTools { // 배치 검증: Unity.exe -batchmode -projectPath -executeMethod VS.EditorTools.HeadlessRun.Run -logFile -quit // 결과 줄 = "[VS] PASS|FAIL <이름> …" · 마지막 "SUMMARY pass=N fail=M" public static partial class HeadlessRun { static int pass, fail; static readonly List fails = new List(); public static Tables LoadTables() => Tables.Load(n => { foreach (var ext in new[] { ".json", ".csv" }) { var p = Path.Combine("Assets/Resources/Data", n + ext); if (File.Exists(p)) return File.ReadAllText(p); } return null; }); public class Clock { public DateTime Base = new DateTime(2026, 1, 1, 9, 0, 0); public double Extra; public Game G; public DateTime Now() => Base.AddSeconds((G != null ? G.Time : 0) + Extra); } public static Game NewGame(Tables t, out Clock clock, SaveData s = null) { clock = new Clock(); var c = clock; var g = new Game(t, s ?? new SaveData(), () => c.Now()); c.G = g; return g; } static void Check(string name, bool ok, string detail = "") { if (ok) pass++; else { fail++; fails.Add(name + " " + detail); } Debug.Log($"[VS] {(ok ? "PASS" : "FAIL")} {name} {detail}"); } static bool Near(decimal a, decimal b, decimal tol) => Math.Abs(a - b) <= tol; static bool Near(double a, double b, double tol) => Math.Abs(a - b) <= tol; public static void Run() { int exit = 0; try { Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.None); pass = fail = 0; fails.Clear(); var t = LoadTables(); CoreTests(t); SystemTests(t); BattleTests(t); foreach (var f in fails) Debug.Log("[VS] FAILED: " + f); Debug.Log($"[VS] SUMMARY pass={pass} fail={fail}"); if (fail > 0) exit = 1; } catch (Exception e) { Debug.LogError("[VS] EXCEPTION " + e); exit = 2; } EditorApplication.Exit(exit); } static void CoreTests(Tables t) { // T1 데이터 Check("T1.rooms", t.Rooms.Length == 6 && t.Rooms[0].Items.Count == 11 && t.Rooms[5].Items[10].Levels.Count == 75); Check("T1.skills", t.Skills.Items.Count == 5 && t.Skills.Items[0].Levels.Count == 451); Check("T1.clothes_rank_quest", t.Clothes.Levels.Count == 72 && t.Ranks.Items.Count == 15 && t.Quests.items.Count == 267); Check("T1.config", Math.Abs(t.Cfg.remote.BaseIncome - 0.007f) < 1e-6f && Math.Abs(t.Cfg.remote.TapMultiplier - 1.5f) < 1e-6f && t.Cfg.input._tapTimeWindow == 1f); Check("T1.loc", t.Loc.Get("Content.Room.House1.0.Name") == "소파", t.Loc.Get("Content.Room.House1.0.Name")); // T2 새 게임 (NewRoom RVA 0x251CF00) var g = NewGame(t, out var clk); Check("T2.newroom", g.S.rooms.Count == 1 && g.S.rooms[0].Type == 10 && g.S.rooms[0].Level == 9, $"lv={g.S.rooms[0].Level}"); Check("T2.follower", g.Follower == t.RoomFollower[0][10][0], g.Follower.ToString()); // T3 생산 수입 식 (RVA 0x255C74C) Check("T3.income1", Math.Abs(g.ItemProductIncome(1) - 0.007f) < 1e-7f); float e10 = 0.007f + 0.007f * 9 * (float)Math.Pow(1.2, 10 / 25.0); Check("T3.income10", Math.Abs(g.ItemProductIncome(10) - e10) < 1e-6f, g.ItemProductIncome(10).ToString("R")); Check("T3.room_income", g.CurrentRoomProductIncome() == (decimal)g.ItemProductIncome(10)); // T4 배율 (RVA 0x255B5A4 / 0x255BE70) var lv9 = t.Rooms[0].Items[10].Levels[9]; Check("T4.passrate", Math.Abs(g.TotalPassRate() - (lv9.PassRate / 10000f + 1f)) < 1e-6f, g.TotalPassRate().ToString("R")); Check("T4.taprate", Math.Abs(g.TotalTapRate() - (lv9.TapRate / 10000f + 1f)) < 1e-6f); Check("T4.boost", g.GlobalIncomeBoost(true) == 1m * t.Ranks.Items[0].RankBoostRate); decimal expPass = (decimal)g.ItemProductIncome(10) * (decimal)g.TotalPassRate() * 1m * 1m; Check("T4.passgps", Near(g.PassGps(), expPass, 0.0000001m), g.PassGps().ToString()); decimal expTap = (decimal)g.ItemProductIncome(10) * (decimal)g.TotalTapRate() * (decimal)1.5f; Check("T4.tapgps", Near(g.TapGps(), expTap, 0.0000001m), g.TapGps().ToString()); // T5 탭 → 후원 적립 · 1 초 1회 제한 (RVA 0x240588C · 0x23FECD0) decimal tg = g.TapGps(); bool a1 = g.Tap(); g.Tick(0.3f); bool a2 = g.Tap(); Check("T5.ratelimit", a1 && !a2); Check("T5.donate", g.CoinDonate == decimal.Ceiling(tg * 100m) / 100m, g.CoinDonate.ToString()); g.Tick(1.0f); Check("T5.next_window", g.Tap()); // T6 후원 받기 (RVA 0x2436500) var g6 = NewGame(t, out _); g6.CoinDonate = 12.75m; var got = g6.ClaimDonate(1); Check("T6.claim", got == 12 && g6.Coin == 12 && g6.CoinDonate == 0.75m, $"{got} {g6.Coin} {g6.CoinDonate}"); g6.CoinDonate = 5m; Check("T6.claim_x2", g6.ClaimDonate(2) == 10 && g6.Coin == 22); // T7 방 아이템 구매 (RVA 0x2431148) var g7 = NewGame(t, out _); g7.Coin = 100; var f0 = g7.Follower; var r7 = g7.BuyRoomItem(0); Check("T7.buy", r7 == BuyResult.Ok && g7.Coin == 100 - t.RoomPrice[0][0][0] && g7.RoomLevel(0) == 0, r7.ToString()); Check("T7.follower", g7.Follower == f0 + t.RoomFollower[0][0][0]); Check("T7.dependency_locked", !g7.RoomUnlocked(5) && g7.BuyRoomItem(5) == BuyResult.Locked); g7.Coin = 0; Check("T7.nomoney", g7.BuyRoomItem(1) == BuyResult.NoMoney); // T8 방치 수입 1 초 단위 (RVA 0x240092C) var g8 = NewGame(t, out _); g8.Coin = 0; decimal pg = g8.PassGps(); for (int i = 0; i < 100; i++) g8.Tick(0.1f); decimal exp8 = decimal.Truncate(pg * 10m); Check("T8.idle", (decimal)g8.Coin >= exp8 - 1m && (decimal)g8.Coin <= exp8 + 1m, $"coin={g8.Coin} exp~{pg * 10m}"); // T9 하이프 (RVA 0x2405E94 · 0x2407440) var g9 = NewGame(t, out _); decimal pBefore = g9.PassGps(); for (int i = 0; i < 60 && !g9.HypePlaying; i++) { g9.Tap(); g9.Tick(0.2f); } Check("T9.hype_start", g9.HypePlaying && g9.AutoMultiplier == t.Cfg.remote.HypeMulti, $"hype={g9.Hype} playing={g9.HypePlaying}"); Check("T9.hype_x2", Near(g9.PassGps(), pBefore * (decimal)t.Cfg.remote.HypeMulti, 0.000001m)); for (int i = 0; i < 100; i++) g9.Tick(0.1f); Check("T9.hype_end", !g9.HypePlaying && g9.AutoMultiplier == 1f, $"hype={g9.Hype}"); // T10 랭크 (RVA 0x25E3450) var g10 = NewGame(t, out _); g10.Follower = t.RankFollower[1]; Check("T10.pending", g10.RankUpPending); var rw = g10.ApplyRankUp(); Check("T10.reward", rw == t.RankReward[1] && g10.S.rankIndex == 2 && g10.Coin == t.RankReward[1], rw.ToString()); g10.Follower = t.RankFollower[4]; var rw2 = g10.ApplyRankUp(); Check("T10.multi", rw2 == t.RankReward[2] + t.RankReward[3] + t.RankReward[4] && g10.S.rankIndex == 5); // T11 스킬 (RVA 0x241AE98 · 0x255D25C) var g11 = NewGame(t, out var c11); g11.Coin = 1000; Check("T11.buy", g11.BuySkill(0) == BuyResult.Ok && g11.SkillLevel(0) == 1 && g11.EffSkillIndex(0) == 0); Check("T11.rate", Math.Abs(g11.TotalSkillPassRate() - t.Skills.Items[0].Levels[0].PassRate) < 0.01f); // 조건 레벨: 해당 레벨 Condition > 현재 집이면 구매 불가 int condLv = t.Skills.Items[0].Levels.FindIndex(l => l.Condition > 0); var g11b = NewGame(t, out _); g11b.S.skills.Add(new SaveItem { Type = 0, Level = condLv }); Check("T11.condition", condLv > 0 && !g11b.CanBuySkill(0), $"condLv={condLv}"); // T12 업그레이드 타이머 → 유효 레벨 (RVA 0x255C658) var g12 = NewGame(t, out var c12); int timedLv = t.Rooms[0].Items[0].Levels.FindIndex(l => l.UpgradeTime > 0f); g12.S.rooms.Add(new SaveItem { Type = 0, Level = timedLv - 1 }); g12.Coin = BigInteger.Parse("1000000000"); var r12 = g12.BuyRoomItem(0); Check("T12.timer", r12 == BuyResult.Ok && g12.RoomUpgrading(0) && g12.EffRoomLevel(0) == timedLv - 1, $"lv={timedLv} r={r12}"); c12.Extra += t.Rooms[0].Items[0].Levels[timedLv].UpgradeTime + 1; Check("T12.timer_done", !g12.RoomUpgrading(0) && g12.EffRoomLevel(0) == timedLv); // T13 새 집 (RVA 0x255E530 · 0x251C780) var g13 = NewGame(t, out _); g13.S.rooms.Clear(); foreach (var it in t.Rooms[0].Items) g13.S.rooms.Add(new SaveItem { Type = it.Index, Level = it.Levels.Count - 1 }); Check("T13.allmax", g13.AllRoomItemsMaxed()); g13.RoomCompletionEconomy(0, out var inc, out var ps, out var tp); bool moved = g13.MoveToNextHouse(); Check("T13.move", moved && g13.S.house == 1 && g13.TotalCompletedIncome == inc && Math.Abs(g13.S.sumPassRoom - ps) < 0.01f && g13.S.rooms.Count == 1 && g13.S.rooms[0].Level == 0); Check("T13.carry", g13.TotalProductIncome() == inc + (decimal)g13.ItemProductIncome(1)); // T14 의상 레벨 배율 (RVA 0x255CA14) var g14 = NewGame(t, out _); Check("T14.stat0", g14.ClosetStatRate(0) == 10000); g14.S.closetLevel = 1; Check("T14.stat1", g14.ClosetStatRate(g14.ClosetEffLevel()) == t.Clothes.Levels[0].StatRate && g14.GlobalIncomeBoost(false) == (decimal)t.Clothes.Levels[0].StatRate / 10000m); } } }