VSClone/Assets/Scripts/Sim/Game.cs

543 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<DateTime> 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<double> coinTaps = new Queue<double>(), hypeTaps = new Queue<double>();
readonly Random rng;
public event Action<string> Log;
public event Action<decimal> TapEarned; // OnTapEarnView(11)
public event Action<int> RoomItemUpgraded; // OnUpdateItemRoom(4)
public event Action<int> 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<DateTime> 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<double> 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<SaveItem>();
NewRoom();
HouseChanged?.Invoke();
return true;
}
}
}