Improve AI director batch metrics and attack filtering
This commit is contained in:
parent
37164ea62d
commit
e972f58eba
@ -67,8 +67,9 @@ Read the batch analyzer output in this order:
|
||||
3. Outcome shape: `avgTurn`, `avgSurvivors`, eliminations, and winners if present.
|
||||
4. Expansion: alive average city count, all-player average city count, max city count, alive `>=2` and `>=3` city ratios.
|
||||
5. Power and attrition: alive unit count, score p10/p50/p90, kills, acting-unit deaths.
|
||||
6. Action quality: no-effect actions, repeated stable actions, max actions per player turn.
|
||||
7. Decision time: average, p95, max, and top lanes/actions.
|
||||
6. Hero count: read both `HeroSummary` from `batch_summary.json` and detailed `Hero:` from JSONL. Prefer hero-eligible ratios when the batch includes non-hero factions.
|
||||
7. Action quality: no-effect actions, repeated stable actions, max actions per player turn.
|
||||
8. Decision time: average, p95, max, and top lanes/actions.
|
||||
|
||||
Do not judge AI quality from a single interrupted or partial run. The batch analyzer filters incomplete logs when the summary only contains completed games, but final before/after claims should use completed batches with the same options.
|
||||
|
||||
@ -134,6 +135,7 @@ Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
|
||||
- Unit count and score p10/p50/p90 should not collapse while expansion improves.
|
||||
- Attrition should be interpreted with context: more kills and more deaths may indicate stronger aggression, not necessarily worse play.
|
||||
- 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.
|
||||
|
||||
@ -215,3 +217,5 @@ Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 2 -Turns 1 -TimeoutSeconds 60
|
||||
```
|
||||
|
||||
The runner writes `batch_summary.json` under `Unity/Logs/AI_Batch/<timestamp>/`. Use `-Games 10`, higher `-Turns`, and the normal 17 players for larger AI quality loops.
|
||||
|
||||
`Tools/RunAIDirectorBatch.ps1` defaults to not stopping on settlement/game-end checks so quality loops can run to the requested `-Turns` even if a settlement winner appears early. Pass `-StopOnGameEnd` only when validating settlement/endgame behavior.
|
||||
|
||||
@ -251,6 +251,9 @@ def analyze_logs(log_paths):
|
||||
"ready_hero_task_delta": 0,
|
||||
"forced_hero_task_delta": 0,
|
||||
"hero_task_progress_delta": 0,
|
||||
"final_selected_hero_counts": [],
|
||||
"final_spawned_hero_counts": [],
|
||||
"final_max_hero_counts": [],
|
||||
"critical_city_threat_turns": 0,
|
||||
"capital_threat_turns": 0,
|
||||
"empty_threatened_city_turns": 0,
|
||||
@ -300,6 +303,7 @@ def analyze_logs(log_paths):
|
||||
decision_lane_by_turn_action = {}
|
||||
decision_reason_by_turn_action = {}
|
||||
last_turn_summary_by_player = {}
|
||||
last_probe_by_player = {}
|
||||
for row in rows:
|
||||
event_type = row.get("eventType", "")
|
||||
if event_type == "Decision":
|
||||
@ -341,6 +345,8 @@ def analyze_logs(log_paths):
|
||||
if event_type == "TurnStart":
|
||||
turn_summary = row.get("turnSummary") or {}
|
||||
player_id = row.get("playerId", 0)
|
||||
if turn_summary:
|
||||
last_probe_by_player[player_id] = turn_summary
|
||||
if int(turn_summary.get("criticalCityThreatCount") or 0) > 0:
|
||||
aggregate["critical_city_threat_turns"] += 1
|
||||
if int(turn_summary.get("capitalThreatCount") or 0) > 0:
|
||||
@ -371,7 +377,10 @@ def analyze_logs(log_paths):
|
||||
continue
|
||||
action = execution.get("action") or {}
|
||||
delta = execution.get("delta") or {}
|
||||
after_probe = execution.get("after") or {}
|
||||
player_id = row.get("playerId", 0)
|
||||
if after_probe:
|
||||
last_probe_by_player[player_id] = after_probe
|
||||
key = action_key(action)
|
||||
turn_action_key = (player_id, row.get("playerTurn", 0), stable_key(action))
|
||||
decision_lane = decision_lane_by_turn_action.get(turn_action_key, "")
|
||||
@ -438,6 +447,11 @@ def analyze_logs(log_paths):
|
||||
if len(grouped_rows) > 1 and not is_tactical_repeat(grouped_rows):
|
||||
aggregate["top_repeated"][str(key)] += len(grouped_rows)
|
||||
|
||||
for probe in last_probe_by_player.values():
|
||||
aggregate["final_selected_hero_counts"].append(int(probe.get("selectedHeroCount") or 0))
|
||||
aggregate["final_spawned_hero_counts"].append(int(probe.get("heroCount") or 0))
|
||||
aggregate["final_max_hero_counts"].append(int(probe.get("maxHeroCount") or 0))
|
||||
|
||||
return aggregate
|
||||
|
||||
|
||||
@ -503,6 +517,20 @@ def summarize_batch(batch):
|
||||
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]
|
||||
selected_hero_counts = [player.get("selectedHeroCount", 0) for _, player in players]
|
||||
spawned_hero_counts = [player.get("spawnedHeroCount", 0) for _, player in players]
|
||||
max_hero_counts = [player.get("maxHeroCount", 0) for _, player in players]
|
||||
eligible_players = [
|
||||
(game, player)
|
||||
for game, player in players
|
||||
if player.get("heroEligible")
|
||||
or player.get("selectedHeroCount", 0) > 0
|
||||
or player.get("spawnedHeroCount", 0) > 0
|
||||
or player.get("maxHeroCount", 0) > 1
|
||||
]
|
||||
eligible_selected_hero_counts = [player.get("selectedHeroCount", 0) for _, player in eligible_players]
|
||||
eligible_spawned_hero_counts = [player.get("spawnedHeroCount", 0) for _, player in eligible_players]
|
||||
eligible_max_hero_counts = [player.get("maxHeroCount", 0) for _, player in eligible_players]
|
||||
|
||||
total_elapsed = sum(elapsed_values)
|
||||
total_actions = sum(net_actions)
|
||||
@ -534,6 +562,26 @@ def summarize_batch(batch):
|
||||
"score_p10": percentile(scores, 0.10),
|
||||
"score_p50": percentile(scores, 0.50),
|
||||
"score_p90": percentile(scores, 0.90),
|
||||
"hero_eligible_count": len(eligible_players),
|
||||
"hero_eligible_ratio": len(eligible_players) / max(1, len(players)),
|
||||
"avg_selected_hero_count": average(selected_hero_counts),
|
||||
"avg_spawned_hero_count": average(spawned_hero_counts),
|
||||
"avg_max_hero_count": average(max_hero_counts),
|
||||
"selected_hero_ge_2_ratio": sum(1 for value in selected_hero_counts if value >= 2) / max(1, len(players)),
|
||||
"spawned_hero_ge_1_ratio": sum(1 for value in spawned_hero_counts if value >= 1) / max(1, len(players)),
|
||||
"spawned_hero_ge_2_ratio": sum(1 for value in spawned_hero_counts if value >= 2) / max(1, len(players)),
|
||||
"eligible_avg_selected_hero_count": average(eligible_selected_hero_counts),
|
||||
"eligible_avg_spawned_hero_count": average(eligible_spawned_hero_counts),
|
||||
"eligible_avg_max_hero_count": average(eligible_max_hero_counts),
|
||||
"eligible_selected_hero_ge_2_ratio": (
|
||||
sum(1 for value in eligible_selected_hero_counts if value >= 2) / max(1, len(eligible_players))
|
||||
),
|
||||
"eligible_spawned_hero_ge_1_ratio": (
|
||||
sum(1 for value in eligible_spawned_hero_counts if value >= 1) / max(1, len(eligible_players))
|
||||
),
|
||||
"eligible_spawned_hero_ge_2_ratio": (
|
||||
sum(1 for value in eligible_spawned_hero_counts if value >= 2) / max(1, len(eligible_players))
|
||||
),
|
||||
"win_players": [
|
||||
{
|
||||
"gameIndex": game.get("gameIndex"),
|
||||
@ -630,6 +678,33 @@ def compact_log_metrics(log_metrics, top):
|
||||
"ready_hero_task_delta": log_metrics["ready_hero_task_delta"],
|
||||
"forced_hero_task_delta": log_metrics["forced_hero_task_delta"],
|
||||
"hero_task_progress_delta": log_metrics["hero_task_progress_delta"],
|
||||
"final_selected_avg": average(log_metrics["final_selected_hero_counts"]),
|
||||
"final_spawned_avg": average(log_metrics["final_spawned_hero_counts"]),
|
||||
"final_max_slots_avg": average(log_metrics["final_max_hero_counts"]),
|
||||
"final_selected_ge_2_ratio": (
|
||||
sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 2)
|
||||
/ max(1, len(log_metrics["final_selected_hero_counts"]))
|
||||
),
|
||||
"final_selected_ge_3_ratio": (
|
||||
sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 3)
|
||||
/ max(1, len(log_metrics["final_selected_hero_counts"]))
|
||||
),
|
||||
"final_spawned_ge_1_ratio": (
|
||||
sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 1)
|
||||
/ max(1, len(log_metrics["final_spawned_hero_counts"]))
|
||||
),
|
||||
"final_spawned_ge_2_ratio": (
|
||||
sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 2)
|
||||
/ max(1, len(log_metrics["final_spawned_hero_counts"]))
|
||||
),
|
||||
"final_max_slots_ge_2_ratio": (
|
||||
sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 2)
|
||||
/ max(1, len(log_metrics["final_max_hero_counts"]))
|
||||
),
|
||||
"final_max_slots_ge_3_ratio": (
|
||||
sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 3)
|
||||
/ max(1, len(log_metrics["final_max_hero_counts"]))
|
||||
),
|
||||
},
|
||||
"fallback": {
|
||||
"top_reasons": log_metrics["fallback_reasons"].most_common(top),
|
||||
@ -675,6 +750,18 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
|
||||
f"allAvgUnits={metrics['avg_unit_count']:.2f} "
|
||||
f"score(p10/p50/p90)={metrics['score_p10']:.0f}/{metrics['score_p50']:.0f}/{metrics['score_p90']:.0f}"
|
||||
)
|
||||
print(
|
||||
"HeroSummary: "
|
||||
f"eligible={metrics['hero_eligible_count']} ({metrics['hero_eligible_ratio']:.1%}) "
|
||||
f"allAvgSelected={metrics['avg_selected_hero_count']:.2f} "
|
||||
f"allAvgSpawned={metrics['avg_spawned_hero_count']:.2f} "
|
||||
f"eligibleAvgSelected={metrics['eligible_avg_selected_hero_count']:.2f} "
|
||||
f"eligibleAvgSpawned={metrics['eligible_avg_spawned_hero_count']:.2f} "
|
||||
f"eligibleAvgMaxSlots={metrics['eligible_avg_max_hero_count']:.2f} "
|
||||
f"eligibleSpawned>=1={metrics['eligible_spawned_hero_ge_1_ratio']:.1%} "
|
||||
f"eligibleSpawned>=2={metrics['eligible_spawned_hero_ge_2_ratio']:.1%} "
|
||||
f"eligibleSelected>=2={metrics['eligible_selected_hero_ge_2_ratio']:.1%}"
|
||||
)
|
||||
if metrics["win_players"]:
|
||||
print("Wins:")
|
||||
for winner in metrics["win_players"]:
|
||||
@ -757,6 +844,26 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
|
||||
f"forcedTask={log_metrics['forced_hero_task_delta']} "
|
||||
f"taskProgress={log_metrics['hero_task_progress_delta']}"
|
||||
)
|
||||
final_hero_sample_count = len(log_metrics["final_selected_hero_counts"])
|
||||
if final_hero_sample_count > 0:
|
||||
selected_ge_2 = sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 2) / final_hero_sample_count
|
||||
selected_ge_3 = sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 3) / final_hero_sample_count
|
||||
spawned_ge_1 = sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 1) / final_hero_sample_count
|
||||
spawned_ge_2 = sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 2) / final_hero_sample_count
|
||||
slots_ge_2 = sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 2) / final_hero_sample_count
|
||||
slots_ge_3 = sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 3) / final_hero_sample_count
|
||||
print(
|
||||
" final counts: "
|
||||
f"avgSelected={average(log_metrics['final_selected_hero_counts']):.2f} "
|
||||
f"avgSpawned={average(log_metrics['final_spawned_hero_counts']):.2f} "
|
||||
f"avgMaxSlots={average(log_metrics['final_max_hero_counts']):.2f} "
|
||||
f"selected>=2={selected_ge_2:.1%} "
|
||||
f"selected>=3={selected_ge_3:.1%} "
|
||||
f"spawned>=1={spawned_ge_1:.1%} "
|
||||
f"spawned>=2={spawned_ge_2:.1%} "
|
||||
f"slots>=2={slots_ge_2:.1%} "
|
||||
f"slots>=3={slots_ge_3:.1%}"
|
||||
)
|
||||
if log_metrics["hero_reasons"]:
|
||||
print(" top reasons:")
|
||||
for key, count in log_metrics["hero_reasons"].most_common(top):
|
||||
|
||||
@ -199,6 +199,7 @@ Expansion 的行为倾向:
|
||||
- Expansion 只扫描已排序的少量高价值扩张目标;扩大城市数不能让每次决策退化成“所有目标 × 所有单位”的全量搜索。
|
||||
- 单位移动优先满足“本回合能占”或“下回合能站到可占点旁边”,远距离目标只保留作低优先级战线。
|
||||
- 扩张目标必须考虑占后守备风险;如果目标格或占领单位附近威胁很高、当前已有严重城市威胁、或己方单位数不足以覆盖城市数,则降低占领/靠近优先级。普通城市威胁只影响危险目标,不应把安全二城扩张整体压掉。
|
||||
- 二城前如果存在严重城市威胁,Expansion 不完全关闭,只保留距离 3 格内、目标格无即时威胁、且可快速转化的村庄/空城目标;其他远距离或危险扩张继续让位给 Emergency。
|
||||
- 危险城市的唯一守军不能外派扩张;非唯一守军可以参与近距离、安全、能快速转化的二城扩张,避免防守过度导致城市数停滞。
|
||||
- Expansion 车道优先处理占领和向目标靠近;UnitOpportunity 负责脚下的占领、遗迹和采集补漏。
|
||||
- Development Front 指向村庄、遗迹、资源和边界。
|
||||
@ -352,11 +353,12 @@ HeroManagement 的节奏:
|
||||
```text
|
||||
没有选择英雄
|
||||
→ 先选择最适合当前阵营和局势的英雄
|
||||
→ 如果已选择英雄数量已经占满当前英雄槽位,先买下一英雄槽位
|
||||
→ 已选择但未上场时,优先在合法城市训练/复活英雄
|
||||
→ 英雄已经能参与战局后,再推进可强制完成的英雄任务
|
||||
```
|
||||
|
||||
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会把英雄当作普通兵随缘训练。
|
||||
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会因为第二、第三英雄槽位没开而卡住英雄体系。
|
||||
|
||||
HeroPlaybook 的判断顺序:
|
||||
|
||||
@ -496,6 +498,8 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。
|
||||
|
||||
UnitAttackAlly 只由 HeroPlaybook、支援战术或明确的友军互动规则使用,不进入 Fallback。友军目标动作如果落到兜底,通常说明它缺少英雄或支援规则。
|
||||
|
||||
普通 UnitAttack 必须有可预期收益。基础伤害为 0 的纯普通攻击不进入 Emergency、Tactic 或 GenericHero 攻击候选,避免防守时把行动浪费在“合法但不掉血”的目标上。带推击、龙船撞击、Reisen 协同或 Kaguya 狼等攻击附加效果的动作可以例外,因为它们可能通过位移、追击、标记或额外伤害产生收益。
|
||||
|
||||
危险动作默认不进入普通 AI 选择:
|
||||
|
||||
- 解散。
|
||||
|
||||
@ -443,6 +443,8 @@ SkillBase
|
||||
如果 target != null 且 action.TargetUnit != target:
|
||||
继续
|
||||
score = ScoreAttackAction(ctx, action)
|
||||
如果 score <= 0:
|
||||
继续
|
||||
best = MaxByScore(best, action, score)
|
||||
|
||||
返回 best.Action
|
||||
@ -1199,7 +1201,9 @@ SkillBase
|
||||
best = None
|
||||
|
||||
对每个 defender in threat.DefenderUnits:
|
||||
action = FindBestAttackAgainstThreat(ctx, defender, threat)
|
||||
action = FindBestUsefulAttack(ctx, defender, BestThreatTarget(ctx, threat))
|
||||
如果 action == None:
|
||||
action = FindBestUsefulAttack(ctx, defender)
|
||||
candidate = Candidate(action, Emergency, ScoreEmergencyAttack(ctx, action, threat), "守军攻击城市威胁")
|
||||
best = MaxCandidate(best, candidate)
|
||||
|
||||
@ -1319,6 +1323,10 @@ SkillBase
|
||||
如果 candidate.IsValid:
|
||||
返回 candidate
|
||||
|
||||
candidate = TryBuyHeroSlotCultureCard(ctx)
|
||||
如果 candidate.IsValid 且 ShouldPrioritizeHeroSlotBeforeSpawn(ctx):
|
||||
返回 candidate
|
||||
|
||||
candidate = TrySpawnSelectedHero(ctx)
|
||||
如果 candidate.IsValid:
|
||||
返回 candidate
|
||||
@ -1405,6 +1413,18 @@ SkillBase
|
||||
返回 Candidate(action, HeroManagement, score, "购买英雄槽位卡")
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ShouldPrioritizeHeroSlotBeforeSpawn(ctx):
|
||||
heroData = ctx.Player.PlayerHeroData
|
||||
如果 heroData == null:
|
||||
返回 false
|
||||
如果 heroData.MaxHeroCount >= 3:
|
||||
返回 false
|
||||
如果 heroData.HeroCount > 0 且 heroData.HeroCount >= heroData.MaxHeroCount:
|
||||
返回 true
|
||||
返回 false
|
||||
```
|
||||
|
||||
### 7.5 FinishHeroTask
|
||||
|
||||
```text
|
||||
@ -1466,10 +1486,13 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
|
||||
|
||||
best = None
|
||||
scannedTargetCount = 0
|
||||
requireSafeUrgentTarget = 有严重城市威胁
|
||||
|
||||
对每个 target in ctx.Cache.DevelopmentTargets:
|
||||
如果 !IsExpansionTarget(ctx, target):
|
||||
继续
|
||||
如果 requireSafeUrgentTarget 且 !IsSafeUrgentExpansionTarget(ctx, target):
|
||||
继续
|
||||
|
||||
如果 Config.MaxExpansionTargetScanCount > 0 且 scannedTargetCount >= Config.MaxExpansionTargetScanCount:
|
||||
break
|
||||
@ -1489,7 +1512,7 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
|
||||
|
||||
```text
|
||||
函数 ShouldPushExpansion(ctx):
|
||||
如果 有严重城市威胁:
|
||||
如果 有严重城市威胁 且 不存在安全紧急扩张目标:
|
||||
返回 false
|
||||
|
||||
如果 ctx.Cache.DevelopmentTargets 为空:
|
||||
@ -1504,6 +1527,25 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
|
||||
返回 false
|
||||
```
|
||||
|
||||
```text
|
||||
函数 HasSafeUrgentExpansionTarget(ctx):
|
||||
对每个 target in ctx.Cache.DevelopmentTargets:
|
||||
如果 IsSafeUrgentExpansionTarget(ctx, target):
|
||||
返回 true
|
||||
返回 false
|
||||
|
||||
函数 IsSafeUrgentExpansionTarget(ctx, target):
|
||||
如果 己方城市数 >= Config.ExpansionUrgentCityThreshold:
|
||||
返回 false
|
||||
如果 target.Type 不在 [Village, EnemyEmptyCity]:
|
||||
返回 false
|
||||
如果 target.Distance > 3:
|
||||
返回 false
|
||||
如果 GridThreat(ctx, target.Grid) > 0:
|
||||
返回 false
|
||||
返回 true
|
||||
```
|
||||
|
||||
### 8.3 IsExpansionTarget
|
||||
|
||||
```text
|
||||
@ -1774,7 +1816,7 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
|
||||
如果 move 可用:
|
||||
返回 Candidate(move, HeroPlaybook, 820, "英雄低血撤退")
|
||||
|
||||
attack = FindBestAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero))
|
||||
attack = FindBestUsefulAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero))
|
||||
如果 attack 可用:
|
||||
返回 Candidate(attack, HeroPlaybook, 760, "英雄通用高价值攻击")
|
||||
|
||||
@ -1915,8 +1957,10 @@ Byakuren / Miko / Zanmu:
|
||||
best = None
|
||||
|
||||
对每个 battle in ctx.Cache.LocalBattles:
|
||||
action = FindBestAttack(ctx, battle.SelfUnit, battle.EnemyUnit)
|
||||
action = FindBestUsefulAttack(ctx, battle.SelfUnit, battle.EnemyUnit)
|
||||
attackScore = ScoreAttackAction(ctx, action)
|
||||
如果 attackScore <= 0:
|
||||
继续
|
||||
score = 700 + battle.Value + attackScore * 0.1
|
||||
如果 priorityOnly 且 (score < Config.PriorityTacticScore 或 !IsPriorityTacticAction(ctx, action, attackScore)):
|
||||
继续
|
||||
@ -1925,6 +1969,8 @@ Byakuren / Miko / Zanmu:
|
||||
|
||||
对每个 action in ctx.Actions.Attacks:
|
||||
score = ScoreAttackAction(ctx, action)
|
||||
如果 score <= 0:
|
||||
继续
|
||||
如果 priorityOnly 且 (score < Config.PriorityTacticScore 或 !IsPriorityTacticAction(ctx, action, score)):
|
||||
继续
|
||||
candidate = Candidate(action, Tactic, score, priorityOnly ? "高价值攻击收益" : "普通攻击收益")
|
||||
@ -1939,6 +1985,9 @@ Byakuren / Miko / Zanmu:
|
||||
target = action.TargetUnitData
|
||||
damage = CalcDamage(ctx.Map, attacker, target)
|
||||
|
||||
如果 !CanTreatAttackAsUseful(attacker, target, damage):
|
||||
返回 false
|
||||
|
||||
如果 damage >= target.Health:
|
||||
返回 true
|
||||
如果 target 是英雄:
|
||||
@ -1958,6 +2007,10 @@ Byakuren / Miko / Zanmu:
|
||||
函数 ScoreAttackAction(ctx, action):
|
||||
attacker = action.UnitData
|
||||
target = action.TargetUnitData
|
||||
damage = CalcDamage(ctx.Map, attacker, target)
|
||||
|
||||
如果 !CanTreatAttackAsUseful(attacker, target, damage):
|
||||
返回 0
|
||||
|
||||
score = 620
|
||||
score += EstimateDamageValue(ctx, attacker, target) * 2
|
||||
@ -1982,6 +2035,42 @@ Byakuren / Miko / Zanmu:
|
||||
返回 score
|
||||
```
|
||||
|
||||
```text
|
||||
函数 FindBestUsefulAttack(ctx, unit, preferredTarget = null):
|
||||
best = None
|
||||
bestScore = -INF
|
||||
|
||||
对每个 action in ctx.Actions.GetAttackActions(unit):
|
||||
target = action.TargetUnitData
|
||||
如果 preferredTarget != null 且 target != preferredTarget:
|
||||
继续
|
||||
score = ScoreAttackAction(ctx, action)
|
||||
如果 score <= 0:
|
||||
继续
|
||||
如果 score > bestScore:
|
||||
bestScore = score
|
||||
best = action
|
||||
|
||||
返回 best
|
||||
```
|
||||
|
||||
```text
|
||||
函数 CanTreatAttackAsUseful(attacker, target, damage):
|
||||
如果 attacker == null 或 target == null:
|
||||
返回 false
|
||||
如果 damage > 0:
|
||||
返回 true
|
||||
如果 attacker 带有攻击附加收益:
|
||||
返回 true
|
||||
返回 false
|
||||
|
||||
攻击附加收益包括:
|
||||
YuugiPush
|
||||
HakureiDragonshipRam
|
||||
REISENFRENCHATTAK
|
||||
KaguyaFrenchWolf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. UnitOpportunity Lane
|
||||
|
||||
@ -15,6 +15,7 @@ param(
|
||||
[switch]$KeepGoing,
|
||||
[switch]$FullDiagnostics,
|
||||
[switch]$AllSight,
|
||||
[switch]$StopOnGameEnd,
|
||||
[switch]$AllowProjectAlreadyOpen,
|
||||
[switch]$DryRun
|
||||
)
|
||||
@ -101,6 +102,7 @@ $logFile = Join-Path $OutDir "unity_batch.log"
|
||||
$failFast = if ($KeepGoing) { "false" } else { "true" }
|
||||
$compactDiagnostics = if ($FullDiagnostics) { "false" } else { "true" }
|
||||
$allSightValue = if ($AllSight) { "true" } else { "false" }
|
||||
$stopOnGameEndValue = if ($StopOnGameEnd) { "true" } else { "false" }
|
||||
$args = @(
|
||||
"-batchmode",
|
||||
"-projectPath", $ProjectPath,
|
||||
@ -119,6 +121,7 @@ $args = @(
|
||||
"-aiBatchFailFast", $failFast,
|
||||
"-aiBatchCompactDiagnostics", $compactDiagnostics,
|
||||
"-aiBatchAllSight", $allSightValue,
|
||||
"-aiBatchStopOnGameEnd", $stopOnGameEndValue,
|
||||
"-aiBatchOut", $OutDir
|
||||
)
|
||||
|
||||
|
||||
@ -147,6 +147,13 @@ namespace Logic.AI.Director
|
||||
return best;
|
||||
}
|
||||
|
||||
public IEnumerable<AIActionBase> GetAttackActions(UnitData unit)
|
||||
{
|
||||
if (unit == null) yield break;
|
||||
if (!_attacksByUnit.TryGetValue(unit.Id, out var actions)) yield break;
|
||||
foreach (var action in actions) yield return action;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestAttackAlly(UnitData unit, UnitData target = null)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
|
||||
@ -145,9 +145,11 @@ namespace Logic.AI.Director
|
||||
|
||||
var best = AIDirectorActionCandidate.None;
|
||||
var scannedTargets = 0;
|
||||
var requireSafeUrgentTarget = HasSevereCityThreat(ctx);
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
{
|
||||
if (!IsExpansionTarget(ctx, target)) continue;
|
||||
if (requireSafeUrgentTarget && !IsSafeUrgentExpansionTarget(ctx, target)) continue;
|
||||
if (ctx.Config.MaxExpansionTargetScanCount > 0 && scannedTargets >= ctx.Config.MaxExpansionTargetScanCount) break;
|
||||
scannedTargets++;
|
||||
|
||||
@ -254,8 +256,8 @@ namespace Logic.AI.Director
|
||||
foreach (var defender in threat.Defenders)
|
||||
{
|
||||
var target = ChooseBestThreatTarget(ctx, threat);
|
||||
var action = ctx.ActionIndex.FindBestAttack(defender, target) ?? ctx.ActionIndex.FindBestAttack(defender);
|
||||
var targetValue = UnitTargetValue(ctx, target);
|
||||
var action = FindBestUsefulAttack(ctx, defender, target) ?? FindBestUsefulAttack(ctx, defender);
|
||||
var targetValue = UnitTargetValue(ctx, action?.Param?.TargetUnitData ?? target);
|
||||
var score = 960f + threat.DangerScore * 20f + targetValue;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.AttackCityThreat", score);
|
||||
AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue));
|
||||
@ -339,6 +341,14 @@ namespace Logic.AI.Director
|
||||
return true;
|
||||
}
|
||||
|
||||
var heroSlotCard = TryHeroSlotCultureCard(ctx, decision);
|
||||
if (heroSlotCard.IsValid && ShouldPrioritizeHeroSlotBeforeSpawn(ctx))
|
||||
{
|
||||
candidate = heroSlotCard;
|
||||
decision.AddTrace($"HeroManagement: buy hero slot card {candidate.ActionId?.CultureCardType}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var heroSpawn = FindBestHeroSpawn(ctx, decision);
|
||||
if (heroSpawn.IsValid)
|
||||
{
|
||||
@ -347,7 +357,6 @@ namespace Logic.AI.Director
|
||||
return true;
|
||||
}
|
||||
|
||||
var heroSlotCard = TryHeroSlotCultureCard(ctx, decision);
|
||||
if (heroSlotCard.IsValid)
|
||||
{
|
||||
candidate = heroSlotCard;
|
||||
@ -378,6 +387,14 @@ namespace Logic.AI.Director
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private bool ShouldPrioritizeHeroSlotBeforeSpawn(AIDirectorContext ctx)
|
||||
{
|
||||
var heroData = ctx?.Player?.PlayerHeroData;
|
||||
if (heroData == null) return false;
|
||||
if (heroData.MaxHeroCount >= 3) return false;
|
||||
return heroData.HeroCount > 0 && heroData.HeroCount >= heroData.MaxHeroCount;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate FindBestHeroSpawn(AIDirectorContext ctx, AIDirectorDecision decision)
|
||||
{
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
@ -472,11 +489,12 @@ namespace Logic.AI.Director
|
||||
if (moveCandidate.IsValid) return moveCandidate;
|
||||
}
|
||||
|
||||
var attack = ctx.ActionIndex.FindBestAttack(state.Hero);
|
||||
var targetValue = UnitTargetValue(ctx, attack?.Param?.TargetUnitData);
|
||||
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));
|
||||
if (attackCandidate.IsValid) return attackCandidate;
|
||||
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("attackScore", attackScore));
|
||||
if (attackCandidate.IsValid && attackScore > 0f) return attackCandidate;
|
||||
|
||||
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
|
||||
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
|
||||
@ -495,8 +513,9 @@ namespace Logic.AI.Director
|
||||
foreach (var battle in ctx.Cache.LocalBattles)
|
||||
{
|
||||
if (battle.SelfUnit == null || battle.EnemyUnit == null) continue;
|
||||
var action = ctx.ActionIndex.FindBestAttack(battle.SelfUnit, battle.EnemyUnit);
|
||||
var action = FindBestUsefulAttack(ctx, battle.SelfUnit, battle.EnemyUnit);
|
||||
var attackScore = ScoreAttackAction(ctx, action);
|
||||
if (attackScore <= 0f) continue;
|
||||
var score = 700f + battle.Value + attackScore * 0.1f;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.LocalBattleAttack", score);
|
||||
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
|
||||
@ -508,6 +527,7 @@ namespace Logic.AI.Director
|
||||
foreach (var action in ctx.ActionIndex.AttackActions)
|
||||
{
|
||||
var score = ScoreAttackAction(ctx, action);
|
||||
if (score <= 0f) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.AttackValue", score);
|
||||
AddTerms(current, ("attackScore", score));
|
||||
if (priorityOnly && (score < minScore || !IsPriorityTacticAction(ctx, action, score))) continue;
|
||||
@ -630,6 +650,7 @@ namespace Logic.AI.Director
|
||||
var attacker = action.Param.UnitData;
|
||||
var target = action.Param.TargetUnitData;
|
||||
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
|
||||
if (!CanTreatAttackAsUseful(attacker, target, damage)) return 0f;
|
||||
var score = 620f;
|
||||
score += damage / Mathf.Max(1f, target.GetMaxHealth()) * UnitTargetValue(ctx, target) * 2f;
|
||||
score += CounterBonus(attacker, target);
|
||||
@ -641,6 +662,39 @@ namespace Logic.AI.Director
|
||||
return score;
|
||||
}
|
||||
|
||||
private AIActionBase FindBestUsefulAttack(AIDirectorContext ctx, UnitData unit, UnitData preferredTarget = null)
|
||||
{
|
||||
AIActionBase best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var action in ctx.ActionIndex.GetAttackActions(unit))
|
||||
{
|
||||
var target = action.Param?.TargetUnitData;
|
||||
if (preferredTarget != null && target?.Id != preferredTarget.Id) continue;
|
||||
var score = ScoreAttackAction(ctx, action);
|
||||
if (score <= 0f || score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool CanTreatAttackAsUseful(UnitData attacker, UnitData target, int damage)
|
||||
{
|
||||
if (attacker == null || target == null) return false;
|
||||
if (damage > 0) return true;
|
||||
return HasAttackSideEffect(attacker);
|
||||
}
|
||||
|
||||
private static bool HasAttackSideEffect(UnitData attacker)
|
||||
{
|
||||
if (attacker == null) return false;
|
||||
if (attacker.UnitType == UnitType.KaguyaFrenchWolf) return true;
|
||||
return attacker.GetSkill(SkillType.YuugiPush, out _)
|
||||
|| attacker.GetSkill(SkillType.HakureiDragonshipRam, out _)
|
||||
|| attacker.GetSkill(SkillType.REISENFRENCHATTAK, out _);
|
||||
}
|
||||
|
||||
private float ScoreFrontMove(AIDirectorContext ctx, UnitData unit, AIActionBase action, AIDirectorFront front)
|
||||
{
|
||||
if (unit == null || action?.Param == null || front == null) return 0f;
|
||||
@ -957,7 +1011,8 @@ namespace Logic.AI.Director
|
||||
|
||||
private bool ShouldPushExpansion(AIDirectorContext ctx)
|
||||
{
|
||||
if (ctx?.Cache == null || (ctx.Cache.HasCriticalCityThreat && HasSevereCityThreat(ctx))) return false;
|
||||
if (ctx?.Cache == null) return false;
|
||||
if (ctx.Cache.HasCriticalCityThreat && HasSevereCityThreat(ctx) && !HasSafeUrgentExpansionTarget(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);
|
||||
@ -973,6 +1028,25 @@ namespace Logic.AI.Director
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasSafeUrgentExpansionTarget(AIDirectorContext ctx)
|
||||
{
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
{
|
||||
if (IsSafeUrgentExpansionTarget(ctx, target)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsSafeUrgentExpansionTarget(AIDirectorContext ctx, AIDirectorDevelopmentTarget target)
|
||||
{
|
||||
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;
|
||||
return GridThreat(ctx, target.Grid) <= 0f;
|
||||
}
|
||||
|
||||
private bool HasExpansionTarget(AIDirectorContext ctx)
|
||||
{
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
@ -1042,6 +1116,7 @@ namespace Logic.AI.Director
|
||||
var attacker = action.Param.UnitData;
|
||||
var target = action.Param.TargetUnitData;
|
||||
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
|
||||
if (!CanTreatAttackAsUseful(attacker, target, damage)) return false;
|
||||
if (damage >= target.Health) return true;
|
||||
if (target.TreatedAsHero(ctx.Map, target)) return true;
|
||||
if (IsThreateningAnyCity(ctx, target)) return true;
|
||||
|
||||
@ -21,11 +21,13 @@ namespace Logic.AI
|
||||
public static bool ForceAllPlayersAi;
|
||||
public static bool SkipPresentationWait;
|
||||
public static bool CompactDiagnostics;
|
||||
public static bool SuppressGameEnd;
|
||||
public static int RandomSeedOverride;
|
||||
#else
|
||||
public const bool ForceAllPlayersAi = false;
|
||||
public const bool SkipPresentationWait = false;
|
||||
public const bool CompactDiagnostics = false;
|
||||
public const bool SuppressGameEnd = false;
|
||||
public const int RandomSeedOverride = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -83,12 +83,12 @@ namespace Logic
|
||||
if (_curState == GameState.Finished) return;
|
||||
if (_curState == GameState.Spectate)
|
||||
{
|
||||
if (Main.MapData.CheckIfGameEnd(out _)) return;
|
||||
if (!AIDirectorBatchRuntime.SuppressGameEnd && Main.MapData.CheckIfGameEnd(out _)) return;
|
||||
Main.MapData.RefreshTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Main.MapData.CheckIfGameEnd(out _))
|
||||
if (!AIDirectorBatchRuntime.SuppressGameEnd && Main.MapData.CheckIfGameEnd(out _))
|
||||
{
|
||||
ChangeState(GameState.Finished);
|
||||
return;
|
||||
|
||||
@ -136,6 +136,7 @@ namespace TH1_Logic.Editor
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
||||
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
||||
#endif
|
||||
CompleteResult(result, gameDirectory);
|
||||
@ -167,6 +168,7 @@ namespace TH1_Logic.Editor
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = true;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics;
|
||||
AIDirectorBatchRuntime.SuppressGameEnd = !options.StopOnGameEnd;
|
||||
AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex;
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.BeginNewSession();
|
||||
@ -228,6 +230,7 @@ namespace TH1_Logic.Editor
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
||||
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
||||
#endif
|
||||
try
|
||||
@ -322,10 +325,17 @@ namespace TH1_Logic.Editor
|
||||
result.curPlayerId = map.CurPlayer?.Id ?? 0;
|
||||
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
|
||||
result.survivingPlayers = CountSurvivingPlayers(map);
|
||||
result.gameState = Main.Instance.GameLogic?.GetCurState().ToString() ?? string.Empty;
|
||||
var gameState = Main.Instance.GameLogic?.GetCurState();
|
||||
result.gameState = gameState?.ToString() ?? string.Empty;
|
||||
|
||||
if (map.CheckIfGameEnd(out var isWin)
|
||||
|| Main.Instance.GameLogic?.GetCurState() == GameState.Finished)
|
||||
if (options.StopOnGameEnd && gameState == GameState.Finished)
|
||||
{
|
||||
result.success = true;
|
||||
result.reason = "GameStateFinished";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (options.StopOnGameEnd && map.CheckIfGameEnd(out var isWin))
|
||||
{
|
||||
result.success = true;
|
||||
result.reason = isWin ? "GameEnd:SelfOrTeamWin" : "GameEnd";
|
||||
@ -468,6 +478,7 @@ namespace TH1_Logic.Editor
|
||||
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
|
||||
options.CompactDiagnostics = GetBoolArg("-aiBatchCompactDiagnostics", options.CompactDiagnostics);
|
||||
options.AllSight = GetBoolArg("-aiBatchAllSight", options.AllSight);
|
||||
options.StopOnGameEnd = GetBoolArg("-aiBatchStopOnGameEnd", options.StopOnGameEnd);
|
||||
|
||||
var outputDirectory = GetStringArg("-aiBatchOut");
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory)) options.OutputDirectory = outputDirectory;
|
||||
@ -572,6 +583,10 @@ namespace TH1_Logic.Editor
|
||||
techPoint = player.PlayerTechPoint,
|
||||
cityCount = CountPlayerCities(map, player.Id),
|
||||
unitCount = CountPlayerUnits(map, player.Id),
|
||||
heroEligible = PlayerHasSelectableHero(player),
|
||||
selectedHeroCount = player.PlayerHeroData?.HeroCount ?? 0,
|
||||
spawnedHeroCount = CountPlayerHeroes(map, player.Id),
|
||||
maxHeroCount = player.PlayerHeroData?.MaxHeroCount ?? 0,
|
||||
isWin = map.MatchSettlement?.IsWin(player.Id) ?? false
|
||||
});
|
||||
}
|
||||
@ -603,6 +618,28 @@ namespace TH1_Logic.Editor
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool PlayerHasSelectableHero(PlayerData player)
|
||||
{
|
||||
if (player?.PlayerHeroData == null) return false;
|
||||
var leader = player.PlayerHeroData.GetLeaderGiantType();
|
||||
return leader != GiantType.None && ContentGate.CanUseHeroForPlayer(player, leader);
|
||||
}
|
||||
|
||||
private static int CountPlayerHeroes(MapData map, uint playerId)
|
||||
{
|
||||
var units = map.UnitMap?.UnitList;
|
||||
if (units == null) return 0;
|
||||
var count = 0;
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (unit == null || !unit.IsAlive()) continue;
|
||||
if (!map.GetPlayerIdByUnitId(unit.Id, out var ownerId) || ownerId != playerId) continue;
|
||||
if (unit.TreatedAsHero(map, unit)) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountSurvivingPlayers(MapData map)
|
||||
{
|
||||
var players = map.PlayerMap?.PlayerDataList;
|
||||
@ -1179,6 +1216,7 @@ namespace TH1_Logic.Editor
|
||||
public bool DisableNearbySpawnPoints = false;
|
||||
public bool CompactDiagnostics = true;
|
||||
public bool AllSight = false;
|
||||
public bool StopOnGameEnd = true;
|
||||
public int Seed = 0;
|
||||
public string OutputDirectory;
|
||||
public string ScenePath;
|
||||
@ -1266,6 +1304,10 @@ namespace TH1_Logic.Editor
|
||||
public int techPoint;
|
||||
public int cityCount;
|
||||
public int unitCount;
|
||||
public bool heroEligible;
|
||||
public int selectedHeroCount;
|
||||
public int spawnedHeroCount;
|
||||
public int maxHeroCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user