Tune AI director expansion and actor metrics

This commit is contained in:
wuwenbo 2026-07-02 19:55:24 +08:00
parent e972f58eba
commit d76c8f8f48
6 changed files with 523 additions and 34 deletions

View File

@ -137,7 +137,9 @@ Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
- 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 count should be read from `HeroSummary` first: in 17-player 20-turn batches, many players may be non-hero factions, so use `eligibleAvgSelected`, `eligibleAvgSpawned`, `eligibleAvgMaxSlots`, `eligibleSpawned>=1`, `eligibleSpawned>=2`, and `eligibleSelected>=2` for hero intelligence. The detailed JSONL `Hero final counts` remains useful for action-level deltas and style buckets. Low eligible hero count means review culture-slot unlock, hero selection timing, and spawn-city pressure before judging hero playbook quality.
- 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.
- Hero personal expression must be checked through `Hero personal expression`: compare each hero's role, executions, kills, damage, healing, moves, skill changes, and top actions. A hero that is selected/spawned but only moves or has near-zero damage/healing/control should trigger HeroPlaybook review.
- Unit role expression must be checked through `Unit role expression`: Mobility should create moves/captures and low waste, Melee should hold line and finish targets, Ranged/Siege should contribute damage with low deaths, Defender should resolve threats without excessive self-deaths, and Special/Summon should show concrete skill/action signatures.
- Special-unit expression should be checked through `Unit skill expression`: `skill-bearing actor actions`, `signature-changing actions`, `actor signatures`, and `skill_signature_expression` reveal whether new faction units are actually participating.
Performance targets:

View File

