VSClone/Assets/Scripts/Sim/Game.cs

543 lines
22 KiB
C#
Raw Permalink Normal View History

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;
}
}
}