using System; using System.Collections.Generic; using System.Numerics; // 원작 경제 재구현 — 근거 = _re/spec/core.md (RVA 표기). 수치는 Tables(SOT) 에서만 읽는다. namespace VS.Sim { public enum BuyResult { Ok, NoMoney, Locked, Max, Busy } public partial class Game { public readonly Tables T; public SaveData S; public Func Now; public double Time; // 진행 시간(초) — 탭 창·하이프·방치 수입용 (Time.unscaledTime 대응) public BigInteger Coin, Follower; public decimal CoinDonate, TotalCompletedIncome; public float AdsMultiplier = 1f, AutoMultiplier = 1f; decimal idleRemainder; double idleAcc; readonly Queue coinTaps = new Queue(), hypeTaps = new Queue(); readonly Random rng; public event Action Log; public event Action TapEarned; // OnTapEarnView(11) public event Action RoomItemUpgraded; // OnUpdateItemRoom(4) public event Action SkillUpgraded; // OnUpdateItemSkill(5) public event Action RankChanged, HouseChanged, HypeStarted, HypeEnded; RemoteCfg R => T.Cfg.remote; InputCfg In => T.Cfg.input; public Game(Tables t, SaveData s, Func now, int seed = 12345) { T = t; S = s ?? new SaveData(); Now = now ?? (() => DateTime.Now); rng = new Random(seed); Coin = Big.P(S.coin); Follower = Big.P(S.follower); CoinDonate = Big.D(S.coinDonate); TotalCompletedIncome = Big.D(S.totalCompletedIncome); if (S.firstPlay == 0) S.firstPlay = Now().Ticks; NewRoom(); InitSystems(); } public void Commit() { S.coin = Coin.ToString(); S.follower = Follower.ToString(); S.coinDonate = Big.S(CoinDonate); S.totalCompletedIncome = Big.S(TotalCompletedIncome); S.lastActive = Now().Ticks; CommitSystems(); } void Say(string m) => Log?.Invoke(m); long NowT => Now().Ticks; // ================= 방 아이템 ================= public ItemTable Room => T.Rooms[Math.Min(S.house, Tables.HouseCount - 1)]; public ItemRow RoomItem(int type) => Room.Items.Find(x => x.Index == type); SaveItem FindRoom(int type) => S.rooms.Find(r => r.Type == type); public int RoomLevel(int type) { var r = FindRoom(type); return r == null ? -1 : r.Level; } public bool RoomUpgrading(int type) { var r = FindRoom(type); return r != null && r.UpgradeEnd > NowT; } public double RoomUpgradeRemain(int type) { var r = FindRoom(type); return r == null ? 0 : Math.Max(0, (r.UpgradeEnd - NowT) / 1e7); } /// RVA 0x255C658 — 타이머 진행 중이면 한 단계 낮게 친다(미구매 = −1) public int EffRoomLevel(int type) { int lv = RoomLevel(type); if (RoomUpgrading(type)) lv = Math.Max(lv - 1, -1); return lv; } public LevelRow RoomLevelData(int type, int lv) { var it = RoomItem(type); if (it == null || lv < 0 || lv >= it.Levels.Count) return Tables.EmptyLevel; return it.Levels[lv]; } /// 새 방 첫 상태 — RVA 0x251CF00: 방 자체(아이템 Index 10) 를 House1 = 레벨 9 · 그 외 0 으로 추가 + 그 방 레벨 0 Follower 지급 void NewRoom() { if (S.rooms.Count > 0) return; S.rooms.Add(new SaveItem { Type = 10, Level = S.house == 0 ? 9 : 0, UpgradeEnd = 0 }); int row = T.ItemRowIndex(S.house, 10); if (row >= 0) Follower += T.RoomFollower[S.house][row][0]; } /// RVA 0x255E1B8 public bool RoomUnlocked(int type) { var it = RoomItem(type); if (it == null) return false; return it.IndexDependency == -1 || RoomLevel(it.IndexDependency) >= 0; } public bool RoomMaxed(int type) { var it = RoomItem(type); return it != null && RoomLevel(type) >= it.Levels.Count - 1; } public BigInteger RoomNextPrice(int type) { int row = T.ItemRowIndex(S.house, type); int next = RoomLevel(type) + 1; if (row < 0 || next >= T.RoomPrice[S.house][row].Length) return BigInteger.Zero; return DiscountedPrice(T.RoomPrice[S.house][row][next]); } /// ItemHouse._OnButtonBuyInternal RVA 0x2431148 (1단계 구매) public BuyResult BuyRoomItem(int type) { var it = RoomItem(type); if (it == null || !RoomUnlocked(type)) return BuyResult.Locked; int lv = RoomLevel(type); if (lv >= it.Levels.Count - 1) return BuyResult.Max; if (RoomUpgrading(type)) return BuyResult.Busy; // 원작은 진행 중에도 버튼 상태로 막음(UpdateInfo) — 클론 = 진행 중 구매 불가 var next = it.Levels[lv + 1]; int row = T.ItemRowIndex(S.house, type); BigInteger price = DiscountedPrice(T.RoomPrice[S.house][row][lv + 1]); if (price > Coin) return BuyResult.NoMoney; var r = FindRoom(type); if (r == null) { r = new SaveItem { Type = type, Level = -1 }; S.rooms.Add(r); } if (next.UpgradeTime > 0f) r.UpgradeEnd = Now().AddSeconds(next.UpgradeTime).Ticks; Follower += T.RoomFollower[S.house][row][lv + 1]; r.Level = lv + 1; SpendCoin(price); OnRoomBought(type, lv, r.Level); RoomItemUpgraded?.Invoke(type); return BuyResult.Ok; } /// 업그레이드 시간 건너뛰기(원작 = 광고/티켓 · 클론 = 시험 지급) public void SkipRoomUpgrade(int type) { var r = FindRoom(type); if (r != null) r.UpgradeEnd = 0; } // ================= 경제 공식 ================= /// RVA 0x255C74C public float ItemProductIncome(int n) => R.BaseIncome + R.GrowthIncome * (n - 1) * (float)Math.Pow(R.KeyLevelMulti, n / 25f); public decimal CurrentRoomProductIncome() { decimal s = 0m; foreach (var r in S.rooms) s += (decimal)ItemProductIncome(EffRoomLevel(r.Type) + 1); return s; } public decimal TotalProductIncome() => TotalCompletedIncome + CurrentRoomProductIncome(); public float TotalSkillPassRate() { float s = 0f; foreach (var it in T.Skills.Items) { int e = EffSkillIndex(it.Index); if (e >= 0 && e < it.Levels.Count) s += it.Levels[e].PassRate; } return s; } public float TotalSkillTapRate() { float s = 0f; foreach (var it in T.Skills.Items) { int e = EffSkillIndex(it.Index); if (e >= 0 && e < it.Levels.Count) s += it.Levels[e].TapRate; } return s; } /// RVA 0x255B5A4 public float TotalPassRate() { float s = 0f; foreach (var r in S.rooms) s += RoomLevelData(r.Type, EffRoomLevel(r.Type)).PassRate; return (s + S.sumPassRoom + TotalSkillPassRate()) / 10000f + 1f; } /// RVA 0x255BE70 public float TotalTapRate() { float s = 0f; foreach (var r in S.rooms) s += RoomLevelData(r.Type, EffRoomLevel(r.Type)).TapRate; return (s + S.sumTapRoom + TotalSkillTapRate()) / 10000f + 1f; } /// RVA 0x255B7CC public decimal GlobalIncomeBoost(bool includeTemp) { decimal b = 1m; if (includeTemp) b = 1m * ((decimal)AdsMultiplier * (decimal)AutoMultiplier); var rk = CurrentRank(); if (rk != null) b *= rk.RankBoostRate; b *= (decimal)ClosetStatRate(ClosetEffLevel()) / 10000m; return b; } static decimal ClampGps(decimal v) => v < 0.0001m ? 0.0001m : v; /// RVA 0x255B344 public decimal PassGps() => ClampGps(TotalProductIncome() * (decimal)TotalPassRate() * GlobalIncomeBoost(true) * GiftPassiveMultiplier()); /// RVA 0x255BC8C public decimal TapGps() => ClampGps(TotalProductIncome() * (decimal)TotalTapRate() * (decimal)R.TapMultiplier * GlobalIncomeBoost(true) * GiftTapMultiplier()); public decimal PassGpsClean() => ClampGps(TotalProductIncome() * (decimal)TotalPassRate() * GlobalIncomeBoost(false)); public decimal TapGpsClean() => ClampGps(TotalProductIncome() * (decimal)TotalTapRate() * GlobalIncomeBoost(false)); /// RVA 0x255CC30 — 가격 인하 선물 효과 중이면 (price × round(f×100) + 50) / 100 public BigInteger DiscountedPrice(BigInteger price) { if (!IsGiftEffectActive(GiftTypes.ReducePrice)) return price; decimal f = ReducePriceFactor; if (f >= 1m) return price; var pct = new BigInteger(decimal.Round(f * 100m, 0, MidpointRounding.AwayFromZero)); return (price * pct + 50) / 100; } // ================= 스킬 ================= SaveItem FindSkill(int type) => S.skills.Find(r => r.Type == type); public ItemRow SkillItem(int type) => T.Skills.Items.Find(x => x.Index == type); public int SkillLevel(int type) { var r = FindSkill(type); return r == null ? 0 : r.Level; } public bool SkillUpgrading(int type) { var r = FindSkill(type); return r != null && r.UpgradeEnd > NowT; } public double SkillUpgradeRemain(int type) { var r = FindSkill(type); return r == null ? 0 : Math.Max(0, (r.UpgradeEnd - NowT) / 1e7); } /// RVA 0x255D25C — 레벨 = 구매 횟수 · 데이터 인덱스 = 레벨−1 · 진행 중이면 한 단계 낮게 public int EffSkillIndex(int type) { int lv = SkillLevel(type); if (SkillUpgrading(type)) lv = Math.Max(lv - 1, 0); return lv >= 1 ? lv - 1 : -1; } /// RVA 0x255DB98 + 0x255DCCC public bool CanBuySkill(int type) { var it = SkillItem(type); if (it == null || it.Levels.Count == 0) return false; int lv = SkillLevel(type); if (lv < 0 || lv >= it.Levels.Count) return false; var d = it.Levels[lv]; return d.Condition < 1 || d.Condition <= S.house; } public bool SkillMaxed(int type) { var it = SkillItem(type); return it != null && SkillLevel(type) >= it.Levels.Count; } public BigInteger SkillNextPrice(int type) { int row = T.SkillRowIndex(type); int lv = SkillLevel(type); if (row < 0 || lv >= T.SkillPrice[row].Length) return BigInteger.Zero; return DiscountedPrice(T.SkillPrice[row][lv]); } /// ItemSkill._OnButtonBuyInternal RVA 0x241AE98 (1단계) public BuyResult BuySkill(int type) { var it = SkillItem(type); if (it == null) return BuyResult.Locked; if (SkillMaxed(type)) return BuyResult.Max; if (!CanBuySkill(type)) return BuyResult.Locked; if (SkillUpgrading(type)) return BuyResult.Busy; int row = T.SkillRowIndex(type); int lv = SkillLevel(type); var d = it.Levels[lv]; BigInteger price = DiscountedPrice(T.SkillPrice[row][lv]); if (price > Coin) return BuyResult.NoMoney; BigInteger gain = T.SkillFollower[row][lv]; BigInteger pending = BigInteger.Zero; var r = FindSkill(type); if (r == null) { r = new SaveItem { Type = type, Level = 0 }; S.skills.Add(r); } if (d.UpgradeTime > 0f) { pending = gain; r.UpgradeEnd = Now().AddSeconds(d.UpgradeTime).Ticks; r.Pending = (Big.P(r.Pending) + pending).ToString(); } var immediate = gain - pending; if (immediate > 0) Follower += immediate; r.Level = lv + 1; SpendCoin(price); OnSkillBought(type, r.Level); SkillUpgraded?.Invoke(type); return BuyResult.Ok; } public void SkipSkillUpgrade(int type) { var r = FindSkill(type); if (r != null) r.UpgradeEnd = 0; } /// 타이머가 끝난 스킬의 보류 팔로워 지급 (RVA 0x251E848) void GrantFinishedSkills() { long now = NowT; foreach (var r in S.skills) { if (r.UpgradeEnd > now) continue; var p = Big.P(r.Pending); if (p > 0) { Follower += p; r.Pending = "0"; } } } // ================= 랭크 ================= public RankRow CurrentRank() { int i = T.RankRowIndex(S.rankIndex); if (i >= 0) return T.Ranks.Items[i]; return T.Ranks.Items.Count > 0 ? T.Ranks.Items[Math.Max(0, Math.Min(T.Ranks.Items.Count - 1, S.rankIndex - 1))] : null; } /// GetIndexRankFollower RVA 0x255F7F4 — 팔로워가 넘은 가장 높은 랭크 Index public int RankIndexFor(BigInteger follower) { int best = T.Ranks.Items.Count > 0 ? T.Ranks.Items[0].Index : 1; for (int k = 0; k < T.Ranks.Items.Count; k++) if (follower >= T.RankFollower[k] && T.Ranks.Items[k].Index > best) best = T.Ranks.Items[k].Index; return best; } public bool RankUpPending => RankIndexFor(Follower) > S.rankIndex; public RankRow NextRank() { int i = T.RankRowIndex(S.rankIndex + 1); return i >= 0 ? T.Ranks.Items[i] : null; } /// RankUpPopup.ApplyRankUp RVA 0x25E3450 — 사이 랭크 보상 합산 → 코인 지급 · 반환 = 지급액 public BigInteger ApplyRankUp() { int target = RankIndexFor(Follower); if (target <= S.rankIndex) return BigInteger.Zero; BigInteger total = BigInteger.Zero; for (int idx = S.rankIndex + 1; idx <= target; idx++) { int k = T.RankRowIndex(idx); if (k < 0) continue; total += T.RankReward[k]; S.rankIndex = idx; } if (total > 0) AddCoin(total); RankChanged?.Invoke(); return total; } // ================= 의상(Closet) 레벨 ================= public bool ClosetUpgrading => S.closetUpgradeEnd > NowT; public double ClosetUpgradeRemain => Math.Max(0, (S.closetUpgradeEnd - NowT) / 1e7); /// ClosetService.GetEffectiveLevel RVA 0x23EAC70 public int ClosetEffLevel() { int lv = S.closetLevel; if (lv > 0 && ClosetUpgrading) lv -= 1; return lv; } public int ClosetMaxLevel => T.Clothes.Levels.Count; /// RVA 0x255CA14 public int ClosetStatRate(int lv) { int max = ClosetMaxLevel; if (lv > 0 && max > 0) { lv = Math.Min(lv, max); int i = T.ClothesRowIndex(lv); if (i >= 0) return T.Clothes.Levels[i].StatRate > 0 ? T.Clothes.Levels[i].StatRate : 10000; } return 10000; } // ================= 탭 · 하이프 · 후원 ================= public float Hype; // 0..1 public bool HypePlaying; int hypeTapCount; double lastHypeTapTime, autoHypeEnd; bool hasHypeTap, resettingHype; double autoHypeNext; bool RegisterTap(Queue q, int max) { double win = Math.Max(0.01, In._tapTimeWindow); while (q.Count > 0 && Time - q.Peek() >= win) q.Dequeue(); if (q.Count < max) { q.Enqueue(Time); return true; } return false; } /// TryProcessAcceptedTap RVA 0x240588C — 반환 = 코인 탭 수용 여부 public bool Tap(bool canIncreaseHype = true, bool bypassRateLimit = false) { bool coin = bypassRateLimit || RegisterTap(coinTaps, Math.Max(1, R.MaxTapCoinCount)); if (coin) { decimal amt = TapGps(); decimal rounded = AddCoinDonateFromTap(amt); if (rounded > 0m) TapEarned?.Invoke(rounded); OnTapCounted(); } if (canIncreaseHype && (bypassRateLimit || RegisterTap(hypeTaps, Math.Max(1, R.MaxTapHypeCount)))) HandleHypeTap(); return coin; } /// RVA 0x23FECD0 — 탭 수익은 coinDonate 로 쌓인다(받기 전에는 코인 아님) public decimal AddCoinDonateFromTap(decimal amount) { decimal rounded = amount > 0m ? decimal.Ceiling(amount * 100m) / 100m : 0m; if (rounded > 0m) CoinDonate += rounded; return rounded; } public BigInteger DonateClaimable => CoinDonate > 0m ? Big.FloorBig(CoinDonate) : BigInteger.Zero; /// ClaimDonatePopup.ClaimDonate RVA 0x2436500 public BigInteger ClaimDonate(int multiplier = 1) { var amount = DonateClaimable; if (amount <= 0) return BigInteger.Zero; var got = amount * Math.Max(1, multiplier); AddCoin(got); CoinDonate = Math.Max(0m, CoinDonate - (decimal)amount); OnDonateClaimed(got); return got; } /// HandleHypeTap RVA 0x2405E94 void HandleHypeTap() { hypeTapCount = resettingHype ? 1 : hypeTapCount + 1; resettingHype = false; hasHypeTap = true; lastHypeTapTime = Time; if (hypeTapCount >= Math.Max(1, In._hypeTapPerPercentage)) { hypeTapCount = 0; SetHype(Hype + 0.02f); } } void SetHype(float v) { float prev = Hype; float c = v < 0f ? 0f : (v > 1f ? 1f : v); if (c > prev && c >= 0.9999f) c = 1f; Hype = c; if (prev < 1f && Hype >= 1f) StartHype(); } /// StartHypeState RVA 0x2407440 + SetAutoHypeActive RVA 0x23FF884 void StartHype() { hasHypeTap = false; resettingHype = false; hypeTapCount = 0; HypePlaying = true; AutoMultiplier = Math.Max(0f, R.HypeMulti); autoHypeEnd = Time + T.Cfg.idol._hypeAnimDuration; // TimeResetAutoHype = WaitForSeconds(_hypeAnimDuration) double interval = Math.Max(0.01, In._tapTimeWindow) / Math.Max(1, R.MaxTapCoinCount); autoHypeNext = Time + interval * 0.5; OnHypeCount(); HypeStarted?.Invoke(); } void EndHype() { Hype = 0f; HypePlaying = false; hasHypeTap = false; resettingHype = false; hypeTapCount = 0; AutoMultiplier = 1f; HypeEnded?.Invoke(); } float HypeResetDuration => Math.Max(0.01f, T.Cfg.idol._hypeAnimDuration); /// UpdateHypeState RVA 0x2404B70 · DrainHypeValue RVA 0x24072E8 · PlayAutoHypeTap MoveNext RVA 0x2407CC4 void UpdateHype(float dt) { if (HypePlaying) { if (Time >= autoHypeEnd && AutoMultiplier != 1f) AutoMultiplier = 1f; if (Time >= autoHypeNext) { Tap(false, false); autoHypeNext = Time + Math.Max(0.01, In._tapTimeWindow) / Math.Max(1, R.MaxTapCoinCount); } if (Hype > 0f) { Hype = Math.Max(0f, Hype - dt / HypeResetDuration); if (Hype > 0f) return; } EndHype(); return; } if (!hasHypeTap) return; if (!resettingHype) { if (Time - lastHypeTapTime < In._timeStartResetHypeState) return; resettingHype = true; hypeTapCount = 0; } if (Hype > 0f) { Hype = Math.Max(0f, Hype - dt / HypeResetDuration); if (Hype > 0f) return; } hasHypeTap = false; resettingHype = false; hypeTapCount = 0; Hype = 0f; } // ================= 진행 ================= public void Tick(float dt) { if (dt <= 0f) return; Time += dt; UpdateHype(dt); idleAcc += dt; while (idleAcc >= 1.0) { idleAcc -= 1.0; IdleIncomeStep(); } GrantFinishedSkills(); TickSystems(dt); } /// GenerateIdleIncome MoveNext RVA 0x240092C — 1 초마다 PassGps 누적, 정수 부분만 코인 void IdleIncomeStep() { decimal g = PassGps(); if (g <= 0m) return; idleRemainder += g; decimal whole = decimal.Truncate(idleRemainder); idleRemainder -= whole; if (whole > 0m) AddCoin(new BigInteger(whole)); } // ================= 새 집 ================= /// TryCalculateRoomCompletionEconomy RVA 0x255E530 — 방 각 아이템 최대 레벨 기준 public bool RoomCompletionEconomy(int house, out decimal income, out float pass, out float tap) { income = 0m; pass = 0f; tap = 0f; if (house < 0 || house >= Tables.HouseCount) return false; foreach (var it in T.Rooms[house].Items) { if (it.Levels == null || it.Levels.Count == 0) continue; LevelRow best = null; foreach (var l in it.Levels) if (best == null || l.Level > best.Level) best = l; income += (decimal)ItemProductIncome(best.Level + 1); pass += best.PassRate; tap += best.TapRate; } return true; } public bool AllRoomItemsMaxed() { foreach (var it in Room.Items) if (!RoomMaxed(it.Index) || RoomUpgrading(it.Index)) return false; return true; } public bool HasNextHouse => S.house + 1 < Tables.HouseCount; /// CompleteRoomTransition RVA 0x251C780 public bool MoveToNextHouse() { if (!HasNextHouse) return false; if (!RoomCompletionEconomy(S.house, out var inc, out var pass, out var tap)) return false; TotalCompletedIncome += inc; S.sumPassRoom += pass; S.sumTapRoom += tap; S.house += 1; S.rooms = new List(); NewRoom(); HouseChanged?.Invoke(); return true; } } }