From d76c8f8f48b0c0a199f7e407459cb9fa807fefa2 Mon Sep 17 00:00:00 2001 From: wuwenbo Date: Thu, 2 Jul 2026 19:55:24 +0800 Subject: [PATCH] Tune AI director expansion and actor metrics --- .codex/skills/th1-ai-director/SKILL.md | 4 +- .../scripts/analyze_ai_batch_quality.py | 347 +++++++++++++++++- MD/GameMDFramework/18-AI导演系统策划文档.md | 10 +- MD/GameMDFramework/19-AI导演系统逻辑语言.md | 91 ++++- .../TH1_Logic/AI/Director/AIDirectorLogic.cs | 103 ++++-- .../TH1_Logic/AI/Director/AIDirectorTypes.cs | 2 +- 6 files changed, 523 insertions(+), 34 deletions(-) diff --git a/.codex/skills/th1-ai-director/SKILL.md b/.codex/skills/th1-ai-director/SKILL.md index 6d79f8aad..931c0737c 100644 --- a/.codex/skills/th1-ai-director/SKILL.md +++ b/.codex/skills/th1-ai-director/SKILL.md @@ -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: diff --git a/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py b/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py index c8aec3191..adaec3052 100644 --- a/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py +++ b/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py @@ -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: ") + 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(" ") + 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: ") + 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: ") + + 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(" ") + + 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(" ") print("Fallback:") if log_metrics["fallback_actions"]: diff --git a/MD/GameMDFramework/18-AI导演系统策划文档.md b/MD/GameMDFramework/18-AI导演系统策划文档.md index 8a4946a73..385582365 100644 --- a/MD/GameMDFramework/18-AI导演系统策划文档.md +++ b/MD/GameMDFramework/18-AI导演系统策划文档.md @@ -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 | 最常参与行动的技能组合,辅助判断新阵营小兵是否真的被用到 | diff --git a/MD/GameMDFramework/19-AI导演系统逻辑语言.md b/MD/GameMDFramework/19-AI导演系统逻辑语言.md index 01e4a442a..1e955f840 100644 --- a/MD/GameMDFramework/19-AI导演系统逻辑语言.md +++ b/MD/GameMDFramework/19-AI导演系统逻辑语言.md @@ -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 + UnitRoleExpression: Map + UnitTypeExpression: Map + SkillSignatureExpression: Map +``` + ```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 diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs index 1a4fe724e..496578204 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs index 7a6f51901..0f1951e49 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs @@ -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;