ThreeKingdomsSlingshot/Assets/Scripts/OutGame/Arena/ArenaRules.cs

458 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.Globalization;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>연무장 전투 1판의 결과(결과 패널 · 화면 표시).</summary>
public class ModeResult
{
public string kind;
public string title;
public bool win;
public List<string> lines = new List<string>();
}
/// <summary>
/// 연무장 규칙(기획서 §9 · S13 · PD #1093 C) — ① 천하제일 탑(tower.csv · 입장권 tower_tickets_daily/일 + 광고 tower_ad_tickets · 입장 = 1장 · 승리 = 다음 층 · 보옥·도면·상자)
/// ② 일일 던전(dungeons.csv · 요일 · entries_per_day/일 · 템플릿 × hp_mult · 보상 = reward_amount) ③ 투기장(arena.csv · 샘플 상대 sample_lineups(heroes.csv 에서 생성 · 세이브) ·
/// 방어 = 기존 EnemyAI · entries_per_day/일 · 메달 승/패 · 주간 순위(로컬) · 메달 → 영웅 조각 상점) ④ 적장 토벌(raid_bosses 주간 순환 · 보스 hp × raid_hp_mult · raid_turns 턴 · 누적 피해 · raid_daily/일 · 명장 상자 + 천기 raid_arcana_pct).
/// 적 편성 = 챕터 스테이지 템플릿(BalanceConfig.stages)을 복사해 배율만 곱한다.
/// </summary>
public static class ArenaRules
{
public const string Tower = "tower";
public const string Dungeon = "dungeon";
public const string Arena = "arena";
public const string Raid = "raid";
// ---------- 공통 ----------
public static bool ParseStageKey(string key, out int chapter, out int stage)
{
chapter = stage = 0;
if (string.IsNullOrEmpty(key)) return false;
var p = key.Split('-');
return p.Length == 2 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out chapter) && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out stage);
}
public static BalanceConfig.StageDef FindStage(BalanceConfig cfg, string key)
{
int ch, st;
if (!ParseStageKey(key, out ch, out st)) return null;
foreach (var s in cfg.stages)
if (s.chapter == ch && s.stage == st) return s;
return null;
}
/// <summary>템플릿 스테이지 복사(적 hp/atk 배율 곱 · 제목 교체).</summary>
public static BalanceConfig.StageDef CopyStage(BalanceConfig.StageDef src, string title, float hpMult, float atkMult)
{
var s = new BalanceConfig.StageDef
{
title = title, subtitle = src.title, aiAccuracy = src.aiAccuracy, aiAggroRange = src.aiAggroRange,
chapter = src.chapter, stage = src.stage, kind = src.kind, place = src.place, faction = src.faction, bgTheme = src.bgTheme, note = src.note,
};
foreach (var e in src.enemies)
s.enemies.Add(new BalanceConfig.EnemySpawn
{
name = e.name, type = e.type, position = e.position, hpMult = e.hpMult * hpMult, atkMult = e.atkMult * atkMult, isBoss = e.isBoss, isGeneral = e.isGeneral, art = e.art,
});
return s;
}
/// <summary>날짜가 바뀌면 일일 횟수 초기화 · 탑 입장권을 하루치까지 채움.</summary>
public static void RollDaily(OutGameData db, SaveData d, DateTime nowUtc)
{
var day = EconomyRules.DayKey(nowUtc);
if (d.dailyC != null && d.dailyC.day == day) return;
d.dailyC = new DailyC { day = day };
d.arena.towerTickets = Mathf.Max(d.arena.towerTickets, db.Economy.TowerTicketsDaily);
}
/// <summary>전투 결과 공통: 전투 카운터 · 쓰러진 적 보스 수(보물 조건 효과).</summary>
public static void CountBattle(SaveData d, BattleManager bm)
{
TreasureRules.AddCount(d, BattleEvents.Battles, 1);
int bosses = 0;
foreach (var u in bm.Units)
if (u != null && u.Faction == Faction.Enemy && u.IsBoss && !u.IsAlive) bosses++;
TreasureRules.AddCount(d, BattleEvents.BossKills, bosses);
}
// ---------- 천하제일 탑 ----------
public static int TowerNext(OutGameData db, SaveData d)
{
return Mathf.Min(d.arena.towerFloor + 1, db.Tower.Top);
}
public static string CanTower(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
if (d.arena.towerFloor >= db.Tower.Top) return "100층 정복 완료";
if (d.arena.towerTickets <= 0) return "입장권 없음(매일 " + db.Economy.TowerTicketsDaily + "장 · 광고 +" + db.Economy.TowerAdTickets + ")";
return null;
}
public static BalanceConfig.StageDef TowerStage(OutGameData db, BalanceConfig cfg, int floor)
{
var f = db.Tower.Get(floor);
var src = f != null ? FindStage(cfg, f.templateStage) : null;
return src == null ? null : CopyStage(src, "천하제일 탑 " + floor + "층", f.hpMult, f.atkMult);
}
/// <summary>입장 = 입장권 1장(승패 무관).</summary>
public static string EnterTower(OutGameData db, SaveData d, DateTime nowUtc)
{
var why = CanTower(db, d, nowUtc);
if (why != null) return why;
d.arena.towerTickets--;
return null;
}
public static string TowerAd(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
if (d.dailyC.towerAd >= 1) return "오늘 광고 입장권을 받았습니다";
d.dailyC.towerAd++;
d.arena.towerTickets += db.Economy.TowerAdTickets;
return null;
}
public static ModeResult TowerResult(OutGameData db, SaveData d, int floor, bool win, System.Random rng)
{
var r = new ModeResult { kind = Tower, win = win, title = "천하제일 탑 " + floor + "층" };
if (!win)
{
r.lines.Add("도전 실패 — 입장권 1장 소모 · 층 유지");
return r;
}
var f = db.Tower.Get(floor);
d.arena.towerFloor = Mathf.Max(d.arena.towerFloor, floor);
int gem = Mathf.RoundToInt(f.rewardGem * DevRules.Mult(db, d, DevRules.GemBonus));
int bp = Mathf.RoundToInt(f.rewardBlueprint * DevRules.Mult(db, d, DevRules.BlueprintGain));
d.gem += gem;
d.inv.blueprint += bp;
r.lines.Add("보옥 +" + gem + " · 도면 +" + bp);
if (f.chest.Length > 0)
{
var got = TreasureRules.AddChest(db, d, f.chest, rng);
if (got != null) r.lines.Add(db.Chests.Get(got).nameKo + " 획득");
}
r.lines.Add("다음 = " + TowerNext(db, d) + "층");
return r;
}
// ---------- 일일 던전 ----------
public static bool OpenToday(DungeonDb.Dungeon dg, DateTime nowUtc)
{
return dg.OpenOn(nowUtc.ToLocalTime().DayOfWeek);
}
public static int DungeonLeft(OutGameData db, SaveData d, DungeonDb.Dungeon dg, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, dg.entriesPerDay - d.dailyC.Dungeon(dg.id));
}
public static string CanDungeon(OutGameData db, SaveData d, string id, DateTime nowUtc)
{
var dg = db.Dungeons.Get(id);
if (dg == null) return "없는 던전";
if (!OpenToday(dg, nowUtc)) return "오늘은 닫힘";
if (DungeonLeft(db, d, dg, nowUtc) <= 0) return "오늘 입장 " + dg.entriesPerDay + "회 완료";
return null;
}
public static BalanceConfig.StageDef DungeonStage(OutGameData db, BalanceConfig cfg, string id)
{
var dg = db.Dungeons.Get(id);
var src = dg != null ? FindStage(cfg, dg.templateStage) : null;
return src == null ? null : CopyStage(src, dg.nameKo + " 던전", dg.hpMult, 1f);
}
/// <summary>입장 횟수는 승리했을 때만 소모(패배 = 다시 도전 가능 · PD 확인 대상).</summary>
public static ModeResult DungeonResult(OutGameData db, SaveData d, string id, bool win, DateTime nowUtc, System.Random rng)
{
var dg = db.Dungeons.Get(id);
var r = new ModeResult { kind = Dungeon, win = win, title = dg.nameKo + " 던전" };
if (!win)
{
r.lines.Add("도전 실패 — 입장 횟수 유지");
return r;
}
RollDaily(db, d, nowUtc);
d.dailyC.AddDungeon(id);
foreach (var k in dg.rewardKinds)
{
int n = dg.Amount(k);
switch (k)
{
case "gold":
n = Mathf.RoundToInt(n * DevRules.Mult(db, d, DevRules.GoldGain));
d.gold += n;
r.lines.Add("골드 +" + n.ToString("N0"));
break;
case "iron":
n = Mathf.RoundToInt(n * DevRules.Mult(db, d, DevRules.IronGain));
d.inv.iron += n;
r.lines.Add("운철 +" + n);
break;
case "skill_book":
d.inv.books.skill += n;
r.lines.Add("스킬서 +" + n);
break;
case "gear":
float gf = n * DevRules.Mult(db, d, DevRules.DungeonGear);
int cnt = Mathf.FloorToInt(gf + 1e-4f) + (rng.NextDouble() < gf - Mathf.Floor(gf + 1e-4f) ? 1 : 0);
var row = db.Rewards.For(Mathf.Max(1, d.chapterMax));
int maxOrder = row != null ? db.Gear.OrderOf(row.gearGradeMax) : 1;
var pool = new List<GearDb.Item>();
foreach (var it in db.Gear.items)
if (it.order <= maxOrder) pool.Add(it);
for (int i = 0; i < cnt && pool.Count > 0; i++)
{
var g = pool[rng.Next(pool.Count)];
d.inv.AddGear(g.id, 1);
r.lines.Add("장비 " + g.nameKo + " (" + UiKit.GradeKo(g.grade) + ")");
}
break;
case "shards":
var heroes = new List<HeroState>();
foreach (var h in d.heroes) heroes.Add(h);
if (heroes.Count == 0) break;
var pick = heroes[rng.Next(heroes.Count)];
pick.shards += n;
var def = db.Heroes.Get(pick.id);
r.lines.Add((def != null ? def.nameKo : pick.id) + " 조각 +" + n);
break;
}
}
r.lines.Add("오늘 남은 입장 " + DungeonLeft(db, d, dg, nowUtc) + " / " + dg.entriesPerDay);
return r;
}
// ---------- 투기장 ----------
public static int ArenaLeft(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, db.Arena.EntriesPerDay - d.dailyC.arena);
}
static int MaxOwnedLevel(SaveData d)
{
int m = 1;
foreach (var h in d.heroes) m = Mathf.Max(m, h.lv);
foreach (var u in d.units) m = Mathf.Max(m, u.lv);
return m;
}
/// <summary>
/// 시즌 유지: 시작 시각이 없으면 지금 시작 · season_days 지나면 순위 보상(1~10위 top10 · 그 밖 top100 보옥) 후 새 시즌 ·
/// 샘플 상대가 없으면 생성(영웅 5인 무작위 · Lv = 보유 최고 Lv ± · 점수 = sample_score_step × 순번 · 임시 규칙 = PD 확인 대상).
/// </summary>
public static void EnsureSeason(OutGameData db, SaveData d, DateTime nowUtc, System.Random rng)
{
var a = d.arena;
var start = SaveStore.ParseIso(a.seasonStart, DateTime.MinValue);
if (start == DateTime.MinValue)
{
a.seasonStart = SaveStore.Iso(nowUtc);
a.samples.Clear();
}
else if ((nowUtc - start).TotalDays >= Mathf.Max(1, db.Arena.SeasonDays))
{
int rank = Rank(d);
int gem = rank <= 10 ? db.Arena.RewardTop10Gem : db.Arena.RewardTop100Gem;
d.gem += gem;
a.lastSeason = "지난 시즌 " + rank + "위 · 보옥 +" + gem;
a.seasonStart = SaveStore.Iso(nowUtc);
a.seasonScore = 0;
a.samples.Clear();
}
if (a.samples.Count == 0) a.samples.AddRange(MakeSamples(db, d, rng));
}
public static List<ArenaSample> MakeSamples(OutGameData db, SaveData d, System.Random rng)
{
var pool = new List<HeroDb.Hero>();
foreach (var h in db.Heroes.heroes)
if (!h.isSoldier) pool.Add(h);
int n = Mathf.Max(1, db.Arena.SampleLineups);
int top = MaxOwnedLevel(d);
var res = new List<ArenaSample>();
for (int k = 0; k < n; k++)
{
var s = new ArenaSample { lv = Mathf.Clamp(top + k - n / 2, 1, db.Levels.MaxLevel), score = db.Arena.SampleScoreStep * (k + 1) };
var bag = new List<HeroDb.Hero>(pool);
for (int i = 0; i < 5 && bag.Count > 0; i++)
{
int j = rng.Next(bag.Count);
s.heroes.Add(bag[j].id);
bag.RemoveAt(j);
}
var lead = db.Heroes.Get(s.heroes[0]);
s.name = (lead != null ? lead.nameKo : "?") + " 군 · Lv " + s.lv;
res.Add(s);
}
return res;
}
/// <summary>내 주간 순위 = 1 + 나보다 점수 높은 샘플 수.</summary>
public static int Rank(SaveData d)
{
int r = 1;
foreach (var s in d.arena.samples)
if (s.score > d.arena.seasonScore) r++;
return r;
}
public static string CanArena(OutGameData db, SaveData d, int sample, DateTime nowUtc)
{
if (sample < 0 || sample >= d.arena.samples.Count) return "없는 상대";
if (ArenaLeft(db, d, nowUtc) <= 0) return "오늘 " + db.Arena.EntriesPerDay + "회 완료";
return null;
}
/// <summary>상대 편성 = 샘플 영웅 5인(Lv · 초기 등급 · ★ = 희귀도 · §3-2 공식) · 자리 = 아군 출발 자리의 위아래 반전 · 무장(패시브 Lv1 · EnemyAI 방어).</summary>
public static BalanceConfig.StageDef ArenaStage(OutGameData db, BalanceConfig cfg, ArenaSample s)
{
var st = new BalanceConfig.StageDef { title = "투기장 — " + s.name, subtitle = "비동기 5v5", chapter = 0, stage = 0, kind = StageKind.Normal };
for (int i = 0; i < s.heroes.Count && i < cfg.playerSpawnPoints.Length; i++)
{
var h = db.Heroes.Get(s.heroes[i]);
if (h == null) continue;
var hs = new HeroState { id = h.id, lv = s.lv, grade = db.Promotion.FirstGrade, star = Mathf.Clamp(h.rarity, 1, db.Evolve.MaxStar) };
var stats = HeroRules.Compute(db, cfg, h, hs);
var p = cfg.playerSpawnPoints[i];
st.enemies.Add(new BalanceConfig.EnemySpawn
{
name = h.nameKo, type = h.type, position = new Vector2(p.x, -p.y), art = h.art, isGeneral = true,
hpMult = h.hpMult * stats.GrowthOf("hp"), atkMult = h.atkMult * stats.GrowthOf("atk"),
});
}
return st;
}
public static ModeResult ArenaResult(OutGameData db, SaveData d, int sample, bool win, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
d.dailyC.arena++;
int medals = win ? db.Arena.MedalWin : db.Arena.MedalLose;
d.arena.medals += medals;
d.arena.seasonScore += medals;
if (win) d.arena.wins++;
else d.arena.losses++;
var s = sample >= 0 && sample < d.arena.samples.Count ? d.arena.samples[sample] : null;
var r = new ModeResult { kind = Arena, win = win, title = "투기장 " + (win ? "승리" : "패배") };
r.lines.Add((s != null ? s.name : "상대") + " 상대");
r.lines.Add("메달 +" + medals + " (보유 " + d.arena.medals + ")");
r.lines.Add("주간 점수 " + d.arena.seasonScore + " · 순위 " + Rank(d) + "위");
r.lines.Add("오늘 남은 도전 " + ArenaLeft(db, d, nowUtc) + " / " + db.Arena.EntriesPerDay);
return r;
}
/// <summary>메달 상점: shop_price 메달 → 그 영웅 조각 shop_shards(하루 shop_daily 회 · 보유 영웅만).</summary>
public static string BuyShards(OutGameData db, SaveData d, string heroId, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
var hs = d.Hero(heroId);
if (hs == null) return "보유 영웅만";
if (d.dailyC.arenaShop >= db.Arena.ShopDaily) return "오늘 " + db.Arena.ShopDaily + "회 완료";
if (d.arena.medals < db.Arena.ShopPrice) return "메달 부족 " + d.arena.medals + " / " + db.Arena.ShopPrice;
d.arena.medals -= db.Arena.ShopPrice;
d.dailyC.arenaShop++;
hs.shards += db.Arena.ShopShards;
return null;
}
// ---------- 적장 토벌 ----------
/// <summary>주 키(현지 · 월요일 시작 · yyyy-ww).</summary>
public static string WeekKey(DateTime nowUtc)
{
var local = nowUtc.ToLocalTime().Date;
int week = CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(local, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
return local.Year.ToString(CultureInfo.InvariantCulture) + "-" + week.ToString("00", CultureInfo.InvariantCulture);
}
/// <summary>이번 주 보스 = raid_bosses 를 주 번호로 순환 · 주가 바뀌면 누적 피해 초기화.</summary>
public static string RaidBoss(OutGameData db, SaveData d, DateTime nowUtc)
{
var bosses = db.Economy.RaidBosses;
if (bosses.Length == 0) return "";
var wk = WeekKey(nowUtc);
int week;
int.TryParse(wk.Substring(wk.Length - 2), NumberStyles.Integer, CultureInfo.InvariantCulture, out week);
var boss = bosses[week % bosses.Length];
if (d.arena.raidWeek != wk || d.arena.raidBoss != boss)
{
d.arena.raidWeek = wk;
d.arena.raidBoss = boss;
d.arena.raidDamage = 0;
}
return boss;
}
public static int RaidLeft(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, db.Economy.RaidDaily - d.dailyC.raid);
}
public static string CanRaid(OutGameData db, SaveData d, DateTime nowUtc)
{
if (RaidLeft(db, d, nowUtc) <= 0) return "오늘 " + db.Economy.RaidDaily + "회 완료";
return null;
}
/// <summary>보스 편성 = 그 영웅 아트가 보스인 첫 스테이지(없으면 보스 1기) · 보스 hp × raid_hp_mult.</summary>
public static BalanceConfig.StageDef RaidStage(OutGameData db, BalanceConfig cfg, string bossId)
{
var h = db.Heroes.Get(bossId);
if (h == null) return null;
string title = "적장 토벌 — " + h.nameKo;
foreach (var s in cfg.stages)
foreach (var e in s.enemies)
if (e.isBoss && e.art == h.art)
{
var c = CopyStage(s, title, 1f, 1f);
foreach (var ce in c.enemies)
if (ce.isBoss && ce.art == h.art) ce.hpMult *= db.Economy.RaidHpMult;
return c;
}
var st = new BalanceConfig.StageDef { title = title, subtitle = "주간 보스", kind = StageKind.Boss };
st.enemies.Add(new BalanceConfig.EnemySpawn { name = h.nameKo, type = h.type, position = new Vector2(0f, 8f), hpMult = h.hpMult * db.Economy.RaidHpMult, atkMult = h.atkMult, isBoss = true, isGeneral = true, art = h.art });
return st;
}
/// <summary>토벌 1회: 보스에게 준 피해 누적 · 명장 상자(raid_chest) · 천기 확률(raid_arcana_pct · 미보유 = 획득 · 보유 = 조각).</summary>
public static ModeResult RaidResult(OutGameData db, SaveData d, string bossId, int damage, bool killed, DateTime nowUtc, System.Random rng)
{
RollDaily(db, d, nowUtc);
d.dailyC.raid++;
RaidBoss(db, d, nowUtc);
d.arena.raidDamage += Mathf.Max(0, damage);
var h = db.Heroes.Get(bossId);
var r = new ModeResult { kind = Raid, win = true, title = "적장 토벌 — " + (h != null ? h.nameKo : bossId) };
r.lines.Add("이번 피해 " + damage.ToString("N0") + (killed ? " (격파!)" : ""));
r.lines.Add("이번 주 누적 " + d.arena.raidDamage.ToString("N0"));
var got = TreasureRules.AddChest(db, d, db.Economy.RaidChest, rng);
if (got != null) r.lines.Add(db.Chests.Get(got).nameKo + " 획득");
if (rng.NextDouble() < db.Economy.RaidArcanaPct)
{
bool isNew = false;
int shards = 0;
var top = db.Economy.TreasureGrades;
var id = top.Count > 0 ? TreasureRules.GrantGrade(db, d, top[top.Count - 1].Key, rng, out isNew, out shards) : null;
if (id != null) r.lines.Add("천기 보물 " + db.Treasures.Get(id).nameKo + (isNew ? " 획득!" : " 조각 +" + shards));
}
return r;
}
}
}