using System; using System.Numerics; using UnityEngine; using VS.Sim; namespace VS.EditorTools { // 부가 시스템 검사 — 근거 = _re/spec/systems.md public static partial class HeadlessRun { static void SystemTests(Tables t) { var R = t.Cfg.remote; // S1 x2 부스터 (§4) var g = NewGame(t, out var c1); decimal p0 = g.PassGps(); g.ActivateX2Boost(); Check("S1.x2", g.AdsMultiplier == 2f && Near(g.PassGps(), p0 * 2m, 0.000001m)); g.ActivateX2Boost(); Check("S1.stack", Math.Abs(g.AdsBoostRemain - R.X2CoinRewardTime * 2) < 0.01f); for (int i = 0; i < 37; i++) g.Tick(100f); Check("S1.expire", g.AdsMultiplier == 1f && g.AdsBoostRemain == 0f); // S2 자동 탭: 코인 탭 1/s · 하이프 탭 5/s → 약 10 s 에 하이프 var g2 = NewGame(t, out _); g2.ActivateAutoClick(); double hypeAt = -1; for (int i = 0; i < 300 && hypeAt < 0; i++) { g2.Tick(0.05f); if (g2.HypePlaying) hypeAt = g2.Time; } Check("S2.auto_hype", hypeAt > 8 && hypeAt < 12, $"t={hypeAt:0.00}"); Check("S2.donate_rate", g2.CoinDonate > 0m); // S3 선물 배율 (§1.2) var g3 = NewGame(t, out _); decimal tap0 = g3.TapGps(), pass0 = g3.PassGps(); g3.Gifts[GiftTypes.X4TapIncome] = new Game.GiftState { Type = GiftTypes.X4TapIncome, Effect = 10f }; Check("S3.x4tap", Near(g3.TapGps(), tap0 * 4m, 0.000001m) && Near(g3.PassGps(), pass0, 0.000001m)); g3.Gifts[GiftTypes.X10Income] = new Game.GiftState { Type = GiftTypes.X10Income, Effect = 10f }; Check("S3.x10", Near(g3.TapGps(), tap0 * 40m, 0.00001m) && Near(g3.PassGps(), pass0 * 10m, 0.00001m)); g3.Gifts[GiftTypes.ReducePrice] = new Game.GiftState { Type = GiftTypes.ReducePrice, Effect = 10f }; var price = t.RoomPrice[0][0][30]; Check("S3.reduce", g3.DiscountedPrice(price) == (price * 85 + 50) / 100, g3.DiscountedPrice(price).ToString()); // S4 돈 꾸러미 (GetMoneyPackReward) var g4 = NewGame(t, out _); var small = new BigInteger(decimal.Round(g4.PassGpsClean() * (decimal)R.SmallMoneyMultiplierX + g4.TapGpsClean() * (decimal)R.SmallMoneyMultiplierY, 0, MidpointRounding.AwayFromZero)); Check("S4.small", g4.MoneyPackReward(GiftTypes.SmallMoneyPack) == BigInteger.Max(1, small)); var big = new BigInteger(decimal.Round(g4.PassGpsClean() * (decimal)R.BigMoneyMultiplierX + g4.TapGpsClean() * (decimal)R.BigMoneyMultiplierY, 0, MidpointRounding.AwayFromZero)); Check("S4.big", g4.MoneyPackReward(GiftTypes.BigMoneyPack) == BigInteger.Max(1, big)); // S5 선물 채우기 — 1일차 첫 선물 = Day1Types[0](X10) · 무료 선물 팝업 · 3일차 무작위 + 방치 30 s 사라짐 var g5 = NewGame(t, out var c5); for (int i = 0; i < (int)(R.GiftUnlockTime + 2); i++) g5.Tick(1f); Check("S5.day1_first", Array.IndexOf(g5.GiftSlot, t.X.giftDay1Types[0]) >= 0 && g5.PendingFreeGiftPopup, string.Join(",", g5.GiftSlot)); int before = g5.S.ticket; g5.ClaimFreeGiftPopup(); Check("S5.free_ticket", g5.S.ticket == before + R.FreeGiftTicketAmount); var s5 = new SaveData { giftPlayDayCount = 2, giftLastPlayDate = c5.Now().AddDays(-1).ToString("yyyy-MM-dd"), giftUnlockedLifetime = true }; var g5b = NewGame(t, out _, s5); Check("S5.playday3", g5b.PlayDay == 3); for (int i = 0; i < 20; i++) g5b.Tick(1f); int filled = 0; foreach (var x in g5b.GiftSlot) if (x >= 0) filled++; Check("S5.day3_fill", filled >= 2, $"filled={filled}"); // 수령: 작은 돈 → 쿨다운 · 슬롯 비움 int sm = Array.IndexOf(g5b.GiftSlot, GiftTypes.SmallMoneyPack); if (sm < 0) { g5b.GiftSlot[0] = GiftTypes.SmallMoneyPack; } var coinB = g5b.Coin; var rw = g5b.ClaimGift(GiftTypes.SmallMoneyPack, true); Check("S5.claim_small_x2", rw == g5b.MoneyPackReward(GiftTypes.SmallMoneyPack) * 2 && g5b.Coin == coinB + rw && Array.IndexOf(g5b.GiftSlot, GiftTypes.SmallMoneyPack) < 0); // S6 방치 보상 (§3.2) var s6 = new SaveData(); var g6 = NewGame(t, out var c6, s6); decimal gps = g6.PassGps(); g6.S.timeAfk = c6.Now().AddSeconds(-3600).Ticks; g6.OfflineCheck(); var exp6 = (new BigInteger(3600) * new BigInteger(decimal.Ceiling(gps * 100m)) * (int)Math.Round(R.AFKRate * 100.0) / 100) / 100; Check("S6.afk", g6.AfkPending == exp6 && Math.Abs(g6.AfkSeconds - 3600) < 1, $"{g6.AfkPending} vs {exp6}"); Check("S6.claim_x5", g6.ClaimAfk(5) == exp6 * 5 && g6.AfkPending == 0); g6.S.timeAfk = c6.Now().AddSeconds(-(R.AFKTimeMin - 10)).Ticks; g6.OfflineCheck(); Check("S6.min", g6.AfkPending == 0); // 방치 선물 = 최소 1시간 이상 지났을 때만 (2026-10-28 PD 지시 · config AFKTimeMin) g6.S.timeAfk = c6.Now().AddMinutes(-59).Ticks; g6.OfflineCheck(); bool none59 = g6.AfkPending == 0; g6.S.timeAfk = c6.Now().AddMinutes(-60).Ticks; g6.OfflineCheck(); Check("S6.min_1h", R.AFKTimeMin >= 3600 && none59 && g6.AfkPending > 0, $"min={R.AFKTimeMin}s"); g6.ClaimAfk(1); g6.S.timeAfk = c6.Now().AddDays(-3).Ticks; g6.OfflineCheck(); Check("S6.max", Math.Abs(g6.AfkSeconds - R.AFKTimeMax) < 1); // S7 퀘스트 (§6) — 표 위에서부터 3개 · 1 = 코인 3 획득 · 2 = 코인 6 사용 · 3 = 후원 1회 var g7 = NewGame(t, out _); var act = g7.ActiveQuests(); Check("S7.slots", act.Count == 3 && act[0].id == 1 && act[1].id == 2 && act[2].id == 3, string.Join(",", act.ConvertAll(q => q.id))); g7.AddCoin(3); Check("S7.increase", act[0].state == 2); decimal snap = Big.D(act[0].gps); var c7 = g7.Coin; g7.ClaimQuest(1); var r1 = g7.T.Quests.items.Find(r => r.id == 1); Check("S7.reward", g7.Coin - c7 == new BigInteger(decimal.Ceiling(Big.D(r1.rewardNum) * snap)) && g7.ActiveQuests().Count == 3); g7.SpendCoin(6); Check("S7.consume", g7.S.quests.Find(q => q.id == 2).state == 2); g7.CoinDonate = 2m; g7.ClaimDonate(1); Check("S7.donate", g7.S.quests.Find(q => q.id == 3).state == 2); // S8 일일 보상 (§7.1) var g8 = NewGame(t, out var c8); int tk = g8.S.ticket; var d1 = g8.ClaimDaily(); Check("S8.day1", d1 != null && g8.S.ticket == tk + t.X.dailyRewards[0].amount && !g8.DailyClaimable); c8.Extra += 86400; g8.ClaimDaily(); c8.Extra += 86400; var d3 = g8.ClaimDaily(); Check("S8.day3_outfit", d3 != null && d3.kind == 1 && g8.OutfitUnlocked(d3.amount)); // S9 온라인 티켓 (§7.2) — 600 s 도달 시 정지 · 수령 후 재개 var g9 = NewGame(t, out _); for (int i = 0; i < 700; i++) g9.Tick(1f); Check("S9.reach", g9.OnlineClaimable && Math.Abs(g9.S.onlineSeconds - 600) < 1.5, g9.S.onlineSeconds.ToString("0.0")); int tk9 = g9.S.ticket; g9.ClaimOnline(); Check("S9.claim", g9.S.ticket == tk9 + 1 && !g9.OnlineClaimable); // S10 새 집 (§10.1) — 가격 = 남은 만렙 비용 · 필요 랭크 = 다음 방 아이템 10 IndexDependency var g10 = NewGame(t, out _); BigInteger sum = 0; foreach (var it in t.Rooms[0].Items) { int row = t.ItemRowIndex(0, it.Index); for (int lv = g10.RoomLevel(it.Index) + 1; lv < it.Levels.Count; lv++) sum += t.RoomPrice[0][row][lv]; } Check("S10.price", g10.NextHousePrice() == sum); g10.Coin = sum; Check("S10.rank_lock", !g10.CanBuyNextHouse() && g10.NextHouseRankReq() == t.Rooms[1].Items.Find(x => x.Index == 10).IndexDependency); g10.S.rankIndex = g10.NextHouseRankReq(); Check("S10.buy", g10.BuyNextHouse() && g10.S.house == 1 && g10.Coin == 0); // S11 의상 (§5) var g11 = NewGame(t, out _); Check("S11.first_10pct", g11.ClosetNextPrice() == t.ClothesPrice[0] * t.X.closetFirstPurchasePercent / 100, g11.ClosetNextPrice().ToString()); g11.Coin = 1000000; g11.UpgradeCloset(); Check("S11.free_unlock", g11.S.closetLevel == 1 && g11.OutfitUnlocked(0) && !g11.OutfitUnlocked(1)); var tix = t.Cfg.closet.outfits.Find(o => o.UnlockType == ClosetUnlock.Ticket); g11.S.ticket = 100; Check("S11.ticket_rank", tix.VisibilityRank <= 0 || g11.UnlockTicketOutfit(tix.Type) == BuyResult.Locked); g11.S.rankIndex = Math.Max(1, tix.VisibilityRank); Check("S11.ticket_buy", g11.UnlockTicketOutfit(tix.Type) == BuyResult.Ok && g11.S.ticket == 100 - Math.Max(1, tix.TicketPrice)); // S12 스토리 (§9) var g12 = NewGame(t, out _); var ch = g12.NextStory(); Check("S12.start", ch != null && ch.id == "1-1"); g12.MarkStorySeen("1-1"); Check("S12.wait", g12.NextStory() == null); g12.S.rooms.Add(new SaveItem { Type = 0, Level = 24 }); var ch2 = g12.NextStory(); Check("S12.item_trigger", ch2 != null && ch2.id == "1-2", ch2?.id); // S13 저장 왕복 var g13 = NewGame(t, out var c13); g13.Coin = BigInteger.Parse("123456789012345"); g13.Follower = 777; g13.CoinDonate = 12.34m; g13.S.closetLevel = 3; g13.ActivateX2Boost(); g13.Tick(1f); g13.Commit(); var json = JsonUtility.ToJson(g13.S); var g13b = new Game(t, JsonUtility.FromJson(json), () => c13.Now()); Check("S13.roundtrip", g13b.Coin == g13.Coin && g13b.Follower == g13.Follower && g13b.CoinDonate == g13.CoinDonate && g13b.S.closetLevel == 3 && g13b.AdsMultiplier == 2f && Math.Abs(g13b.AdsBoostRemain - g13.AdsBoostRemain) < 0.01f); } } }