Improve AI Director expansion metrics
This commit is contained in:
parent
a670fde6df
commit
9649658e2f
@ -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()
|
||||
@ -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:]],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user