ThreeKingdomsSlingshot/Assets/Tests/PlayMode/SkillTests.cs

1218 lines
68 KiB
C#
Raw Normal View History

스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
/// <summary>
/// 스킬 시스템(PD #1093 B · 시스템 기획 §5·§3-5) — effect_key 51종 처리기(수치·조건·레벨 계수) · 쿨·예약·취소 · 즉시형 · ★3/★5 · 적 무장 패시브 · 스킬 레벨업·스킬서.
/// 기대값은 전부 skills.csv(SkillCatalog)·BalanceConfig·표에서 계산한다(C45 · 테스트에 수치 상수 없음 · 스킬 id 만 지정).
/// 결정 피해 비교 = 같은 시드의 난수로 Combat.Damage 를 부른다.
/// </summary>
public class SkillTests
{
const int Seed = 7;
static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc);
BattleManager bm;
BalanceConfig cfg;
[UnitySetUp]
public IEnumerator SetUp()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
bm = BattleManager.Instance;
Assert.IsNotNull(bm);
var wm = WorldMapScreen.Instance;
if (wm != null && wm.HasData) wm.EnterStage(0, 0);
yield return null;
cfg = bm.Config;
Time.timeScale = 20f;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = 1f;
yield return null;
}
IEnumerator Fresh()
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.PlayerTurn);
bm.SetTurnForTest(1);
yield return null;
}
// ---------- 도우미 ----------
static OutGameData Db
{
get { return OutGameData.Default; }
}
static SkillEffect Eff(string skillId, string key, int lv = 1, float star = 1f)
{
var d = SkillCatalog.Get(skillId);
Assert.IsNotNull(d, skillId);
foreach (var e in d.ScaledEffects(lv, star))
if (e.Key == key) return e;
Assert.Fail(skillId + " 에 " + key + " 없음");
return null;
}
static int Cd(string skillId)
{
return SkillCatalog.Get(skillId).Cooldown;
}
Unit Spawn(UnitType t, Faction f, float x, float y, string name, bool boss = false)
{
return bm.SpawnUnit(t, f, new Vector2(x, y), name, 1f, 1f, boss);
}
UnitSkills Give(Unit u, string heroId, int alv = 1, int plv = 1, float pm = 1f)
{
var s = bm.Skills.AttachHero(u, heroId, alv, plv, pm);
Assert.IsNotNull(s, heroId);
return s;
}
void Reserve(Unit u)
{
bm.Skills.Of(u).Reserved = true;
}
void Arm(Unit u, bool on)
{
bm.Skills.Of(u).Armed = on;
}
IEnumerator Act(Unit u, Vector2 target, ActionOutcome o, bool moveOnly = false)
{
yield return bm.ResolveForTest(u, target, moveOnly, o);
}
static float CastContact(Unit mover, Unit other)
{
return mover.Radius * Combat.CastRadiusScale + other.Radius;
}
static float Gap(Unit a, Unit b)
{
return Vector2.Distance(a.Position, b.Position) - a.Radius - b.Radius;
}
int Dmg(Unit a, Unit d, bool melee = true, float scale = 1f)
{
return Combat.Damage(cfg, a, d, false, new System.Random(Seed), melee, scale);
}
/// <summary>피해 공식 기대값(같은 시드 난수) = (공 × 상성 − 방 × 계수) × 배율 × 분산.</summary>
int Expect(float atk, float matchup, float def, float scale)
{
var r = new System.Random(Seed);
float v = 1f + ((float)r.NextDouble() * 2f - 1f) * cfg.damageVariance;
return Mathf.Max(1, Mathf.RoundToInt((atk * matchup - def * cfg.defenseFactor) * scale * v));
}
IEnumerator WaitPhase(BattlePhase p, float sec = 10f)
{
float t0 = Time.realtimeSinceStartup;
while (bm.Phase != p && Time.realtimeSinceStartup - t0 < sec) yield return null;
Assert.AreEqual(p, bm.Phase);
}
// ---------- 표 · 임포터 · 레벨 계수 ----------
[Test]
public void Import_EffectKeys_All51_Handled_PColumns_Parsed()
{
var keys = new HashSet<string>();
foreach (var s in Db.Skills.skills)
foreach (var k in s.EffectKeys)
{
Assert.IsTrue(SkillKeys.Known(k), s.id + " " + k);
keys.Add(k);
}
아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
// 영웅 56명 확장(PD #1096): 표 키 ⊆ 처리기 55종 · 표에서 안 쓰는 처리기 = 대장군(기령 제외)뿐
var unused = new List<string>();
foreach (var k in SkillKeys.All.Keys)
if (!keys.Contains(k)) unused.Add(k);
CollectionAssert.AreEquivalent(new[] { SkillKeys.PushedAllyAttackOnPush }, unused, "표에서 안 쓰는 처리기");
foreach (var k in new[] { SkillKeys.TeamDmgPctTurn, SkillKeys.CooldownAdd, SkillKeys.Charm, SkillKeys.HealSelfPct }) Assert.IsTrue(keys.Contains(k), "확장 키 " + k);
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
// 자산 = CSV(effect_key · p1~p3 · lv_scale · needs_impl)
var dir = Path.Combine(Application.dataPath, OutGameTables.Folder);
var texts = new string[OutGameTables.Files.Length];
for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, OutGameTables.Files[i]), System.Text.Encoding.UTF8);
var r = OutGameTables.Parse(texts);
try
{
Assert.AreEqual(0, r.errors.Count, string.Join("\n", r.errors.ToArray()));
foreach (var s in r.data.Skills.skills)
{
var a = Db.Skills.Get(s.id);
Assert.AreEqual(s.effectKey, a.effectKey, s.id);
Assert.AreEqual(s.pMask, a.pMask, s.id);
Assert.AreEqual(s.p1, a.p1, 1e-5f, s.id);
Assert.AreEqual(s.p2, a.p2, 1e-5f, s.id);
Assert.AreEqual(s.p3, a.p3, 1e-5f, s.id);
Assert.AreEqual(s.lvScale, a.lvScale, 1e-5f, s.id);
Assert.AreEqual(s.needsImpl, a.needsImpl, s.id);
var d = SkillCatalog.Get(s.id);
Assert.AreEqual(s.EffectKeys.Length, d.Effects.Count, s.id);
if (s.EffectKeys.Length > 1)
for (int i = 0; i < d.Effects.Count; i++) Assert.AreEqual(s.P(i), d.Effects[i].P, 1e-5f, s.id + " p" + (i + 1) + " = 키 순서");
}
}
finally
{
OutGameTables.Destroy(r);
}
// 처리기 없는 키 = 임포트 오류
int si = Array.IndexOf(OutGameTables.Files, "skills.csv");
var broken = (string[])texts.Clone();
broken[si] = broken[si].Replace("counter_ratio", "counter_ratio_x");
var rb = OutGameTables.Parse(broken);
try
{
Assert.IsTrue(rb.errors.Exists(e => e.Contains("처리기 없음")), "모르는 effect_key → 오류");
}
finally
{
OutGameTables.Destroy(rb);
}
}
[Test]
public void LevelScale_Value_Mult_Count_Fixed_AndStar5Passive()
{
var cr = SkillCatalog.Get("guanyu_p");
float raw = cr.Effects[0].P;
Assert.AreEqual(raw * (1f + cr.LvScale * 2f), Eff("guanyu_p", SkillKeys.CounterRatio, 3).P, 1e-4f, "값 × (1 + lv_scale·(lv−1))");
float star = SkillRules.PassiveMult(Db, Db.Evolve.MaxStar);
Assert.Greater(star, 1f, "★5 패시브 배율(evolve.csv)");
Assert.AreEqual(raw * star, Eff("guanyu_p", SkillKeys.CounterRatio, 1, star).P, 1e-4f, "★5 = 패시브 ×배율");
var pm = SkillCatalog.Get("zhoucang_a");
float praw = pm.Effects[0].P;
Assert.AreEqual(1f + (praw - 1f) * (1f + pm.LvScale * 4f), Eff("zhoucang_a", SkillKeys.PushMult, 5).P, 1e-4f, "배율 = 1 을 넘는 몫만 키움");
Assert.AreEqual(praw, Eff("zhoucang_a", SkillKeys.PushMult, 1, star).P, 1e-4f, "액티브에는 ★5 패시브 배율 없음");
var em = SkillCatalog.Get("liubei_a");
float craw = em.First(SkillKeys.ExtraMoveCount).P;
Assert.AreEqual(Mathf.Floor(craw * (1f + em.LvScale * 4f) + 0.5f), Eff("liubei_a", SkillKeys.ExtraMoveCount, 5).P, 1e-4f, "횟수 = 반올림");
Assert.AreEqual(SkillCatalog.Get("guanyu_a").First(SkillKeys.OverwatchUnlimited).P, Eff("guanyu_a", SkillKeys.OverwatchUnlimited, 5).P, 1e-4f, "켜기 값은 그대로");
var lb = SkillCatalog.Get("lvbu_p").Effects[0];
var lb3 = Eff("lvbu_p", SkillKeys.SlingDistPct, 3);
Assert.AreEqual(lb.A2 * (1f + SkillCatalog.Get("lvbu_p").LvScale * 2f), lb3.A2, 1e-4f, "절대 가산(p2)도 레벨 계수");
var hg = SkillCatalog.Get("huanggai_a").Effects[0];
Assert.AreEqual(hg.A2, Eff("huanggai_a", SkillKeys.HpCostAtk, 5).A2, 1e-4f, "HP 소모(p2)는 커지지 않음");
}
// ---------- 보병 ----------
[UnityTest]
public IEnumerator Infantry_Rende_NoSelfBounce_ExtraMoves_CooldownFromTable()
{
yield return Fresh();
var inf = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "유비");
Give(inf, "liubei");
Reserve(inf);
var foe = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적창병");
float contactY = -3f + (3f - CastContact(inf, foe));
var o = new ActionOutcome();
yield return Act(inf, foe.Position, o);
Assert.AreEqual("liubei_a", o.SkillUsed != null ? o.SkillUsed.Id : null, "예약 = 다음 행동에 발동");
Assert.AreEqual(contactY, inf.Position.y, 0.1f, "self_bounce_mult 0 = 자신은 튕기지 않음");
Assert.IsTrue(o.BonusMove);
int n = Mathf.RoundToInt(Eff("liubei_a", SkillKeys.ExtraMoveCount).P);
Assert.AreEqual(n, o.BonusMoves, "extra_move_count = 추가 이동 n 회");
var s = bm.Skills.Of(inf);
Assert.IsFalse(s.Reserved);
Assert.AreEqual(bm.TurnNumber + Cd("liubei_a"), s.ReadyTurn, "쿨 = 사용 턴 + cooldown");
// 추가 이동 n 회: 첫 명중 → 1 회 부여 + 남은 n−1
Assert.IsTrue(bm.Skills.NextBonus(inf, false, o));
for (int i = 1; i < n; i++) Assert.IsTrue(bm.Skills.NextBonus(inf, true, new ActionOutcome()), "남은 추가 이동 " + i);
Assert.IsFalse(bm.Skills.NextBonus(inf, true, new ActionOutcome()), "n 회 뒤 끝");
}
[UnityTest]
public IEnumerator Infantry_PushMult_KnockbackTaken_DmgPct_ExtraMoveAttack()
{
yield return Fresh();
// 청룡도 지기(push_mult): 밀리는 거리 = 남은 힘 × collisionPushRatio × p
var a = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "주창");
Give(a, "zhoucang");
Reserve(a);
var foe = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적");
float remaining = CastContact(a, foe);
var o = new ActionOutcome();
yield return Act(a, foe.Position, o);
Assert.AreEqual(remaining * cfg.collisionPushRatio * Eff("zhoucang_a", SkillKeys.PushMult).P, foe.Position.y, 0.12f, "push_mult");
// 거구(knockback_taken_pct): 부딪힌 쪽이 밀리는 거리 × (1 + p)
yield return Fresh();
var b = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "보병");
var xu = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "허저");
Give(xu, "xuchu");
float rem2 = CastContact(b, xu);
var o2 = new ActionOutcome();
yield return Act(b, xu.Position, o2);
Assert.AreEqual(rem2 * cfg.collisionPushRatio * (1f + Eff("xuchu_p", SkillKeys.KnockbackTakenPct).P), xu.Position.y, 0.12f, "knockback_taken_pct");
// 강동의 호랑이(dmg_pct · extra_move_attack): 피해 × (1 + p) · 명중 시 추가 이동에서 공격 가능
yield return Fresh();
var sj = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "손견");
var plain = Spawn(UnitType.Infantry, Faction.Player, 6f, -3f, "보병");
var d = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적");
Give(sj, "sunjian");
Arm(sj, true);
Assert.AreEqual(Expect(plain.Atk, cfg.Matchup(UnitType.Infantry, UnitType.Spear), d.Def, 1f + Eff("sunjian_a", SkillKeys.DmgPct).P), Dmg(sj, d), 1, "dmg_pct");
Arm(sj, false);
Assert.AreEqual(Dmg(plain, d), Dmg(sj, d), "예약 효과는 행동 중에만");
Reserve(sj);
var o3 = new ActionOutcome();
yield return Act(sj, d.Position, o3);
Assert.IsTrue(o3.BonusMove && o3.BonusAttack, "extra_move_attack");
Assert.IsTrue(bm.Skills.NextBonus(sj, false, o3));
Assert.IsTrue(bm.Skills.BonusCanAttack(sj), "추가 이동 = 공격 가능");
}
[UnityTest]
public IEnumerator Infantry_Yujin_PushedAllyAttacksTwice_Liubei_PushedAttackBonus_Gaoshun_Collision()
{
yield return Fresh();
// 군령(pushed_ally_attack_count): 알까기로 밀린 아군이 적과 닿으면 n 회 공격
var a = Spawn(UnitType.Infantry, Faction.Player, -3f, -2f, "우금");
var b = Spawn(UnitType.Spear, Faction.Player, 0f, -2f, "창병B");
Give(a, "yujin");
Reserve(a);
float pushX = CastContact(a, b);
var foe = Spawn(UnitType.Infantry, Faction.Enemy, pushX, -2f + b.Radius + 0.45f + 0.05f, "적");
foe.MaxHp = foe.Hp = 10000;
var o = new ActionOutcome();
yield return Act(a, new Vector2(0f, -2f), o);
Assert.AreEqual(Mathf.RoundToInt(Eff("yujin_a", SkillKeys.PushedAllyAttackCount).P), o.Notes.Count, "pushed_ally_attack_count");
// 도원의 맹약(pushed_ally_attack_pct · 같은 편 전원): 밀린 아군 공격 피해 × (1 + p)
yield return Fresh();
var lb = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "유비");
Give(lb, "liubei");
var ally = Spawn(UnitType.Infantry, Faction.Player, 0f, -2f, "보병");
var en = Spawn(UnitType.Infantry, Faction.Enemy, 6f, 6f, "적");
float p = Eff("liubei_p", SkillKeys.PushedAllyAttackPct).P;
Assert.AreEqual(p, SkillMods.Sum(ally, SkillKeys.PushedAllyAttackPct), 1e-5f, "같은 편 = 적용");
Assert.AreEqual(0f, SkillMods.Sum(en, SkillKeys.PushedAllyAttackPct), 1e-5f, "상대 = 미적용");
// 함진영(push_mult p2): 밀린 적이 벽·유닛에 막히면 알까기 피해 × p2 추가
yield return Fresh();
var gs = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "고순");
Give(gs, "gaoshun");
Reserve(gs);
var f1 = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적1");
var f2 = Spawn(UnitType.Spear, Faction.Enemy, 0f, f1.Radius * 2f + 0.01f, "적2");
f1.MaxHp = f1.Hp = 10000;
var o2 = new ActionOutcome();
yield return Act(gs, f1.Position, o2);
float bonus = Eff("gaoshun_a", SkillKeys.PushMult).A2;
int total = f1.MaxHp - f1.Hp;
Assert.AreEqual(total, o2.DamageDealt, "충돌 추가 피해도 집계");
int impact = Mathf.RoundToInt(total / (1f + bonus));
Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(impact * bonus)), total - impact, 2, "막혀서 충돌 = 알까기 피해 × p2");
}
// ---------- 기병 ----------
[UnityTest]
public IEnumerator Cavalry_NoCounter_ReturnOnMiss_Knockback_Repeat_PerHit_MinPierce()
{
yield return Fresh();
// 천하무쌍(pierce_dmg_mult · pierce_no_counter)
var lv = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "여포");
Give(lv, "lvbu");
Reserve(lv);
var e1 = Spawn(UnitType.Spear, Faction.Enemy, 0f, -1.5f, "적창병");
e1.MaxHp = e1.Hp = 10000;
var o = new ActionOutcome();
yield return Act(lv, new Vector2(0f, 1.5f), o);
Assert.Less(e1.Hp, e1.MaxHp);
Assert.AreEqual(0, o.CounterTaken, "pierce_no_counter = 살아남은 근접 적도 반격 없음");
Arm(lv, true);
Assert.AreEqual(Eff("lvbu_a", SkillKeys.PierceDmgMult).P, SkillMods.PierceDmgMult(lv, 1), 1e-5f, "pierce_dmg_mult");
Arm(lv, false);
// 단기구주(pierce_return_on_miss) + 상산의 창(pierce_knockback_mult)
yield return Fresh();
var zy = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "조운");
Give(zy, "zhaoyun");
Reserve(zy);
var e2 = Spawn(UnitType.Infantry, Faction.Enemy, 0.3f, -1.5f, "적");
e2.MaxHp = e2.Hp = 10000;
var o2 = new ActionOutcome();
yield return Act(zy, new Vector2(0f, 2f), o2);
Assert.IsTrue(o2.Returned, "빗나가도 출발점 복귀");
Assert.Less(Vector2.Distance(zy.Position, new Vector2(0f, -4f)), 0.05f);
Assert.AreEqual(0, o2.CounterTaken, "단기구주 = 반격 없음");
var disp = o2.Displaced.Find(x => x.Unit == e2);
Assert.IsNotNull(disp.Unit);
Assert.AreEqual(cfg.cavalryKnockback * Eff("zhaoyun_p", SkillKeys.PierceKnockbackMult).P, Vector2.Distance(disp.From, disp.To), 0.05f, "pierce_knockback_mult");
// 위진소요(pierce_repeat) + 팔백 돌격(pierce_dmg_pct_per_hit)
yield return Fresh();
var zl = Spawn(UnitType.Cavalry, Faction.Player, 0f, -7f, "장료");
Give(zl, "zhangliao");
Reserve(zl);
var t1 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -4f, "적1");
var t2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1.2f, "적2");
t1.MaxHp = t1.Hp = t2.MaxHp = t2.Hp = 10000;
var o3 = new ActionOutcome();
yield return Act(zl, t1.Position, o3);
Assert.Less(t2.Hp, t2.MaxHp, "명중 → 같은 방향 한 번 더 관통");
Assert.IsFalse(o3.Returned, "복귀 대신 추가 관통");
Assert.AreEqual(t1.MaxHp - t1.Hp, o3.DamageDealt - (t2.MaxHp - t2.Hp), "앞서 관통한 적은 다시 맞지 않음");
var ps = Eff("zhangliao_p", SkillKeys.PierceDmgPctPerHit);
int hits = bm.Skills.Of(zl).PierceHits;
Assert.AreEqual(2, hits, "관통한 적 누적");
Assert.AreEqual(1f + Mathf.Min(ps.P * hits, ps.A2), SkillMods.AttackMult(zl), 1e-4f, "pierce_dmg_pct_per_hit(최대 p2)");
// 만인지적(pierce_dmg_mult p2 = 최소 관통 수)
yield return Fresh();
var zf = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "장비");
Give(zf, "zhangfei");
var mp = Eff("zhangfei_p", SkillKeys.PierceDmgMult);
int need = Mathf.RoundToInt(mp.A2);
Assert.AreEqual(1f, SkillMods.PierceDmgMult(zf, need - 1), 1e-5f, "최소 관통 수 미만");
Assert.AreEqual(mp.P, SkillMods.PierceDmgMult(zf, need), 1e-5f, "최소 관통 수 이상");
}
// ---------- 창병 · 견제 ----------
int StrikeHi(Unit spear, Unit mover)
{
int one = Combat.OverwatchDamage(cfg, spear, mover, new System.Random(Seed));
return Mathf.CeilToInt(one / (1f - cfg.damageVariance) * (1f + cfg.damageVariance)) + 1;
}
int StrikeLo(Unit spear, Unit mover)
{
int one = Combat.OverwatchDamage(cfg, spear, mover, new System.Random(Seed));
return Mathf.FloorToInt(one / (1f + cfg.damageVariance) * (1f - cfg.damageVariance)) - 1;
}
[UnityTest]
public IEnumerator Spear_OverwatchSkills_Radius_Unlimited_Count_DmgMult_HealOnKill()
{
yield return Fresh();
// 청룡언월(overwatch_radius_mult · overwatch_unlimited): 명중 → 대기 반경 × p · 반경을 스친 구간마다 + 밀린 적도
var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관우");
Give(gy, "guanyu");
Reserve(gy);
var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
var o = new ActionOutcome();
yield return Act(gy, foe.Position, o);
Assert.IsTrue(o.Overwatch && gy.Overwatch);
float r = SkillMods.OverwatchRadius(gy);
Assert.AreEqual(gy.AttackRange * Eff("guanyu_a", SkillKeys.OverwatchRadiusMult).P, r, 1e-4f, "overwatch_radius_mult");
Assert.IsTrue(SkillMods.OverwatchUnlimited(gy));
var mover = Spawn(UnitType.Infantry, Faction.Enemy, gy.Position.x + r + 3f, gy.Position.y, "이동 적");
mover.MaxHp = mover.Hp = 10000;
var path = new ActionOutcome();
Vector2 c = gy.Position;
path.Path.AddRange(new[] { c + new Vector2(r + 2f, 0.3f), c + new Vector2(0.5f, 0.3f), c + new Vector2(r + 2f, 0.6f), c + new Vector2(0.5f, 0.6f) });
yield return bm.ApplyOverwatch(mover, path);
int drop = mover.MaxHp - mover.Hp;
Assert.GreaterOrEqual(drop, StrikeLo(gy, mover) * 2, "반경을 스친 구간 2 번 = 견제 2 회(무제한)");
// 밀린 적(Displaced)도 견제
var pushedFoe = Spawn(UnitType.Infantry, Faction.Enemy, c.x - r - 3f, c.y, "밀린 적");
pushedFoe.MaxHp = pushedFoe.Hp = 10000;
var o2 = new ActionOutcome();
o2.Path.Add(mover.Position);
o2.Displaced.Add(new Displacement { Unit = pushedFoe, From = c + new Vector2(-r - 1f, 0f), To = c + new Vector2(-0.5f, 0f) });
yield return bm.ApplyOverwatch(mover, o2);
Assert.Less(pushedFoe.Hp, pushedFoe.MaxHp, "무제한 = 밀려서 반경을 지난 적도 견제");
// 후계(overwatch_radius_add) · 삼대 노신(overwatch_count) · 교변(overwatch_dmg_mult)
yield return Fresh();
var gp = Spawn(UnitType.Spear, Faction.Player, -6f, -6f, "관평");
Give(gp, "guanping");
gp.SetOverwatch(true);
Assert.AreEqual(gp.AttackRange + Eff("guanping_p", SkillKeys.OverwatchRadiusAdd).P, SkillMods.OverwatchRadius(gp), 1e-4f, "overwatch_radius_add");
var cp = Spawn(UnitType.Spear, Faction.Player, 4f, -6f, "정보");
Give(cp, "chengpu");
cp.SetOverwatch(true);
var m2 = Spawn(UnitType.Infantry, Faction.Enemy, 4f, 2f, "적");
m2.MaxHp = m2.Hp = 10000;
var po = new ActionOutcome();
po.Path.AddRange(new[] { new Vector2(3f, -3f), new Vector2(3f, -8f) });
yield return bm.ApplyOverwatch(m2, po);
int strikes = Mathf.RoundToInt(Eff("chengpu_p", SkillKeys.OverwatchCount).P);
Assert.GreaterOrEqual(m2.MaxHp - m2.Hp, StrikeLo(cp, m2) * strikes, "overwatch_count = 지나간 적 1기당 n 회");
Assert.LessOrEqual(m2.MaxHp - m2.Hp, StrikeHi(cp, m2) * strikes);
var zh = Spawn(UnitType.Spear, Faction.Player, 8f, 8f, "장합");
Give(zh, "zhanghe");
var plainSpear = Spawn(UnitType.Spear, Faction.Player, -8f, 8f, "창병");
int baseOw = Combat.OverwatchDamage(cfg, plainSpear, m2, new System.Random(Seed));
int zhOw = Combat.OverwatchDamage(cfg, zh, m2, new System.Random(Seed));
Assert.AreEqual(baseOw * Eff("zhanghe_p", SkillKeys.OverwatchDmgMult).P, zhOw, 1.01f, "overwatch_dmg_mult");
// 하북 명장(heal_on_overwatch_kill): 견제로 쓰러뜨리면 최대 HP × p 회복
yield return Fresh();
var yl = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "안량");
Give(yl, "yanliang");
yl.SetOverwatch(true);
yl.Hp = yl.MaxHp / 2;
int hp0 = yl.Hp;
var weak = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 1f, "적");
weak.Hp = 1;
var ko = new ActionOutcome();
ko.Path.AddRange(new[] { new Vector2(-1f, -1.5f), new Vector2(1f, -1.5f) });
yield return bm.ApplyOverwatch(weak, ko);
Assert.IsFalse(weak.IsAlive);
Assert.AreEqual(Mathf.Min(yl.MaxHp, hp0 + Mathf.Max(1, Mathf.RoundToInt(yl.MaxHp * Eff("yanliang_p", SkillKeys.HealOnOverwatchKill).P))), yl.Hp, "heal_on_overwatch_kill");
}
[UnityTest]
public IEnumerator Spear_HitTwo_OnPushAllyAttacks_ChengpuAdvance()
{
yield return Fresh();
아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
// 비도(spear_hit_two · 축융 · 기령은 영웅 풀 제외 PD #1096): 밀며 전진 끝에 닿은 적까지 최대 n 기 동시 피해
var jl = Spawn(UnitType.Spear, Faction.Player, 0f, -4f, "축융");
Give(jl, "zhurong");
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
Reserve(jl);
var f1 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f, "적1");
var f2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f + f1.Radius * 2f + 0.03f, "적2");
f1.MaxHp = f1.Hp = f2.MaxHp = f2.Hp = 10000;
var o = new ActionOutcome();
yield return Act(jl, new Vector2(0f, -1f), o);
Assert.Less(f1.Hp, f1.MaxHp);
Assert.Less(f2.Hp, f2.MaxHp, "spear_hit_two = 닿은 둘째 적도 피해");
Assert.Contains(f2, o.Attacked);
아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
// 대장군(pushed_ally_attack_on_push)은 기령 제외로 표에서 쓰지 않음(처리기만 유지 · PD #1096)
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
// 철척사모(push_mult · 창병): 밀며 전진 거리 × p
yield return Fresh();
var ch = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "정보");
Give(ch, "chengpu");
Reserve(ch);
var t = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
t.MaxHp = t.Hp = 10000;
float rem = CastContact(ch, t);
var o3 = new ActionOutcome();
yield return Act(ch, t.Position, o3);
Assert.AreEqual(rem * Eff("chengpu_a", SkillKeys.PushMult).P, t.Position.y, 0.12f, "push_mult(창병 = 밀며 전진 거리)");
}
// ---------- 궁병 · 책사 ----------
[UnityTest]
public IEnumerator Ranged_Range_TeamRange_MeleePenaltyOff_PassAllies_CritAlways()
{
yield return Fresh();
var ts = Spawn(UnitType.Archer, Faction.Player, -6f, -8f, "태사자");
Give(ts, "taishici");
Arm(ts, true);
Assert.AreEqual(ts.AttackRange * Eff("taishici_a", SkillKeys.RangeMult).P, SkillMods.Range(ts), 1e-4f, "range_mult");
Arm(ts, false);
var xs = Spawn(UnitType.Strategist, Faction.Player, -3f, -8f, "서서");
Give(xs, "xushu");
Assert.AreEqual(xs.AttackRange + Eff("xushu_p", SkillKeys.RangeAdd).P, SkillMods.Range(xs), 1e-4f, "range_add");
var zy = Spawn(UnitType.Strategist, Faction.Player, 0f, -8f, "주유");
Give(zy, "zhouyu");
var archer = Spawn(UnitType.Archer, Faction.Player, 3f, -8f, "궁병");
var inf = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "보병");
var enemyArcher = Spawn(UnitType.Archer, Faction.Enemy, 6f, 8f, "적궁병");
float tr = Eff("zhouyu_p", SkillKeys.TeamRangeAdd).P;
Assert.AreEqual(archer.AttackRange + tr, SkillMods.Range(archer), 1e-4f, "team_range_add = 아군 궁병");
Assert.AreEqual(0f, SkillMods.Sum(inf, SkillKeys.TeamRangeAdd), 1e-5f, "보병 제외");
Assert.AreEqual(enemyArcher.AttackRange, SkillMods.Range(enemyArcher), 1e-4f, "상대 제외");
// 돌파(melee_penalty_off): 근접 전투 패널티 없음(주는·받는 피해)
var foe = Spawn(UnitType.Infantry, Faction.Enemy, 8f, 8f, "적보병");
float m = cfg.Matchup(UnitType.Archer, UnitType.Infantry);
Assert.AreEqual(Expect(ts.Atk, m, foe.Def, 1f), Dmg(ts, foe, true), 1, "주는 근접 피해 패널티 없음");
Assert.AreEqual(Expect(archer.Atk, m, foe.Def, cfg.archerMeleeDealtRatio), Dmg(archer, foe, true), 1, "일반 궁병은 패널티");
float m2 = cfg.Matchup(UnitType.Infantry, UnitType.Archer);
Assert.AreEqual(Expect(foe.Atk, m2, ts.Def, 1f), Dmg(foe, ts, true), 1, "받는 근접 피해 패널티 없음");
// 백발백중(arrow_pass_allies · arrow_crit_always): 사선의 아군 통과 · 목표점 명중 없이도 크리
yield return Fresh();
var hz = Spawn(UnitType.Archer, Faction.Player, 0f, -4f, "황충");
Give(hz, "huangzhong");
Reserve(hz);
var block = Spawn(UnitType.Infantry, Faction.Player, 0f, -2.6f, "아군");
var target = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -0.6f, "적");
target.MaxHp = target.Hp = 10000;
var o = new ActionOutcome();
yield return Act(hz, new Vector2(0f, -3.2f), o);
Assert.AreEqual(block.MaxHp, block.Hp, "arrow_pass_allies = 사선의 아군 무피해");
Assert.Less(target.Hp, target.MaxHp);
Assert.IsTrue(o.Crit, "arrow_crit_always = 목표점 명중 없이 크리");
}
[UnityTest]
public IEnumerator Strategist_SplashRadiusRatio_AllyZero_Ganning_Teleport()
{
yield return Fresh();
// 화공(splash_radius_mult · splash_ratio)
var zy = Spawn(UnitType.Strategist, Faction.Player, 0f, -4f, "주유");
Give(zy, "zhouyu");
Reserve(zy);
var c = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f, "중심");
float mult = Eff("zhouyu_a", SkillKeys.SplashRadiusMult).P;
float far = cfg.strategistSplashRadius * (1f + mult) * 0.5f + 0.45f; // 기본 반경 밖 · 화공 반경 안
var side = Spawn(UnitType.Infantry, Faction.Enemy, far, -1f, "곁");
var plan = bm.Resolver.PreviewShot(zy, c.Position, false);
Assert.IsNotNull(plan);
Assert.AreEqual(cfg.strategistSplashRadius * mult, plan.SplashRadius, 1e-4f, "splash_radius_mult");
Assert.Contains(side, plan.Splash, "커진 광역에 들어옴");
Arm(zy, true);
Assert.AreEqual(Eff("zhouyu_a", SkillKeys.SplashRatio).P, SkillMods.SplashRatio(zy, cfg), 1e-4f, "splash_ratio");
Arm(zy, false);
Assert.AreEqual(cfg.strategistSplashRatio, SkillMods.SplashRatio(zy, cfg), 1e-4f, "예약 없으면 자산 값");
// 팔문 간파(splash_ally_ratio): 광역의 아군 피해 = 0
yield return Fresh();
var xs = Spawn(UnitType.Strategist, Faction.Player, 0f, -4f, "서서");
Give(xs, "xushu");
Reserve(xs);
var en = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -0.6f, "적");
en.MaxHp = en.Hp = 10000;
var ally = Spawn(UnitType.Infantry, Faction.Player, 1.0f, -0.6f, "아군");
var o = new ActionOutcome();
yield return Act(xs, en.Position, o);
Assert.Less(en.Hp, en.MaxHp);
Assert.AreEqual(ally.MaxHp, ally.Hp, "splash_ally_ratio = 0");
// 백기습영(teleport · arrow_crit_always): 사거리 밖이어도 목표점까지 이동 후 사격 · 크리
yield return Fresh();
var gn = Spawn(UnitType.Archer, Faction.Player, 0f, -9f, "감녕");
Give(gn, "ganning");
var far2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0.5f, "먼 적");
far2.MaxHp = far2.Hp = 10000;
Assert.Greater(Gap(gn, far2), SkillMods.Range(gn) + gn.ArcherMoveAllow, "출발점에서는 사거리 밖");
Reserve(gn);
Vector2 to = gn.Position + Vector2.up * SkillMods.SlingMax(gn);
var o2 = new ActionOutcome();
yield return Act(gn, to, o2);
Assert.Greater(gn.Position.y, -9f + SkillMods.SlingMax(gn) - 0.3f, "목표점까지 이동");
Assert.IsTrue(o2.Fired && far2.Hp < far2.MaxHp, "이동 후 사격");
Assert.IsTrue(o2.Crit, "명중 시 크리");
}
// ---------- 피해 · 반격 · 공격력 ----------
[UnityTest]
public IEnumerator Damage_MatchupFloor_HpBelow_Turn1_General_Taken_Archer_Counter_Death_Full_HpMax()
{
yield return Fresh();
var cav = Spawn(UnitType.Cavalry, Faction.Enemy, 6f, 8f, "적기병");
var xh = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "서황");
var plain = Spawn(UnitType.Infantry, Faction.Player, -3f, -8f, "보병");
Give(xh, "xuhuang");
Arm(xh, true);
float floor = Eff("xuhuang_a", SkillKeys.MatchupFloor).P;
Assert.AreEqual(Expect(xh.Atk, Mathf.Max(cfg.Matchup(UnitType.Infantry, UnitType.Cavalry), floor), cav.Def, 1f), Dmg(xh, cav), 1, "matchup_floor");
Arm(xh, false);
Assert.AreEqual(Dmg(plain, cav), Dmg(xh, cav));
// 노익장(dmg_pct_hp_below): HP 임계 이하 공 +p
var hz = Spawn(UnitType.Archer, Faction.Player, 0f, -8f, "황충");
Give(hz, "huangzhong");
var hb = Eff("huangzhong_p", SkillKeys.DmgPctHpBelow);
Assert.AreEqual(1f, SkillMods.AttackMult(hz), 1e-5f, "HP 가득 = 없음");
hz.Hp = Mathf.FloorToInt(hz.MaxHp * hb.A2);
Assert.AreEqual(1f + hb.P, SkillMods.AttackMult(hz), 1e-5f, "dmg_pct_hp_below");
// 선봉(dmg_pct_turn1)
var hx = Spawn(UnitType.Infantry, Faction.Player, 3f, -8f, "화웅");
Give(hx, "huaxiong");
bm.SetTurnForTest(1);
Assert.AreEqual(1f + Eff("huaxiong_p", SkillKeys.DmgPctTurn1).P, SkillMods.AttackMult(hx), 1e-5f, "dmg_pct_turn1");
bm.SetTurnForTest(2);
Assert.AreEqual(1f, SkillMods.AttackMult(hx), 1e-5f, "2턴 = 없음");
// 파로(dmg_vs_general_mult p2 1 = 보스만) · 참장(p2 0 = 무장 전체)
var sj = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "손견");
Give(sj, "sunjian");
var boss = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "보스", true);
var gen = Spawn(UnitType.Infantry, Faction.Enemy, 3f, 8f, "장수");
bm.Skills.MarkGeneral(gen);
float im = cfg.Matchup(UnitType.Infantry, UnitType.Infantry);
Assert.AreEqual(Expect(sj.Atk, im, boss.Def, Eff("sunjian_p", SkillKeys.DmgVsGeneralMult).P), Dmg(sj, boss), 1, "보스 = 배율");
Assert.AreEqual(Expect(sj.Atk, im, gen.Def, 1f), Dmg(sj, gen), 1, "보스만 = 일반 장수 제외");
Arm(hx, true);
Assert.AreEqual(Expect(hx.Atk, im, gen.Def, Eff("huaxiong_a", SkillKeys.DmgVsGeneralMult).P), Dmg(hx, gen), 1, "참장 = 무장 전체");
Arm(hx, false);
// 수성(dmg_taken_pct) · 창천이사(dmg_taken_from_archer_pct)
var cr = Spawn(UnitType.Infantry, Faction.Player, -6f, -5f, "조인");
Give(cr, "caoren");
Assert.AreEqual(Expect(gen.Atk, im, cr.Def, 1f + Eff("caoren_p", SkillKeys.DmgTakenPct).P), Dmg(gen, cr), 1, "dmg_taken_pct");
var zj = Spawn(UnitType.Infantry, Faction.Player, -3f, -5f, "장각");
Give(zj, "zhangjiao");
var ea = Spawn(UnitType.Archer, Faction.Enemy, -3f, 8f, "적궁병");
float am = cfg.Matchup(UnitType.Archer, UnitType.Infantry);
Assert.AreEqual(Expect(ea.Atk, am, zj.Def, 1f + Eff("zhangjiao_p", SkillKeys.DmgTakenFromArcherPct).P), Dmg(ea, zj, false), 1, "적 궁병 피해 −");
Assert.AreEqual(Expect(gen.Atk, im, zj.Def, 1f), Dmg(gen, zj), 1, "궁병 아님 = 그대로");
// 무성(counter_ratio) · 진중(p2 1 = 밀렸을 때만) · 군율(counter_taken_pct) · 금범적(−1 = 반격 없음)
var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -5f, "관우");
Give(gy, "guanyu");
int baseGy = Combat.Damage(cfg, gy, gen, false, new System.Random(Seed), true);
Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseGy * Eff("guanyu_p", SkillKeys.CounterRatio).P)), Combat.CounterDamage(cfg, gy, gen, new System.Random(Seed)), "counter_ratio");
var yj = Spawn(UnitType.Infantry, Faction.Player, 3f, -5f, "우금");
Give(yj, "yujin");
int baseYj = Combat.Damage(cfg, yj, gen, false, new System.Random(Seed), true);
Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseYj * cfg.counterRatio)), Combat.CounterDamage(cfg, yj, gen, new System.Random(Seed), false), "안 밀림 = 기본 반격");
Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseYj * Eff("yujin_p", SkillKeys.CounterRatio).P)), Combat.CounterDamage(cfg, yj, gen, new System.Random(Seed), true), "밀렸을 때 = 반격 배율");
int baseC = Combat.Damage(cfg, gen, xh, false, new System.Random(Seed), true);
Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseC * cfg.counterRatio * (1f + Eff("xuhuang_p", SkillKeys.CounterTakenPct).P))), Combat.CounterDamage(cfg, gen, xh, new System.Random(Seed)), "counter_taken_pct");
var gn = Spawn(UnitType.Archer, Faction.Player, 6f, -5f, "감녕");
Give(gn, "ganning");
Assert.AreEqual(0, Combat.CounterDamage(cfg, gen, gn, new System.Random(Seed)), "금범적 = 근접 반격 없음");
// 복수(on_ally_death_atk) · 패자(team_atk_pct_full · 출전 인원 조건)
yield return Fresh();
var wc = Spawn(UnitType.Cavalry, Faction.Player, 0f, -8f, "문추");
Give(wc, "wenchou");
var dead = Spawn(UnitType.Infantry, Faction.Player, 3f, -8f, "전사자");
Assert.AreEqual(1f, SkillMods.AttackMult(wc), 1e-5f);
dead.TakeDamage(dead.Hp);
Assert.AreEqual(1f + Eff("wenchou_p", SkillKeys.OnAllyDeathAtk).P, SkillMods.AttackMult(wc), 1e-5f, "on_ally_death_atk");
yield return Fresh();
var cc = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
Give(cc, "caocao");
var fe = Eff("caocao_p", SkillKeys.TeamAtkPctFull);
int needN = Mathf.RoundToInt(fe.A2);
var mates = new List<Unit>();
for (int i = 1; i < needN; i++) mates.Add(Spawn(UnitType.Infantry, Faction.Player, -6f + i * 2.5f, -8f, "아군" + i));
bm.Skills.BeginBattle();
Assert.AreEqual(1f + fe.P, SkillMods.AttackMult(mates[0]), 1e-5f, "출전 n 인 = 아군 전원 공 +p");
mates[0].gameObject.SetActive(false);
bm.ClearUnits();
yield return null;
var cc2 = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
Give(cc2, "caocao");
var mate = Spawn(UnitType.Infantry, Faction.Player, 0f, -8f, "아군");
bm.Skills.BeginBattle();
Assert.AreEqual(1f, SkillMods.AttackMult(mate), 1e-5f, "인원 미달 = 없음");
// 산채 체력(hp_max_pct)
var zc = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "주창");
int baseHp = zc.MaxHp;
Give(zc, "zhoucang");
Assert.AreEqual(Mathf.RoundToInt(baseHp * (1f + Eff("zhoucang_p", SkillKeys.HpMaxPct).P)), zc.MaxHp, "hp_max_pct");
Assert.AreEqual(zc.MaxHp, zc.Hp);
}
// ---------- 즉시형(needs_impl 처리기 포함) ----------
[UnityTest]
public IEnumerator Instant_PushFear_Fog_Guard_Aura_TeamTaken_HpCost_SlingTeam_Aggro()
{
yield return Fresh();
// 장판 일갈(aoe_push_fear): 반경 p2 안 적을 바깥으로 p1 · 이번 적 턴 교전 반응 없음
var zf = Spawn(UnitType.Cavalry, Faction.Player, 0f, -3f, "장비");
Give(zf, "zhangfei");
var pf = Eff("zhangfei_a", SkillKeys.AoePushFear);
var near = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -3f + 1.5f, "가까운 적");
var farE = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -3f + pf.A2 + 3f, "먼 적");
float y0 = near.Position.y;
bm.Sling.Select(zf);
Assert.IsNotNull(bm.Hud.PressSkill());
yield return WaitPhase(BattlePhase.PlayerTurn);
Assert.AreEqual(y0 + pf.P, near.Position.y, 0.08f, "바깥으로 p1 밀기");
Assert.AreEqual(bm.TurnNumber + Cd("zhangfei_a"), bm.Skills.Of(zf).ReadyTurn, "즉시형도 쿨");
Assert.IsFalse(zf.ActedThisTurn, "행동 소모 없음");
Assert.IsFalse(bm.Skills.Suppressed(near), "아군 턴에는 해당 없음");
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.IsTrue(bm.Skills.Suppressed(near), "이번 적 턴 교전 반응 없음");
Assert.IsFalse(bm.Skills.Suppressed(farE), "반경 밖 적은 정상");
bm.ForcePhase(BattlePhase.PlayerTurn);
// 태평요술(enemy_aggro_off_turn): 이번 적 턴 적 전원
var zj = Spawn(UnitType.Infantry, Faction.Player, 4f, -6f, "장각");
Give(zj, "zhangjiao");
bm.Sling.Select(zj);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.IsTrue(bm.Skills.Suppressed(farE), "안개 = 적 전원");
bm.ForcePhase(BattlePhase.PlayerTurn);
// 적 턴을 실제로 돌리면 아무도 움직이지 않고 · 끝나면 효과가 사라진다
zf.MarkActed();
zj.MarkActed();
Vector2 fp = farE.Position;
yield return bm.RunEnemyTurn();
Assert.AreEqual(fp, farE.Position, "교전 반응 없음 = 행동 안 함");
Assert.IsFalse(bm.Skills.Suppressed(farE), "다음 아군 턴 = 해제");
// 호위(guard_share) · 철벽(dmg_taken_aura) · 간웅(team_dmg_taken_pct_turn)
yield return Fresh();
var gp = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관평");
Give(gp, "guanping");
var gs = Eff("guanping_a", SkillKeys.GuardShare);
var mate = Spawn(UnitType.Infantry, Faction.Player, gs.A2 * 0.8f, -3f, "아군");
mate.MaxHp = mate.Hp = gp.MaxHp = gp.Hp = 10000;
Spawn(UnitType.Infantry, Faction.Enemy, 0f, 9f, "먼 적"); // 적이 없으면 즉시 발동 뒤 승리 판정
bm.Sling.Select(gp);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
int share = Mathf.RoundToInt(100 * Mathf.Clamp01(gs.P));
Assert.AreEqual(100, SkillSystem.Deal(mate, 100), "아군 턴 = 분담 없음");
bm.ForcePhase(BattlePhase.EnemyTurn);
int mh = mate.Hp, gh = gp.Hp;
Assert.AreEqual(100 - share, SkillSystem.Deal(mate, 100), "guard_share = 받는 피해 일부를 관평이");
Assert.AreEqual(gh - share, gp.Hp);
Assert.AreEqual(mh - (100 - share), mate.Hp);
bm.ForcePhase(BattlePhase.PlayerTurn);
yield return Fresh();
var cr = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "조인");
Give(cr, "caoren");
var au = Eff("caoren_a", SkillKeys.DmgTakenAura);
var inR = Spawn(UnitType.Infantry, Faction.Player, au.A2 * 0.8f, -3f, "곁");
var outR = Spawn(UnitType.Infantry, Faction.Player, au.A2 + 3f, -3f, "먼 아군");
var hitter = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
bm.Sling.Select(cr);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
Assert.AreEqual(1f, SkillMods.TakenMult(inR, hitter), 1e-5f, "아군 턴 = 아직");
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.AreEqual(1f + au.P, SkillMods.TakenMult(inR, hitter), 1e-5f, "dmg_taken_aura 반경 안");
Assert.AreEqual(1f, SkillMods.TakenMult(outR, hitter), 1e-5f, "반경 밖");
bm.ForcePhase(BattlePhase.PlayerTurn);
var cc = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
Give(cc, "caocao");
bm.Sling.Select(cc);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.AreEqual(1f + Eff("caocao_a", SkillKeys.TeamDmgTakenPctTurn).P, SkillMods.TakenMult(outR, hitter), 1e-5f, "team_dmg_taken_pct_turn");
bm.ForcePhase(BattlePhase.PlayerTurn);
// 고육계(hp_cost_atk): HP p2 소모 · 이번 턴 공 +p1 · 적 턴 시작에 끝
yield return Fresh();
var hg = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "황개");
Give(hg, "huanggai");
var hc = Eff("huanggai_a", SkillKeys.HpCostAtk);
Spawn(UnitType.Infantry, Faction.Enemy, 0f, 9f, "먼 적");
int hp0 = hg.Hp;
bm.Sling.Select(hg);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
Assert.AreEqual(Mathf.Max(1, hp0 - Mathf.RoundToInt(hg.MaxHp * hc.A2)), hg.Hp, "HP 소모");
Assert.AreEqual(1f + hc.P, SkillMods.AttackMult(hg), 1e-5f, "이번 턴 공 +");
bm.Skills.OnEnemyTurnStart();
Assert.AreEqual(1f, SkillMods.AttackMult(hg), 1e-5f, "적 턴 시작 = 끝");
// 동남풍(sling_dist_pct · p3 1 = 아군 전원 · 이번 턴) · 적토마(p2 절대 가산) · 신산(enemy_aggro_add)
yield return Fresh();
var zg = Spawn(UnitType.Strategist, Faction.Player, 0f, -6f, "제갈량");
Give(zg, "zhugeliang");
var lb = Spawn(UnitType.Cavalry, Faction.Player, 4f, -6f, "여포");
Give(lb, "lvbu");
var foot = Spawn(UnitType.Infantry, Faction.Player, -4f, -6f, "보병");
var en = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
var lbp = Eff("lvbu_p", SkillKeys.SlingDistPct);
Assert.AreEqual(lb.SlingMaxDistance * (1f + lbp.P) + lbp.A2, SkillMods.SlingMax(lb), 1e-4f, "적토마 = 절대 가산");
Assert.AreEqual(Eff("zhugeliang_p", SkillKeys.EnemyAggroAdd).P, bm.Skills.AggroAdd(Faction.Enemy), 1e-5f, "신산 = 적 교전 범위 +p");
Assert.AreEqual(0f, bm.Skills.AggroAdd(Faction.Player), 1e-5f);
bm.Sling.Select(zg);
bm.Hud.PressSkill();
yield return WaitPhase(BattlePhase.PlayerTurn);
var dn = Eff("zhugeliang_a", SkillKeys.SlingDistPct);
Assert.AreEqual(foot.SlingMaxDistance * (1f + dn.P) + dn.A2, SkillMods.SlingMax(foot), 1e-4f, "동남풍 = 아군 전원");
Assert.AreEqual(en.SlingMaxDistance, SkillMods.SlingMax(en), 1e-4f, "상대 제외");
bm.Skills.OnEnemyTurnStart();
Assert.AreEqual(foot.SlingMaxDistance, SkillMods.SlingMax(foot), 1e-4f, "이번 턴만");
}
[UnityTest]
public IEnumerator Instant_Teleport_TapInsideRadius_NoActionUsed_OutsideCancels()
{
yield return Fresh();
var zh = Spawn(UnitType.Spear, Faction.Player, 0f, -5f, "장합");
Give(zh, "zhanghe");
Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
float r = Eff("zhanghe_a", SkillKeys.Teleport).P;
bm.Sling.Select(zh);
bm.Hud.PressSkill();
Assert.AreEqual(zh, bm.Skills.PendingTeleport, "변진 = 지점 탭 대기");
yield return null;
Assert.IsTrue(bm.Sling.TeleportAreaVisible, "반경 원 표시");
string msg;
Assert.IsTrue(bm.Skills.TryTeleportTap(zh.Position + new Vector2(r + 1f, 0f), out msg));
Assert.IsNull(bm.Skills.PendingTeleport);
Assert.AreEqual(new Vector2(0f, -5f), zh.Position, "반경 밖 = 취소");
Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(zh), "취소 = 쿨 소모 없음");
bm.Hud.PressSkill();
Vector2 dest = zh.Position + new Vector2(r * 0.7f, 0f);
Assert.IsTrue(bm.Skills.TryTeleportTap(dest, out msg));
yield return WaitPhase(BattlePhase.PlayerTurn);
Assert.Less(Vector2.Distance(dest, zh.Position), 0.01f, "teleport = 반경 안 빈 곳으로 이동");
Assert.IsFalse(zh.ActedThisTurn, "행동 소모 없음");
Assert.AreEqual(bm.TurnNumber + Cd("zhanghe_a"), bm.Skills.Of(zh).ReadyTurn);
}
// ---------- 쿨 · 예약 · 취소 · HUD ----------
[UnityTest]
public IEnumerator Active_Reserve_Cancel_UseCooldown_HudButton_PassiveLog()
{
yield return Fresh();
var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관우");
Give(gy, "guanyu");
var other = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "보병");
var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
foe.MaxHp = foe.Hp = 10000;
bm.Sling.Select(gy);
yield return null;
Assert.IsTrue(bm.Hud.SkillButtonVisible, "유닛 선택 → 스킬 버튼");
StringAssert.Contains(SkillCatalog.Get("guanyu_a").NameKo, bm.Hud.SkillButtonText, "이름");
Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(gy), "전투 시작 = 사용 가능");
bm.Hud.PressSkill();
Assert.IsTrue(bm.Skills.Of(gy).Reserved, "탭 = 예약");
yield return null;
Assert.IsTrue(gy.View.SkillBadgeVisible, "예약 표시");
bm.Hud.PressSkill();
Assert.IsFalse(bm.Skills.Of(gy).Reserved, "다시 탭 = 취소");
Assert.AreEqual(1, bm.Skills.Of(gy).ReadyTurn, "취소 = 쿨 소모 없음");
// 예약 후 쓰지 않고 적 턴 = 예약만 사라짐
bm.Hud.PressSkill();
bm.Skills.OnEnemyTurnStart();
Assert.IsFalse(bm.Skills.Of(gy).Reserved);
Assert.AreEqual(1, bm.Skills.Of(gy).ReadyTurn);
// 예약 → 실제 행동(RequestPlayerAction) → 쿨
bm.Sling.Select(gy);
bm.Hud.PressSkill();
Assert.IsTrue(bm.RequestPlayerAction(gy, foe.Position));
yield return WaitPhase(BattlePhase.PlayerTurn);
int cd = Cd("guanyu_a");
Assert.AreEqual(1 + cd, bm.Skills.Of(gy).ReadyTurn, "사용한 턴 + 쿨");
StringAssert.Contains(SkillCatalog.Get("guanyu_a").NameKo, bm.LastMessage, "행동 문구에 스킬 이름");
StringAssert.Contains("액티브", string.Join(" ", bm.Skills.RecentLog(30f, 8).ToArray()), "HUD 액티브 한 줄");
gy.ResetForNewTurn();
bm.SetTurnForTest(cd);
Assert.AreEqual(SkillSystem.ActiveState.Cooldown, bm.Skills.StateOf(gy), "쿨 중 = 사용 불가");
Assert.AreEqual(1, bm.Skills.Of(gy).CooldownLeft(bm.TurnNumber));
bm.Hud.PressSkill();
Assert.IsFalse(bm.Skills.Of(gy).Reserved, "쿨 중 탭 = 예약 안 됨");
bm.SetTurnForTest(1 + cd);
Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(gy), "N 턴 뒤 재사용");
// 행동 끝난 유닛 = next_action 예약 불가 · 적 유닛 = 버튼은 패시브만
gy.MarkActed();
Assert.AreEqual(SkillSystem.ActiveState.Acted, bm.Skills.StateOf(gy));
var eb = Spawn(UnitType.Infantry, Faction.Enemy, 6f, 6f, "적 무장");
Give(eb, "caoren");
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(eb), "적 = 액티브 사용 없음");
// 패시브 발동 = HUD 한 줄
Combat.Damage(cfg, foe, eb, false, new System.Random(Seed), true);
StringAssert.Contains(SkillCatalog.Get("caoren_p").NameKo, bm.Skills.Log[bm.Skills.Log.Count - 1].Text, "패시브 발동 한 줄");
StringAssert.Contains("패시브", bm.Skills.Log[bm.Skills.Log.Count - 1].Text);
bm.Sling.Select(eb);
yield return null;
StringAssert.Contains("패시브", bm.Hud.SkillButtonText);
}
// ---------- ★3 · ★5 · 편성 · 적 무장 ----------
[UnityTest]
public IEnumerator Star3ActiveCap_Star5PassiveMult_Lineup_EnemyGeneralPassiveOnly()
{
var db = Db;
int capStar = 0, multStar = 0;
foreach (var r in db.Evolve.rows)
{
if (capStar == 0 && SkillRules.ActiveCapBonus(db, r.star) > 0) capStar = r.star;
if (multStar == 0 && SkillRules.PassiveMult(db, r.star) > 1f) multStar = r.star;
}
Assert.Greater(capStar, 1, "evolve.csv 액티브 상한 해금 ★");
Assert.Greater(multStar, 1, "evolve.csv 패시브 배율 ★");
var save = HeroRules.NewSave(db, cfg.playerSpawnPoints.Length, T0);
var h = db.Heroes.Get(db.Economy.StartHeroes[0]);
var hs = save.Hero(h.id);
hs.star = capStar - 1;
int capBefore = SkillRules.Cap(db, h, hs, 0);
hs.star = capStar;
Assert.AreEqual(Mathf.Min(capBefore + SkillRules.ActiveCapBonus(db, capStar), db.Skills.Get(h.skillActive).lvMax), SkillRules.Cap(db, h, hs, 0), "★3 = 액티브 상한 +");
Assert.AreEqual(HeroRules.SkillCap(db, hs), SkillRules.Cap(db, h, hs, 1), "패시브 상한 = 등급");
var e = new LineupEntry();
hs.star = multStar - 1;
SkillRules.FillLineup(db, save, h, e);
Assert.AreEqual(1f, e.passiveMult, 1e-5f);
hs.star = multStar;
SkillRules.FillLineup(db, save, h, e);
Assert.AreEqual(SkillRules.PassiveMult(db, multStar), e.passiveMult, 1e-5f, "★5 = 패시브 배율");
Assert.AreEqual(h.skillActive, e.skillActive);
Assert.IsTrue(e.general);
// 편성 → 전투 유닛에 스킬(시작 영웅 = 액티브·패시브) · ★5 패시브 수치 ×
yield return Fresh();
var u = Spawn(h.type, Faction.Player, 0f, -6f, h.nameKo);
bm.Skills.AttachLineup(u, e);
var s = bm.Skills.Of(u);
Assert.AreEqual(h.skillActive, s.Active.Id);
var raw = SkillCatalog.Get(h.skillPassive).Effects[0];
SkillKeys.Info info = SkillKeys.All[raw.Key];
Assert.AreEqual(SkillKeys.Apply(info.Main, raw.P, e.passiveMult), s.PassiveEffects[0].P, 1e-4f, "★5 패시브 효과 적용");
// 적 무장(heroes.csv 에 있는 보스·엘리트) = 패시브만
int idx = -1;
for (int i = 0; i < cfg.stages.Length && idx < 0; i++)
foreach (var en in cfg.stages[i].enemies)
if ((en.isBoss || en.isGeneral) && SkillCatalog.HeroForEnemy(en.art, en.name) != null)
{
idx = i;
break;
}
Assert.GreaterOrEqual(idx, 0, "영웅 표에 있는 적 무장 스테이지");
bm.StartStage(idx);
yield return null;
int withPassive = 0;
foreach (var x in bm.Units)
{
if (x == null || x.Faction != Faction.Enemy) continue;
var es = bm.Skills.Of(x);
if (es == null || es.Passive == null) continue;
withPassive++;
지형 기믹 레이어(PD #1097 세션 D) + 병종 규칙 개정 2건 · 예측 시뮬레이션 적 AI(PD #1098) - 데이터: Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv + Assets/Data/AI/ai_weights.csv(기획 tables/ 사본) → 메뉴 TKS/Import Gimmicks(검증 · 오류 시 자산 불변) → Resources/Gimmicks/GimmickDb(16)·StageGimmickTable(16 + 팁 3) · Resources/AI/AiWeights(32) - 레이어: GimmickLayer(전투 시작 = 스테이지 배치 · 배경 위 유닛 아래 · 원/사각/다각 · 색 도형 + 글자 · 내구 핍 · 강 콜라이더 = 다리 구간 제외 · 겹친 유닛 보정) · 장애물 = 병종별 내구 피해·튕김·창 목책 밀기·바위 굴림·기병 정지 · 투사체 pass/block/destroy · 폭약 광역(아군 포함)+밀기+불 · 책사 광역 덤불 제거 · 강 낙하 - 지대: 경로 스윕(독 1→2→3 % · 늪 ×2 · 불 10 % · 빙판 미끄러짐 · 고지 · 안개 · 군기 · 함정) · 양 진영 턴 시작 훅 - 규칙 개정: 보병 튕김 중 다른 적과 연쇄 1회 · 창병 반작용(spearRecoilRatio · 밀며 전진 폐기) · 기존 테스트 기대값 갱신 - 적 AI: Runtime/AI(Sim 가상 실행 · TacticalAI 후보·점수·난이도·턴 운영 · 1부 보스 액티브) · 조준선 = 가상 실행 예상 궤적·기믹 반응·지대 띠 · TKS/Gimmick Painter · 캡처 GimmickImporter.Capture - 검증: 컴파일 0 · 플레이모드 113/113(신규 26) · console error 0 · 적 10기 턴 결정 62 ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:20:07 +00:00
// PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만
if (x.IsBoss && TacticalAI.BossUsesActive(bm.Stage, new BalanceConfig.EnemySpawn { isBoss = true })) Assert.IsNotNull(es.Active, "1부 보스 = 액티브도");
else Assert.IsNull(es.Active, "엘리트 무장 = 액티브 없음");
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x), "적 액티브는 HUD 버튼 상태 없음(AI 전용)");
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
Assert.IsTrue(es.IsGeneral);
}
Assert.Greater(withPassive, 0, "적 무장 패시브 부착");
int playersWithActive = 0;
foreach (var x in bm.Units)
if (x != null && x.Faction == Faction.Player && bm.Skills.Of(x) != null && bm.Skills.Of(x).Active != null) playersWithActive++;
Assert.AreEqual(bm.Deployed.Count, playersWithActive, "출전 영웅 = 액티브 부착");
}
// ---------- 스킬 레벨업 · 스킬서 ----------
[Test]
public void SkillLevelUp_BooksGold_Cap_Max_BookShopDaily()
{
var db = Db;
var save = HeroRules.NewSave(db, Resources.Load<BalanceConfig>("BalanceConfig").playerSpawnPoints.Length, T0);
Assert.AreEqual(db.Economy.StartSkillBooks, save.inv.books.skill, "신규 유저 스킬서");
var h = db.Heroes.Get(db.Economy.StartHeroes[0]);
var hs = save.Hero(h.id);
int cap = SkillRules.Cap(db, h, hs, 1);
Assert.AreEqual(1, SkillRules.Level(hs, 1));
if (cap <= 1) StringAssert.Contains("상한", SkillRules.CanLevelUp(db, save, h, hs, 1), "상한 = 등급");
var cost = db.Skills.CostTo(2);
int books0 = save.inv.books.skill, gold0 = save.gold;
hs.grade = db.Promotion.Get(hs.grade).nextGrade; // 승급 → 상한 +1
Assert.IsNull(SkillRules.LevelUp(db, save, h, hs, 1));
Assert.AreEqual(2, SkillRules.Level(hs, 1));
Assert.AreEqual(books0 - cost.books, save.inv.books.skill, "스킬서 소모");
Assert.AreEqual(gold0 - cost.gold, save.gold, "골드 소모");
save.inv.books.skill = 0;
hs.grade = db.Promotion.Get(hs.grade).nextGrade;
StringAssert.Contains("스킬서", SkillRules.CanLevelUp(db, save, h, hs, 1), "스킬서 부족");
hs.skillLv[0] = db.Skills.Get(h.skillActive).lvMax;
Assert.AreEqual("최고 레벨", SkillRules.CanLevelUp(db, save, h, hs, 0));
// 스킬서 임시 판매: 하루 n 개 · 골드 · 다음 날 다시
var e = db.Economy;
save.gold = e.SkillBookPrice * (e.SkillBookShopDaily + 2);
int g = save.gold;
for (int i = 0; i < e.SkillBookShopDaily; i++) Assert.IsNull(SkillRules.BuyBook(db, save, T0), "구매 " + i);
Assert.IsNotNull(SkillRules.BuyBook(db, save, T0), "하루 한도");
Assert.AreEqual(e.SkillBookShopDaily, save.inv.books.skill);
Assert.AreEqual(g - e.SkillBookPrice * e.SkillBookShopDaily, save.gold);
Assert.IsNull(SkillRules.BuyBook(db, save, T0.AddDays(1)), "다음 날 다시");
}
[UnityTest]
public IEnumerator HeroDetail_SkillUpButton_OpensModal_LevelsUp_ShopHasBookCell()
{
var og = OutGameRoot.Instance;
Assert.IsNotNull(og);
og.ShowLobby();
var id = og.Session.Db.Economy.StartHeroes[0];
og.ShowHero(id);
yield return null;
og.HeroDetail.OnSkillUp();
Assert.IsTrue(og.ModalVisible, "스킬 레벨업 = 고르기 팝업");
og.CloseModal();
var h = og.Session.Def(id);
var hs = og.Session.Save.Hero(id);
int lv0 = SkillRules.Level(hs, 0);
if (SkillRules.CanLevelUp(og.Session.Db, og.Session.Save, h, hs, 0) == null)
{
Assert.IsNull(SkillRules.LevelUp(og.Session.Db, og.Session.Save, h, hs, 0));
og.Session.Commit();
Assert.AreEqual(lv0 + 1, og.Session.Save.Hero(id).skillLv[0], "세이브 반영");
}
og.Trade.OpenShop();
yield return null;
아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
Assert.IsFalse(og.Trade.SkillBookCellVisible, "스킬서 임시 판매 제거 = 사당 던전(PD #1093 C)");
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
Assert.AreEqual(og.Session.Db.Economy.ShopSlots, og.Trade.ShopCardCount, "장비 칸 수는 그대로");
}
아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
// ---------- 영웅 56명 확장(PD #1096) — 신규 키 4종 · 기존 키 조건 열 ----------
[UnityTest]
public IEnumerator Expansion_NewKeys_TeamDmg_Cooldown_Heal_Charm()
{
// 십승십패(team_dmg_pct_turn · 즉시): 이번 아군 턴 아군 전원 가하는 피해 × (1 + p) · 귀재(cooldown_add 패시브 · 자기) = 쿨 + p(최소 1)
yield return Fresh();
var gj = Spawn(UnitType.Strategist, Faction.Player, -6f, -8f, "곽가");
var gs = Give(gj, "guojia");
var ally = Spawn(UnitType.Infantry, Faction.Player, 0f, -2f, "보병");
var foe = Spawn(UnitType.Infantry, Faction.Enemy, 6f, 6f, "적");
float before = SkillMods.DealtMult(ally, foe);
yield return bm.Skills.Instant(gs);
Assert.AreEqual(before * (1f + Eff("guojia_a", SkillKeys.TeamDmgPctTurn).P), SkillMods.DealtMult(ally, foe), 1e-4f, "team_dmg_pct_turn = 아군 전원");
Assert.AreEqual(1f, SkillMods.DealtMult(foe, ally), 1e-4f, "상대 = 미적용");
Assert.AreEqual(Mathf.Max(1, Cd("guojia_a") + Mathf.RoundToInt(Eff("guojia_p", SkillKeys.CooldownAdd).P)), gs.ReadyTurn - bm.TurnNumber, "cooldown_add 자기 쿨");
bm.Skills.OnEnemyTurnStart();
Assert.AreEqual(before, SkillMods.DealtMult(ally, foe), 1e-4f, "적 턴 시작 = 해제");
// 금슬(cooldown_add 즉시 · p2 1 = 아군 전원): 다른 아군의 남은 쿨 + p(이번 턴 아래로는 안 감 · 시전자 제외)
yield return Fresh();
var xq = Spawn(UnitType.Strategist, Faction.Player, -6f, -8f, "소교");
var xs = Give(xq, "xiaoqiao");
var zy = Spawn(UnitType.Cavalry, Faction.Player, 0f, -2f, "조운");
var zs = Give(zy, "zhaoyun");
zs.ReadyTurn = bm.TurnNumber + 3;
yield return bm.Skills.Instant(xs);
Assert.AreEqual(bm.TurnNumber + 3 + Mathf.RoundToInt(Eff("xiaoqiao_a", SkillKeys.CooldownAdd).P), zs.ReadyTurn, "아군 쿨 감소");
Assert.AreEqual(bm.TurnNumber + Cd("xiaoqiao_a"), xs.ReadyTurn, "시전자 쿨 = 표 그대로");
// 꿀물(heal_self_pct)
yield return Fresh();
var ys = Spawn(UnitType.Infantry, Faction.Player, 0f, -4f, "원술");
var yss = Give(ys, "yuanshu");
ys.Hp = 1;
yield return bm.Skills.Instant(yss);
Assert.AreEqual(Mathf.Min(ys.MaxHp, 1 + Mathf.RoundToInt(ys.MaxHp * Eff("yuanshu_a", SkillKeys.HealSelfPct).P)), ys.Hp, "heal_self_pct");
// 연환계(charm): 반경 p1 안 상대 무장 우선 1기 → p2 적 턴 동안 교전 반응 없음
yield return Fresh();
var dc = Spawn(UnitType.Strategist, Faction.Player, 0f, -4f, "초선");
var dcs = Give(dc, "diaochan");
var ce = Eff("diaochan_a", SkillKeys.Charm);
var soldier = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -4f + ce.P * 0.4f, "병사");
var general = Spawn(UnitType.Infantry, Faction.Enemy, 1f, -4f + ce.P * 0.8f, "무장");
bm.Skills.MarkGeneral(general);
yield return bm.Skills.Instant(dcs);
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.IsTrue(bm.Skills.Suppressed(general), "무장 우선 매혹");
Assert.IsFalse(bm.Skills.Suppressed(soldier), "1기만");
for (int i = 0; i < Mathf.RoundToInt(ce.A2); i++) bm.Skills.OnPlayerTurnStart();
Assert.IsFalse(bm.Skills.Suppressed(general), "p2 적 턴 뒤 해제");
}
[UnityTest]
public IEnumerator Expansion_ConditionColumns_TakenHpTurnCounterTeam()
{
// 받는 피해 조건: 위연(p2 = HP 임계) · 원술(p2 2 = 보스에게서만) · 초선(p2 3 = 근접에게서만)
yield return Fresh();
var wy = Spawn(UnitType.Infantry, Faction.Player, -4f, -8f, "위연");
Give(wy, "weiyan");
var atk = Spawn(UnitType.Infantry, Faction.Enemy, 4f, 8f, "적");
Assert.AreEqual(1f, SkillMods.TakenMult(wy, atk), 1e-5f, "HP 가득 = 미적용");
var we = Eff("weiyan_p", SkillKeys.DmgTakenPct);
wy.Hp = Mathf.FloorToInt(wy.MaxHp * we.A2);
Assert.AreEqual(1f + we.P, SkillMods.TakenMult(wy, atk), 1e-5f, "HP 임계 이하 = 적용");
yield return Fresh();
var yu = Spawn(UnitType.Infantry, Faction.Player, -4f, -8f, "원술");
Give(yu, "yuanshu");
var boss = Spawn(UnitType.Infantry, Faction.Enemy, 4f, 8f, "보스", true);
var grunt = Spawn(UnitType.Infantry, Faction.Enemy, -4f, 8f, "병사");
Assert.AreEqual(1f + Eff("yuanshu_p", SkillKeys.DmgTakenPct).P, SkillMods.TakenMult(yu, boss), 1e-5f, "보스에게");
Assert.AreEqual(1f, SkillMods.TakenMult(yu, grunt), 1e-5f, "병사에게 = 미적용");
yield return Fresh();
var dch = Spawn(UnitType.Strategist, Faction.Player, -4f, -8f, "초선");
Give(dch, "diaochan");
var mel = Spawn(UnitType.Cavalry, Faction.Enemy, 4f, 8f, "기병");
var arc = Spawn(UnitType.Archer, Faction.Enemy, -4f, 8f, "궁병");
Assert.AreEqual(1f + Eff("diaochan_p", SkillKeys.DmgTakenPct).P, SkillMods.TakenMult(dch, mel), 1e-5f, "근접에게");
Assert.AreEqual(1f, SkillMods.TakenMult(dch, arc), 1e-5f, "궁병에게 = 미적용");
// 원소(p3 = 출전 n 인 이상 · 아군 전원)
var yp = Eff("yuanshao_p", SkillKeys.DmgTakenPct);
int need = Mathf.RoundToInt(yp.A3);
yield return Fresh();
var ysh = Spawn(UnitType.Archer, Faction.Player, -6f, -8f, "원소");
Give(ysh, "yuanshao");
var mates = new List<Unit>();
for (int i = 1; i < need; i++) mates.Add(Spawn(UnitType.Infantry, Faction.Player, -6f + i * 2.5f, -10f, "병" + i));
var en = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
bm.Skills.BeginBattle();
Assert.AreEqual(1f + yp.P, SkillMods.TakenMult(mates[0], en), 1e-5f, "출전 " + need + "인 = 아군 전원");
Assert.AreEqual(1f, SkillMods.TakenMult(en, mates[0]), 1e-5f, "상대 = 미적용");
yield return Fresh();
var en2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
var solo = Spawn(UnitType.Archer, Faction.Player, -6f, -8f, "원소");
Give(solo, "yuanshao");
bm.Skills.BeginBattle();
Assert.AreEqual(1f, SkillMods.TakenMult(solo, en2), 1e-5f, "출전 인원 미달 = 미적용");
// 순욱 패시브(hp_max_pct p2 1 = 아군 전원 · 배치 뒤) · 액티브(counter_ratio p3 1 = 아군 전원 · 이번 적 턴)
yield return Fresh();
var xy = Spawn(UnitType.Strategist, Faction.Player, -6f, -8f, "순욱");
var xys = Give(xy, "xunyu");
var mate = Spawn(UnitType.Infantry, Faction.Player, 0f, -8f, "보병");
var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
int m0 = mate.MaxHp, x0 = xy.MaxHp, f0 = foe.MaxHp;
bm.Skills.BeginBattle();
float hp = Eff("xunyu_p", SkillKeys.HpMaxPct).P;
Assert.AreEqual(Mathf.RoundToInt(m0 * (1f + hp)), mate.MaxHp, "아군 전원 최대 HP");
Assert.AreEqual(Mathf.RoundToInt(x0 * (1f + hp)), xy.MaxHp, "자기 포함");
Assert.AreEqual(f0, foe.MaxHp, "상대 = 미적용");
yield return bm.Skills.Instant(xys);
bm.ForcePhase(BattlePhase.EnemyTurn);
Assert.AreEqual(Eff("xunyu_a", SkillKeys.CounterRatio).P, SkillMods.CounterRatio(mate, cfg.counterRatio, false), 1e-5f, "아군 전원 반격 배율");
Assert.AreEqual(cfg.counterRatio, SkillMods.CounterRatio(foe, cfg.counterRatio, false), 1e-5f, "상대 = 기본");
// 여몽 패시브(dmg_pct p2 = 발동 턴 이상)
yield return Fresh();
var lm = Spawn(UnitType.Infantry, Faction.Player, -4f, -8f, "여몽");
Give(lm, "lvmeng");
var tg = Spawn(UnitType.Infantry, Faction.Enemy, 4f, 8f, "적");
var lp = Eff("lvmeng_p", SkillKeys.DmgPct);
bm.SetTurnForTest(Mathf.RoundToInt(lp.A2) - 1);
Assert.AreEqual(1f, SkillMods.DealtMult(lm, tg), 1e-5f, "발동 턴 전");
bm.SetTurnForTest(Mathf.RoundToInt(lp.A2));
Assert.AreEqual(1f + lp.P, SkillMods.DealtMult(lm, tg), 1e-5f, "발동 턴부터");
}
스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서 - 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표) - 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매) - 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영) - UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시) - 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인) - 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:31:27 +00:00
}
}