Tune AI director expansion and actor metrics
This commit is contained in:
parent
e972f58eba
commit
d76c8f8f48
@ -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:
|
||||
|
||||
|
||||
@ -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"]:
|
||||
|
||||
@ -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 | 最常参与行动的技能组合,辅助判断新阵营小兵是否真的被用到 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user