@ -16,6 +16,89 @@ from analyze_ai_director_log import (
summarize,
)
HERO_ROLE_BY_GIANT = {
"EgyptianFlandre": "Assassin",
"GermanyMomiji": "Assassin",
"EgyptianRemilia": "Support",
"FrenchTewi": "Support",
"FrenchEirin": "Support",
"NorwayReimu": "Support",
"EgyptianPatchouli": "Caster",
"FrenchReisen": "Caster",
"GermanySanae": "Caster",
"IndianUtsuho": "Caster",
"IndianRin": "Caster",
"NorwaySumireko": "Caster",
"GermanyAya": "Mobility",
"IndianSatori": "Control",
"IndianKoishi": "Control",
"FrenchKaguya": "Control",
"GermanySuwako": "Vanguard",
"FrenchMokou": "Vanguard",
"IndianYuugi": "Vanguard",
"NorwayKasen": "Vanguard",
"NorwaySuika": "Summoner",
"GermanyKanako": "Economy",
"EgyptianSakuya": "Defender",
"EgyptianMeiling": "Defender",
"NorwayAunn": "Defender",
}
UNIT_ROLE_BY_TYPE = {
"Warrior": "Melee",
"Swordsman": "Melee",
"KaguyaFrenchAnimalWarrior": "Melee",
"KaguyaFrenchWarrior": "Melee",
"NoUseHakureiBerserkWarrior": "Melee",
"NoUseHakureiBerserker": "Melee",
"Rider": "Mobility",
"Knights": "Mobility",
"MoriyaRider": "Mobility",
"MoriyaKnight": "Mobility",
"KomeijiIndianRider": "Mobility",
"KomeijiIndianKnight": "Mobility",
"HakureiValkyrie": "Mobility",
"Archer": "Ranged",
"KomeijiIndianArcher": "Ranged",
"Catapult": "Siege",
"KaguyaFrenchCatapult": "Siege",
"KomeijiIndianCatapult": "Siege",
"Defender": "Defender",
"HakureiRoundShieldman": "Defender",
"Boat": "Naval",
"Ship": "Naval",
"RammerShip": "Naval",
"BomberShip": "Naval",
"Juggernaut": "Naval",
"GiantJuggernaut": "NavalHero",
"WolfJuggernaut": "Naval",
"KomeijiIndianShip": "Naval",
"KomeijiIndianBomberShip": "Naval",
"KomeijiIndianJuggernaut": "Naval",
"DaggerShip": "Naval",
"HakureiKarvi": "Naval",
"HakureiLongship": "Naval",
"HakureiDragonship": "Naval",
"Cloak": "Special",
"Minder": "Special",
"Dagger": "Special",
"BigGuy": "Special",
"KaguyaFrenchMokouEgg": "Special",
"KaguyaFrenchReisenIllusion": "Special",
"KaguyaFrenchWolf": "Special",
"RemiliaEgyptianKoakuma": "Special",
"RemiliaEgyptianKoakumaLion": "Special",
"MoriyaHebi": "Special",
"BonePile": "Special",
"KomeijiIndianBigGuy": "Special",
"SumirekoNorwayOrb": "Special",
"SumirekoDenmarkOrb": "Special",
"SumirekoEnglandOrb": "Special",
"KasenBeastGuideMarker": "Special",
"AunnTwin": "Special",
"SuikaMini": "Summon",
}
def repo_root() -> Path:
return Path(__file__).resolve().parents[4]
@ -100,6 +183,88 @@ def is_hero_action(action: dict) -> bool:
)
def non_empty(value: str) -> str:
if value in (None, "", "None", "NONE"):
return ""
return str(value)
def hero_identity(action: dict) -> str:
if not action:
return ""
return (
non_empty(action.get("actorGiantType"))
or non_empty(action.get("giantType"))
or non_empty(action.get("targetActorGiantType"))
)
def hero_role(hero: str) -> str:
return HERO_ROLE_BY_GIANT.get(hero, "Vanguard" if hero else "")
def actor_unit_type(action: dict) -> str:
if not action:
return ""
return non_empty(action.get("actorUnitType")) or non_empty(action.get("unitType"))
def unit_role(unit_type: str) -> str:
if not unit_type:
return ""
if unit_type == "Giant":
return "Hero"
return UNIT_ROLE_BY_TYPE.get(unit_type, "Other")
def delta_damage_to_target(delta: dict) -> int:
return max(0, -int(delta.get("targetUnitHealthDelta") or 0))
def delta_damage_taken(delta: dict) -> int:
return max(0, -int(delta.get("unitHealthDelta") or 0))
def delta_self_heal(delta: dict) -> int:
return max(0, int(delta.get("unitHealthDelta") or 0))
def delta_has_skill_change(delta: dict) -> bool:
return (
bool(delta.get("unitSkillSignatureChanged"))
or bool(delta.get("targetUnitSkillSignatureChanged"))
or int(delta.get("unitSkillDelta") or 0) != 0
or int(delta.get("targetUnitSkillDelta") or 0) != 0
)
def update_expression_stats(stats: Counter, action_key_value: str, delta: dict, execution: dict):
stats["executions"] += 1
stats["damage"] += delta_damage_to_target(delta)
stats["taken"] += delta_damage_taken(delta)
stats["healed"] += delta_self_heal(delta)
if delta.get("unitMoved"):
stats["moves"] += 1
if delta.get("targetUnitDied"):
stats["kills"] += 1
if delta.get("unitDied"):
stats["selfDeaths"] += 1
if delta.get("cityThreatResolved"):
stats["threatResolved"] += 1
if delta.get("cityThreatWorsened"):
stats["threatWorsened"] += 1
if delta.get("cityOwnerChanged") or delta.get("targetCityOwnerChanged"):
stats["cityOwnerChanged"] += 1
if delta_has_skill_change(delta):
stats["skillChanged"] += 1
if action_key_value.startswith("UnitAction:Recover"):
stats["recoverActions"] += 1
if action_key_value.startswith("UnitAction:Capture"):
stats["captures"] += 1
if has_no_effect_delta(execution):
stats["noEffect"] += 1
def compact_skill_signature(signature: str) -> str:
if not signature:
return ""
@ -109,6 +274,51 @@ def compact_skill_signature(signature: str) -> str:
return "|".join(parts[:4]) + "|..."
def expression_rows(stats_by_key, action_counters, top, role_lookup=None, style_counters=None):
rows = []
for key, stats in stats_by_key.items():
row = {"key": key}
if role_lookup is not None:
row["role"] = role_lookup(key)
for name in (
"decisions",
"executions",
"kills",
"selfDeaths",
"damage",
"taken",
"healed",
"moves",
"captures",
"recoverActions",
"skillChanged",
"threatResolved",
"threatWorsened",
"cityOwnerChanged",
"selected",
"spawned",
"tasks",
"noEffect",
):
value = stats.get(name, 0)
if value:
row[name] = value
row["top_actions"] = action_counters.get(key, Counter()).most_common(5)
if style_counters is not None:
row["top_styles"] = style_counters.get(key, Counter()).most_common(5)
rows.append(row)
rows.sort(
key=lambda item: (
item.get("executions", 0),
item.get("decisions", 0),
item.get("kills", 0),
item.get("damage", 0),
),
reverse=True,
)
return rows[:top]
def percentile(values, ratio: float) -> float:
values = sorted(value for value in values if value is not None)
if not values:
@ -275,6 +485,15 @@ def analyze_logs(log_paths):
"unit_skill_actions": Counter(),
"unit_skill_changed_actions": Counter(),
"actor_skill_signatures": Counter(),
"hero_personal": defaultdict(Counter),
"hero_personal_actions": defaultdict(Counter),
"hero_personal_styles": defaultdict(Counter),
"unit_role_stats": defaultdict(Counter),
"unit_role_actions": defaultdict(Counter),
"unit_type_stats": defaultdict(Counter),
"unit_type_actions": defaultdict(Counter),
"skill_signature_stats": defaultdict(Counter),
"skill_signature_actions": defaultdict(Counter),
}
for path in log_paths:
@ -328,6 +547,12 @@ def analyze_logs(log_paths):
aggregate["emergency_decisions"] += 1
aggregate["defense_reasons"][reason] += 1
aggregate["defense_actions"][action_key(action)] += 1
hero = hero_identity(action)
if hero:
hero_key = action_key(action)
hero_bucket = classify_style_bucket(reason, hero_key)
aggregate["hero_personal"][hero]["decisions"] += 1
aggregate["hero_personal_styles"][hero][hero_bucket] += 1
if lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_lane_counts"][lane] += 1
aggregate["hero_reasons"][reason] += 1
@ -386,11 +611,22 @@ def analyze_logs(log_paths):
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
hero = hero_identity(action)
if hero:
update_expression_stats(aggregate["hero_personal"][hero], key, delta, execution)
aggregate["hero_personal_actions"][hero][key] += 1
aggregate["hero_personal_styles"][hero][classify_style_bucket(decision_reason, key)] += 1
if int(delta.get("selectedHeroDelta") or 0) > 0:
aggregate["hero_personal"][hero]["selected"] += int(delta.get("selectedHeroDelta") or 0)
if int(delta.get("heroDelta") or 0) > 0:
aggregate["hero_personal"][hero]["spawned"] += int(delta.get("heroDelta") or 0)
if int(delta.get("heroTaskDelta") or 0) != 0:
aggregate["hero_personal"][hero]["tasks"] += int(delta.get("heroTaskDelta") or 0)
if decision_lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_executed_actions"][key] += 1
if decision_lane == "Fallback":
aggregate["fallback_executed_actions"][key] += 1
if has_no_effect_delta(delta):
if has_no_effect_delta(execution):
aggregate["fallback_no_effect_actions"][key] += 1
aggregate["hero_delta"] += int(delta.get("heroDelta") or 0)
@ -414,15 +650,21 @@ def analyze_logs(log_paths):
before = execution.get("before") or {}
skill_signature = before.get("unitSkillSignature") or ""
unit_type = actor_unit_type(action)
role = unit_role(unit_type)
if unit_type:
update_expression_stats(aggregate["unit_type_stats"][unit_type], key, delta, execution)
aggregate["unit_type_actions"][unit_type][key] += 1
if role and role != "Hero":
update_expression_stats(aggregate["unit_role_stats"][role], key, delta, execution)
aggregate["unit_role_actions"][role][key] += 1
if skill_signature:
compact_signature = compact_skill_signature(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["actor_skill_signatures"][compact_signature] += 1
update_expression_stats(aggregate["skill_signature_stats"][compact_signature], key, delta, execution)
aggregate["skill_signature_actions"][compact_signature][key] += 1
if delta_has_skill_change(delta):
aggregate["unit_skill_changed_actions"][key] += 1
if delta.get("targetUnitDied"):
@ -716,7 +958,30 @@ def compact_log_metrics(log_metrics, top):
"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),
"skill_signature_expression": expression_rows(
log_metrics["skill_signature_stats"],
log_metrics["skill_signature_actions"],
top,
),
},
"hero_personal_expression": expression_rows(
log_metrics["hero_personal"],
log_metrics["hero_personal_actions"],
top,
role_lookup=hero_role,
style_counters=log_metrics["hero_personal_styles"],
),
"unit_role_expression": expression_rows(
log_metrics["unit_role_stats"],
log_metrics["unit_role_actions"],
top,
),
"unit_type_expression": expression_rows(
log_metrics["unit_type_stats"],
log_metrics["unit_type_actions"],
top,
role_lookup=unit_role,
),
}
@ -883,6 +1148,30 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
else:
print(" style buckets: <none>")
print("Hero personal expression:")
hero_rows = expression_rows(
log_metrics["hero_personal"],
log_metrics["hero_personal_actions"],
top,
role_lookup=hero_role,
style_counters=log_metrics["hero_personal_styles"],
)
if hero_rows:
for row in hero_rows:
top_actions = ", ".join(f"{key}={count}" for key, count in row.get("top_actions", [])[:3]) or "-"
top_styles = ", ".join(f"{key}={count}" for key, count in row.get("top_styles", [])[:3]) or "-"
print(
f" {row['key']}[{row.get('role', '')}]: "
f"dec={row.get('decisions', 0)} exec={row.get('executions', 0)} "
f"sel={row.get('selected', 0)} spawn={row.get('spawned', 0)} "
f"kill={row.get('kills', 0)} dmg={row.get('damage', 0)} "
f"move={row.get('moves', 0)} heal={row.get('healed', 0)} "
f"skill={row.get('skillChanged', 0)} noEff={row.get('noEffect', 0)} "
f"styles=[{top_styles}] actions=[{top_actions}]"
)
else:
print(" <none>")
print("Unit skill expression:")
if log_metrics["unit_skill_actions"]:
print(" skill-bearing actor actions:")
@ -896,6 +1185,48 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
print(f" {count:>5} {key}")
else:
print(" signature-changing actions: <none>")
if log_metrics["actor_skill_signatures"]:
print(" actor signatures:")
for key, count in log_metrics["actor_skill_signatures"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" actor signatures: <none>")
print("Unit role expression:")
role_rows = expression_rows(log_metrics["unit_role_stats"], log_metrics["unit_role_actions"], top)
if role_rows:
for row in role_rows:
top_actions = ", ".join(f"{key}={count}" for key, count in row.get("top_actions", [])[:3]) or "-"
print(
f" {row['key']}: exec={row.get('executions', 0)} "
f"kill={row.get('kills', 0)} death={row.get('selfDeaths', 0)} "
f"dmg={row.get('damage', 0)} taken={row.get('taken', 0)} "
f"move={row.get('moves', 0)} cap={row.get('captures', 0)} "
f"skill={row.get('skillChanged', 0)} noEff={row.get('noEffect', 0)} "
f"actions=[{top_actions}]"
)
else:
print(" <none>")
print("Unit type expression:")
type_rows = expression_rows(
log_metrics["unit_type_stats"],
log_metrics["unit_type_actions"],
top,
role_lookup=unit_role,
)
if type_rows:
for row in type_rows:
top_actions = ", ".join(f"{key}={count}" for key, count in row.get("top_actions", [])[:3]) or "-"
print(
f" {row['key']}[{row.get('role', '')}]: exec={row.get('executions', 0)} "
f"kill={row.get('kills', 0)} death={row.get('selfDeaths', 0)} "
f"dmg={row.get('damage', 0)} move={row.get('moves', 0)} "
f"cap={row.get('captures', 0)} skill={row.get('skillChanged', 0)} "
f"noEff={row.get('noEffect', 0)} actions=[{top_actions}]"
)
else:
print(" <none>")
print("Fallback:")
if log_metrics["fallback_actions"]:

