VSClone/Assets/Scripts/Sim/Game.Systems.cs

620 lines
28 KiB
C#
Raw Permalink Normal View History

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
// 부가 시스템 — 근거 = _re/spec/systems.md (절 번호 표기) · 값 = config.json(RemoteConfig·GiftDataSO) + config_extra.json
namespace VS.Sim
{
public static class GiftTypes
{
public const int NoAds = 0, SmallMoneyPack = 1, BigMoneyPack = 2, ReducePrice = 3, X4TapIncome = 4, X4PassiveIncome = 5, X10Income = 6;
public static bool IsInstant(int t) => t == SmallMoneyPack || t == BigMoneyPack;
}
public static class QuestCond
{
public const int ConsumeCoin = 1, IncreaseCoin = 2, ConsumeCoinDynamic = 3, IncreaseCoinDynamic = 4, UnlockRoomItem = 5, UpgradeRoomItem = 6, UnlockHouse = 7,
ReachFollower = 8, ClaimDonate = 9, ClaimGiftMoney = 10, ClaimDonateOrGift = 11, ActivateX2Boost = 12, ActivateAutoClick = 13, HypeCount = 14, ClaimGiftBuff = 15;
}
public partial class Game
{
float sessionPlay, questTimer;
string Today => Now().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
ExtraCfg X => T.X;
GiftCfg GC => T.Cfg.gift;
void InitSystems()
{
InitStory();
RefreshPlayDay();
RestoreBoosters();
RestoreGifts();
FillQuests();
OnlineDayCheck();
}
void CommitSystems()
{
S.adsBoostRemain = AdsBoostRemain; S.autoClickRemain = AutoClickRemain;
SaveGifts();
}
void TickSystems(float dt)
{
sessionPlay += dt; S.playSeconds += dt;
TickBoosters(dt);
TickGifts(dt);
TickOnline(dt);
questTimer -= dt;
if (questTimer <= 0f) { questTimer = 0.5f; RefreshQuestThresholds(); }
}
// ================= 코인 출입 (퀘스트 1~4) =================
public void AddCoin(BigInteger v) { if (v <= 0) return; Coin += v; QuestCoin(v); }
public void SpendCoin(BigInteger v) { if (v <= 0) return; Coin -= v; QuestCoin(-v); }
// ================= 부스터 (§4) =================
public float AdsBoostRemain, AutoClickRemain;
double autoClickNext;
public bool AutoClickActive => AutoClickRemain > 0f;
double AcceptedTapInterval
{
get { double w = Math.Max(0.01, In._tapTimeWindow); return Math.Min(w / Math.Max(1, R.MaxTapCoinCount), w / Math.Max(1, R.MaxTapHypeCount)); }
}
void RestoreBoosters()
{
AdsBoostRemain = Math.Max(0f, S.adsBoostRemain); AutoClickRemain = Math.Max(0f, S.autoClickRemain);
if (AdsBoostRemain > 0f) AdsMultiplier = X.x2BoostMultiplier;
}
/// StartAdsBoostForDuration RVA 0x23FF180 — 남은 시간 + X2CoinRewardTime 누적 · AdsMultiplier 2
public void ActivateX2Boost()
{
AdsBoostRemain += R.X2CoinRewardTime;
AdsMultiplier = X.x2BoostMultiplier;
QuestEvent(QuestCond.ActivateX2Boost, 0);
}
/// StartAutoClickForDuration RVA 0x23FF400
public void ActivateAutoClick()
{
if (AutoClickRemain <= 0f) autoClickNext = Time + AcceptedTapInterval * 0.5;
AutoClickRemain += R.AutoTapRewardTime;
QuestEvent(QuestCond.ActivateAutoClick, 0);
}
/// PlayAutoClick MoveNext RVA 0x2407AD8 — 0.2 s 마다 자동 탭(하이프 연출 중 제외)
void TickBoosters(float dt)
{
if (AdsBoostRemain > 0f)
{
AdsBoostRemain -= dt;
if (AdsBoostRemain <= 0f) { AdsBoostRemain = 0f; AdsMultiplier = 1f; }
}
if (AutoClickRemain > 0f)
{
AutoClickRemain -= dt;
if (!HypePlaying && Time >= autoClickNext) { Tap(true, false); autoClickNext = Time + AcceptedTapInterval; }
if (AutoClickRemain <= 0f) AutoClickRemain = 0f;
}
}
// ================= 선물 (§1) =================
public class GiftState { public int Type; public float Effect; public long CooldownEnd; public int Rv; public float DismissHold; }
public readonly Dictionary<int, GiftState> Gifts = new Dictionary<int, GiftState>();
public int[] GiftSlot;
float[] slotIdle;
readonly List<int> giftQueue = new List<int>();
float fillTimer, rotateTimer;
bool introDone; int introIndex; bool firstFill = true;
public bool PendingFreeGiftPopup;
public event Action GiftsChanged;
public int PlayDay => Math.Max(1, S.giftPlayDayCount);
/// RefreshGiftPlayDay RVA 0x251FAE8 — 서로 다른 날짜 수
void RefreshPlayDay()
{
if (S.giftLastPlayDate != Today) { S.giftPlayDayCount += 1; S.giftLastPlayDate = Today; }
}
public bool GiftSystemOn => R.OnOffGift;
/// IsGiftFeatureUnlocked RVA 0x24CCB70 (튜토리얼 미구현 → 세션 플레이 시간 기준)
public bool GiftFeatureUnlocked
{
get
{
if (S.giftUnlockedLifetime) return true;
if (sessionPlay >= R.GiftUnlockTime) { S.giftUnlockedLifetime = true; return true; }
return false;
}
}
GiftState GS(int t) { if (!Gifts.TryGetValue(t, out var g)) { g = new GiftState { Type = t }; Gifts[t] = g; } return g; }
public float GiftEffectDuration(int t) => t switch
{
GiftTypes.NoAds => R.NoADsGiftTime, GiftTypes.ReducePrice => R.SaleOffGiftTime, GiftTypes.X4TapIncome => R.X4TapIncomeGiftTime,
GiftTypes.X4PassiveIncome => R.X4PassiveIncomeGiftTime, GiftTypes.X10Income => R.X10IncomeTime, _ => 0f
};
/// GetCooldownSeconds RVA 0x24CABE8
public float GiftCooldown(int t) => t switch
{
GiftTypes.SmallMoneyPack => R.SmallMoneyGiftCooldown, GiftTypes.BigMoneyPack => R.BigMoneyGiftCooldown,
GiftTypes.X10Income => R.X10IncomeGiftCooldown, _ => R.GiftCooldownNormal
};
float RefillDelay => PlayDay == 2 ? GC.day2RefillDelaySeconds : R.GiftAppearTime;
float IdleTimeout => R.GiftDisappearTime > 0 ? R.GiftDisappearTime : GC.idleTimeoutSeconds;
float AppearDelay => Math.Abs(R.GiftAppearTime - R.GiftDisappearTime) < 0.0001f ? GC.refillDelaySeconds : R.GiftAppearTime;
float DismissHoldSeconds => PlayDay <= 2 ? GC.dismissHoldDay1And2Seconds : GC.dismissHoldDay3PlusSeconds;
/// IsSpawnAllowed RVA 0x24CEF14
bool SpawnAllowed(int t)
{
if (PlayDay == 1) return Array.IndexOf(X.giftDay1Types, t) >= 0;
if (PlayDay == 2) return Array.IndexOf(X.giftDay2Types, t) >= 0;
return true;
}
/// IsAvailableForFill RVA 0x24CDE3C
bool AvailableForFill(int t)
{
var g = GS(t); var def = T.Gift(t);
return SpawnAllowed(t) && (def == null || def.UnlockRankIndex <= S.rankIndex) && Array.IndexOf(GiftSlot, t) < 0
&& g.DismissHold <= 0f && g.CooldownEnd <= NowT && g.Effect <= 0f;
}
public bool IsGiftEffectActive(int type) => Gifts.TryGetValue(type, out var g) && g.Effect > 0f;
public float GiftEffectRemain(int type) => Gifts.TryGetValue(type, out var g) ? g.Effect : 0f;
/// GetTapIncomeMultiplier RVA 0x24D1E30
public decimal GiftTapMultiplier() => !GiftSystemOn ? 1m : (IsGiftEffectActive(GiftTypes.X4TapIncome) ? 4m : 1m) * (IsGiftEffectActive(GiftTypes.X10Income) ? 10m : 1m);
/// GetPassiveIncomeMultiplier RVA 0x24D1F64
public decimal GiftPassiveMultiplier() => !GiftSystemOn ? 1m : (IsGiftEffectActive(GiftTypes.X4PassiveIncome) ? 4m : 1m) * (IsGiftEffectActive(GiftTypes.X10Income) ? 10m : 1m);
/// ReducePriceFactor RVA 0x24CA908 = 1 − max(SaleOffGiftValue, 0)
decimal ReducePriceFactor => 1m - (decimal)Math.Max(0f, R.SaleOffGiftValue);
void RestoreGifts()
{
int n = Math.Max(1, GC.maxVisibleSlots);
GiftSlot = new int[n]; slotIdle = new float[n];
for (int i = 0; i < n; i++) GiftSlot[i] = -1;
foreach (var sg in S.gifts) { var g = GS(sg.Type); g.Effect = Math.Max(0f, sg.PausedRemain); g.CooldownEnd = sg.CooldownEnd; g.Rv = sg.RvProgress; }
// 효과 진행 중인 선물은 슬롯에 남겨 남은 시간을 보여준다
foreach (var g in Gifts.Values) if (g.Effect > 0f) PlaceInSlot(g.Type);
introDone = PlayDay >= 3;
fillTimer = 0f;
}
void SaveGifts()
{
S.gifts.Clear();
foreach (var g in Gifts.Values) S.gifts.Add(new SaveGift { Type = g.Type, PausedRemain = g.Effect, CooldownEnd = g.CooldownEnd, RvProgress = g.Rv });
}
bool PlaceInSlot(int t)
{
for (int i = 0; i < GiftSlot.Length; i++)
if (GiftSlot[i] < 0)
{
GiftSlot[i] = t; slotIdle[i] = 0f; giftQueue.Remove(t);
if (t == GiftTypes.X10Income && !S.freeGiftPopupClaimed) PendingFreeGiftPopup = true; // PlaceInSlot RVA 0x24CE94C
GiftsChanged?.Invoke();
return true;
}
return false;
}
void ReleaseSlot(int t, bool toQueue)
{
int i = Array.IndexOf(GiftSlot, t);
if (i >= 0) { GiftSlot[i] = -1; slotIdle[i] = 0f; }
if (toQueue && !giftQueue.Contains(t)) giftQueue.Add(t);
GiftsChanged?.Invoke();
}
bool HasEmptySlot { get { foreach (var s in GiftSlot) if (s < 0) return true; return false; } }
/// GiftManager.Update RVA 0x24CCD38 계열 (일차별 채우기·사라짐)
void TickGifts(float dt)
{
if (!GiftSystemOn || !GiftFeatureUnlocked) return;
foreach (var g in Gifts.Values)
{
if (g.Effect > 0f) { g.Effect -= dt; if (g.Effect <= 0f) { g.Effect = 0f; g.CooldownEnd = Now().AddSeconds(GiftCooldown(g.Type)).Ticks; ReleaseSlot(g.Type, false); } }
if (g.DismissHold > 0f) { g.DismissHold -= dt; if (g.DismissHold <= 0f) g.DismissHold = 0f; }
}
foreach (var t in X.giftSeedOrder) if (!giftQueue.Contains(t) && AvailableForFill(t)) giftQueue.Add(t);
if (!introDone)
{
var types = PlayDay == 1 ? X.giftDay1Types : X.giftDay2Types;
fillTimer -= dt;
if (firstFill || fillTimer <= 0f)
{
firstFill = false;
fillTimer = GC.introStepSeconds;
while (introIndex < types.Length && !AvailableForFill(types[introIndex])) introIndex++;
if (introIndex < types.Length && HasEmptySlot) { PlaceInSlot(types[introIndex]); introIndex++; }
if (introIndex >= types.Length || !HasEmptySlot) { introDone = true; fillTimer = RefillDelay; }
}
}
else if (HasEmptySlot)
{
fillTimer -= dt;
if (fillTimer <= 0f)
{
int pick = -1;
var cands = giftQueue.FindAll(AvailableForFill);
if (cands.Count > 0) pick = PlayDay >= 3 ? cands[rng.Next(cands.Count)] : cands[0];
if (pick >= 0) PlaceInSlot(pick);
fillTimer = PlayDay >= 3 ? AppearDelay : RefillDelay;
}
}
else fillTimer = PlayDay >= 3 ? AppearDelay : RefillDelay;
// 방치 시 사라짐: 3일차+ 슬롯마다 IdleTimeout · 2일차 120 s 마다 가장 오래된 것 1개
int oldest = -1;
for (int i = 0; i < GiftSlot.Length; i++)
{
int t = GiftSlot[i];
if (t < 0) continue;
var g = GS(t);
if (g.Effect > 0f || (t == GiftTypes.NoAds && g.Rv > 0)) { slotIdle[i] = 0f; continue; }
slotIdle[i] += dt;
if (oldest < 0 || slotIdle[i] > slotIdle[oldest]) oldest = i;
if (PlayDay >= 3 && slotIdle[i] >= IdleTimeout) ReleaseSlot(t, true);
}
if (PlayDay == 2)
{
rotateTimer += dt;
if (rotateTimer >= GC.day2IdleTimeoutSeconds) { rotateTimer = 0f; if (oldest >= 0 && GiftSlot[oldest] >= 0) ReleaseSlot(GiftSlot[oldest], true); }
}
}
/// GetMoneyPackReward RVA 0x24D34A4 = Round(PassGPSClean × X + TapGPSClean × Y) (최소 1)
public BigInteger MoneyPackReward(int t)
{
decimal x = t == GiftTypes.SmallMoneyPack ? (decimal)R.SmallMoneyMultiplierX : (decimal)R.BigMoneyMultiplierX;
decimal y = t == GiftTypes.SmallMoneyPack ? (decimal)R.SmallMoneyMultiplierY : (decimal)R.BigMoneyMultiplierY;
var v = new BigInteger(decimal.Round(PassGpsClean() * x + TapGpsClean() * y, 0, MidpointRounding.AwayFromZero));
return v < 1 ? BigInteger.One : v;
}
/// GiftPopup.ApplyClaim RVA 0x25EB89C + GiftManager.ClaimGift RVA 0x24D1874 · viaAd = 광고/티켓(작은 돈만 ×2)
public BigInteger ClaimGift(int t, bool viaAd)
{
if (Array.IndexOf(GiftSlot, t) < 0) return BigInteger.Zero;
var g = GS(t);
if (GiftTypes.IsInstant(t))
{
var reward = MoneyPackReward(t) * ((viaAd && t == GiftTypes.SmallMoneyPack) ? X.giftSmallPackAdMultiplier : 1);
AddCoin(reward);
g.CooldownEnd = Now().AddSeconds(GiftCooldown(t)).Ticks;
ReleaseSlot(t, false);
QuestEvent(QuestCond.ClaimGiftMoney, t);
return reward;
}
if (t == GiftTypes.NoAds)
{
g.Rv += 1;
int i = Array.IndexOf(GiftSlot, t); if (i >= 0) slotIdle[i] = 0f;
if (g.Rv >= Math.Max(1, X.giftNoAdsVideos)) { g.Rv = 0; g.Effect = GiftEffectDuration(t); }
GiftsChanged?.Invoke();
return BigInteger.Zero;
}
g.Effect = GiftEffectDuration(t);
QuestEvent(QuestCond.ClaimGiftBuff, t);
GiftsChanged?.Invoke();
return BigInteger.Zero;
}
/// DismissGift RVA 0x24D1D64
public void DismissGift(int t)
{
if (Array.IndexOf(GiftSlot, t) < 0 || IsGiftEffectActive(t)) return;
ReleaseSlot(t, false);
GS(t).DismissHold = DismissHoldSeconds;
}
/// 검사·렌더용: 플레이 일차 바꾸기
public void DebugSetPlayDay(int day) { S.giftPlayDayCount = day; introDone = day >= 3; introIndex = 0; firstFill = true; fillTimer = 0f; }
public void ClaimFreeGiftPopup() { if (S.freeGiftPopupClaimed) return; S.freeGiftPopupClaimed = true; PendingFreeGiftPopup = false; S.ticket += R.FreeGiftTicketAmount; }
// ================= 방치 보상 (§3) =================
public BigInteger AfkPending => Big.P(S.afkPending);
public double AfkSeconds;
/// 백그라운드·종료 시 (GamePlayUI.OnApplicationPause(true) RVA 0x2605080)
public void MarkAfkStamp() { if (S.timeAfk == 0) S.timeAfk = NowT; }
/// CalculateCoinAfk RVA 0x2604068
public void OfflineCheck()
{
if (AfkPending > 0) { S.timeAfk = 0; return; }
long stamp = S.timeAfk != 0 ? S.timeAfk : S.lastActive;
S.timeAfk = 0;
if (stamp <= 0 || stamp >= NowT) return;
double t = (NowT - stamp) / 1e7;
double min = Math.Max(0, R.AFKTimeMin), max = Math.Max(min, R.AFKTimeMax);
if (t < min) return;
if (t > max) t = max;
var gps100 = new BigInteger(decimal.Ceiling(PassGps() * 100m));
int rate100 = (int)Math.Round(R.AFKRate * 100.0);
var coin = (new BigInteger(t) * gps100 * rate100 / 100) / 100;
AfkSeconds = t;
if (coin > 0) S.afkPending = coin.ToString();
}
/// ClaimAfkPopup._OnClaim RVA 0x2435190 / GrantMultipliedRewardAndComplete RVA 0x24357EC
public BigInteger ClaimAfk(int multiplier)
{
var c = AfkPending;
if (c <= 0) return BigInteger.Zero;
var got = c * Math.Max(1, multiplier);
AddCoin(got);
S.afkPending = "0";
return got;
}
// ================= 퀘스트 (§6) =================
public List<SaveQuest> ActiveQuests() => S.quests.FindAll(q => q.state == 1 || q.state == 2);
public QuestRow QuestRowOf(int id) => T.Quests.items.Find(r => r.id == id);
SaveQuest QState(int id) => S.quests.Find(q => q.id == id);
/// FillNormalSlots RVA 0x2583958 · FindNextNormalToActivate RVA 0x2583CA4
public void FillQuests()
{
if (!R.OnOffQuest) return;
int guard = 0;
while (ActiveQuests().Count < Math.Max(1, X.questSlots) && guard++ < 10)
{
QuestRow next = null;
foreach (var r in T.Quests.items)
{
var s = QState(r.id);
if (s != null && s.state >= 1) continue;
if (r.preconditionId > 0 && (QState(r.preconditionId)?.state ?? 0) != 3) continue;
next = r; break;
}
if (next == null) break;
ActivateQuest(next);
}
}
static decimal Dec(string s) { decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d); return d; }
/// ActivateQuest RVA 0x2583E80 · ComputeTargetSnapshot RVA 0x2584390
void ActivateQuest(QuestRow r)
{
var q = QState(r.id);
if (q == null) { q = new SaveQuest { id = r.id }; S.quests.Add(q); }
q.state = 1; q.progress = "0";
decimal gps = PassGps();
q.gps = Big.S(gps);
decimal p1 = Dec(r.param1), p2 = Dec(r.param2), target;
switch (r.conditionType)
{
case QuestCond.ConsumeCoin: case QuestCond.IncreaseCoin: target = decimal.Ceiling(p1); break;
case QuestCond.ConsumeCoinDynamic: case QuestCond.IncreaseCoinDynamic: target = Math.Max(1m, decimal.Ceiling(gps * p1)); break;
case QuestCond.UpgradeRoomItem: target = p2; break;
case QuestCond.ReachFollower: target = p1; break;
case QuestCond.ClaimDonate: case QuestCond.HypeCount: target = Math.Max(1m, p1); break;
default: target = 1m; break;
}
q.target = Big.S(target);
RefreshThreshold(q, r);
}
void AddProgress(SaveQuest q, decimal v)
{
if (q.state != 1) return;
q.progress = Big.S(Dec(q.progress) + v);
CheckQuest(q);
}
/// IsTargetReached RVA 0x2584BB8
void CheckQuest(SaveQuest q) { if (q.state == 1 && Dec(q.progress) + 0.0001m >= Dec(q.target)) q.state = 2; }
void QuestCoin(BigInteger delta)
{
if (S.quests.Count == 0) return;
decimal d = Big.ToDec(BigInteger.Abs(delta));
foreach (var q in S.quests)
{
if (q.state != 1) continue;
var r = QuestRowOf(q.id); if (r == null) continue;
int c = r.conditionType;
if (delta > 0 && (c == QuestCond.IncreaseCoin || c == QuestCond.IncreaseCoinDynamic)) AddProgress(q, d);
if (delta < 0 && (c == QuestCond.ConsumeCoin || c == QuestCond.ConsumeCoinDynamic)) AddProgress(q, d);
}
}
/// 이벤트형 조건 (MatchesGiftFilter RVA 0x2585744)
void QuestEvent(int cond, int param)
{
foreach (var q in S.quests)
{
if (q.state != 1) continue;
var r = QuestRowOf(q.id); if (r == null || r.conditionType != cond) continue;
int p1 = (int)Dec(r.param1);
if (cond == QuestCond.ClaimGiftMoney && !(p1 <= 0 ? (param == 1 || param == 2) : param == p1)) continue;
if (cond == QuestCond.ClaimGiftBuff && !(p1 <= 0 ? (param >= 3 && param <= 6) : param == p1)) continue;
AddProgress(q, 1m);
}
}
/// RefreshThresholdProgress RVA 0x2585340 — 5~8 = 현재 상태값
void RefreshQuestThresholds() { foreach (var q in S.quests) if (q.state == 1) { var r = QuestRowOf(q.id); if (r != null) RefreshThreshold(q, r); } }
void RefreshThreshold(SaveQuest q, QuestRow r)
{
decimal v;
switch (r.conditionType)
{
case QuestCond.UnlockRoomItem: v = TargetValue(r.param1, true); break;
case QuestCond.UpgradeRoomItem: v = TargetValue(r.param1, false); break;
case QuestCond.UnlockHouse: v = S.house >= (int)Dec(r.param1) ? 1m : 0m; break;
case QuestCond.ReachFollower: v = Big.ToDec(Follower); break;
default: return;
}
q.progress = Big.S(v);
CheckQuest(q);
}
/// QuestProductionTargetParser.TryParse RVA 0x2586198 · GetUpgradeProgress RVA 0x258697C
decimal TargetValue(string target, bool unlockOnly)
{
var p = (target ?? "").Split('_');
if (p.Length == 0) return 0m;
if (p[0] == "0")
{
if (p.Length >= 2 && int.TryParse(p[1], out var ot)) return OutfitUnlocked(ot) ? 1m : 0m;
return unlockOnly ? (S.closetLevel >= 1 ? 1m : 0m) : S.closetLevel;
}
if (p[0] == "1" && p.Length >= 3 && int.TryParse(p[1], out var h) && int.TryParse(p[2], out var it))
{
if (h < S.house) return unlockOnly ? 1m : 9999m;
if (h > S.house) return 0m;
int lv = RoomLevel(it);
return unlockOnly ? (lv >= 0 ? 1m : 0m) : lv + 1;
}
if (p[0] == "2" && p.Length >= 2 && int.TryParse(p[1], out var sk)) { int lv = SkillLevel(sk); return unlockOnly ? (lv > 0 ? 1m : 0m) : lv; }
return 0m;
}
/// TryClaim RVA 0x25822A0 · QuestRewardGranter RVA 0x25869E4 — 집 이동 = 티켓 · 그 외 = rewardNum × 활성화 시 PassGps 코인
public string ClaimQuest(int id)
{
var q = QState(id); var r = QuestRowOf(id);
if (q == null || r == null || q.state != 2) return null;
string got;
if (r.conditionType == QuestCond.UnlockHouse)
{
int n = (int)Math.Round(Dec(r.rewardNum), MidpointRounding.AwayFromZero);
S.ticket += n; got = "티켓 " + n;
}
else
{
decimal gps = Dec(q.gps); if (gps <= 0m) gps = PassGps();
var c = new BigInteger(decimal.Ceiling(Dec(r.rewardNum) * gps));
AddCoin(c); got = c.ToString();
}
q.state = 3;
FillQuests();
return got;
}
public decimal QuestRewardPreview(SaveQuest q)
{
var r = QuestRowOf(q.id); if (r == null) return 0m;
if (r.conditionType == QuestCond.UnlockHouse) return Dec(r.rewardNum);
decimal gps = Dec(q.gps); if (gps <= 0m) gps = PassGps();
return decimal.Ceiling(Dec(r.rewardNum) * gps);
}
public bool AnyQuestClaimable() => S.quests.Exists(q => q.state == 2);
// ================= 일일 보상 · 온라인 티켓 (§7) =================
public bool DailyClaimable => X.dailyRewards != null && S.dailyClaimedDays < X.dailyRewards.Count && S.dailyLastDate != Today;
/// DailyRewardManager RVA 0x24DB250~ — 받은 일수+1 번째 칸 · 하루 1회 · 7일 끝
public DailyReward ClaimDaily()
{
if (!DailyClaimable) return null;
var d = X.dailyRewards[S.dailyClaimedDays];
if (d.kind == 0) S.ticket += d.amount; else GrantEventOutfit(d.amount);
S.dailyClaimedDays++; S.dailyLastDate = Today;
return d;
}
double[] OnlineSteps
{
get
{
var a = R.TimeDailyTicket;
if (a == null) return new double[0];
var s = new double[a.Length]; double sum = 0;
for (int i = 0; i < a.Length; i++) { sum += a[i]; s[i] = sum; }
return s;
}
}
public int OnlineReached { get { int n = 0; foreach (var v in OnlineSteps) if (S.onlineSeconds >= v) n++; return n; } }
public bool OnlineClaimable => S.onlineClaimed < OnlineReached;
public double OnlineNextAt { get { var s = OnlineSteps; return S.onlineClaimed < s.Length ? s[S.onlineClaimed] : 0; } }
void OnlineDayCheck() { if (S.onlineDate != Today) { S.onlineDate = Today; S.onlineSeconds = 0; S.onlineClaimed = 0; } }
/// OnlineTicketRewardManager RVA 0x256FAF8~ — 받을 단계가 있으면 시간 멈춤
void TickOnline(float dt)
{
OnlineDayCheck();
if (S.onlineClaimed >= OnlineSteps.Length || OnlineClaimable) return;
S.onlineSeconds += dt;
}
public bool ClaimOnline()
{
if (!OnlineClaimable) return false;
S.ticket += X.onlineTicketPerStep; S.onlineClaimed++;
return true;
}
public bool TrySpendTicket() { if (S.ticket <= 0) return false; S.ticket -= 1; return true; }
// ================= 새 집 (§10.1) =================
/// GetTotalPriceToMaxCurrentRoom RVA 0x255F4E4 — 현재 방을 전부 만렙으로 만드는 남은 비용(할인 없음)
public BigInteger NextHousePrice()
{
BigInteger sum = BigInteger.Zero;
for (int i = 0; i < Room.Items.Count; i++)
{
var it = Room.Items[i];
int row = T.ItemRowIndex(S.house, it.Index);
for (int lv = RoomLevel(it.Index) + 1; lv < it.Levels.Count; lv++) sum += T.RoomPrice[S.house][row][lv];
}
return sum;
}
/// 다음 방 아이템 10(방 자체)의 IndexDependency = 필요 랭크
public int NextHouseRankReq()
{
if (!HasNextHouse) return int.MaxValue;
var it = T.Rooms[S.house + 1].Items.Find(x => x.Index == 10);
return it != null ? it.IndexDependency : 0;
}
/// CanBuyNextRoom RVA 0x255F350
public bool CanBuyNextHouse() => HasNextHouse && Coin >= NextHousePrice() && NextHouseRankReq() <= S.rankIndex;
/// BuyNewHousePopup._OnBuy RVA 0x2438214 — 방 승격 후 가격 차감
public bool BuyNextHouse()
{
if (!CanBuyNextHouse()) return false;
var price = NextHousePrice();
if (!MoveToNextHouse()) return false;
SpendCoin(price);
RefreshQuestThresholds();
return true;
}
// ================= 훅 =================
public void OnRankUp(int from, int to) { }
public void DebugAdvance(double sec) { S.firstPlay -= (long)(sec * 1e7); foreach (var r in S.rooms) if (r.UpgradeEnd > 0) r.UpgradeEnd -= (long)(sec * 1e7); foreach (var r in S.skills) if (r.UpgradeEnd > 0) r.UpgradeEnd -= (long)(sec * 1e7); foreach (var g in Gifts.Values) if (g.CooldownEnd > 0) g.CooldownEnd -= (long)(sec * 1e7); }
void OnClosetUpgraded() { RefreshQuestThresholds(); }
void OnRoomBought(int type, int fromLevel, int toLevel) { RefreshQuestThresholds(); }
void OnSkillBought(int type, int level) { RefreshQuestThresholds(); }
void OnTapCounted() { }
void OnDonateClaimed(BigInteger amount) { QuestEvent(QuestCond.ClaimDonate, 0); }
void OnHypeCount() { QuestEvent(QuestCond.HypeCount, 0); }
}
}