Add AI Director defense and style metrics

This commit is contained in:
wuwenbo 2026-07-01 16:49:15 +08:00
parent d6fec5029e
commit 5947ea048f
9 changed files with 803 additions and 12 deletions

View File

@ -95,7 +95,7 @@ Use this loop for all long-running AI intelligence work. The goal is not just "n
4. Classify the problem type.
- Correctness: failed games, exceptions, null references, action timeout, action budget stop, no-effect successful actions, illegal repeated actions.
- Intelligence: weak expansion, low city count, poor unit count, bad attrition, obvious idle/economic waste, bad hero task timing, over-defense, under-attack.
- Intelligence: weak expansion, low city count, poor unit count, bad attrition, city loss, long capital threat, empty threatened cities, obvious idle/economic waste, bad hero task timing, weak hero style expression, weak special-unit skill expression, over-defense, under-attack.
- Performance: low actions/sec, high avg game runtime, high decision p95/max, too many actions per player turn, oversized JSONL output.
- Noise: legal repeated city upgrades, normal tactical move/attack chains, expected Steam warmup messages, incomplete logs from killed batches.
@ -124,6 +124,7 @@ Primary guardrails:
- `repeated` should be `0` after excluding known legal repeats such as same-turn `CityLevelUpAction:Park` consuming city upgrade points.
- `maxActions/playerTurn` should stay well below the forced-stop budget; investigate anything above `80`.
- No AI loop, forced AI stop, fatal exception, or unresolved null reference is acceptable.
- `cityLost` and `capitalOwnershipChanged` are high-severity intelligence/correctness signals; inspect the TurnStart-to-TurnStart city signature before changing combat weights.
Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
@ -132,6 +133,9 @@ Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
- Track max city count and alive `>=3` city ratio as snowball signals, but do not overfit to one high-roll player.
- Unit count and score p10/p50/p90 should not collapse while expansion improves.
- Attrition should be interpreted with context: more kills and more deaths may indicate stronger aggression, not necessarily worse play.
- Defense should be read from `Defense:` in the batch analyzer: `cityLost=0` is the target for short compact batches; rising `capitalThreatTurns`, `emptyThreatTurns`, or `worsened > resolved` means Emergency, city production, or Hold/Front logic needs review.
- Hero style should not collapse to only `General`; `style buckets` should show expected class/faction roles such as Defense, Recovery, Burst, Control, Summon, Economy, Mobility, and HeroLifecycle as heroes appear.
- Special-unit expression should be checked through `Unit skill expression`: `skill-bearing actor actions`, `signature-changing actions`, and `actor signatures` reveal whether new faction units are actually participating.
Performance targets:

View File