View File

@ -177,6 +177,7 @@ Defense 的行为倾向:
- Emergency 车道优先阻止丢城;危险城市如果空防或只剩唯一守军,先补兵/城墙,再考虑攻击和外派。
- 城市优先训练防守单位、建城墙、保留占城格单位。
- 如果危险城市中心为空,城市生产要优先补可站城/守城单位;扩张变强后不能让空城威胁长期升高。
- 科技优先防御、基础兵种、移动和克制。
- 英雄优先治疗、保护、控场、守城。
@ -440,6 +441,7 @@ HeroPlaybook 的判断顺序:
- 按英雄等级和技能入口识别可用 Action。
- 治疗和保护类动作优先给残血英雄和高价值友军。
- 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。
- 刺客和先锋英雄在有合法有效攻击目标时,攻击优先级略高于普通 MoveToFront否则已上场英雄容易只移动、不形成个人威胁。
- 自身主动类动作只在满足局部价值时使用。
- 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。
@ -590,8 +592,9 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 扩张 | aliveAvgCities、alive>=2、alive>=3、maxCities | 二城率和三城率稳定提升,不能靠单个高滚玩家掩盖整体弱扩张 |
| 战斗 | UnitAttack 数、kills、actingUnitDeaths、PriorityTactic/Tactic 占比 | 有战果且不过度白送,城市威胁能被处理 |
| 防守 | cityLost、capitalThreatTurns、emptyThreatenedCityTurns、resolved/worsened、emergencyResponse、defenderReturn | 少丢城,首都少长期受压,危险城市有回防或生产响应 |
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、英雄专属 reason/action、styleBuckets | 已选择英雄应尽快上场,英雄动作应体现个人机制 |
| 特色单位 | unitSkillActions、unitSkillChangedActions、actorSkillSignatures | 新阵营小兵和特殊单位要通过技能签名、特殊动作、状态变化展示存在感 |
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、heroPersonalExpression、styleBuckets | 已选择英雄应尽快上场;不同英雄的攻击、治疗、控场、召唤、经济和防守表达要能区分 |
| 小兵定位 | unitRoleExpression、unitTypeExpression、kills、selfDeaths、damage、taken、captures、moves | 机动兵要推进和占领,近战要站线和收割,远程/攻城要打出输出,防守兵要少送死并解决威胁 |
| 特色单位 | unitSkillActions、unitSkillChangedActions、actorSkillSignatures、skillSignatureExpression | 新阵营小兵和特殊单位要通过技能签名、特殊动作、状态变化展示存在感 |
| Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 |
| 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 |
@ -612,6 +615,9 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|---|---|
| styleBuckets | 把英雄 reason/action 归为 Defense、Recovery、Burst、Control、Summon、Economy、Mobility、HeroLifecycle、General |
| heroDefensiveUse | 英雄在防守、治疗、保护或 Emergency 中参与的次数 |
| heroPersonalExpression | 按英雄身份统计真实执行次数、击杀、伤害、治疗、移动、技能变化和 top actions判断个人机制是否真的被使用 |
| unitRoleExpression | 按 Melee、Mobility、Ranged、Siege、Defender、Naval、Special、Summon 统计真实行为,判断小兵定位是否健康 |
| unitTypeExpression | 按具体 UnitType 统计行为,用来发现某个特色小兵只被生产但不行动、只移动不输出、或死亡过高 |
| unitSkillActions | 带技能签名的单位实际执行了哪些动作 |
| unitSkillChangedActions | 动作前后改变了单位或目标技能签名的动作 |
| actorSkillSignatures | 最常参与行动的技能组合,辅助判断新阵营小兵是否真的被用到 |

