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

620 lines
28 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.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); }
}
}