@ -55,6 +55,60 @@ def average(values) -> float:
return sum(values) / len(values)
def contains_any(value: str, tokens) -> bool:
if not value:
return False
lower = value.lower()
return any(token.lower() in lower for token in tokens if token)
def classify_style_bucket(reason: str, action: str) -> str:
text = f"{reason or ''} {action or ''}"
if contains_any(text, ("LowHp", "Recover", "Heal", "Eirin", "Sanae", "Patchouli", "Absorb", "Revive")):
return "Recovery"
if contains_any(text, ("Defense", "Defend", "Protect", "Guard", "MoveToCity", "Aunn", "Meiling", "Sakuya", "Reimu", "ShakeOff", "Unsit")):
return "Defense"
if contains_any(text, ("Kill", "Flandre", "Assassin", "AttackValue", "LocalBattle", "Boom", "Mokou", "Reisen", "Yuugi")):
return "Burst"
if contains_any(text, ("Ban", "Fear", "Control", "Satori", "Koishi", "Sumireko", "Orb", "Ground")):
return "Control"
if contains_any(text, ("Summon", "CreateMini", "Suwako", "Snake", "BonePile", "Mini")):
return "Summon"
if contains_any(text, ("Economy", "CityExp", "Corpse", "KanakoSit", "Rin", "Tewi", "Kaguya")):
return "Economy"
if contains_any(text, ("MoveAgain", "MoveToFront", "Retreat", "Mobility", "Aya", "Kasen")):
return "Mobility"
if contains_any(text, ("SelectHero", "SpawnHero", "FinishLowestTask")):
return "HeroLifecycle"
return "General"
def is_defensive_style(bucket: str) -> bool:
return bucket in ("Defense", "Recovery")
def is_hero_action(action: dict) -> bool:
if not action:
return False
actor_unit_type = action.get("actorUnitType") or ""
actor_giant_type = action.get("actorGiantType") or ""
giant_type = action.get("giantType") or ""
return (
actor_unit_type == "Giant"
or actor_giant_type not in ("", "None")
or giant_type not in ("", "None")
)
def compact_skill_signature(signature: str) -> str:
if not signature:
return ""
parts = signature.split("|")
if len(parts) <= 4:
return signature
return "|".join(parts[:4]) + "|..."
def percentile(values, ratio: float) -> float:
values = sorted(value for value in values if value is not None)
if not values:
@ -197,6 +251,27 @@ def analyze_logs(log_paths):
"ready_hero_task_delta": 0,
"forced_hero_task_delta": 0,
"hero_task_progress_delta": 0,
"critical_city_threat_turns": 0,
"capital_threat_turns": 0,
"empty_threatened_city_turns": 0,
"empty_threatened_city_total": 0,
"city_threat_resolved": 0,
"city_threat_worsened": 0,
"city_lost": 0,
"city_gained": 0,
"capital_ownership_changed": 0,
"emergency_decisions": 0,
"emergency_executions": 0,
"defender_return": 0,
"hero_defensive_use": 0,
"defense_reasons": Counter(),
"defense_actions": Counter(),
"hero_style_buckets": Counter(),
"hero_style_reasons": Counter(),
"hero_style_actions": Counter(),
"unit_skill_actions": Counter(),
"unit_skill_changed_actions": Counter(),
"actor_skill_signatures": Counter(),
}
for path in log_paths:
@ -223,6 +298,8 @@ def analyze_logs(log_paths):
stable_rows_by_turn = defaultdict(list)
turn_rows = defaultdict(list)
decision_lane_by_turn_action = {}
decision_reason_by_turn_action = {}
last_turn_summary_by_player = {}
for row in rows:
event_type = row.get("eventType", "")
if event_type == "Decision":
@ -242,15 +319,50 @@ def analyze_logs(log_paths):
stable_key(action),
)
decision_lane_by_turn_action[turn_action_key] = lane
decision_reason_by_turn_action[turn_action_key] = reason
if lane == "Emergency":
aggregate["emergency_decisions"] += 1
aggregate["defense_reasons"][reason] += 1
aggregate["defense_actions"][action_key(action)] += 1
if lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_lane_counts"][lane] += 1
aggregate["hero_reasons"][reason] += 1
aggregate["hero_actions"][action_key(action)] += 1
key = action_key(action)
aggregate["hero_actions"][key] += 1
bucket = classify_style_bucket(reason, key)
aggregate["hero_style_buckets"][bucket] += 1
aggregate["hero_style_reasons"][f"{bucket}:{reason}"] += 1
aggregate["hero_style_actions"][f"{bucket}:{key}"] += 1
if decision.get("isFallback"):
aggregate["fallback_reasons"][reason] += 1
aggregate["fallback_actions"][action_key(action)] += 1
continue
if event_type == "TurnStart":
turn_summary = row.get("turnSummary") or {}
player_id = row.get("playerId", 0)
if int(turn_summary.get("criticalCityThreatCount") or 0) > 0:
aggregate["critical_city_threat_turns"] += 1
if int(turn_summary.get("capitalThreatCount") or 0) > 0:
aggregate["capital_threat_turns"] += 1
empty_threatened = int(turn_summary.get("emptyThreatenedCityCount") or 0)
if empty_threatened > 0:
aggregate["empty_threatened_city_turns"] += 1
aggregate["empty_threatened_city_total"] += empty_threatened
previous = last_turn_summary_by_player.get(player_id)
if previous:
previous_cities = int(previous.get("cityCount") or 0)
current_cities = int(turn_summary.get("cityCount") or 0)
if current_cities < previous_cities:
aggregate["city_lost"] += previous_cities - current_cities
if current_cities > previous_cities:
aggregate["city_gained"] += current_cities - previous_cities
if (previous.get("capitalCityIdsSignature") or "") != (turn_summary.get("capitalCityIdsSignature") or ""):
aggregate["capital_ownership_changed"] += 1
last_turn_summary_by_player[player_id] = turn_summary
continue
if event_type != "Execution":
continue
@ -263,6 +375,7 @@ def analyze_logs(log_paths):
key = action_key(action)
turn_action_key = (player_id, row.get("playerTurn", 0), stable_key(action))
decision_lane = decision_lane_by_turn_action.get(turn_action_key, "")
decision_reason = decision_reason_by_turn_action.get(turn_action_key, "")
aggregate["player_actions"][player_id] += 1
if decision_lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_executed_actions"][key] += 1
@ -277,6 +390,31 @@ def analyze_logs(log_paths):
aggregate["ready_hero_task_delta"] += int(delta.get("readyHeroTaskDelta") or 0)
aggregate["forced_hero_task_delta"] += int(delta.get("forcedHeroTaskDelta") or 0)
aggregate["hero_task_progress_delta"] += int(delta.get("heroTaskProgressDelta") or 0)
if delta.get("cityThreatResolved"):
aggregate["city_threat_resolved"] += 1
if delta.get("cityThreatWorsened"):
aggregate["city_threat_worsened"] += 1
if decision_lane == "Emergency":
aggregate["emergency_executions"] += 1
if decision_lane == "Emergency" and key.startswith("UnitMove"):
aggregate["defender_return"] += 1
if is_hero_action(action):
bucket = classify_style_bucket(decision_reason, key)
if is_defensive_style(bucket) or decision_lane == "Emergency":
aggregate["hero_defensive_use"] += 1
before = execution.get("before") or {}
skill_signature = before.get("unitSkillSignature") or ""
if skill_signature:
aggregate["unit_skill_actions"][key] += 1
aggregate["actor_skill_signatures"][compact_skill_signature(skill_signature)] += 1
if (
delta.get("unitSkillSignatureChanged")
or delta.get("targetUnitSkillSignatureChanged")
or int(delta.get("unitSkillDelta") or 0) != 0
or int(delta.get("targetUnitSkillDelta") or 0) != 0
):
aggregate["unit_skill_changed_actions"][key] += 1
if delta.get("targetUnitDied"):
aggregate["kills"] += 1
@ -329,6 +467,17 @@ def quality_warnings(batch, metrics, log_metrics):
warnings.append(f"NO_EFFECT_ACTIONS: count={log_metrics['no_effect']}.")
if log_metrics["repeated_stable"] > 0:
warnings.append(f"REPEATED_ACTIONS: count={log_metrics['repeated_stable']}.")
if log_metrics["city_lost"] > 0:
warnings.append(f"CITY_LOSS: cityLost={log_metrics['city_lost']}.")
if log_metrics["capital_ownership_changed"] > 0:
warnings.append(f"CAPITAL_CHANGED: count={log_metrics['capital_ownership_changed']}.")
defense_opportunities = log_metrics["critical_city_threat_turns"] + log_metrics["empty_threatened_city_turns"]
if defense_opportunities > 0 and log_metrics["emergency_decisions"] == 0:
warnings.append(f"NO_EMERGENCY_RESPONSE: defenseOpportunities={defense_opportunities}.")
if log_metrics["city_threat_worsened"] > log_metrics["city_threat_resolved"] + max(2, defense_opportunities // 4):
warnings.append(
f"DEFENSE_WORSENING: worsened={log_metrics['city_threat_worsened']} resolved={log_metrics['city_threat_resolved']}."
)
if log_metrics["decide_ms"]:
p95 = percentile(log_metrics["decide_ms"], 0.95)
if p95 > 60:
@ -412,6 +561,22 @@ def to_jsonable(value):
def compact_log_metrics(log_metrics, top):
decide_ms = log_metrics["decide_ms"]
defense_opportunities = log_metrics["critical_city_threat_turns"] + log_metrics["empty_threatened_city_turns"]
emergency_response_rate = (
min(1.0, log_metrics["emergency_decisions"] / defense_opportunities)
if defense_opportunities > 0
else 0.0
)
defense_score = (
log_metrics["city_threat_resolved"] * 3
+ log_metrics["emergency_executions"]
+ log_metrics["defender_return"]
+ log_metrics["hero_defensive_use"] * 2
- log_metrics["city_lost"] * 8
- log_metrics["capital_threat_turns"] * 2
- log_metrics["empty_threatened_city_turns"] * 2
- log_metrics["city_threat_worsened"] * 2
)
return {
"logs": [str(path) for path in log_metrics["logs"]],
"rows": log_metrics["rows"],
@ -424,6 +589,26 @@ def compact_log_metrics(log_metrics, top):
"self_deaths": log_metrics["self_deaths"],
"city_owner_changes": log_metrics["city_owner_changes"],
"target_city_owner_changes": log_metrics["target_city_owner_changes"],
"defense": {
"critical_city_threat_turns": log_metrics["critical_city_threat_turns"],
"capital_threat_turns": log_metrics["capital_threat_turns"],
"empty_threatened_city_turns": log_metrics["empty_threatened_city_turns"],
"empty_threatened_city_total": log_metrics["empty_threatened_city_total"],
"defense_opportunity_turns": defense_opportunities,
"city_threat_resolved": log_metrics["city_threat_resolved"],
"city_threat_worsened": log_metrics["city_threat_worsened"],
"city_lost": log_metrics["city_lost"],
"city_gained": log_metrics["city_gained"],
"capital_ownership_changed": log_metrics["capital_ownership_changed"],
"emergency_decisions": log_metrics["emergency_decisions"],
"emergency_executions": log_metrics["emergency_executions"],
"defender_return": log_metrics["defender_return"],
"hero_defensive_use": log_metrics["hero_defensive_use"],
"emergency_response_rate": emergency_response_rate,
"defense_score": defense_score,
"top_reasons": log_metrics["defense_reasons"].most_common(top),
"top_actions": log_metrics["defense_actions"].most_common(top),
},
"decision_time": {
"avg_ms": average(decide_ms),
"p95_ms": percentile(decide_ms, 0.95),
@ -436,6 +621,9 @@ def compact_log_metrics(log_metrics, top):
"top_reasons": log_metrics["hero_reasons"].most_common(top),
"top_actions": log_metrics["hero_actions"].most_common(top),
"top_executed_actions": log_metrics["hero_executed_actions"].most_common(top),
"style_buckets": log_metrics["hero_style_buckets"].most_common(top),
"style_reasons": log_metrics["hero_style_reasons"].most_common(top),
"style_actions": log_metrics["hero_style_actions"].most_common(top),
"hero_delta": log_metrics["hero_delta"],
"selected_hero_delta": log_metrics["selected_hero_delta"],
"hero_task_delta": log_metrics["hero_task_delta"],
@ -449,6 +637,11 @@ def compact_log_metrics(log_metrics, top):
"top_executed_actions": log_metrics["fallback_executed_actions"].most_common(top),
"top_no_effect_actions": log_metrics["fallback_no_effect_actions"].most_common(top),
},
"unit_skill_expression": {
"top_actions": log_metrics["unit_skill_actions"].most_common(top),
"top_changed_actions": log_metrics["unit_skill_changed_actions"].most_common(top),
"top_actor_signatures": log_metrics["actor_skill_signatures"].most_common(top),
},
}
@ -508,6 +701,36 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
f"cityOwnerChanges={log_metrics['city_owner_changes']} "
f"targetCityOwnerChanges={log_metrics['target_city_owner_changes']}"
)
defense_opportunities = log_metrics["critical_city_threat_turns"] + log_metrics["empty_threatened_city_turns"]
emergency_response_rate = (
min(1.0, log_metrics["emergency_decisions"] / defense_opportunities)
if defense_opportunities > 0
else 0.0
)
defense_score = (
log_metrics["city_threat_resolved"] * 3
+ log_metrics["emergency_executions"]
+ log_metrics["defender_return"]
+ log_metrics["hero_defensive_use"] * 2
- log_metrics["city_lost"] * 8
- log_metrics["capital_threat_turns"] * 2
- log_metrics["empty_threatened_city_turns"] * 2
- log_metrics["city_threat_worsened"] * 2
)
print(
"Defense: "
f"score={defense_score:.1f} "
f"criticalThreatTurns={log_metrics['critical_city_threat_turns']} "
f"capitalThreatTurns={log_metrics['capital_threat_turns']} "
f"emptyThreatTurns={log_metrics['empty_threatened_city_turns']} "
f"resolved={log_metrics['city_threat_resolved']} "
f"worsened={log_metrics['city_threat_worsened']} "
f"cityLost={log_metrics['city_lost']} "
f"cityGained={log_metrics['city_gained']} "
f"emergencyResponse={emergency_response_rate:.1%} "
f"defenderReturn={log_metrics['defender_return']} "
f"heroDefense={log_metrics['hero_defensive_use']}"
)
if log_metrics["decide_ms"]:
print(
"Decision time: "
@ -546,6 +769,26 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
print(f" {count:>5} {key}")
else:
print(" executed actions: <none>")
if log_metrics["hero_style_buckets"]:
print(" style buckets:")
for key, count in log_metrics["hero_style_buckets"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" style buckets: <none>")
print("Unit skill expression:")
if log_metrics["unit_skill_actions"]:
print(" skill-bearing actor actions:")
for key, count in log_metrics["unit_skill_actions"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" skill-bearing actor actions: <none>")
if log_metrics["unit_skill_changed_actions"]:
print(" signature-changing actions:")
for key, count in log_metrics["unit_skill_changed_actions"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" signature-changing actions: <none>")
print("Fallback:")
if log_metrics["fallback_actions"]:

View File

@ -13,6 +13,8 @@ ZERO_DELTA_FIELDS = (
"scoreDelta",
"sightGridDelta",
"cityDelta",
"cityLostDelta",
"cityGainedDelta",
"unitDelta",
"heroDelta",
"selectedHeroDelta",
@ -23,6 +25,9 @@ ZERO_DELTA_FIELDS = (
"heroTaskProgressDelta",
"criticalCityThreatDelta",
"cityThreatDelta",
"capitalThreatDelta",
"criticalCapitalThreatDelta",
"emptyThreatenedCityDelta",
"unitHealthDelta",
"unitSkillDelta",
"unitGridBuildingLevelDelta",
@ -110,6 +115,12 @@ def has_no_effect_delta(execution: dict) -> bool:
return False
if delta.get("cityOwnerChanged") or delta.get("targetCityOwnerChanged"):
return False
if delta.get("cityOwnershipSignatureChanged") or delta.get("capitalOwnershipSignatureChanged"):
return False
if delta.get("cityThreatResolved") or delta.get("cityThreatWorsened"):
return False
if delta.get("cityThreatSignatureChanged") or delta.get("criticalCityThreatSignatureChanged"):
return False
if delta.get("unitSkillSignatureChanged") or delta.get("targetUnitSkillSignatureChanged"):
return False
grid_change_fields = (

View File

@ -575,17 +575,40 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 |
| 性能 | 单个 AI 动作决策无明显卡顿 |
当前批跑调优采用以下基线指标,不用单局主观感觉判断 AI 是否变聪明
当前批跑调优采用以下基线指标,不用单局主观感觉判断 AI 是否变聪明。指标只用于诊断和调参,不反向要求 AI 套统一万能公式。
| 指标组 | 观察项 | 目标 |
|---|---|---|
| 正确性 | failedGames、noEffect、repeated、maxActions/playerTurn | 必须为 0 或远低于强制停止线 |
| 扩张 | aliveAvgCities、alive>=2、alive>=3、maxCities | 二城率和三城率稳定提升,不能靠单个高滚玩家掩盖整体弱扩张 |
| 战斗 | UnitAttack 数、kills、actingUnitDeaths、PriorityTactic/Tactic 占比 | 有战果且不过度白送,城市威胁能被处理 |
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、英雄专属 reason/action | 已选择英雄应尽快上场,英雄动作应体现个人机制 |
| 防守 | cityLost、capitalThreatTurns、emptyThreatenedCityTurns、resolved/worsened、emergencyResponse、defenderReturn | 少丢城,首都少长期受压,危险城市有回防或生产响应 |
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、英雄专属 reason/action、styleBuckets | 已选择英雄应尽快上场,英雄动作应体现个人机制 |
| 特色单位 | unitSkillActions、unitSkillChangedActions、actorSkillSignatures | 新阵营小兵和特殊单位要通过技能签名、特殊动作、状态变化展示存在感 |
| Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 |
| 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 |
防守指标解释:
| 指标 | 含义 |
|---|---|
| cityLost | 同一玩家两次回合开始之间城市数减少,代表上一轮循环没守住 |
| capitalThreatTurns | 回合开始时首都处于城市威胁范围 |
| emptyThreatenedCityTurns | 回合开始时有受威胁城市中心没有己方或同盟单位驻守 |
| resolved/worsened | 某个行动后城市威胁数量、危急威胁或 dangerScore 改善/恶化 |
| emergencyResponse | 有防守机会时 Emergency 决策的覆盖率 |
| defenderReturn | Emergency 中实际执行回防移动的次数 |
英雄和特色单位指标解释:
| 指标 | 含义 |
|---|---|
| styleBuckets | 把英雄 reason/action 归为 Defense、Recovery、Burst、Control、Summon、Economy、Mobility、HeroLifecycle、General |
| heroDefensiveUse | 英雄在防守、治疗、保护或 Emergency 中参与的次数 |
| unitSkillActions | 带技能签名的单位实际执行了哪些动作 |
| unitSkillChangedActions | 动作前后改变了单位或目标技能签名的动作 |
| actorSkillSignatures | 最常参与行动的技能组合,辅助判断新阵营小兵是否真的被用到 |
问题定位:
| 现象 | 优先检查 |

View File

@ -2552,12 +2552,142 @@ Growth: 只遍历合法动作
## 18. 测试脚本标准
### 18.0 批量诊断指标
批量指标不参与单次决策,只服务测试循环。
```text
结构 DefenseMetrics:
CriticalCityThreatTurns
CapitalThreatTurns
EmptyThreatenedCityTurns
CityLost
CityGained
CityThreatResolved
CityThreatWorsened
EmergencyDecisions
EmergencyExecutions
DefenderReturn
HeroDefensiveUse
EmergencyResponseRate
DefenseScore
```
```text
函数 BuildTurnDefenseSnapshot(map, player):
snapshot.CityIds = 当前 player 拥有城市 Id 排序签名
snapshot.CapitalCityIds = 当前 player 首都 Id 排序签名
snapshot.CityThreatCount = 受威胁城市数量
snapshot.CriticalCityThreatCount = 危急城市数量
snapshot.CapitalThreatCount = 受威胁首都数量
snapshot.EmptyThreatenedCityCount = 受威胁且城市中心无己方/同盟单位驻守的城市数量
snapshot.MaxCityDangerScore = 所有城市威胁中的最高危险值
返回 snapshot
```
```text
函数 BuildActionDefenseDelta(before, after):
delta.CityThreatResolved =
before.CityThreatCount > 0
且 (
after.CriticalCityThreatCount < before.CriticalCityThreatCount
或 after.CityThreatCount < before.CityThreatCount
或 after.MaxCityDangerScore < before.MaxCityDangerScore
)
delta.CityThreatWorsened =
after.CriticalCityThreatCount > before.CriticalCityThreatCount
或 after.CityThreatCount > before.CityThreatCount
或 after.EmptyThreatenedCityCount > before.EmptyThreatenedCityCount
或 after.MaxCityDangerScore > before.MaxCityDangerScore
返回 delta
```
城市丢失不从单个 action 直接统计,按同一玩家两次 `TurnStart` 对比:
```text
函数 AccumulateCityLoss(previousTurnStart, currentTurnStart):
如果 current.CityCount < previous.CityCount:
CityLost += previous.CityCount - current.CityCount
如果 current.CityCount > previous.CityCount:
CityGained += current.CityCount - previous.CityCount
如果 current.CapitalCityIds != previous.CapitalCityIds:
CapitalOwnershipChanged += 1
```
防守综合分只用于批量对比:
```text
DefenseScore =
CityThreatResolved * 3
+ EmergencyExecutions
+ DefenderReturn
+ HeroDefensiveUse * 2
- CityLost * 8
- CapitalThreatTurns * 2
- EmptyThreatenedCityTurns * 2
- CityThreatWorsened * 2
```
```text
EmergencyResponseRate =
EmergencyDecisions / max(1, CriticalCityThreatTurns + EmptyThreatenedCityTurns)
```
英雄风格表达按 reason/action 归桶:
```text
函数 ClassifyHeroStyle(reason, actionKey):
如果 包含 LowHp、Recover、Heal、Eirin、Sanae、Patchouli、Absorb、Revive:
返回 Recovery
如果 包含 Defense、Protect、Guard、MoveToCity、Aunn、Meiling、Sakuya、Reimu:
返回 Defense
如果 包含 Kill、Flandre、AttackValue、Boom、Mokou、Reisen、Yuugi:
返回 Burst
如果 包含 Ban、Fear、Control、Satori、Koishi、Sumireko、Orb、Ground:
返回 Control
如果 包含 Summon、CreateMini、Suwako、Snake、BonePile、Mini:
返回 Summon
如果 包含 Economy、CityExp、Corpse、KanakoSit、Rin、Tewi、Kaguya:
返回 Economy
如果 包含 MoveAgain、MoveToFront、Retreat、Mobility、Aya、Kasen:
返回 Mobility
如果 包含 SelectHero、SpawnHero、FinishLowestTask:
返回 HeroLifecycle
返回 General
```
特色单位表达按技能签名统计:
```text
函数 AccumulateUnitSkillExpression(execution):
before = execution.Before
action = execution.Action
delta = execution.Delta
如果 before.UnitSkillSignature 为空:
返回
UnitSkillActions[action.ActionKey] += 1
ActorSkillSignatures[Compact(before.UnitSkillSignature)] += 1
如果 delta.UnitSkillSignatureChanged
或 delta.TargetUnitSkillSignatureChanged
或 delta.UnitSkillDelta != 0
或 delta.TargetUnitSkillDelta != 0:
UnitSkillChangedActions[action.ActionKey] += 1
```
### 18.1 城市防守
```text
给 AI 城市附近放敌军。
期望:
Emergency 返回攻击威胁、回防、或危险城市生产。
批量日志中 cityLost 尽量为 0。
capitalThreatTurns 不应长期升高。
resolved 应高于 worsened。
```
### 18.2 占领扩张
@ -2574,6 +2704,17 @@ Growth: 只遍历合法动作
给 AI 放残血友军、敌方英雄、地面攻击目标、自爆窗口。
期望:
HeroPlaybook 选择对应英雄动作。
styleBuckets 中出现对应英雄风格,不应长期只有 General。
```
### 18.3.1 特色小兵机制
```text
给 AI 放置新阵营特色小兵和普通小兵混合场景。
期望:
unitSkillActions 中出现特色小兵动作。
actorSkillSignatures 能看到该阵营技能组合。
如果技能会改变状态unitSkillChangedActions 应出现对应 action。
```
### 18.4 战线移动

View File

@ -34,6 +34,8 @@ AI 测试必须回答四个问题:
| 扩张 | `aliveAvgCities``alive>=2``alive>=3``maxCities` | 看整体城市数和二城/三城率是否提升 |
| 战斗 | `UnitAttack``kills``actingUnitDeaths``PriorityTactic/Tactic` | 看是否有战果,是否过度送兵 |
| 英雄 | `selected``spawned``HeroManagement``HeroPlaybook`、英雄 reason/action | 看英雄是否上场、是否用个人机制 |
| 防守 | `cityLost``capitalThreatTurns``emptyThreatenedCityTurns``resolved/worsened``emergencyResponse` | 看是否少丢城、是否响应危险城市 |
| 特色单位 | `unitSkillActions``unitSkillChangedActions``actorSkillSignatures` | 看英雄/小兵技能是否真的参与行动 |
| Fallback | Fallback 总数、actionType、no-effect | 用来发现规则缺口 |
| 性能 | `actions/sec``avgGame``decision avg/p95/max` | 只处理明显尖峰,不牺牲聪明度做极端优化 |
@ -280,7 +282,7 @@ playerGrowth
资源coin / techPoint / culture / cultureCardCount
规模cityCount / unitCount / heroCount
军力selfMilitary / enemyMilitary
威胁criticalCityThreatCount / cityThreatCount / maxCityDangerScore
威胁criticalCityThreatCount / cityThreatCount / capitalThreatCount / emptyThreatenedCityCount / maxCityDangerScore
关键对象:单位血量、位置、死亡,城市归属、等级
```
@ -289,11 +291,13 @@ playerGrowth
```text
netActionDelta
coinDelta / techPointDelta / cultureDelta
cityDelta / unitDelta / heroDelta
cityDelta / cityLostDelta / cityGainedDelta / unitDelta / heroDelta
selfMilitaryDelta / enemyMilitaryDelta
criticalCityThreatDelta / cityThreatDelta
criticalCityThreatDelta / cityThreatDelta / capitalThreatDelta / emptyThreatenedCityDelta
cityThreatResolved / cityThreatWorsened
unitMoved / unitDied / targetUnitDied
cityOwnerChanged / targetCityOwnerChanged
unitSkillSignatureChanged / targetUnitSkillSignatureChanged
```
重点看:
@ -305,9 +309,29 @@ netActionDelta 是否推进
delta 是否符合动作意图
攻击是否造成 targetUnitHealthDelta 或 targetUnitDied
回防是否降低 criticalCityThreatDelta 或 maxCityDangerScoreDelta
防守行动是否让 cityThreatResolved=true或至少不让 cityThreatWorsened=true
占领是否造成 cityOwnerChanged 或 cityDelta
特色单位行动是否带有 unitSkillSignature并在需要时改变 skill signature
```
### 5.7 batch diagnostics
`batch_summary.json``diagnostics` 会聚合单局 JSONL重点字段
| 字段 | 用途 |
|---|---|
| cityLostCount / cityGainedCount | 同一玩家两次 TurnStart 之间的城市减少/增加 |
| capitalThreatTurnCount | 首都处于威胁中的回合开始次数 |
| emptyThreatenedCityTurnCount | 受威胁且城市中心无人驻守的回合开始次数 |
| cityThreatResolvedCount / cityThreatWorsenedCount | 行动后城市威胁改善/恶化次数 |
| emergencyResponseRate | 防守机会中 Emergency 决策覆盖率 |
| defenderReturnCount | Emergency 中执行回防移动的次数 |
| heroDefensiveUse | 英雄参与防守、保护、治疗或 Emergency 的次数 |
| defenseScore | 防守综合分,用于同参数批跑前后对比 |
| topHeroStyleBuckets | 英雄风格分布 |
| topUnitSkillActionTypes | 带技能签名单位执行的动作 |
| topActorSkillSignatures | 最常行动的技能组合 |
---
## 6. 单回合阅读流程
@ -582,9 +606,14 @@ ActionPool 最大数量
| 执行失败率 | execution.executed | 参数或同步问题 |
| 候选生成量 | actionPool.all | 性能风险 |
| 城市威胁变化 | execution.delta.criticalCityThreatDelta | 防守效果 |
| 城市丢失 | 相邻 TurnStart 的 cityCount / cityIdsSignature | 防守结果 |
| 首都压力 | turnSummary.capitalThreatCount | 防守风险 |
| 空城压力 | turnSummary.emptyThreatenedCityCount | 驻防质量 |
| 军力交换 | selfMilitaryDelta / enemyMilitaryDelta | 战斗收益 |
| 扩张收益 | cityDelta / cityOwnerChanged | 占领能力 |
| 英雄存活 | heroDelta / unitDied | 英雄保命 |
| 英雄风格 | Hero reason/action -> styleBuckets | 英雄个性表达 |
| 特色单位表达 | unitSkillSignature / unitSkillChangedActions | 小兵技能是否发挥 |
| 重复动作 | action.stableKey | 循环风险 |
自动归因规则:
@ -608,6 +637,12 @@ NoAction率低但想要的动作不在 lanes
Emergency 后 criticalCityThreatDelta 不下降
=> 防守动作没有真正解决问题,调整 Emergency 行动优先级
cityLost 高或 capitalThreatTurns 长期高
=> 城市威胁识别、Emergency 响应、城市生产和回防目标优先级需要调整
emptyThreatenedCityTurns 高
=> AI 可能只扩张不驻防,检查 Front/Hold、EmergencyMove、城市训练防守兵
Tactic 后 enemyMilitaryDelta 不下降且 selfMilitaryDelta 下降
=> 攻击评分低估反击或高估输出
@ -617,6 +652,12 @@ UnitOpportunity 长期不触发 Capture/Examine/Gather
HeroPlaybook 候选少或长期无效
=> 英雄规则条件或目标策略需要补
styleBuckets 长期只有 General
=> 英雄 Playbook 缺专属 reason/action或分类规则缺失需要补英雄机制表达
unitSkillActions 低
=> 特色小兵没进入行动主体检查训练、Front、UnitOpportunity 和 Tactic 对该兵种的使用
decideMs 尖峰且 actionPool.all 高
=> 限制候选数量、Front/DevelopmentTarget TopN 或移动枚举
```

View File

@ -13,7 +13,7 @@ namespace Logic.AI.Director
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
public static class AIDirectorDiagnostics
{
private const string SchemaVersion = "1.3";
private const string SchemaVersion = "1.4";
private const int MaxItemsPerSection = 32;
#if UNITY_EDITOR
@ -217,7 +217,7 @@ namespace Logic.AI.Director
priority = diagnostic.priority,
lane = candidate?.Lane.ToString() ?? AIDirectorLane.None.ToString(),
reason = candidate?.Reason ?? string.Empty,
action = BuildActionSnapshot(candidate?.AIAction),
action = BuildActionSnapshot(candidate?.AIAction),
scoreTerms = BuildScoreTerms(candidate?.ScoreTerms)
};
}
@ -294,7 +294,7 @@ namespace Logic.AI.Director
if (action?.ActionLogic?.ActionId == null) return null;
var id = action.ActionLogic.ActionId;
var param = action.Param;
return new ActionSnapshot
var snapshot = new ActionSnapshot
{
stableKey = AIDirectorActionIndex.StableActionKey(action),
actionType = id.ActionType.ToString(),
@ -320,6 +320,28 @@ namespace Logic.AI.Director
targetGridId = param?.TargetGridId ?? 0,
targetPlayerId = param?.TargetPlayerId ?? 0
};
FillActionUnitTypeSnapshot(param?.UnitData, true, snapshot);
FillActionUnitTypeSnapshot(param?.TargetUnitData, false, snapshot);
return snapshot;
}
private static void FillActionUnitTypeSnapshot(UnitData unit, bool self, ActionSnapshot snapshot)
{
if (unit == null || snapshot == null) return;
if (self)
{
snapshot.actorUnitType = unit.UnitType.ToString();
snapshot.actorGiantType = unit.GiantType.ToString();
snapshot.actorUnitLevel = unit.UnitLevel;
snapshot.actorChessType = unit.ChessType.ToString();
}
else
{
snapshot.targetActorUnitType = unit.UnitType.ToString();
snapshot.targetActorGiantType = unit.GiantType.ToString();
snapshot.targetActorUnitLevel = unit.UnitLevel;
snapshot.targetActorChessType = unit.ChessType.ToString();
}
}
private static List<CityThreatSnapshot> BuildCityThreats(List<AIDirectorCityThreat> threats)
@ -582,6 +604,7 @@ namespace Logic.AI.Director
using var cityHandle = THCollectionPool.GetListHandle<CityData>(out var cities);
map.GetCityDataListByPlayerId(player.Id, cities);
probe.CityCount = cities.Count;
FillCitySignatureProbe(cities, probe);
using var unitHandle = THCollectionPool.GetListHandle<UnitData>(out var units);
map.GetUnitDataListByPlayerId(player.Id, units);
@ -608,6 +631,12 @@ namespace Logic.AI.Director
var enemyCount = 0;
var enemyPower = 0f;
var defenderPower = 0f;
var hasCityCenterDefender = cityGrid.RealUnit(map, out var cityCenterUnit)
&& cityCenterUnit != null
&& cityCenterUnit.IsAlive()
&& map.GetPlayerDataByUnitId(cityCenterUnit.Id, out var cityCenterOwner)
&& cityCenterOwner != null
&& map.SameUnion(player.Id, cityCenterOwner.Id);
if (map.UnitMap?.UnitList != null)
{
@ -631,17 +660,62 @@ namespace Logic.AI.Director
if (enemyCount <= 0) continue;
probe.CityThreatCount++;
AddUniqueId(probe.ThreatenedCityIds, city.Id);
if (city.IsCapital) probe.CapitalThreatCount++;
if (!hasCityCenterDefender) probe.EmptyThreatenedCityCount++;
var danger = enemyPower - defenderPower;
if (danger > probe.MaxCityDangerScore) probe.MaxCityDangerScore = danger;
if (danger > 0f) probe.CriticalCityThreatCount++;
if (danger > 0f)
{
probe.CriticalCityThreatCount++;
AddUniqueId(probe.CriticalThreatenedCityIds, city.Id);
if (city.IsCapital) probe.CriticalCapitalThreatCount++;
}
}
probe.ThreatenedCityIdsSignature = BuildIdSignature(probe.ThreatenedCityIds);
probe.CriticalThreatenedCityIdsSignature = BuildIdSignature(probe.CriticalThreatenedCityIds);
if (probe.CriticalCityThreatCount > 0) probe.StrategicPosture = AIDirectorStrategicPosture.Defense.ToString();
else if (probe.EnemyMilitary > 0f && probe.SelfMilitary >= probe.EnemyMilitary) probe.StrategicPosture = AIDirectorStrategicPosture.Attack.ToString();
else probe.StrategicPosture = AIDirectorStrategicPosture.Development.ToString();
return probe;
}
private static void FillCitySignatureProbe(List<CityData> cities, AIDirectorOutcomeProbe probe)
{
if (probe == null) return;
using var ownedHandle = THCollectionPool.GetListHandle<uint>(out var ownedIds);
using var capitalHandle = THCollectionPool.GetListHandle<uint>(out var capitalIds);
if (cities != null)
{
foreach (var city in cities)
{
if (city == null) continue;
ownedIds.Add(city.Id);
if (city.IsCapital) capitalIds.Add(city.Id);
}
}
probe.OwnedCityIdsSignature = BuildIdSignature(ownedIds);
probe.CapitalCityIdsSignature = BuildIdSignature(capitalIds);
}
private static void AddUniqueId(List<uint> values, uint id)
{
if (id == 0 || values == null || values.Contains(id)) return;
values.Add(id);
}
private static string BuildIdSignature(List<uint> values)
{
if (values == null || values.Count <= 0) return string.Empty;
values.Sort();
using var handle = THCollectionPool.GetListHandle<string>(out var parts);
foreach (var value in values) parts.Add(value.ToString());
return string.Join("|", parts);
}
private static void FillHeroOutcomeProbe(MapData map, PlayerData player, AIDirectorOutcomeProbe probe)
{
var heroData = player?.PlayerHeroData;
@ -844,6 +918,8 @@ namespace Logic.AI.Director
playerScore = probe.PlayerScore,
sightGridCount = probe.SightGridCount,
cityCount = probe.CityCount,
ownedCityIdsSignature = probe.OwnedCityIdsSignature,
capitalCityIdsSignature = probe.CapitalCityIdsSignature,
unitCount = probe.UnitCount,
heroCount = probe.HeroCount,
selectedHeroCount = probe.SelectedHeroCount,
@ -856,6 +932,11 @@ namespace Logic.AI.Director
enemyMilitary = probe.EnemyMilitary,
criticalCityThreatCount = probe.CriticalCityThreatCount,
cityThreatCount = probe.CityThreatCount,
capitalThreatCount = probe.CapitalThreatCount,
criticalCapitalThreatCount = probe.CriticalCapitalThreatCount,
emptyThreatenedCityCount = probe.EmptyThreatenedCityCount,
threatenedCityIdsSignature = probe.ThreatenedCityIdsSignature,
criticalThreatenedCityIdsSignature = probe.CriticalThreatenedCityIdsSignature,
maxCityDangerScore = probe.MaxCityDangerScore,
strategicPosture = probe.StrategicPosture,
unitId = probe.UnitId,
@ -922,6 +1003,10 @@ namespace Logic.AI.Director
scoreDelta = after.PlayerScore - before.PlayerScore,
sightGridDelta = after.SightGridCount - before.SightGridCount,
cityDelta = after.CityCount - before.CityCount,
cityLostDelta = Mathf.Max(0, before.CityCount - after.CityCount),
cityGainedDelta = Mathf.Max(0, after.CityCount - before.CityCount),
cityOwnershipSignatureChanged = before.OwnedCityIdsSignature != after.OwnedCityIdsSignature,
capitalOwnershipSignatureChanged = before.CapitalCityIdsSignature != after.CapitalCityIdsSignature,
unitDelta = after.UnitCount - before.UnitCount,
heroDelta = after.HeroCount - before.HeroCount,
selectedHeroDelta = after.SelectedHeroCount - before.SelectedHeroCount,
@ -934,6 +1019,19 @@ namespace Logic.AI.Director
enemyMilitaryDelta = after.EnemyMilitary - before.EnemyMilitary,
criticalCityThreatDelta = after.CriticalCityThreatCount - before.CriticalCityThreatCount,
cityThreatDelta = after.CityThreatCount - before.CityThreatCount,
capitalThreatDelta = after.CapitalThreatCount - before.CapitalThreatCount,
criticalCapitalThreatDelta = after.CriticalCapitalThreatCount - before.CriticalCapitalThreatCount,
emptyThreatenedCityDelta = after.EmptyThreatenedCityCount - before.EmptyThreatenedCityCount,
cityThreatSignatureChanged = before.ThreatenedCityIdsSignature != after.ThreatenedCityIdsSignature,
criticalCityThreatSignatureChanged = before.CriticalThreatenedCityIdsSignature != after.CriticalThreatenedCityIdsSignature,
cityThreatResolved = before.CityThreatCount > 0
&& (after.CriticalCityThreatCount < before.CriticalCityThreatCount
|| after.CityThreatCount < before.CityThreatCount
|| after.MaxCityDangerScore + 0.001f < before.MaxCityDangerScore),
cityThreatWorsened = after.CriticalCityThreatCount > before.CriticalCityThreatCount
|| after.CityThreatCount > before.CityThreatCount
|| after.EmptyThreatenedCityCount > before.EmptyThreatenedCityCount
|| after.MaxCityDangerScore > before.MaxCityDangerScore + 0.001f,
maxCityDangerScoreDelta = after.MaxCityDangerScore - before.MaxCityDangerScore,
unitMoved = before.UnitGridId != after.UnitGridId,
unitHealthDelta = after.UnitHealth - before.UnitHealth,
@ -1098,6 +1196,8 @@ namespace Logic.AI.Director
public int playerScore;
public int sightGridCount;
public int cityCount;
public string ownedCityIdsSignature;
public string capitalCityIdsSignature;
public int unitCount;
public int heroCount;
public int selectedHeroCount;
@ -1110,6 +1210,11 @@ namespace Logic.AI.Director
public float enemyMilitary;
public int criticalCityThreatCount;
public int cityThreatCount;
public int capitalThreatCount;
public int criticalCapitalThreatCount;
public int emptyThreatenedCityCount;
public string threatenedCityIdsSignature;
public string criticalThreatenedCityIdsSignature;
public float maxCityDangerScore;
public string strategicPosture;
public uint unitId;
@ -1173,6 +1278,10 @@ namespace Logic.AI.Director
public int scoreDelta;
public int sightGridDelta;
public int cityDelta;
public int cityLostDelta;
public int cityGainedDelta;
public bool cityOwnershipSignatureChanged;
public bool capitalOwnershipSignatureChanged;
public int unitDelta;
public int heroDelta;
public int selectedHeroDelta;
@ -1185,6 +1294,13 @@ namespace Logic.AI.Director
public float enemyMilitaryDelta;
public int criticalCityThreatDelta;
public int cityThreatDelta;
public int capitalThreatDelta;
public int criticalCapitalThreatDelta;
public int emptyThreatenedCityDelta;
public bool cityThreatSignatureChanged;
public bool criticalCityThreatSignatureChanged;
public bool cityThreatResolved;
public bool cityThreatWorsened;
public float maxCityDangerScoreDelta;
public bool unitMoved;
public int unitHealthDelta;
@ -1289,6 +1405,14 @@ namespace Logic.AI.Director
public uint unitLevel;
public string wonderType;
public string skillType;
public string actorUnitType;
public string actorGiantType;
public uint actorUnitLevel;
public string actorChessType;
public string targetActorUnitType;
public string targetActorGiantType;
public uint targetActorUnitLevel;
public string targetActorChessType;
public string mainObjectType;
public uint playerId;
public uint unitId;

View File

@ -536,6 +536,10 @@ namespace Logic.AI.Director
public int PlayerScore;
public int SightGridCount;
public int CityCount;
public string OwnedCityIdsSignature;
public string CapitalCityIdsSignature;
public readonly List<uint> ThreatenedCityIds = new();
public readonly List<uint> CriticalThreatenedCityIds = new();
public int UnitCount;
public int HeroCount;
public int SelectedHeroCount;
@ -548,6 +552,11 @@ namespace Logic.AI.Director
public float EnemyMilitary;
public int CriticalCityThreatCount;
public int CityThreatCount;
public int CapitalThreatCount;
public int CriticalCapitalThreatCount;
public int EmptyThreatenedCityCount;
public string ThreatenedCityIdsSignature;
public string CriticalThreatenedCityIdsSignature;
public float MaxCityDangerScore;
public string StrategicPosture;
public uint UnitId;

View File

@ -674,7 +674,17 @@ namespace TH1_Logic.Editor
var fallbackReasons = new Dictionary<string, int>();
var fallbackExecutedActionTypes = new Dictionary<string, int>();
var fallbackNoEffectActionTypes = new Dictionary<string, int>();
var defenseReasons = new Dictionary<string, int>();
var defenseActionTypes = new Dictionary<string, int>();
var heroStyleBuckets = new Dictionary<string, int>();
var heroStyleReasons = new Dictionary<string, int>();
var heroStyleActionTypes = new Dictionary<string, int>();
var unitSkillActionTypes = new Dictionary<string, int>();
var unitSkillChangedActionTypes = new Dictionary<string, int>();
var actorSkillSignatures = new Dictionary<string, int>();
var decisionLaneByAction = new Dictionary<string, string>();
var decisionReasonByAction = new Dictionary<string, string>();
var lastTurnSummaryByPlayer = new Dictionary<uint, JToken>();
try
{
@ -713,19 +723,32 @@ namespace TH1_Logic.Editor
Increment(reasons, reason);
Increment(selectedActionTypes, actionType);
Increment(laneActionTypes, $"{lane}:{actionType}");
if (!string.IsNullOrEmpty(turnActionKey)) decisionLaneByAction[turnActionKey] = lane;
if (!string.IsNullOrEmpty(turnActionKey))
{
decisionLaneByAction[turnActionKey] = lane;
decisionReasonByAction[turnActionKey] = reason;
}
if (lane == "Emergency")
{
summary.emergencyDecisions++;
Increment(defenseReasons, reason);
Increment(defenseActionTypes, actionKey);
}
if (lane == "HeroManagement")
{
summary.heroManagementDecisions++;
Increment(heroReasons, reason);
Increment(heroActionTypes, actionKey);
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
}
else if (lane == "HeroPlaybook")
{
summary.heroPlaybookDecisions++;
Increment(heroReasons, reason);
Increment(heroActionTypes, actionKey);
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
if (reason.StartsWith("HeroPlaybook.", StringComparison.Ordinal)) summary.genericHeroDecisions++;
else summary.heroRuleDecisions++;
}
@ -737,6 +760,35 @@ namespace TH1_Logic.Editor
Increment(fallbackActionTypes, actionKey);
}
}
else if (eventType == "TurnStart")
{
var turnSummary = record["turnSummary"];
if (turnSummary == null) continue;
var playerId = record.Value<uint?>("playerId") ?? 0;
if ((turnSummary.Value<int?>("criticalCityThreatCount") ?? 0) > 0) summary.criticalCityThreatTurnCount++;
if ((turnSummary.Value<int?>("capitalThreatCount") ?? 0) > 0) summary.capitalThreatTurnCount++;
var emptyThreatenedCityCount = turnSummary.Value<int?>("emptyThreatenedCityCount") ?? 0;
if (emptyThreatenedCityCount > 0)
{
summary.emptyThreatenedCityTurnCount++;
summary.emptyThreatenedCityTotal += emptyThreatenedCityCount;
}
if (lastTurnSummaryByPlayer.TryGetValue(playerId, out var previous))
{
var previousCityCount = previous.Value<int?>("cityCount") ?? 0;
var currentCityCount = turnSummary.Value<int?>("cityCount") ?? 0;
if (currentCityCount < previousCityCount) summary.cityLostCount += previousCityCount - currentCityCount;
if (currentCityCount > previousCityCount) summary.cityGainedCount += currentCityCount - previousCityCount;
var previousCapital = previous.Value<string>("capitalCityIdsSignature") ?? string.Empty;
var currentCapital = turnSummary.Value<string>("capitalCityIdsSignature") ?? string.Empty;
if (!string.Equals(previousCapital, currentCapital, StringComparison.Ordinal)) summary.capitalOwnershipChangedCount++;
}
lastTurnSummaryByPlayer[playerId] = turnSummary;
}
else if (eventType == "Execution")
{
summary.executions++;
@ -759,6 +811,7 @@ namespace TH1_Logic.Editor
var turnActionKey = BuildTurnActionKey(record, action);
decisionLaneByAction.TryGetValue(turnActionKey, out var executedLane);
decisionReasonByAction.TryGetValue(turnActionKey, out var executedReason);
if (executedLane == "HeroManagement" || executedLane == "HeroPlaybook")
{
Increment(heroExecutedActionTypes, actionKey);
@ -777,6 +830,34 @@ namespace TH1_Logic.Editor
summary.readyHeroTaskDelta += delta?.Value<int?>("readyHeroTaskDelta") ?? 0;
summary.forcedHeroTaskDelta += delta?.Value<int?>("forcedHeroTaskDelta") ?? 0;
summary.heroTaskProgressDelta += delta?.Value<int?>("heroTaskProgressDelta") ?? 0;
summary.cityThreatResolvedCount += delta?.Value<bool?>("cityThreatResolved") == true ? 1 : 0;
summary.cityThreatWorsenedCount += delta?.Value<bool?>("cityThreatWorsened") == true ? 1 : 0;
if (executedLane == "Emergency") summary.emergencyExecutions++;
if (executedLane == "Emergency" && actionKey == "UnitMove")
{
summary.defenderReturnCount++;
}
if (IsHeroAction(action))
{
var bucket = ClassifyStyleBucket(executedReason, actionKey);
if (IsDefensiveStyle(bucket) || executedLane == "Emergency") summary.heroDefensiveUse++;
}
var before = execution?["before"];
var skillSignature = before?.Value<string>("unitSkillSignature") ?? string.Empty;
if (!string.IsNullOrEmpty(skillSignature))
{
Increment(unitSkillActionTypes, actionKey);
Increment(actorSkillSignatures, CompactSkillSignature(skillSignature));
if (delta?.Value<bool?>("unitSkillSignatureChanged") == true
|| delta?.Value<bool?>("targetUnitSkillSignatureChanged") == true
|| (delta?.Value<int?>("unitSkillDelta") ?? 0) != 0
|| (delta?.Value<int?>("targetUnitSkillDelta") ?? 0) != 0)
{
Increment(unitSkillChangedActionTypes, actionKey);
}
}
}
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(delta))
@ -806,10 +887,30 @@ namespace TH1_Logic.Editor
summary.topHeroReasons = TopCounts(heroReasons, 16);
summary.topHeroActionTypes = TopCounts(heroActionTypes, 12);
summary.topHeroExecutedActionTypes = TopCounts(heroExecutedActionTypes, 12);
summary.topDefenseReasons = TopCounts(defenseReasons, 12);
summary.topDefenseActionTypes = TopCounts(defenseActionTypes, 12);
summary.topHeroStyleBuckets = TopCounts(heroStyleBuckets, 12);
summary.topHeroStyleReasons = TopCounts(heroStyleReasons, 16);
summary.topHeroStyleActionTypes = TopCounts(heroStyleActionTypes, 12);
summary.topUnitSkillActionTypes = TopCounts(unitSkillActionTypes, 12);
summary.topUnitSkillChangedActionTypes = TopCounts(unitSkillChangedActionTypes, 12);
summary.topActorSkillSignatures = TopCounts(actorSkillSignatures, 12);
summary.topFallbackReasons = TopCounts(fallbackReasons, 12);
summary.topFallbackActionTypes = TopCounts(fallbackActionTypes, 12);
summary.topFallbackExecutedActionTypes = TopCounts(fallbackExecutedActionTypes, 12);
summary.fallbackNoEffectActionTypes = TopCounts(fallbackNoEffectActionTypes, 12);
summary.defenseOpportunityTurns = summary.criticalCityThreatTurnCount + summary.emptyThreatenedCityTurnCount;
summary.emergencyResponseRate = summary.defenseOpportunityTurns <= 0
? 0f
: Mathf.Clamp01((float)summary.emergencyDecisions / summary.defenseOpportunityTurns);
summary.defenseScore = summary.cityThreatResolvedCount * 3f
+ summary.emergencyExecutions
+ summary.defenderReturnCount
+ summary.heroDefensiveUse * 2f
- summary.cityLostCount * 8f
- summary.capitalThreatTurnCount * 2f
- summary.emptyThreatenedCityTurnCount * 2f
- summary.cityThreatWorsenedCount * 2f;
return summary;
}
@ -843,6 +944,76 @@ namespace TH1_Logic.Editor
: $"{actionType}:{subType}";
}
private static void IncrementHeroStyle(
Dictionary<string, int> buckets,
Dictionary<string, int> reasons,
Dictionary<string, int> actions,
string reason,
string actionKey)
{
var bucket = ClassifyStyleBucket(reason, actionKey);
Increment(buckets, bucket);
Increment(reasons, $"{bucket}:{reason}");
Increment(actions, $"{bucket}:{actionKey}");
}
private static string ClassifyStyleBucket(string reason, string actionKey)
{
var text = $"{reason ?? string.Empty} {actionKey ?? string.Empty}";
if (ContainsAny(text, "LowHp", "Recover", "Heal", "Eirin", "Sanae", "Patchouli", "Absorb", "Revive"))
return "Recovery";
if (ContainsAny(text, "Defense", "Defend", "Protect", "Guard", "MoveToCity", "Aunn", "Meiling", "Sakuya", "Reimu", "ShakeOff", "Unsit"))
return "Defense";
if (ContainsAny(text, "Kill", "Flandre", "Assassin", "AttackValue", "LocalBattle", "Boom", "Mokou", "Reisen", "Yuugi"))
return "Burst";
if (ContainsAny(text, "Ban", "Fear", "Control", "Satori", "Koishi", "Sumireko", "Orb", "Ground"))
return "Control";
if (ContainsAny(text, "Summon", "CreateMini", "Suwako", "Snake", "BonePile", "Mini"))
return "Summon";
if (ContainsAny(text, "Economy", "CityExp", "Corpse", "KanakoSit", "Rin", "Tewi", "Kaguya"))
return "Economy";
if (ContainsAny(text, "MoveAgain", "MoveToFront", "Retreat", "Mobility", "Aya", "Kasen"))
return "Mobility";
if (ContainsAny(text, "SelectHero", "SpawnHero", "FinishLowestTask"))
return "HeroLifecycle";
return "General";
}
private static bool ContainsAny(string value, params string[] tokens)
{
if (string.IsNullOrEmpty(value) || tokens == null) return false;
foreach (var token in tokens)
{
if (!string.IsNullOrEmpty(token) && value.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
}
private static bool IsDefensiveStyle(string bucket)
{
return bucket == "Defense" || bucket == "Recovery";
}
private static bool IsHeroAction(JToken action)
{
if (action == null) return false;
var actorUnitType = action.Value<string>("actorUnitType") ?? string.Empty;
var actorGiantType = action.Value<string>("actorGiantType") ?? string.Empty;
var giantType = action.Value<string>("giantType") ?? string.Empty;
return actorUnitType == "Giant"
|| (!string.IsNullOrEmpty(actorGiantType) && actorGiantType != "None")
|| (!string.IsNullOrEmpty(giantType) && giantType != "None");
}
private static string CompactSkillSignature(string signature)
{
if (string.IsNullOrWhiteSpace(signature)) return string.Empty;
var parts = signature.Split('|');
if (parts.Length <= 4) return signature;
return string.Join("|", parts.Take(4)) + "|...";
}
private static bool HasMeaningfulDelta(JToken delta)
{
if (delta == null) return false;
@ -1116,6 +1287,22 @@ namespace TH1_Logic.Editor
public int readyHeroTaskDelta;
public int forcedHeroTaskDelta;
public int heroTaskProgressDelta;
public int criticalCityThreatTurnCount;
public int capitalThreatTurnCount;
public int emptyThreatenedCityTurnCount;
public int emptyThreatenedCityTotal;
public int defenseOpportunityTurns;
public int cityThreatResolvedCount;
public int cityThreatWorsenedCount;
public int cityLostCount;
public int cityGainedCount;
public int capitalOwnershipChangedCount;
public int emergencyDecisions;
public int emergencyExecutions;
public int defenderReturnCount;
public int heroDefensiveUse;
public float emergencyResponseRate;
public float defenseScore;
public float avgDecideMs;
public float p95DecideMs;
public float maxDecideMs;
@ -1137,6 +1324,14 @@ namespace TH1_Logic.Editor
public List<BatchCountMetric> topHeroReasons = new();
public List<BatchCountMetric> topHeroActionTypes = new();
public List<BatchCountMetric> topHeroExecutedActionTypes = new();
public List<BatchCountMetric> topDefenseReasons = new();
public List<BatchCountMetric> topDefenseActionTypes = new();
public List<BatchCountMetric> topHeroStyleBuckets = new();
public List<BatchCountMetric> topHeroStyleReasons = new();
public List<BatchCountMetric> topHeroStyleActionTypes = new();
public List<BatchCountMetric> topUnitSkillActionTypes = new();
public List<BatchCountMetric> topUnitSkillChangedActionTypes = new();
public List<BatchCountMetric> topActorSkillSignatures = new();
public List<BatchCountMetric> topFallbackReasons = new();
public List<BatchCountMetric> topFallbackActionTypes = new();
public List<BatchCountMetric> topFallbackExecutedActionTypes = new();