View File

@ -1818,7 +1818,10 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
attack = FindBestUsefulAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero))
如果 attack 可用:
返回 Candidate(attack, HeroPlaybook, 760, "英雄通用高价值攻击")
roleBonus = 0
如果 state.Role 是 Assassin 或 Vanguard:
roleBonus = 90
返回 Candidate(attack, HeroPlaybook, 760 + TargetValue(attack.Target) + roleBonus, "英雄通用高价值攻击")
返回 None
```
@ -2786,6 +2789,34 @@ Growth: 只遍历合法动作
DefenseScore
```
```text
结构 ActorExpressionMetrics:
Decisions
Executions
Kills
SelfDeaths
DamageToTarget
DamageTaken
SelfHeal
Moves
Captures
RecoverActions
SkillChanged
ThreatResolved
ThreatWorsened
CityOwnerChanged
NoEffect
TopActions
```
```text
结构 BatchExpressionMetrics:
HeroPersonalExpression: Map<GiantType, ActorExpressionMetrics>
UnitRoleExpression: Map<UnitRole, ActorExpressionMetrics>
UnitTypeExpression: Map<UnitType, ActorExpressionMetrics>
SkillSignatureExpression: Map<CompactSkillSignature, ActorExpressionMetrics>
```
```text
函数 BuildTurnDefenseSnapshot(map, player):
snapshot.CityIds = 当前 player 拥有城市 Id 排序签名
@ -2892,6 +2923,64 @@ EmergencyResponseRate =
UnitSkillChangedActions[action.ActionKey] += 1
```
英雄个人表达按真实执行统计,不只看 HeroPlaybook
```text
函数 AccumulateHeroPersonalExpression(decision, execution):
action = execution.Action
delta = execution.Delta
hero = action.ActorGiantType 或 action.GiantType 或 action.TargetActorGiantType
如果 hero 为空:
返回
metrics = HeroPersonalExpression[hero]
metrics.Executions += 1
metrics.DamageToTarget += max(0, -delta.TargetUnitHealthDelta)
metrics.DamageTaken += max(0, -delta.UnitHealthDelta)
metrics.SelfHeal += max(0, delta.UnitHealthDelta)
如果 delta.TargetUnitDied: metrics.Kills += 1
如果 delta.UnitDied: metrics.SelfDeaths += 1
如果 delta.UnitMoved: metrics.Moves += 1
如果 delta.UnitSkillSignatureChanged 或 delta.TargetUnitSkillSignatureChanged: metrics.SkillChanged += 1
如果 delta.SelectedHeroDelta > 0: metrics.Selected += delta.SelectedHeroDelta
如果 delta.HeroDelta > 0: metrics.Spawned += delta.HeroDelta
metrics.TopActions[action.ActionKey] += 1
```
小兵定位表达按角色和具体类型同时统计:
```text
函数 ResolveUnitRole(unitType):
如果 unitType in [Warrior, Swordsman, KaguyaFrenchWarrior, KaguyaFrenchAnimalWarrior, HakureiBerserk]:
返回 Melee
如果 unitType in [Rider, Knights, MoriyaRider, MoriyaKnight, KomeijiIndianRider, KomeijiIndianKnight, HakureiValkyrie]:
返回 Mobility
如果 unitType in [Archer, KomeijiIndianArcher]:
返回 Ranged
如果 unitType in [Catapult, KaguyaFrenchCatapult, KomeijiIndianCatapult]:
返回 Siege
如果 unitType in [Defender, HakureiRoundShieldman]:
返回 Defender
如果 unitType 是船:
返回 Naval
如果 unitType 是召唤物:
返回 Summon
返回 Special
函数 AccumulateUnitRoleExpression(execution):
action = execution.Action
delta = execution.Delta
unitType = action.ActorUnitType 或 action.UnitType
如果 unitType 为空 或 unitType 是英雄 Giant:
返回
role = ResolveUnitRole(unitType)
同时累加 UnitRoleExpression[role] 和 UnitTypeExpression[unitType]:
Executions、Kills、SelfDeaths、DamageToTarget、DamageTaken、Moves、Captures、SkillChanged、NoEffect、TopActions
```
### 18.1 城市防守
```text

View File

@ -218,11 +218,12 @@ namespace Logic.AI.Director
if (endDistance >= startDistance && startDistance > 1) continue;
var targetScore = ScoreExpansionTarget(ctx, target);
var progressScore = Mathf.Max(0, startDistance - endDistance) * 90f;
var reachBonus = endDistance == 0 ? 360f : endDistance == 1 ? 160f : 0f;
var nearConversionBonus = startDistance <= 2 ? 180f : startDistance <= 3 ? 80f : 0f;
var mobilityBonus = unit.GetActionPoint(ActionPointType.Move) >= 2 ? 120f : 0f;
var longDragPenalty = startDistance >= 5 && startDistance - endDistance <= 1 ? 140f : 0f;
var isUrgentSecondCity = ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold;
var progressScore = Mathf.Max(0, startDistance - endDistance) * (isUrgentSecondCity ? 105f : 90f);
var reachBonus = endDistance == 0 ? 380f : endDistance == 1 ? 190f : 0f;
var nearConversionBonus = startDistance <= 2 ? 220f : startDistance <= 3 ? 120f : isUrgentSecondCity && startDistance <= 4 ? 60f : 0f;
var mobilityBonus = UnitMobilityExpansionBonus(unit);
var longDragPenalty = startDistance >= 5 && startDistance - endDistance <= 1 ? 160f : 0f;
var safetyPenalty = GridThreat(ctx, endGrid) * (target.TargetType == AIDirectorDevelopmentTargetType.Village ? 0.25f : 0.5f);
var targetRiskPenalty = ExpansionTargetRiskPenalty(ctx, target, endGrid);
var score = targetScore + progressScore + reachBonus + nearConversionBonus + mobilityBonus - longDragPenalty - endDistance * 28f - safetyPenalty - targetRiskPenalty;
@ -492,8 +493,9 @@ namespace Logic.AI.Director
var attack = FindBestUsefulAttack(ctx, state.Hero);
var attackScore = ScoreAttackAction(ctx, attack);
var targetValue = attackScore > 0f ? UnitTargetValue(ctx, attack?.Param?.TargetUnitData) : 0f;
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericHighValueAttack", 760f + targetValue);
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("attackScore", attackScore));
var roleAttackBonus = state.Role is AIDirectorHeroRole.Assassin or AIDirectorHeroRole.Vanguard ? 90f : 0f;
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericHighValueAttack", 760f + targetValue + roleAttackBonus);
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("roleAttack", roleAttackBonus), ("attackScore", attackScore));
if (attackCandidate.IsValid && attackScore > 0f) return attackCandidate;
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
@ -953,7 +955,13 @@ namespace Logic.AI.Director
var localThreat = actingGrid != null ? GridThreat(ctx, actingGrid) * 0.25f : 0f;
var penalty = gridThreat * 0.75f + localThreat;
if (target.TargetType == AIDirectorDevelopmentTargetType.EnemyEmptyCity) penalty += gridThreat * 0.35f;
if (HasSevereCityThreat(ctx)) penalty += 180f;
if (HasSevereCityThreat(ctx))
{
penalty += ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold
&& target.TargetType is AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity
? 80f
: 180f;
}
if (ctx.Cache.SelfUnits.Count <= ctx.Cache.SelfCities.Count + 1 && gridThreat > 0f) penalty += 120f;
return penalty;
}
@ -1043,7 +1051,7 @@ namespace Logic.AI.Director
if (ctx?.Cache == null || target?.Grid == null) return false;
if (ctx.Cache.SelfCities.Count >= ctx.Config.ExpansionUrgentCityThreshold) return false;
if (target.TargetType is not (AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)) return false;
if (target.Distance > 3) return false;
if (target.Distance > 4) return false;
return GridThreat(ctx, target.Grid) <= 0f;
}
@ -1227,10 +1235,16 @@ namespace Logic.AI.Director
var score = UnitBaseMilitaryValue(id.UnitType);
if (threat != null)
{
if (!CityCenterHasFriendlyGuard(ctx, threat.CityGrid))
{
score += IsDefenderUnit(id.UnitType) ? 100f : IsMeleeUnit(id.UnitType) ? 60f : 35f;
}
foreach (var enemy in threat.EnemyUnits)
{
score += id.UnitType == UnitType.Defender ? 45f : 20f;
if (enemy.GetAttackRange(ctx.Map) > 1 && id.UnitType == UnitType.Rider) score += 30f;
score += IsDefenderUnit(id.UnitType) ? 45f : 20f;
if (enemy.GetAttackRange(ctx.Map) > 1 && IsMobilityUnit(id.UnitType)) score += 35f;
if (enemy.UnitType == UnitType.Defender && IsSiegeUnit(id.UnitType)) score += 50f;
}
}
@ -1243,7 +1257,8 @@ namespace Logic.AI.Director
var score = UnitBaseMilitaryValue(id.UnitType);
if (ShouldPushExpansion(ctx)) score += TrainUnitExpansionValue(id.UnitType);
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Attack) score += 60f;
if (id.UnitType is UnitType.Rider or UnitType.Knights or UnitType.Catapult) score += 40f;
if (IsMobilityUnit(id.UnitType) || IsSiegeUnit(id.UnitType)) score += 45f;
if (IsRangedUnit(id.UnitType) && ctx.Cache.HasAnyEnemyContact) score += 35f;
return score;
}
@ -1251,10 +1266,11 @@ namespace Logic.AI.Director
{
return unitType switch
{
UnitType.Rider or UnitType.MoriyaRider or UnitType.KomeijiIndianRider => 130f,
UnitType.Warrior or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior => 90f,
UnitType.Knights or UnitType.MoriyaKnight or UnitType.KomeijiIndianKnight => 80f,
UnitType.Defender => -40f,
UnitType.Rider or UnitType.MoriyaRider or UnitType.KomeijiIndianRider or UnitType.HakureiValkyrie => 170f,
UnitType.Warrior or UnitType.Swordsman or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker => 105f,
UnitType.Knights or UnitType.MoriyaKnight or UnitType.KomeijiIndianKnight => 100f,
UnitType.Archer or UnitType.KomeijiIndianArcher => 30f,
UnitType.Defender or UnitType.HakureiRoundShieldman => -45f,
_ => 20f
};
}
@ -1263,16 +1279,61 @@ namespace Logic.AI.Director
{
return unitType switch
{
UnitType.Defender => 90f,
UnitType.Warrior => 70f,
UnitType.Archer => 75f,
UnitType.Rider or UnitType.Knights => 85f,
UnitType.Catapult => 100f,
UnitType.Defender or UnitType.HakureiRoundShieldman => 90f,
UnitType.Warrior or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker => 70f,
UnitType.Swordsman => 95f,
UnitType.Archer or UnitType.KomeijiIndianArcher => 78f,
UnitType.Rider or UnitType.Knights or UnitType.MoriyaRider or UnitType.MoriyaKnight or UnitType.KomeijiIndianRider or UnitType.KomeijiIndianKnight or UnitType.HakureiValkyrie => 88f,
UnitType.Catapult or UnitType.KaguyaFrenchCatapult or UnitType.KomeijiIndianCatapult => 105f,
UnitType.Giant or UnitType.GiantJuggernaut => 140f,
_ => 50f
};
}
private static bool IsMobilityUnit(UnitType unitType)
{
return unitType is UnitType.Rider or UnitType.Knights or UnitType.MoriyaRider or UnitType.MoriyaKnight
or UnitType.KomeijiIndianRider or UnitType.KomeijiIndianKnight or UnitType.HakureiValkyrie;
}
private static bool IsMeleeUnit(UnitType unitType)
{
return unitType is UnitType.Warrior or UnitType.Swordsman or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior
or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker;
}
private static bool IsRangedUnit(UnitType unitType)
{
return unitType is UnitType.Archer or UnitType.KomeijiIndianArcher;
}
private static bool IsSiegeUnit(UnitType unitType)
{
return unitType is UnitType.Catapult or UnitType.KaguyaFrenchCatapult or UnitType.KomeijiIndianCatapult;
}
private static bool IsDefenderUnit(UnitType unitType)
{
return unitType is UnitType.Defender or UnitType.HakureiRoundShieldman;
}
private static float UnitMobilityExpansionBonus(UnitData unit)
{
if (unit == null) return 0f;
if (IsMobilityUnit(unit.UnitType)) return 170f;
return unit.GetActionPoint(ActionPointType.Move) >= 2 ? 120f : 0f;
}
private static bool CityCenterHasFriendlyGuard(AIDirectorContext ctx, GridData cityGrid)
{
if (ctx?.Map == null || ctx.Player == null || cityGrid == null) return false;
return cityGrid.RealUnit(ctx.Map, out var unit)
&& unit != null
&& ctx.Map.GetPlayerDataByUnitId(unit.Id, out var owner)
&& owner != null
&& ctx.Map.SameUnion(ctx.Player.Id, owner.Id);
}
private bool NeedStandingArmy(AIDirectorContext ctx, CityData city)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) return true;

View File

@ -142,7 +142,7 @@ namespace Logic.AI.Director
public int MaxDevelopmentTargetCount = 20;
public int MaxExpansionTargetScanCount = 6;
public int MaxMoveActionsPerUnit = 5;
public int MaxExpansionMoveIntentsPerTurn = 2;
public int MaxExpansionMoveIntentsPerTurn = 3;
public int MaxEmergencyMoveIntentsPerTurn = 2;
public int MaxFrontMoveIntentsPerTurn = 1;
public int MaxEmergencyRescueDistance = 6;