2026-11-13 13:34:16 +00:00
|
|
|
using System;
|
2026-11-05 12:11:10 +00:00
|
|
|
using System.Collections.Generic;
|
2026-11-13 13:34:16 +00:00
|
|
|
using System.Globalization;
|
2026-11-05 12:11:10 +00:00
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
namespace TKS.OutGame
|
|
|
|
|
{
|
|
|
|
|
/// <summary>뽑기 1장 결과.</summary>
|
|
|
|
|
public class GachaPull
|
|
|
|
|
{
|
|
|
|
|
public string heroId;
|
|
|
|
|
/// <summary>뽑힌 희귀도 등급(1~3 · 확률 표 기준).</summary>
|
|
|
|
|
public int tier;
|
|
|
|
|
public bool isNew;
|
|
|
|
|
public bool pickup;
|
|
|
|
|
public bool pity;
|
|
|
|
|
public int shards;
|
|
|
|
|
public int gold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-11-13 13:34:16 +00:00
|
|
|
/// 명장 등용(뽑기 · 기획서 §4 · gacha.csv · gacha_pool.csv · gacha_banners.csv): 등급 확률 p_r3/p_r2/p_r1 · 천장 = pity 번째 ★★★ 확정(획득 시 0 · 배너의 pity_group 끼리 같이 씀) ·
|
|
|
|
|
/// 10연 = ten_guarantee_rarity 이상 1 보장 · 병사 유닛 = ★ 풀 · 중복 = 조각(shard_dup_r*) / 병사 = 골드(soldier_dup_gold) · 첫 10연 무료(배너 공통 1번).
|
|
|
|
|
/// 배너(PD 2026-11-13): 상시(standing) = 그 배너 pool 진영의 영웅만(+ soldiers) · 픽업(pickup) = ★★★ 중 pickup_share 는 featured 영웅 ·
|
|
|
|
|
/// pickup_exclusive = 픽업 영웅은 상시 배너에서 안 나옴 · 기간(start + days · UTC) 밖의 배너는 등용 불가.
|
2026-11-05 12:11:10 +00:00
|
|
|
/// </summary>
|
|
|
|
|
public static class GachaRules
|
|
|
|
|
{
|
|
|
|
|
public static bool FirstTenFree(OutGameData db, SaveData d)
|
|
|
|
|
{
|
|
|
|
|
return db.Gacha.FreeFirstTen && !d.gacha.firstTenUsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>count 회 비용(1 = cost_single · ten_count = cost_ten · 첫 10연 무료).</summary>
|
|
|
|
|
public static int Cost(OutGameData db, SaveData d, int count)
|
|
|
|
|
{
|
|
|
|
|
var g = db.Gacha;
|
|
|
|
|
if (count == g.TenCount) return FirstTenFree(db, d) ? 0 : g.CostTen;
|
|
|
|
|
return g.CostSingle * count;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-13 13:34:16 +00:00
|
|
|
// ---------- 배너 ----------
|
|
|
|
|
|
|
|
|
|
/// <summary>배너 시작 시각(UTC · start 가 비면 null = 기간 없음).</summary>
|
|
|
|
|
public static DateTime? StartOf(GachaConfig.Banner b)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
2026-11-13 13:34:16 +00:00
|
|
|
DateTime day;
|
|
|
|
|
if (b == null || string.IsNullOrEmpty(b.start)) return null;
|
|
|
|
|
if (!DateTime.TryParseExact(b.start, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out day)) return null;
|
|
|
|
|
return day;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>배너 끝 시각(UTC · 기간 없음 = null).</summary>
|
|
|
|
|
public static DateTime? EndOf(GachaConfig.Banner b)
|
|
|
|
|
{
|
|
|
|
|
var s = StartOf(b);
|
|
|
|
|
if (s == null || b.days <= 0) return null;
|
|
|
|
|
return s.Value.AddDays(b.days);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>지금 등용할 수 있는 배너인가(기간 없음 = 항상 · 기간 있음 = 시작 ≤ 지금 < 끝).</summary>
|
|
|
|
|
public static bool IsOpen(GachaConfig.Banner b, DateTime nowUtc)
|
|
|
|
|
{
|
|
|
|
|
if (b == null) return false;
|
|
|
|
|
var s = StartOf(b);
|
|
|
|
|
if (s != null && nowUtc < s.Value) return false;
|
|
|
|
|
var e = EndOf(b);
|
|
|
|
|
return e == null || nowUtc < e.Value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>남은 기간(기간 없음·끝남 = 0).</summary>
|
|
|
|
|
public static TimeSpan Remaining(GachaConfig.Banner b, DateTime nowUtc)
|
|
|
|
|
{
|
|
|
|
|
var e = EndOf(b);
|
|
|
|
|
if (e == null || nowUtc >= e.Value) return TimeSpan.Zero;
|
|
|
|
|
return e.Value - nowUtc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>화면을 열 때 고르는 배너 = 표 순서에서 처음 열려 있는 것(없으면 첫 줄).</summary>
|
|
|
|
|
public static GachaConfig.Banner Default(OutGameData db, DateTime nowUtc)
|
|
|
|
|
{
|
|
|
|
|
foreach (var b in db.Gacha.banners)
|
|
|
|
|
if (IsOpen(b, nowUtc)) return b;
|
|
|
|
|
return db.Gacha.banners.Count > 0 ? db.Gacha.banners[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>어느 픽업 배너의 주요 영웅인가(pickup_exclusive 판정).</summary>
|
|
|
|
|
public static bool IsPickupHero(GachaConfig g, string heroId)
|
|
|
|
|
{
|
|
|
|
|
foreach (var b in g.banners)
|
|
|
|
|
if (b.IsPickup && b.featured.Contains(heroId)) return true;
|
|
|
|
|
return false;
|
2026-11-05 12:11:10 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-13 13:34:16 +00:00
|
|
|
/// <summary>그 유닛이 이 배너에서 나오는가(풀 등록 여부 in_pool 은 Candidates 가 본다).</summary>
|
|
|
|
|
public static bool InBanner(GachaConfig g, HeroDb heroes, GachaConfig.Banner b, HeroDb.Hero h)
|
|
|
|
|
{
|
|
|
|
|
if (b == null || h == null) return false;
|
|
|
|
|
if (h.isSoldier) return b.soldiers;
|
|
|
|
|
if (!b.pool.Contains(GachaConfig.PoolAll) && !b.pool.Contains(h.faction)) return false;
|
|
|
|
|
if (!b.IsPickup && g.PickupExclusive && IsPickupHero(g, h.id)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>배너에서 등급 tier 에 나오는 유닛(영웅 = 희귀도 = tier · 병사 = ★ 풀).</summary>
|
|
|
|
|
public static List<GachaConfig.PoolEntry> Candidates(GachaConfig g, HeroDb heroes, GachaConfig.Banner b, int tier)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
|
|
|
|
var res = new List<GachaConfig.PoolEntry>();
|
2026-11-13 13:34:16 +00:00
|
|
|
foreach (var p in g.pool)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
|
|
|
|
if (!p.inPool) continue;
|
2026-11-13 13:34:16 +00:00
|
|
|
var h = heroes.Get(p.heroId);
|
|
|
|
|
if (h == null || !InBanner(g, heroes, b, h)) continue;
|
2026-11-05 12:11:10 +00:00
|
|
|
int t = h.isSoldier ? 1 : h.rarity;
|
|
|
|
|
if (t == tier) res.Add(p);
|
|
|
|
|
}
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-13 13:34:16 +00:00
|
|
|
public static List<GachaConfig.PoolEntry> Candidates(OutGameData db, GachaConfig.Banner b, int tier)
|
|
|
|
|
{
|
|
|
|
|
return Candidates(db.Gacha, db.Heroes, b, tier);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 천장 ----------
|
|
|
|
|
|
|
|
|
|
public static int Pity(SaveData d, GachaConfig.Banner b)
|
|
|
|
|
{
|
|
|
|
|
return b != null ? d.gacha.PityOf(b.pityGroup) : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 등용 ----------
|
|
|
|
|
|
|
|
|
|
public static string CanDraw(OutGameData db, SaveData d, GachaConfig.Banner b, int count, DateTime nowUtc)
|
|
|
|
|
{
|
|
|
|
|
if (b == null) return "등용 배너 없음";
|
|
|
|
|
if (!IsOpen(b, nowUtc)) return "등용 기간이 아닙니다";
|
|
|
|
|
int cost = Cost(db, d, count);
|
|
|
|
|
if (d.gem < cost) return "보옥 부족 " + d.gem + " / " + cost;
|
|
|
|
|
bool any = false;
|
|
|
|
|
for (int t = 1; t <= 3 && !any; t++) any = Candidates(db, b, t).Count > 0;
|
|
|
|
|
if (!any) return "등용 풀 비었음";
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static List<GachaPull> Draw(OutGameData db, SaveData d, GachaConfig.Banner b, int count, System.Random rng, DateTime nowUtc)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
|
|
|
|
var res = new List<GachaPull>();
|
2026-11-13 13:34:16 +00:00
|
|
|
if (CanDraw(db, d, b, count, nowUtc) != null) return res;
|
2026-11-05 12:11:10 +00:00
|
|
|
var g = db.Gacha;
|
|
|
|
|
if (count == g.TenCount && FirstTenFree(db, d)) d.gacha.firstTenUsed = true;
|
|
|
|
|
else d.gem -= Cost(db, d, count);
|
|
|
|
|
|
|
|
|
|
int gearSlots = HeroRules.GearSlotCount(db);
|
|
|
|
|
bool guaranteed = false;
|
|
|
|
|
for (int i = 0; i < count; i++)
|
|
|
|
|
{
|
2026-11-13 13:34:16 +00:00
|
|
|
int n = d.gacha.PityOf(b.pityGroup) + 1;
|
2026-11-05 12:11:10 +00:00
|
|
|
int tier;
|
|
|
|
|
bool pity = n >= g.Pity;
|
|
|
|
|
if (pity) tier = 3;
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
double r = rng.NextDouble();
|
|
|
|
|
tier = r < g.P(3) ? 3 : r < g.P(3) + g.P(2) ? 2 : 1;
|
|
|
|
|
}
|
|
|
|
|
if (tier >= g.TenGuaranteeRarity) guaranteed = true;
|
|
|
|
|
if (count == g.TenCount && i == count - 1 && !guaranteed) tier = g.TenGuaranteeRarity;
|
2026-11-13 13:34:16 +00:00
|
|
|
d.gacha.SetPity(b.pityGroup, tier == 3 ? 0 : n);
|
2026-11-05 12:11:10 +00:00
|
|
|
d.gacha.total++;
|
2026-11-13 13:34:16 +00:00
|
|
|
var pull = Pick(db, b, tier, rng);
|
2026-11-05 12:11:10 +00:00
|
|
|
if (pull == null) continue;
|
|
|
|
|
pull.pity = pity;
|
|
|
|
|
var h = db.Heroes.Get(pull.heroId);
|
|
|
|
|
if (HeroRules.Grant(db, d, h, gearSlots)) pull.isNew = true;
|
|
|
|
|
else if (h.isSoldier)
|
|
|
|
|
{
|
|
|
|
|
pull.gold = db.Economy.SoldierDupGold;
|
|
|
|
|
d.gold += pull.gold;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
pull.shards = db.Economy.ShardDup(h.rarity);
|
|
|
|
|
d.Hero(h.id).shards += pull.shards;
|
|
|
|
|
}
|
|
|
|
|
res.Add(pull);
|
|
|
|
|
}
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-13 13:34:16 +00:00
|
|
|
static GachaPull Pick(OutGameData db, GachaConfig.Banner b, int tier, System.Random rng)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
2026-11-13 13:34:16 +00:00
|
|
|
// 그 등급 풀이 비면 아래 등급 · 아래도 없으면 위 등급(진영 풀은 등급이 빌 수 있음)
|
|
|
|
|
var list = Candidates(db, b, tier);
|
|
|
|
|
for (int t = tier - 1; list.Count == 0 && t >= 1; t--) list = Candidates(db, b, t);
|
|
|
|
|
for (int t = tier + 1; list.Count == 0 && t <= 3; t++) list = Candidates(db, b, t);
|
2026-11-05 12:11:10 +00:00
|
|
|
if (list.Count == 0) return null;
|
2026-11-13 13:34:16 +00:00
|
|
|
bool pickup = false;
|
|
|
|
|
if (tier == 3 && b.IsPickup)
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
|
|
|
|
var pick = new List<GachaConfig.PoolEntry>();
|
|
|
|
|
var rest = new List<GachaConfig.PoolEntry>();
|
2026-11-13 13:34:16 +00:00
|
|
|
foreach (var p in list) (b.featured.Contains(p.heroId) ? pick : rest).Add(p);
|
|
|
|
|
if (pick.Count > 0 && (rest.Count == 0 || rng.NextDouble() < db.Gacha.PickupShare))
|
|
|
|
|
{
|
|
|
|
|
list = pick;
|
|
|
|
|
pickup = true;
|
|
|
|
|
}
|
2026-11-05 12:11:10 +00:00
|
|
|
else if (rest.Count > 0) list = rest;
|
|
|
|
|
}
|
|
|
|
|
var e = list[rng.Next(list.Count)];
|
2026-11-13 13:34:16 +00:00
|
|
|
return new GachaPull { heroId = e.heroId, tier = tier, pickup = pickup };
|
2026-11-05 12:11:10 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|