ThreeKingdomsSlingshot/Assets/Tests/PlayMode/TacticalAITests.cs

200 lines
9.3 KiB
C#
Raw Permalink Normal View History

지형 기믹 레이어(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
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using NUnit.Framework;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
/// <summary>
/// 적 AI 예측 시뮬레이션(PD #1098 · 전술규칙_적AI_기획서 §2) — 전술 항목이 점수에 반영되는지(함정 쪽 튕기기 · 창병 아군 밀어 전진 · 기병 일렬 휩쓸기 ·
/// 궁병 목책 엄폐 · 책사 덤불 제거) · 가상 실행이 상태를 되돌리는지 · 성능(적 10기 턴 결정 ≤ 1.5 초). 가중치 = ai_weights.csv(AiWeights 자산).
/// </summary>
public class TacticalAITests
{
BattleManager bm;
GimmickLayer L;
AiWeights W;
[UnitySetUp]
public IEnumerator SetUp()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
bm = BattleManager.Instance;
var wm = WorldMapScreen.Instance;
if (wm != null && wm.HasData) wm.EnterStage(0, 0);
yield return null;
W = AiWeights.Load();
Assert.IsNotNull(W, "AiWeights 자산(TKS/Import Gimmicks)");
Time.timeScale = 20f;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = 1f;
yield return null;
}
IEnumerator Fresh()
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.EnemyTurn);
L = GimmickLayer.Ensure(bm);
L.Clear();
yield return null;
}
AiPlan Best(Unit e)
{
List<AiPlan> list;
var p = TacticalAI.Evaluate(bm, e, bm.AliveCount(Faction.Enemy), out list);
Assert.IsNotNull(p, "후보 있음");
return p;
}
[UnityTest]
public IEnumerator Infantry_BouncesPlayerIntoTrap_AndSimRestoresState()
{
yield return Fresh();
var trap = L.Spawn("pit_trap", GimmickShape.Circle(new Vector2(0f, 3.4f), 0.6f));
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병");
var victim = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, 1.2f), "아군창병");
var other = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-4f, -1.5f), "아군보병");
Vector2 v0 = victim.Position, e0 = e.Position;
int hv = victim.Hp, he = e.Hp;
var best = Best(e);
Assert.IsTrue(best.Why.Contains("기믹 Trap"), "함정 쪽으로 튕겨내는 수가 최고 점수(" + best.Why + ")");
Assert.AreEqual(v0, victim.Position, "가상 실행 뒤 위치 복원");
Assert.AreEqual(e0, e.Position);
Assert.AreEqual(hv, victim.Hp, "HP 복원");
Assert.AreEqual(he, e.Hp);
Assert.IsFalse(trap.Removed, "기믹 상태 복원");
var vs = L.StateOf(victim);
Assert.IsTrue(vs == null || vs.Stun == 0, "행동 불가 복원");
var o = new ActionOutcome();
yield return bm.ResolveForTest(e, best.Target, false, o);
Assert.IsTrue(trap.Revealed, "실제 실행 = 함정 발동");
Assert.Greater(L.StateOf(victim).Stun, 0);
Assert.IsTrue(other.IsAlive);
}
[UnityTest]
public IEnumerator Spear_PushesOwnAllyTowardPlayers_SupportScore()
{
yield return Fresh();
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, new Vector2(0f, 4f), "적창병");
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 2.5f), "적보병");
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -4f), "아군");
var best = Best(spear);
Assert.IsTrue(best.Why.Contains("서포트"), "창병 = 아군을 밀어 적진에 빨리 닿게(" + best.Why + ")");
Assert.AreEqual(0, best.Group, "서포터 그룹 = 먼저 움직인다");
Assert.IsTrue(p.IsAlive && ally.IsAlive);
}
[UnityTest]
public IEnumerator Cavalry_SweepsLinedUpPlayers()
{
yield return Fresh();
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Enemy, new Vector2(0f, 4f), "적기병");
for (int i = 0; i < 3; i++)
{
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 1.5f - i * 1.5f), "줄" + i);
u.MaxHp = u.Hp = 5000;
}
bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(5f, 3f), "따로");
var best = Best(cav);
int n = 0;
var m = System.Text.RegularExpressions.Regex.Match(best.Why, "관통 (\\d+)");
if (m.Success) n = int.Parse(m.Groups[1].Value);
Assert.GreaterOrEqual(n, 2, "일렬로 선 적을 한 번에 꿰뚫는 수(" + best.Why + ")");
}
[UnityTest]
public IEnumerator Archer_BehindPalisade_CoverScore()
{
yield return Fresh();
var a = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 3f), "적궁병");
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -0.8f), "아군");
p.MaxHp = p.Hp = 5000;
float open = Best(a).Score;
L.Spawn("palisade", GimmickShape.Box(new Vector2(0f, 1.6f), new Vector2(6f, 0.4f)));
var best = Best(a);
Assert.IsTrue(best.Why.Contains("목책 엄폐"), "목책 뒤 사격(" + best.Why + ")");
Assert.AreEqual(open + W["archer_cover"], best.Score, 1.5f, "엄폐 가중치만큼 점수 +");
}
[UnityTest]
public IEnumerator Strategist_ClearsThorn_OpensAllyRoute()
{
yield return Fresh();
var st = bm.SpawnUnit(UnitType.Strategist, Faction.Enemy, new Vector2(0f, 3.5f), "적책사");
L.Spawn("thorn_bush", GimmickShape.Box(Vector2.zero, new Vector2(3f, 0.6f)));
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -0.9f), "아군");
p.MaxHp = p.Hp = 5000;
bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(1.6f, 1.9f), "적보병");
var best = Best(st);
Assert.IsTrue(best.Why.Contains("덤불 제거"), "책사 = 덤불을 지워 아군 돌격로를 연다(" + best.Why + ")");
Assert.AreEqual(0, best.Group, "서포터 그룹");
}
[UnityTest]
public IEnumerator Performance_10Enemies_TurnDecisions_Under1500ms()
{
yield return Fresh();
var pending = new List<Unit>();
var types = new[] { UnitType.Infantry, UnitType.Spear, UnitType.Cavalry, UnitType.Archer, UnitType.Strategist };
for (int i = 0; i < 10; i++)
pending.Add(bm.SpawnUnit(types[i % 5], Faction.Enemy, new Vector2(-8f + (i % 5) * 4f, 2f + (i / 5) * 2.5f), "적" + i));
for (int i = 0; i < 5; i++) bm.SpawnUnit(types[i], Faction.Player, new Vector2(-8f + i * 4f, -3f), "아군" + i);
L.Spawn("palisade", GimmickShape.Box(new Vector2(-4f, 0f), new Vector2(4f, 0.4f)));
L.Spawn("swamp", GimmickShape.Box(new Vector2(4f, 0f), new Vector2(6f, 2f)));
L.Spawn("pit_trap", GimmickShape.Circle(new Vector2(0f, -1f), 0.6f));
var sw = Stopwatch.StartNew();
int decisions = 0;
var rng = new System.Random(3);
while (pending.Count > 0)
{
var plan = TacticalAI.Next(bm, pending, 0.6f, 30f, rng);
if (plan == null) break;
pending.Remove(plan.Unit);
decisions++;
}
sw.Stop();
UnityEngine.Debug.Log("[TacticalAITests] 적 10기 턴 결정 " + decisions + "회 · " + sw.ElapsedMilliseconds + " ms");
Assert.AreEqual(10, decisions);
Assert.LessOrEqual(sw.ElapsedMilliseconds, 1500, "적 10기 턴(1기마다 재평가) ≤ 1.5 초");
}
[UnityTest]
public IEnumerator EnemyTurn_UsesTacticalAI_AndBossActiveGate()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군");
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 2f), "적");
p.MarkActed();
bm.LastAiPlan = null;
yield return bm.RunEnemyTurn();
Assert.IsNotNull(bm.LastAiPlan, "적 턴 = 예측 AI 결정");
Assert.AreEqual(e, bm.LastAiPlan.Unit);
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
var boss = new BalanceConfig.EnemySpawn { isBoss = true };
var ch1 = new BalanceConfig.StageDef { chapter = 1 };
var ch11 = new BalanceConfig.StageDef { chapter = Mathf.RoundToInt(W["active_max_chapter"]) + 1 };
Assert.IsTrue(TacticalAI.BossUsesActive(ch1, boss), "1부 보스 = 액티브 사용");
Assert.IsFalse(TacticalAI.BossUsesActive(ch11, boss), "2부 = 아직");
Assert.IsFalse(TacticalAI.BossUsesActive(ch1, new BalanceConfig.EnemySpawn { isGeneral = true }), "보스만");
}
}
}