Improve AI Director expansion metrics

This commit is contained in:
wuwenbo 2026-06-30 02:00:14 +08:00
parent a670fde6df
commit 9649658e2f
6 changed files with 718 additions and 9 deletions

View File

@ -0,0 +1,491 @@
#!/usr/bin/env python3
import argparse
import json
import math
import re
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from analyze_ai_director_log import (
action_key,
has_no_effect_delta,
is_tactical_repeat,
load_rows,
stable_key,
summarize,
)
def repo_root() -> Path:
return Path(__file__).resolve().parents[4]
def find_latest_batch(root: Path) -> Path:
batch_dir = root / "Unity" / "Logs" / "AI_Batch"
summaries = sorted(batch_dir.glob("*/batch_summary.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if not summaries:
raise FileNotFoundError(f"No batch_summary.json found under {batch_dir}")
return summaries[0]
def parse_time(value):
if not value:
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
normalized = re.sub(r"(\.\d{6})\d+([+-]\d\d:\d\d)?$", r"\1\2", normalized)
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
def seconds_between(start, end) -> float:
if start is None or end is None:
return 0.0
return max(0.0, (end - start).total_seconds())
def average(values) -> float:
values = [value for value in values if value is not None]
if not values:
return 0.0
return sum(values) / len(values)
def percentile(values, ratio: float) -> float:
values = sorted(value for value in values if value is not None)
if not values:
return 0.0
if len(values) == 1:
return float(values[0])
position = (len(values) - 1) * ratio
low = math.floor(position)
high = math.ceil(position)
if low == high:
return float(values[low])
weight = position - low
return float(values[low] * (1 - weight) + values[high] * weight)
def load_json(path: Path):
with path.open("r", encoding="utf-8-sig") as f:
return json.load(f)
def player_rows(batch):
for game in batch.get("results", []):
for player in game.get("players", []) or []:
yield game, player
def game_elapsed(game) -> float:
return seconds_between(parse_time(game.get("startedAt", "")), parse_time(game.get("endedAt", "")))
def local_session_time_from_name(path: Path):
stem = path.stem
prefix = "ai_director_"
if not stem.startswith(prefix):
return None
try:
return datetime.strptime(stem[len(prefix):], "%Y%m%d_%H%M%S")
except ValueError:
return None
def batch_local_window(batch_path: Path, batch):
output = (batch.get("options") or {}).get("OutputDirectory") or str(batch_path.parent)
start = None
try:
start = datetime.strptime(Path(output).name, "%Y%m%d_%H%M%S")
except ValueError:
pass
generated_utc = parse_time(batch.get("generatedAt", ""))
end = None
if generated_utc is not None:
if generated_utc.tzinfo is None:
generated_utc = generated_utc.replace(tzinfo=timezone.utc)
end = generated_utc.astimezone().replace(tzinfo=None)
if start is None and end is not None:
start = end - timedelta(hours=2)
if end is None and start is not None:
end = start + timedelta(hours=2)
if start is None or end is None:
return None, None
return start - timedelta(minutes=2), end + timedelta(minutes=2)
def find_matching_logs(root: Path, batch_path: Path, batch, explicit_logs):
if explicit_logs:
result = []
for value in explicit_logs:
path = Path(value)
if not path.is_absolute():
path = root / path
result.append(path)
return result
start, end = batch_local_window(batch_path, batch)
log_dir = root / "Unity" / "Logs" / "AI_Director_Diagnostics"
logs = sorted(log_dir.glob("ai_director_*.jsonl"), key=lambda p: p.stat().st_mtime)
if start is None or end is None:
return logs[-1:] if logs else []
matched = []
for path in logs:
session_time = local_session_time_from_name(path)
if session_time is None:
continue
if start <= session_time <= end:
matched.append(path)
completed_games = len(batch.get("results", []) or [])
if completed_games > 0 and len(matched) > completed_games:
matched = matched[:completed_games]
return matched
def analyze_logs(log_paths):
aggregate = {
"logs": [],
"rows": 0,
"decisions": 0,
"executions": 0,
"no_effect": 0,
"repeated_stable": 0,
"max_actions_per_player_turn": 0,
"kills": 0,
"self_deaths": 0,
"city_owner_changes": 0,
"target_city_owner_changes": 0,
"action_counts": Counter(),
"lane_counts": Counter(),
"player_actions": Counter(),
"player_kills": Counter(),
"player_self_deaths": Counter(),
"decide_ms": [],
"top_no_effect": Counter(),
"top_repeated": Counter(),
}
for path in log_paths:
rows = load_rows(path)
summary = summarize(rows, top=50, last=0)
aggregate["logs"].append(str(path))
aggregate["rows"] += len(rows)
aggregate["decisions"] += summary["decision_count"]
aggregate["executions"] += summary["execution_count"]
no_effect_items = summary["no_effect_actions"]
aggregate["no_effect"] += sum(count for _, count in no_effect_items)
aggregate["repeated_stable"] += sum(count for _, count in summary["repeated_stable_keys"])
if summary["max_actions_per_player_turn"]:
aggregate["max_actions_per_player_turn"] = max(
aggregate["max_actions_per_player_turn"],
max(count for _, count in summary["max_actions_per_player_turn"]),
)
aggregate["action_counts"].update(dict(summary["execution_actions"]))
aggregate["top_no_effect"].update(dict(no_effect_items))
for key, count in summary["repeated_stable_keys"]:
aggregate["top_repeated"][str(key)] += count
stable_rows_by_turn = defaultdict(list)
turn_rows = defaultdict(list)
for row in rows:
event_type = row.get("eventType", "")
if event_type == "Decision":
decision = row.get("decision") or {}
lane = decision.get("lane") or ""
if lane and lane != "None":
aggregate["lane_counts"][lane] += 1
decide_ms = decision.get("decideMs")
if isinstance(decide_ms, (int, float)):
aggregate["decide_ms"].append(float(decide_ms))
continue
if event_type != "Execution":
continue
execution = row.get("execution") or {}
if not execution.get("executed"):
continue
action = execution.get("action") or {}
delta = execution.get("delta") or {}
player_id = row.get("playerId", 0)
key = action_key(action)
aggregate["player_actions"][player_id] += 1
if delta.get("targetUnitDied"):
aggregate["kills"] += 1
aggregate["player_kills"][player_id] += 1
if delta.get("unitDied"):
aggregate["self_deaths"] += 1
aggregate["player_self_deaths"][player_id] += 1
if delta.get("cityOwnerChanged"):
aggregate["city_owner_changes"] += 1
if delta.get("targetCityOwnerChanged"):
aggregate["target_city_owner_changes"] += 1
turn_key = (player_id, row.get("playerTurn", 0))
turn_rows[turn_key].append(row)
stable_rows_by_turn[(turn_key[0], turn_key[1], stable_key(action))].append(row)
for key, grouped_rows in stable_rows_by_turn.items():
turn_key = (key[0], key[1])
for row in grouped_rows:
row["_turn_rows"] = turn_rows[turn_key]
if len(grouped_rows) > 1 and not is_tactical_repeat(grouped_rows):
aggregate["top_repeated"][str(key)] += len(grouped_rows)
return aggregate
def quality_warnings(batch, metrics, log_metrics):
warnings = []
options = batch.get("options") or {}
max_turns = int(options.get("MaxTurns") or 0)
failed = int(batch.get("failedGames") or 0)
if failed:
warnings.append(f"FAILED_GAMES: {failed} games failed.")
if metrics["games"] and metrics["avg_elapsed_sec"] > 0:
if metrics["avg_actions_per_sec"] < 15:
warnings.append(f"SLOW_ACTION_THROUGHPUT: actions/sec={metrics['avg_actions_per_sec']:.1f}.")
if metrics["avg_elapsed_sec"] > max(90, max_turns * 8):
warnings.append(f"SLOW_GAME_RUNTIME: avg seconds/game={metrics['avg_elapsed_sec']:.1f}.")
if metrics["avg_alive_city_count"] < 1.35 and max_turns >= 12:
warnings.append(f"LOW_EXPANSION: alive avg city count={metrics['avg_alive_city_count']:.2f}.")
if metrics["alive_city_ge_2_ratio"] < 0.25 and max_turns >= 12:
warnings.append(f"FEW_SECOND_CITIES: alive players with >=2 cities={metrics['alive_city_ge_2_ratio']:.1%}.")
if metrics["avg_surviving_players"] < metrics["players_per_game"] * 0.65 and max_turns <= 20:
warnings.append(f"EARLY_ELIMINATION: avg survivors={metrics['avg_surviving_players']:.1f}/{metrics['players_per_game']:.1f}.")
if log_metrics["max_actions_per_player_turn"] > 80:
warnings.append(f"HIGH_ACTIONS_PER_TURN: max={log_metrics['max_actions_per_player_turn']}.")
if log_metrics["no_effect"] > 0:
warnings.append(f"NO_EFFECT_ACTIONS: count={log_metrics['no_effect']}.")
if log_metrics["repeated_stable"] > 0:
warnings.append(f"REPEATED_ACTIONS: count={log_metrics['repeated_stable']}.")
if log_metrics["decide_ms"]:
p95 = percentile(log_metrics["decide_ms"], 0.95)
if p95 > 60:
warnings.append(f"SLOW_DECISION_P95: p95={p95:.1f}ms.")
return warnings
def summarize_batch(batch):
games = batch.get("results", []) or []
succeeded = [game for game in games if game.get("success")]
players = list(player_rows(batch))
alive_players = [(game, player) for game, player in players if player.get("alive")]
elapsed_values = [game_elapsed(game) for game in games]
net_actions = [game.get("netActions", 0) for game in games]
frames = [game.get("frames", 0) for game in games]
max_turns = [game.get("maxPlayerTurn", 0) for game in games]
survivors = [game.get("survivingPlayers", 0) for game in games]
players_per_game = average([game.get("playerCount", 0) for game in games])
city_counts = [player.get("cityCount", 0) for _, player in players]
alive_city_counts = [player.get("cityCount", 0) for _, player in alive_players]
unit_counts = [player.get("unitCount", 0) for _, player in players]
alive_unit_counts = [player.get("unitCount", 0) for _, player in alive_players]
scores = [player.get("score", 0) for _, player in players]
total_elapsed = sum(elapsed_values)
total_actions = sum(net_actions)
total_frames = sum(frames)
alive_count = len(alive_players)
return {
"games": len(games),
"succeeded": len(succeeded),
"failed": int(batch.get("failedGames") or 0),
"players_per_game": players_per_game,
"avg_elapsed_sec": average(elapsed_values),
"total_elapsed_sec": total_elapsed,
"avg_turn": average(max_turns),
"avg_net_actions": average(net_actions),
"avg_actions_per_turn": total_actions / max(1.0, sum(max_turns)),
"avg_actions_per_sec": total_actions / max(0.001, total_elapsed),
"avg_frames_per_sec": total_frames / max(0.001, total_elapsed),
"avg_surviving_players": average(survivors),
"avg_eliminations": average([max(0.0, players_per_game - value) for value in survivors]),
"avg_city_count": average(city_counts),
"avg_alive_city_count": average(alive_city_counts),
"max_city_count": max(city_counts) if city_counts else 0,
"alive_city_ge_2_ratio": sum(1 for value in alive_city_counts if value >= 2) / max(1, alive_count),
"alive_city_ge_3_ratio": sum(1 for value in alive_city_counts if value >= 3) / max(1, alive_count),
"avg_unit_count": average(unit_counts),
"avg_alive_unit_count": average(alive_unit_counts),
"avg_score": average(scores),
"score_p10": percentile(scores, 0.10),
"score_p50": percentile(scores, 0.50),
"score_p90": percentile(scores, 0.90),
"win_players": [
{
"gameIndex": game.get("gameIndex"),
"playerId": player.get("id"),
"civId": player.get("civId"),
"score": player.get("score"),
"cityCount": player.get("cityCount"),
"unitCount": player.get("unitCount"),
}
for game, player in players
if player.get("isWin")
],
}
def to_jsonable(value):
if isinstance(value, Counter):
return value.most_common()
if isinstance(value, dict):
return {key: to_jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [to_jsonable(item) for item in value]
return value
def compact_log_metrics(log_metrics, top):
decide_ms = log_metrics["decide_ms"]
return {
"logs": [str(path) for path in log_metrics["logs"]],
"rows": log_metrics["rows"],
"decisions": log_metrics["decisions"],
"executions": log_metrics["executions"],
"no_effect": log_metrics["no_effect"],
"repeated_stable": log_metrics["repeated_stable"],
"max_actions_per_player_turn": log_metrics["max_actions_per_player_turn"],
"kills": log_metrics["kills"],
"self_deaths": log_metrics["self_deaths"],
"city_owner_changes": log_metrics["city_owner_changes"],
"target_city_owner_changes": log_metrics["target_city_owner_changes"],
"decision_time": {
"avg_ms": average(decide_ms),
"p95_ms": percentile(decide_ms, 0.95),
"max_ms": max(decide_ms) if decide_ms else 0,
},
"top_actions": log_metrics["action_counts"].most_common(top),
"top_lanes": log_metrics["lane_counts"].most_common(top),
}
def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
print(f"Batch: {batch_path}")
print(f"Games: {metrics['games']} success={metrics['succeeded']} failed={metrics['failed']}")
print(
"Runtime: "
f"avgGame={metrics['avg_elapsed_sec']:.1f}s "
f"actions/sec={metrics['avg_actions_per_sec']:.1f} "
f"frames/sec={metrics['avg_frames_per_sec']:.1f} "
f"actions/turn={metrics['avg_actions_per_turn']:.1f}"
)
print(
"Outcome: "
f"avgTurn={metrics['avg_turn']:.1f} "
f"avgSurvivors={metrics['avg_surviving_players']:.1f}/{metrics['players_per_game']:.1f} "
f"avgElims={metrics['avg_eliminations']:.1f}"
)
print(
"Expansion: "
f"aliveAvgCities={metrics['avg_alive_city_count']:.2f} "
f"allAvgCities={metrics['avg_city_count']:.2f} "
f"maxCities={metrics['max_city_count']} "
f"alive>=2={metrics['alive_city_ge_2_ratio']:.1%} "
f"alive>=3={metrics['alive_city_ge_3_ratio']:.1%}"
)
print(
"Power: "
f"aliveAvgUnits={metrics['avg_alive_unit_count']:.2f} "
f"allAvgUnits={metrics['avg_unit_count']:.2f} "
f"score(p10/p50/p90)={metrics['score_p10']:.0f}/{metrics['score_p50']:.0f}/{metrics['score_p90']:.0f}"
)
if metrics["win_players"]:
print("Wins:")
for winner in metrics["win_players"]:
print(
" "
f"game={winner['gameIndex']} player={winner['playerId']} civ={winner['civId']} "
f"score={winner['score']} cities={winner['cityCount']} units={winner['unitCount']}"
)
else:
print("Wins: <none>")
print(f"Logs: {len(log_metrics['logs'])} rows={log_metrics['rows']}")
print(
"Action quality: "
f"executions={log_metrics['executions']} "
f"maxActions/playerTurn={log_metrics['max_actions_per_player_turn']} "
f"noEffect={log_metrics['no_effect']} "
f"repeated={log_metrics['repeated_stable']}"
)
print(
"Attrition proxy: "
f"kills={log_metrics['kills']} "
f"actingUnitDeaths={log_metrics['self_deaths']} "
f"cityOwnerChanges={log_metrics['city_owner_changes']} "
f"targetCityOwnerChanges={log_metrics['target_city_owner_changes']}"
)
if log_metrics["decide_ms"]:
print(
"Decision time: "
f"avg={average(log_metrics['decide_ms']):.1f}ms "
f"p95={percentile(log_metrics['decide_ms'], 0.95):.1f}ms "
f"max={max(log_metrics['decide_ms']):.1f}ms"
)
print("Top actions:")
for key, count in log_metrics["action_counts"].most_common(top):
print(f" {count:>5} {key}")
print("Top lanes:")
for key, count in log_metrics["lane_counts"].most_common(top):
print(f" {count:>5} {key}")
print("Warnings:")
if warnings:
for warning in warnings:
print(f" {warning}")
else:
print(" <none>")
def main():
parser = argparse.ArgumentParser(description="Analyze TH1 AI Director batch quality metrics.")
parser.add_argument("--batch", type=Path, help="Specific AI_Batch/*/batch_summary.json path.")
parser.add_argument("--log", action="append", default=[], help="Specific AI_Director_Diagnostics jsonl log path. Can repeat.")
parser.add_argument("--top", type=int, default=12, help="Number of top actions/lanes to print.")
parser.add_argument("--json", action="store_true", help="Emit JSON summary.")
args = parser.parse_args()
root = repo_root()
batch_path = args.batch if args.batch else find_latest_batch(root)
if not batch_path.is_absolute():
batch_path = root / batch_path
batch = load_json(batch_path)
metrics = summarize_batch(batch)
log_paths = find_matching_logs(root, batch_path, batch, args.log)
log_metrics = analyze_logs(log_paths)
warnings = quality_warnings(batch, metrics, log_metrics)
result = {
"batch": str(batch_path),
"metrics": metrics,
"logs": compact_log_metrics(log_metrics, args.top),
"warnings": warnings,
}
if args.json:
print(json.dumps(to_jsonable(result), ensure_ascii=False, indent=2))
return
print_report(batch_path, batch, metrics, log_metrics, warnings, args.top)
if __name__ == "__main__":
main()

View File

@ -182,6 +182,13 @@ def is_tactical_repeat(rows: list) -> bool:
if len(rows) <= 1:
return False
first_action = ((rows[0].get("execution") or {}).get("action") or {})
if action_key(first_action) == "CityLevelUpAction:Park":
return all(
((row.get("execution") or {}).get("delta") or {}).get("cityLevelUpPointDelta", 0) < 0
and ((row.get("execution") or {}).get("delta") or {}).get("netActionDelta", 0) == 1
for row in rows
)
if action_key(first_action) != "UnitMove:":
return False
@ -247,7 +254,7 @@ def summarize(rows, top: int, last: int):
if len(grouped_rows) > 1 and not is_tactical_repeat(grouped_rows)
][:top],
"no_effect_actions": no_effect_actions.most_common(top),
"last_executions": [short_action_row(row, True) for row in executions[-last:]],
"last_executions": [] if last <= 0 else [short_action_row(row, True) for row in executions[-last:]],
}

View File

@ -44,6 +44,7 @@ namespace Logic.AI.Director
if (TryEmergencyLane(ctx, decision, out var candidate)
|| TryHeroManagementLane(ctx, decision, out candidate)
|| TryExpansionLane(ctx, decision, out candidate)
|| TryHeroPlaybookLane(ctx, decision, out candidate)
|| TryTacticLane(ctx, decision, out candidate)
|| TryUnitOpportunityLane(ctx, decision, out candidate)
@ -86,7 +87,7 @@ namespace Logic.AI.Director
foreach (var threat in ctx.Cache.CityThreats)
{
if (threat == null) continue;
if (!threat.IsCritical && threat.EnemyUnits.Count < ctx.Config.CityDangerEnemyCount && threat.DangerScore <= 0f) continue;
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
var attack = TryEmergencyAttack(ctx, decision, threat);
if (attack.IsValid)
@ -116,6 +117,101 @@ namespace Logic.AI.Director
return false;
}
private bool TryExpansionLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
if (!ShouldPushExpansion(ctx)) return false;
var best = AIDirectorActionCandidate.None;
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (!IsExpansionTarget(ctx, target)) continue;
var capture = TryExpansionCapture(ctx, decision, target);
best = MaxCandidate(best, capture);
var move = TryExpansionMove(ctx, decision, target);
best = MaxCandidate(best, move);
}
if (!best.IsValid) return false;
candidate = best;
decision.AddTrace($"Expansion: target={candidate.TargetGrid?.Id}, unit={candidate.Unit?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private AIDirectorActionCandidate TryExpansionCapture(
AIDirectorContext ctx,
AIDirectorDecision decision,
AIDirectorDevelopmentTarget target)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var action in ctx.ActionIndex.UnitActions)
{
if (action?.ActionLogic?.ActionId?.UnitActionType != UnitActionType.Capture) continue;
var unit = action.Param.UnitData;
var grid = unit?.Grid(ctx.Map);
if (grid == null || target?.Grid?.Id != grid.Id) continue;
var score = ScoreExpansionTarget(ctx, target) + 420f;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Capture.{target.TargetType}", score);
AddTerms(current, ("target", ScoreExpansionTarget(ctx, target)), ("capture", 420f));
current.Unit = current.Unit ?? unit;
current.TargetGrid = current.TargetGrid ?? target.Grid;
RecordCandidate(ctx, decision, "ExpansionCapture", current, current.IsValid ? null : "CheckCanFailed");
best = MaxCandidate(best, current);
}
return best;
}
private AIDirectorActionCandidate TryExpansionMove(
AIDirectorContext ctx,
AIDirectorDecision decision,
AIDirectorDevelopmentTarget target)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
if (UnitIsCriticalCityDefender(ctx, unit)) continue;
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var startGrid)) continue;
if (target?.Grid == null || startGrid.Id == target.Grid.Id) continue;
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.CriticalHealthRatio && GridThreat(ctx, startGrid) > 0f) continue;
var action = ctx.ActionIndex.FindBestMove(unit, target.Grid);
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
if (endGrid == null) continue;
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target.Grid);
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, target.Grid);
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 mobilityBonus = unit.GetActionPoint(ActionPointType.Move) >= 2 ? 80f : 0f;
var safetyPenalty = GridThreat(ctx, endGrid) * (target.TargetType == AIDirectorDevelopmentTargetType.Village ? 0.25f : 0.5f);
var score = targetScore + progressScore + reachBonus + mobilityBonus - endDistance * 28f - safetyPenalty;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Move.{target.TargetType}", score);
AddTerms(
current,
("target", targetScore),
("progress", progressScore),
("reach", reachBonus),
("mobility", mobilityBonus),
("distance", -endDistance * 28f),
("threat", -safetyPenalty));
current.Unit = current.Unit ?? unit;
current.TargetGrid = current.TargetGrid ?? target.Grid;
RecordCandidate(ctx, decision, "ExpansionMove", current, action == null ? "NoMoveAction" : (current.IsValid ? null : "CheckCanFailed"));
best = MaxCandidate(best, current);
}
return best;
}
private AIDirectorActionCandidate TryEmergencyAttack(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
@ -454,7 +550,11 @@ namespace Logic.AI.Director
else if (kind == AIDirectorCityPlanKind.BacklineGrowth)
{
if (id.ActionType == CommonActionType.CityLevelUpAction) score += 140f;
if (id.ActionType == CommonActionType.TrainUnit) score += NeedStandingArmy(ctx, city) ? 80f : 20f;
if (id.ActionType == CommonActionType.TrainUnit)
{
score += NeedStandingArmy(ctx, city) ? 80f : 20f;
if (ShouldPushExpansion(ctx)) score += TrainUnitExpansionValue(id.UnitType);
}
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 20f;
}
else if (kind == AIDirectorCityPlanKind.Wonder)
@ -641,6 +741,80 @@ namespace Logic.AI.Director
return false;
}
private bool ShouldUseEmergency(AIDirectorCityThreat threat, AIDirectorConfig config)
{
if (threat == null) return false;
if (threat.HasEnemyOnTerritory) return true;
if (threat.EnemyUnits.Count >= config.CityCriticalDangerEnemyCount) return true;
if (threat.EnemyPower > threat.DefenderPower * config.CityThreatPowerRatio && threat.EnemyUnits.Count >= config.CityDangerEnemyCount) return true;
if (threat.DangerScore >= config.EmergencyDangerScore) return true;
return threat.IsCritical && threat.CanBeThreatenedNextTurn && threat.DefenderPower <= 0f;
}
private bool ShouldPushExpansion(AIDirectorContext ctx)
{
if (ctx?.Cache == null || (ctx.Cache.HasCriticalCityThreat && HasSevereCityThreat(ctx))) return false;
if (ctx.Cache.DevelopmentTargets.Count == 0) return false;
if (ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) return true;
return ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn && HasExpansionTarget(ctx);
}
private bool HasSevereCityThreat(AIDirectorContext ctx)
{
foreach (var threat in ctx.Cache.CityThreats)
{
if (ShouldUseEmergency(threat, ctx.Config)) return true;
}
return false;
}
private bool HasExpansionTarget(AIDirectorContext ctx)
{
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (IsExpansionTarget(ctx, target)) return true;
}
return false;
}
private bool IsExpansionTarget(AIDirectorContext ctx, AIDirectorDevelopmentTarget target)
{
if (target?.Grid == null) return false;
if (target.TargetType == AIDirectorDevelopmentTargetType.Village) return true;
if (target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) return false;
return ctx.Cache.SelfCities.Count >= ctx.Config.ExpansionUrgentCityThreshold
|| ctx.Player.Turn > ctx.Config.ExpansionHardPressureTurn;
}
private float ScoreExpansionTarget(AIDirectorContext ctx, AIDirectorDevelopmentTarget target)
{
if (target == null) return 0f;
var score = 740f + target.Value;
if (target.TargetType == AIDirectorDevelopmentTargetType.Village) score += 520f;
else if (target.TargetType == AIDirectorDevelopmentTargetType.EnemyEmptyCity) score += 180f;
if (ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) score += 360f;
if (ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn) score += 180f;
score -= target.Distance * 20f;
return score;
}
private bool UnitIsCriticalCityDefender(AIDirectorContext ctx, UnitData unit)
{
if (unit == null) return false;
foreach (var threat in ctx.Cache.CityThreats)
{
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
foreach (var defender in threat.Defenders)
{
if (defender?.Id == unit.Id) return true;
}
}
return false;
}
private bool IsThreateningAnyCity(AIDirectorContext ctx, UnitData target)
{
if (target == null) return false;
@ -736,11 +910,24 @@ namespace Logic.AI.Director
{
var id = action.ActionLogic.ActionId;
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;
return score;
}
private float TrainUnitExpansionValue(UnitType unitType)
{
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,
_ => 20f
};
}
private float UnitBaseMilitaryValue(UnitType unitType)
{
return unitType switch

View File

@ -10,6 +10,7 @@ namespace Logic.AI.Director
{
None,
Emergency,
Expansion,
HeroManagement,
HeroPlaybook,
Tactic,
@ -130,6 +131,9 @@ namespace Logic.AI.Director
public int FrontSearchRange = 6;
public int LocalBattleRange = 2;
public int DevelopmentSearchRange = 6;
public int ExpansionUrgentCityThreshold = 2;
public int ExpansionHardPressureTurn = 24;
public float EmergencyDangerScore = 8f;
public int MaxGeneratedActions = 4096;
public int MaxFrontCount = 12;
public int MaxDevelopmentTargetCount = 20;

View File

@ -466,10 +466,15 @@ namespace Logic.AI.Director
if (grid.CityOnGrid(ctx.Map, out var city) && city != null)
{
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null || !ctx.Map.SameUnion(ctx.Player.Id, owner.Id))
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null)
{
type = AIDirectorDevelopmentTargetType.Village;
value = 1180f + (ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn ? 180f : 0f);
}
else if (!ctx.Map.SameUnion(ctx.Player.Id, owner.Id))
{
type = AIDirectorDevelopmentTargetType.EnemyEmptyCity;
value = 950f + (city.IsCapital ? 120f : 0f);
value = 900f + (city.IsCapital ? 160f : 0f);
}
}
else if (grid.Resource != ResourceType.None && !grid.HasBuilding())
@ -515,7 +520,7 @@ namespace Logic.AI.Director
var grid = action.Param.GridData ?? unit?.Grid(ctx.Map);
var score = type switch
{
AIDirectorUnitOpportunityType.Capture => 820f,
AIDirectorUnitOpportunityType.Capture => 980f,
AIDirectorUnitOpportunityType.Examine => 760f,
AIDirectorUnitOpportunityType.Gather => 680f + ResourceValue(grid),
AIDirectorUnitOpportunityType.HeroUpgrade => 760f,
@ -527,7 +532,13 @@ namespace Logic.AI.Director
if (grid != null)
{
score += DevelopmentTargetValue(ctx.Cache, grid) * 0.2f;
var target = FindDevelopmentTarget(ctx.Cache, grid);
if (target != null)
{
score += target.Value * 0.45f;
if (target.TargetType == AIDirectorDevelopmentTargetType.Village) score += 260f;
else if (target.TargetType == AIDirectorDevelopmentTargetType.EnemyEmptyCity) score += 140f;
}
score -= GridThreat(ctx, ctx.Cache, grid) * 0.25f;
}
@ -752,12 +763,19 @@ namespace Logic.AI.Director
private float DevelopmentTargetValue(AIDirectorWorldCache cache, GridData grid)
{
if (grid == null) return 0f;
var target = FindDevelopmentTarget(cache, grid);
return target?.Value ?? 0f;
}
private AIDirectorDevelopmentTarget FindDevelopmentTarget(AIDirectorWorldCache cache, GridData grid)
{
if (cache == null || grid == null) return null;
foreach (var target in cache.DevelopmentTargets)
{
if (target.Grid?.Id == grid.Id) return target.Value;
if (target.Grid?.Id == grid.Id) return target;
}
return 0f;
return null;
}
private bool UnitIsCriticalCityDefender(AIDirectorWorldCache cache, UnitData unit)

View File

@ -115,6 +115,8 @@ public class DebugUI
{
if (!DebugCenter.Instance.DebugMode)
return;
if (Main.MapData?.PlayerMap?.SelfPlayerData == null)
return;
//return;
if (Main.MapData.PlayerMap.SelfPlayerData.Turn != _turn)
{