Compare commits
15 Commits
6d79d3df46
...
7ea8f7c852
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ea8f7c852 | |||
| d76c8f8f48 | |||
| e972f58eba | |||
| 37164ea62d | |||
| 15593c487b | |||
| dc93fab7da | |||
| 5947ea048f | |||
| d6fec5029e | |||
| 7eca426175 | |||
| 05db312da7 | |||
| 694df5ac4c | |||
| 420baca63d | |||
| 9649658e2f | |||
| a670fde6df | |||
| 8e8c16c41c |
@ -43,6 +43,14 @@ python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --last 5
|
||||
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --json
|
||||
```
|
||||
|
||||
For batch-level quality work, use the batch analyzer first:
|
||||
|
||||
```powershell
|
||||
python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py
|
||||
python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py --batch Unity/Logs/AI_Batch/YYYYMMDD_HHMMSS/batch_summary.json --top 12
|
||||
python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py --json
|
||||
```
|
||||
|
||||
Read the analyzer output in this order:
|
||||
|
||||
1. Latest log path and event counts.
|
||||
@ -52,6 +60,107 @@ Read the analyzer output in this order:
|
||||
5. No-effect successful actions.
|
||||
6. Last execution rows.
|
||||
|
||||
Read the batch analyzer output in this order:
|
||||
|
||||
1. `Games` success/failure count and whether any run timed out or was manually interrupted.
|
||||
2. Runtime throughput: `avgGame`, `actions/sec`, `frames/sec`, `actions/turn`.
|
||||
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. 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.
|
||||
|
||||
## Self-Optimization Loop
|
||||
|
||||
Use this loop for all long-running AI intelligence work. The goal is not just "no crash"; the AI must become measurably stronger without becoming slower or more brittle.
|
||||
|
||||
1. Establish a baseline before changing behavior.
|
||||
- Use the same map size, player count, turn limit, difficulty, and action budgets that will be used after the patch.
|
||||
- Save the `Unity/Logs/AI_Batch/<timestamp>/batch_summary.json` path and the batch analyzer output.
|
||||
- If there is no usable baseline, run at least one compact baseline batch first.
|
||||
|
||||
2. Run a bounded local batch, never an open-ended Unity session.
|
||||
- Close the visible Unity Editor before batchmode runs.
|
||||
- Always use explicit `-TimeoutSeconds`, `-MaxActions`, and `-MaxActionsPerPlayerTurn`.
|
||||
- Prefer `-KeepGoing` for quality loops so one bad game still yields evidence.
|
||||
- If Unity appears slow, inspect `unity_batch.log`, `batch_summary.json`, latest JSONL logs, and the Unity process before killing it.
|
||||
- After killing or interrupting a batch, confirm no `Unity.exe` process remains.
|
||||
|
||||
3. Analyze metrics before reading raw logs.
|
||||
- Use `analyze_ai_batch_quality.py` for the batch.
|
||||
- Use `analyze_ai_director_log.py` only after the batch report points to repeated actions, no-effect actions, outlier players, or slow lanes.
|
||||
- If the log is too sparse to explain the issue, improve diagnostics before guessing at AI logic.
|
||||
|
||||
4. Classify the problem type.
|
||||
- Correctness: failed games, exceptions, null references, action timeout, action budget stop, no-effect successful actions, illegal repeated actions.
|
||||
- Intelligence: weak expansion, low city count, poor unit count, bad attrition, city loss, long capital threat, empty threatened cities, obvious idle/economic waste, bad hero task timing, weak hero style expression, weak special-unit skill expression, over-defense, under-attack.
|
||||
- Performance: low actions/sec, high avg game runtime, high decision p95/max, too many actions per player turn, oversized JSONL output.
|
||||
- Noise: legal repeated city upgrades, normal tactical move/attack chains, expected Steam warmup messages, incomplete logs from killed batches.
|
||||
|
||||
5. Fix in the right layer.
|
||||
- If the design intent is wrong, update `18-AI导演系统策划文档.md` and `19-AI导演系统逻辑语言.md` before or alongside code.
|
||||
- If action availability is wrong for every caller, fix `CheckCan` or action semantics.
|
||||
- If the action is only wrong for AI, filter/scoring-gate it in AI generation or Director indexing.
|
||||
- If scoring is wrong, adjust lane priority, target value, or cache features; do not add broad hardcoded hacks that only satisfy one replay.
|
||||
- If performance is wrong, prefer caching, indexed lookup, action-pool pruning, and cheaper diagnostics before reducing strategic search quality.
|
||||
|
||||
6. Re-run the same batch options and compare.
|
||||
- A change only counts as an AI improvement when the target metric improves and no core guardrail regresses.
|
||||
- If a performance shortcut improves p95 but hurts expansion/city count/action quality, revert it or redesign it.
|
||||
- Keep failed experiments out of the final patch; mention them in the final report only when they explain the chosen direction.
|
||||
|
||||
7. Commit only coherent, verified changes.
|
||||
- Stage AI code, docs, scripts, and diagnostics changes that belong together.
|
||||
- Do not stage Unity auto-generated side effects such as `Unity.sln`, `ProjectSettings.asset`, or `packages-lock.json` unless they are intentionally part of the task.
|
||||
|
||||
## Quality Metrics
|
||||
|
||||
Primary guardrails:
|
||||
|
||||
- `failedGames` must be `0` for normal quality claims.
|
||||
- `noEffect` should be `0`; any nonzero value needs raw-log explanation.
|
||||
- `repeated` should be `0` after excluding known legal repeats such as same-turn `CityLevelUpAction:Park` consuming city upgrade points.
|
||||
- `maxActions/playerTurn` should stay well below the forced-stop budget; investigate anything above `80`.
|
||||
- No AI loop, forced AI stop, fatal exception, or unresolved null reference is acceptable.
|
||||
- `cityLost` and `capitalOwnershipChanged` are high-severity intelligence/correctness signals; inspect the TurnStart-to-TurnStart city signature before changing combat weights.
|
||||
|
||||
Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
|
||||
|
||||
- Expansion should not trigger `LOW_EXPANSION`; target `aliveAvgCities >= 1.35`.
|
||||
- Second-city rate should not trigger `FEW_SECOND_CITIES`; target alive `>=2` city ratio at least `25%`.
|
||||
- Track max city count and alive `>=3` city ratio as snowball signals, but do not overfit to one high-roll player.
|
||||
- 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.
|
||||
- Hero personal expression must be checked through `Hero personal expression`: compare each hero's role, executions, kills, damage, healing, moves, skill changes, and top actions. A hero that is selected/spawned but only moves or has near-zero damage/healing/control should trigger HeroPlaybook review.
|
||||
- Unit role expression must be checked through `Unit role expression`: Mobility should create moves/captures and low waste, Melee should hold line and finish targets, Ranged/Siege should contribute damage with low deaths, Defender should resolve threats without excessive self-deaths, and Special/Summon should show concrete skill/action signatures.
|
||||
- Special-unit expression should be checked through `Unit skill expression`: `skill-bearing actor actions`, `signature-changing actions`, `actor signatures`, and `skill_signature_expression` reveal whether new faction units are actually participating.
|
||||
|
||||
Performance targets:
|
||||
|
||||
- `actions/sec` should stay at or above `15` in compact batches.
|
||||
- Decision p95 above `60ms` is a warning and above `100ms` should be treated as an optimization target.
|
||||
- If p95 is high and top slow lanes are Front/Expansion/Emergency, inspect action generation, move lookup, `CheckCan`, and world-cache computation before trimming strategic behavior.
|
||||
- Large diagnostic output is acceptable for local debug, but batch analysis JSON should remain compact enough for automated comparison.
|
||||
|
||||
Recommended batch commands:
|
||||
|
||||
```powershell
|
||||
# Fast smoke after compile-sensitive changes.
|
||||
Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 2 -Turns 1 -TimeoutSeconds 60
|
||||
|
||||
# Compact quality loop for before/after comparison.
|
||||
Tools/RunAIDirectorBatch.ps1 -Games 3 -Players 17 -Width 20 -Height 20 -Turns 20 -TimeoutSeconds 420 -MaxActions 9000 -MaxActionsPerPlayerTurn 120 -Difficulty LUNATIC -KeepGoing
|
||||
|
||||
# Larger confidence loop when compact metrics look good.
|
||||
Tools/RunAIDirectorBatch.ps1 -Games 5 -Players 17 -Width 30 -Height 30 -Turns 30 -TimeoutSeconds 900 -MaxActions 16000 -MaxActionsPerPlayerTurn 160 -Difficulty LUNATIC -KeepGoing
|
||||
```
|
||||
|
||||
## Infinite Loop Triage
|
||||
|
||||
Classify the repeated action before patching:
|
||||
@ -64,7 +173,7 @@ Classify the repeated action before patching:
|
||||
|
||||
Known temporary filters from this iteration:
|
||||
|
||||
- `CommonActionType.BuyCultureCard` is disabled for AI generation.
|
||||
- `CommonActionType.BuyCultureCard` is only generated for `SecondHero` and `ThirdHero` slot unlock cards; other culture cards remain disabled for AI generation.
|
||||
- `UnitActionType.ToggleShenlan` is filtered because it is a debug/visual toggle with no action-point cost.
|
||||
- `PlayerActionType.FinishHeroTask` is filtered because it can execute repeatedly without observable AI turn progress.
|
||||
|
||||
@ -110,3 +219,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.
|
||||
|
||||
1286
.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py
Normal file
1286
.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,15 +11,41 @@ ZERO_DELTA_FIELDS = (
|
||||
"cultureDelta",
|
||||
"cultureCardDelta",
|
||||
"scoreDelta",
|
||||
"sightGridDelta",
|
||||
"cityDelta",
|
||||
"cityLostDelta",
|
||||
"cityGainedDelta",
|
||||
"unitDelta",
|
||||
"heroDelta",
|
||||
"selectedHeroDelta",
|
||||
"maxHeroDelta",
|
||||
"heroTaskDelta",
|
||||
"readyHeroTaskDelta",
|
||||
"forcedHeroTaskDelta",
|
||||
"heroTaskProgressDelta",
|
||||
"criticalCityThreatDelta",
|
||||
"cityThreatDelta",
|
||||
"capitalThreatDelta",
|
||||
"criticalCapitalThreatDelta",
|
||||
"emptyThreatenedCityDelta",
|
||||
"unitHealthDelta",
|
||||
"unitSkillDelta",
|
||||
"unitGridBuildingLevelDelta",
|
||||
"targetUnitHealthDelta",
|
||||
"targetUnitSkillDelta",
|
||||
"targetUnitGridBuildingLevelDelta",
|
||||
"actionGridBuildingLevelDelta",
|
||||
"targetActionGridBuildingLevelDelta",
|
||||
"cityLevelDelta",
|
||||
"cityLevelExpDelta",
|
||||
"cityLevelUpPointDelta",
|
||||
"cityParkDelta",
|
||||
"cityTerritoryDelta",
|
||||
"targetCityLevelDelta",
|
||||
"targetCityLevelExpDelta",
|
||||
"targetCityLevelUpPointDelta",
|
||||
"targetCityParkDelta",
|
||||
"targetCityTerritoryDelta",
|
||||
)
|
||||
|
||||
|
||||
@ -89,6 +115,34 @@ def has_no_effect_delta(execution: dict) -> bool:
|
||||
return False
|
||||
if delta.get("cityOwnerChanged") or delta.get("targetCityOwnerChanged"):
|
||||
return False
|
||||
if delta.get("cityOwnershipSignatureChanged") or delta.get("capitalOwnershipSignatureChanged"):
|
||||
return False
|
||||
if delta.get("cityThreatResolved") or delta.get("cityThreatWorsened"):
|
||||
return False
|
||||
if delta.get("cityThreatSignatureChanged") or delta.get("criticalCityThreatSignatureChanged"):
|
||||
return False
|
||||
if delta.get("unitSkillSignatureChanged") or delta.get("targetUnitSkillSignatureChanged"):
|
||||
return False
|
||||
grid_change_fields = (
|
||||
"unitGridResourceChanged",
|
||||
"unitGridResourceUnderBuildingChanged",
|
||||
"unitGridSpTypeChanged",
|
||||
"targetUnitGridResourceChanged",
|
||||
"targetUnitGridResourceUnderBuildingChanged",
|
||||
"targetUnitGridSpTypeChanged",
|
||||
"actionGridResourceChanged",
|
||||
"actionGridResourceUnderBuildingChanged",
|
||||
"actionGridSpTypeChanged",
|
||||
"targetActionGridResourceChanged",
|
||||
"targetActionGridResourceUnderBuildingChanged",
|
||||
"targetActionGridSpTypeChanged",
|
||||
)
|
||||
if any(delta.get(field) for field in grid_change_fields):
|
||||
return False
|
||||
if delta.get("cityWorkshopChanged") or delta.get("cityWallChanged"):
|
||||
return False
|
||||
if delta.get("targetCityWorkshopChanged") or delta.get("targetCityWallChanged"):
|
||||
return False
|
||||
for field in ZERO_DELTA_FIELDS:
|
||||
if delta.get(field, 0) != 0:
|
||||
return False
|
||||
@ -118,11 +172,59 @@ def short_action_row(row: dict, execution_mode: bool) -> dict:
|
||||
"coinDelta": delta.get("coinDelta", 0),
|
||||
"cultureDelta": delta.get("cultureDelta", 0),
|
||||
"scoreDelta": delta.get("scoreDelta", 0),
|
||||
"sightDelta": delta.get("sightGridDelta", 0),
|
||||
"selectedHeroDelta": delta.get("selectedHeroDelta", 0),
|
||||
"heroTaskDelta": delta.get("heroTaskDelta", 0),
|
||||
"heroTaskProgressDelta": delta.get("heroTaskProgressDelta", 0),
|
||||
"unitSkillDelta": delta.get("unitSkillDelta", 0),
|
||||
"targetUnitSkillDelta": delta.get("targetUnitSkillDelta", 0),
|
||||
"cityLevelUpPointDelta": delta.get("cityLevelUpPointDelta", 0),
|
||||
"cityParkDelta": delta.get("cityParkDelta", 0),
|
||||
"cityTerritoryDelta": delta.get("cityTerritoryDelta", 0),
|
||||
"unitMoved": delta.get("unitMoved", False),
|
||||
"unitGridResource": after.get("unitGridResource", ""),
|
||||
"actionGridResource": after.get("actionGridResource", ""),
|
||||
"actionGridSpType": after.get("actionGridSpType", ""),
|
||||
"executed": block.get("executed", False),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
unit_id = first_action.get("unitId", 0)
|
||||
if not unit_id:
|
||||
return False
|
||||
|
||||
start_seq = rows[0].get("eventSequence", 0)
|
||||
end_seq = rows[-1].get("eventSequence", 0)
|
||||
if end_seq <= start_seq:
|
||||
return False
|
||||
|
||||
return any(
|
||||
row.get("eventType") == "Execution"
|
||||
and start_seq < row.get("eventSequence", 0) < end_seq
|
||||
and action_key(((row.get("execution") or {}).get("action") or {})) == "UnitAttack:"
|
||||
and (((row.get("execution") or {}).get("action") or {}).get("unitId", 0) == unit_id)
|
||||
and (
|
||||
((row.get("execution") or {}).get("delta") or {}).get("targetUnitDied")
|
||||
or (((row.get("execution") or {}).get("delta") or {}).get("targetUnitHealthDelta", 0) < 0)
|
||||
)
|
||||
for row in rows[0].get("_turn_rows", [])
|
||||
)
|
||||
|
||||
|
||||
def summarize(rows, top: int, last: int):
|
||||
event_counts = Counter(row.get("eventType", "") for row in rows)
|
||||
decisions = [row for row in rows if row.get("eventType") == "Decision"]
|
||||
@ -131,7 +233,16 @@ def summarize(rows, top: int, last: int):
|
||||
decision_actions = Counter(action_key((row.get("decision") or {}).get("action") or {}) for row in decisions)
|
||||
execution_actions = Counter(action_key((row.get("execution") or {}).get("action") or {}) for row in executions)
|
||||
executions_by_player = Counter(row.get("playerId", 0) for row in executions)
|
||||
repeated_stable = Counter(stable_key((row.get("execution") or {}).get("action") or {}) for row in executions)
|
||||
stable_rows_by_turn = defaultdict(list)
|
||||
turn_rows = defaultdict(list)
|
||||
for row in executions:
|
||||
turn_key = (row.get("playerId", 0), row.get("playerTurn", 0))
|
||||
turn_rows[turn_key].append(row)
|
||||
stable_rows_by_turn[(turn_key[0], turn_key[1], stable_key((row.get("execution") or {}).get("action") or {}))].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]
|
||||
|
||||
per_turn = Counter()
|
||||
for row in executions:
|
||||
@ -148,9 +259,13 @@ def summarize(rows, top: int, last: int):
|
||||
"max_actions_per_player_turn": per_turn.most_common(top),
|
||||
"decision_actions": decision_actions.most_common(top),
|
||||
"execution_actions": execution_actions.most_common(top),
|
||||
"repeated_stable_keys": [(k, v) for k, v in repeated_stable.most_common(top) if v > 1],
|
||||
"repeated_stable_keys": [
|
||||
(key, len(grouped_rows))
|
||||
for key, grouped_rows in sorted(stable_rows_by_turn.items(), key=lambda item: len(item[1]), reverse=True)
|
||||
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:]],
|
||||
}
|
||||
|
||||
|
||||
@ -212,6 +327,10 @@ def main():
|
||||
f"unit={row['unit']} grid={row['grid']} targetUnit={row['targetUnit']} "
|
||||
f"unitGrid={row['beforeGrid']}->{row['afterGrid']} "
|
||||
f"coin={row['coinDelta']} culture={row['cultureDelta']} score={row['scoreDelta']} "
|
||||
f"sight={row['sightDelta']} selectedHero={row['selectedHeroDelta']} "
|
||||
f"task={row['heroTaskDelta']} taskProgress={row['heroTaskProgressDelta']} "
|
||||
f"cityPoint={row['cityLevelUpPointDelta']} park={row['cityParkDelta']} "
|
||||
f"territory={row['cityTerritoryDelta']} "
|
||||
f"moved={row['unitMoved']}"
|
||||
)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: th1-server-backend
|
||||
description: TH1 project-specific server/backend guide for Aliyun Function Compute, OSS, Tablestore, Steam AuthTicket pre-verification/cache, STS token issuing, GameUploadFunction, Unity OSS upload clients, player bug reports, collect data downloads, and Tools/OSS pulled data. Use whenever Codex works on TH1 服务端, 阿里云函数计算, OSS/STS 上传失败, Steam 预校验, 表格存储缓存, game-upload-function, collectdata/ossdata/bugreport paths, player bug viewer, cloud save or telemetry upload bugs, server deploy/debug work, or analysis of Tools/OSS/Data files.
|
||||
description: TH1 project-specific server/backend guide for Aliyun Function Compute, OSS, Tablestore, Steam AuthTicket pre-verification/cache, STS token issuing, GameUploadFunction, Unity OSS upload clients, upload flow tests, player bug reports, multilingual reports, questionnaire answers, collect data downloads, and Tools/OSS pulled data. Use whenever Codex works on TH1 服务端, 阿里云函数计算, OSS/STS 上传失败, Steam 预校验, 表格存储缓存, game-upload-function, collectdata/ossdata/bugreport/multilingualreport/questionnaire paths, player bug/multilingual/questionnaire viewers, cloud save or telemetry upload bugs, server deploy/debug work, or analysis of Tools/OSS/Data files.
|
||||
---
|
||||
|
||||
# TH1 Server Backend
|
||||
@ -15,10 +15,14 @@ Use this skill for TH1 server-side upload/auth/data work in `F:\th1new`. Treat `
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/StsTokenService.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/OssUploadService.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs` when working on player-submitted bug packages or save archive pairing
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs` when working on player-submitted bug packages, multilingual report packages, or save archive pairing
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Questionnaire/QuestionnaireUploadService.cs` when working on questionnaire answer JSON upload payloads
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs` when working on Steam-authenticated upload flow tests
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Editor/OssEditorWindow.cs` when working on collect downloads, upload tests, stats, or JSON export
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs` when working on the temporary Unity editor bug-report UI
|
||||
- `Tools/PlayerBugViewer/player_bug_viewer.py` when working on developer-side bug report browsing/restoring
|
||||
- `Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py` when working on developer-side multilingual report browsing
|
||||
- `Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py` when working on developer-side questionnaire answer browsing
|
||||
|
||||
For the detailed service contract, data layout, and triage notes, read `references/server-map.md`.
|
||||
|
||||
@ -35,6 +39,8 @@ Keep the current single Function Compute service/file unless the user explicitly
|
||||
- Steam identity pre-verification: `action=steamauth`, cache key `{steamId}#steamauth`, 10-minute identity cache.
|
||||
- Standard uploads: `type=ossdata` and `type=collectdata`, 3 MB Post Policy limit, 5-minute STS credential cache.
|
||||
- Player bug reports: `type=bugreport`, 10 MB Post Policy limit, unique `.zip` object key, no STS credential cache to avoid overwrites.
|
||||
- Multilingual reports: `type=multilingualreport`, 1 MB Post Policy limit, unique `.zip` object key, no STS credential cache.
|
||||
- Questionnaire answers: `type=questionnaire`, 512 KB Post Policy limit, unique `.json` object key, no STS credential cache.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
@ -57,23 +63,28 @@ Keep the current single Function Compute service/file unless the user explicitly
|
||||
2. Trace both sides of the contract.
|
||||
- Backend request/response shape lives in `index.js`.
|
||||
- Unity request serialization lives in `StsTokenService.cs`.
|
||||
- Unity credential caching, Steam auth warmup, and `ossdata`/`collectdata`/`bugreport` selection live in `OssManager.cs`.
|
||||
- Unity credential caching, Steam auth warmup, and `ossdata`/`collectdata`/`bugreport`/`multilingualreport`/`questionnaire` selection live in `OssManager.cs`.
|
||||
- Multipart PostObject field order and field names live in `OssUploadService.cs`.
|
||||
- Player bug zip creation and save archive selection live in `PlayerBugReportService.cs`.
|
||||
- Player bug zip creation, multilingual report zip creation, and save archive selection live in `PlayerBugReportService.cs`.
|
||||
- Questionnaire answer JSON creation lives in `QuestionnaireUploadService.cs`.
|
||||
|
||||
3. Check path semantics before changing data code.
|
||||
- `ossdata`: `{version}/{steamId}/{timestamp}.dat` or `common/{steamId}/{timestamp}.dat`.
|
||||
- `collectdata`: `collect/{version}/{steamId}/{timestamp}.dat` or `collect/common/{steamId}/{timestamp}.dat`.
|
||||
- `bugreport`: `bugreport/{version}/{steamId}/{timestamp}-{random}.zip` or `bugreport/common/{steamId}/{timestamp}-{random}.zip`.
|
||||
- `multilingualreport`: `multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip` or `multilingualreport/common/{steamId}/{timestamp}-{random}.zip`.
|
||||
- `questionnaire`: `questionnaire/{version}/{steamId}/{timestamp}-{random}.json` or `questionnaire/common/{steamId}/{timestamp}-{random}.json`.
|
||||
- Local pulled collect files remove the `collect/` prefix and land under `Tools/OSS/Data/{version}/{steamId}/{timestamp}.dat`.
|
||||
- Local pulled player bug reports are managed by `Tools/PlayerBugViewer` under its ignored `Data/` folder.
|
||||
- Local pulled multilingual reports are managed by `Tools/PlayerMultilingualReportViewer` under its ignored `Data/` folder.
|
||||
- Local pulled questionnaire answers are managed by `Tools/PlayerQuestionnaireViewer` under its ignored `Data/` folder.
|
||||
|
||||
4. Treat Tablestore cache as a functional part of behavior.
|
||||
- STS credential cache key is `{steamId}#{type}` in table `Players`, primary key `PlayerId`.
|
||||
- Steam identity cache key is `{steamId}#steamauth` and only proves the player recently passed Steam Web API verification.
|
||||
- STS cache only hits when version matches, issued time is within 5 minutes, and STS has more than 2 minutes remaining.
|
||||
- If changing `type`, `version`, `objectKey`, or expiry logic, update both cache read and cache write paths.
|
||||
- Do not cache `bugreport` STS credentials; each bug report needs a unique object key.
|
||||
- Do not cache `bugreport`, `multilingualreport`, or `questionnaire` STS credentials; each player submission needs a unique object key.
|
||||
|
||||
5. Use production reports carefully.
|
||||
- CrashSight `UnityLogError` upload failures often indicate debug/environment noise rather than blocking crashes.
|
||||
@ -82,20 +93,24 @@ Keep the current single Function Compute service/file unless the user explicitly
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- Backend code changes: edit `Tools/OSS/game-upload-function/index.js`, then run at least `npm install` if dependencies changed and a local smoke server check when env vars are available.
|
||||
- Backend code changes: edit `Tools/OSS/game-upload-function/index.js`, then run `npm run check` in `Tools/OSS/game-upload-function`. Run `npm install` only if dependencies changed or are missing, and a local smoke server check when env vars are available.
|
||||
- Unity upload/client changes: edit only the relevant `TH1_Logic/Oss` files, then run a Unity compile or targeted editor test when practical.
|
||||
- Player bug report changes: keep Unity-side package shape aligned with `Tools/PlayerBugViewer`; verify `manifest.json`, `description.txt`, device/time fields, `crashSightDeviceId`, and `saves/{single|multi}/...dat` entries.
|
||||
- Developer bug viewer changes: edit `Tools/PlayerBugViewer`; preserve local credential/cache ignores for `config.local.json` and `Data/`.
|
||||
- Multilingual report changes: keep Unity-side zip shape aligned with `Tools/PlayerMultilingualReportViewer`; verify `manifest.json`, selected text, resolved text, language, and player description.
|
||||
- Questionnaire changes: keep Unity-side JSON shape aligned with `Tools/PlayerQuestionnaireViewer`; verify `schema`, `questionnaireId`, `answerSheet.Answers[]`, 512 KB limit, and `questionnaire/*.json` object key validation.
|
||||
- Developer report viewer changes: edit the relevant `Tools/PlayerBugViewer`, `Tools/PlayerMultilingualReportViewer`, or `Tools/PlayerQuestionnaireViewer` folder; preserve local credential/cache ignores for `config.local.json` and `Data/`.
|
||||
- Collect data download/stat changes: use `OssEditorWindow.cs` and `OssDownloadService.cs`; verify the mapping from `collect/` OSS keys to `Tools/OSS/Data`.
|
||||
- Steam upload flow test changes: use `Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs`. The current end-to-end tester menu is `Tools/Steam 上传流程测试器`; it requires Play Mode Steam login/AuthTicket and covers `action=steamauth`, `type=ossdata`, `type=bugreport`, `type=multilingualreport`, and `type=questionnaire` in sequential test 1-5. It does not cover `collectdata`; use `OssEditorWindow.cs` for collect upload policy tests.
|
||||
- OSS data analysis: use `Tools/OSS/Data` for `.dat` files and `Tools/OSS/Data/JsonExport` for exported JSON; avoid hand-decoding MemoryPack outside Unity unless there is already a project utility for it.
|
||||
|
||||
## Checks Before Finishing
|
||||
|
||||
- Confirm request/response fields still match Unity `StsCredentials`, `SteamAuthWarmupResponse`, and `StsRequest`.
|
||||
- Confirm `ossdata`, `collectdata`, and `bugreport` still work, including cache keys, path prefixes, upload limits, and bugreport cache bypass.
|
||||
- Confirm security limits remain intentional: 1024-byte STS request body, 3 MB standard Post Policy/Unity upload guard, 10 MB bugreport limit.
|
||||
- Run `node -c Tools/OSS/game-upload-function/index.js` after backend edits.
|
||||
- Run `python -m py_compile Tools/PlayerBugViewer/player_bug_viewer.py` after player bug viewer edits.
|
||||
- Confirm `ossdata`, `collectdata`, `bugreport`, `multilingualreport`, and `questionnaire` still work, including cache keys, path prefixes, upload limits, and unique-key cache bypass for player submissions.
|
||||
- Confirm security limits remain intentional: 1024-byte STS request body, 3 MB standard Post Policy/Unity upload guard, 10 MB bugreport limit, 1 MB multilingual report limit, 512 KB questionnaire limit.
|
||||
- Run `npm run check` in `Tools/OSS/game-upload-function` after backend edits.
|
||||
- Run `python -m py_compile` on the changed viewer script after player report viewer edits.
|
||||
- Run `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore` after Unity editor upload-flow or OSS tool edits when practical.
|
||||
- Confirm no secrets, raw tickets, or full STS credentials were added to files, logs, docs, or reports.
|
||||
- For CrashSight correlation, `CrashSightManager.GetCrashSightDeviceId()` is the shared source: prefer `SystemInfo.deviceUniqueIdentifier`; if unsupported, use a PlayerPrefs-persisted `th1-{guid}` fallback; if PlayerPrefs is unavailable, use a process-local `th1-device-id-unavailable-{guid}` fallback. Set the same value into CrashSight and bug-report manifests.
|
||||
- For CrashSight correlation, `CrashSightManager.GetCrashSightDeviceId()` is the shared source: prefer `SystemInfo.deviceUniqueIdentifier`; if unsupported, use a PlayerPrefs-persisted `th1-{guid}` fallback; if PlayerPrefs is unavailable, use a process-local `th1-device-id-unavailable-{guid}` fallback. Set the same value into CrashSight and player-submitted bug/multilingual/questionnaire payloads.
|
||||
- Report which side was changed: Function Compute code, Unity client upload code, editor OSS tooling, local OSS data, or documentation.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "TH1 Server Backend"
|
||||
short_description: "TH1 Aliyun Function Compute, OSS, and Tablestore guide"
|
||||
default_prompt: "Use $th1-server-backend for TH1 server, Aliyun Function Compute, OSS upload, Tablestore cache, or cloud save data work."
|
||||
short_description: "TH1 Aliyun Function Compute, OSS upload, Tablestore, and report viewer guide"
|
||||
default_prompt: "Use $th1-server-backend for TH1 server, Aliyun Function Compute, OSS upload flows, upload tests, Tablestore cache, player report viewers, questionnaire uploads, or cloud save data work."
|
||||
|
||||
@ -10,6 +10,8 @@ This reference covers the TH1 backend upload service and its local data/tooling:
|
||||
- Unity editor OSS tools: `Unity/Assets/Scripts/TH1_Logic/Editor/OssEditorWindow.cs` and `OssDownloadService.cs`
|
||||
- Pulled OSS collect data: `Tools/OSS/Data/`
|
||||
- Player bug report viewer/restorer: `Tools/PlayerBugViewer/`
|
||||
- Player multilingual report viewer: `Tools/PlayerMultilingualReportViewer/`
|
||||
- Player questionnaire answer viewer: `Tools/PlayerQuestionnaireViewer/`
|
||||
|
||||
## Function Compute Service
|
||||
|
||||
@ -257,6 +259,7 @@ Runtime files:
|
||||
- `StsTokenService.cs`: sends JSON to Function Compute and parses `StsCredentials` or `SteamAuthWarmupResponse`.
|
||||
- `OssUploadService.cs`: builds multipart PostObject requests to `https://{bucket}.{endpoint}`.
|
||||
- `PlayerBugReportService.cs`: builds player bug report zip packages and selects recent `start + continue/end` save pairs.
|
||||
- `QuestionnaireUploadService.cs`: builds questionnaire answer JSON payloads using schema `th1.questionnaire-answer.v1`.
|
||||
- `OssData.cs`: MemoryPack payload containing `StartMap`, `Actions`, and `CollectData`.
|
||||
|
||||
Important Unity details:
|
||||
@ -268,6 +271,7 @@ Important Unity details:
|
||||
- Multipart fields include `key`, `OSSAccessKeyId`, `policy`, `Signature`, `x-oss-security-token`, then `file` last.
|
||||
- `OssManager.UploadMapData` and `UploadCollectData` skip multiplayer uploads on non-lobby-owner clients.
|
||||
- `OssManager.UploadPlayerBugReportAsync` requests `type=bugreport` and uploads `application/zip`.
|
||||
- `OssManager.UploadPlayerMultilingualReportAsync` requests `type=multilingualreport` and uploads `application/zip`.
|
||||
- `OssManager.UploadQuestionnaireAnswerAsync` requests `type=questionnaire` and uploads `application/json`.
|
||||
|
||||
## Player Bug Report Tooling
|
||||
@ -300,17 +304,66 @@ Viewer capabilities:
|
||||
- Preview manifest fields, attached save files, and player description.
|
||||
- Replace local saves by deleting `map_archive_*.dat` / `map_archive_*.dat.bak` in the configured local Config directory, then copying archive files from the selected report.
|
||||
|
||||
## Player Multilingual Report Tooling
|
||||
|
||||
Unity temporary submit UI:
|
||||
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Editor/PlayerBugReportEditorWindow.cs`
|
||||
- Menu: `Tools/玩家多语言汇报`
|
||||
- Inputs: version, multilingual ID, selected text, language, and player description.
|
||||
|
||||
Standalone developer viewer:
|
||||
|
||||
- Folder: `Tools/PlayerMultilingualReportViewer/`
|
||||
- Entry point: `启动玩家多语言汇报查看器.bat`
|
||||
- Main script: `player_multilingual_report_viewer.py`
|
||||
- Local credentials: `config.local.json`, ignored by git.
|
||||
- Download cache: `Data/`, ignored by git.
|
||||
|
||||
Viewer capabilities:
|
||||
|
||||
- Pull `multilingualreport/` zip objects from OSS using OSS REST V1 HMAC-SHA1 signing.
|
||||
- Filter by version, SteamID, language, and multilingual ID.
|
||||
- Preview manifest fields, reported text, resolved text, and player description.
|
||||
|
||||
## Player Questionnaire Tooling
|
||||
|
||||
Unity client upload payload:
|
||||
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Questionnaire/QuestionnaireUploadService.cs`
|
||||
- Schema: `th1.questionnaire-answer.v1`
|
||||
- Upload type: `questionnaire`
|
||||
- Content type: `application/json`
|
||||
- Max size: 512 KB
|
||||
- Expected object key: `questionnaire/{version}/{steamId}/{timestamp}-{random}.json`
|
||||
|
||||
Standalone developer viewer:
|
||||
|
||||
- Folder: `Tools/PlayerQuestionnaireViewer/`
|
||||
- Entry point: `启动玩家问卷查看器.bat`
|
||||
- Main script: `player_questionnaire_viewer.py`
|
||||
- Local credentials: `config.local.json`, ignored by git.
|
||||
- Download cache: `Data/`, ignored by git.
|
||||
|
||||
Viewer capabilities:
|
||||
|
||||
- Pull `questionnaire/` JSON objects from OSS using OSS REST V1 HMAC-SHA1 signing.
|
||||
- Filter by version, questionnaire ID, SteamID, and question ID.
|
||||
- Preview response metadata, device fields, and answer details under `answerSheet.Answers[]`.
|
||||
|
||||
## Unity Editor OSS Tooling
|
||||
|
||||
Editor files:
|
||||
|
||||
- `OssEditorWindow.cs`
|
||||
- `OssDownloadService.cs`
|
||||
- `SteamEditorWindow.cs`
|
||||
- `OssStatisticEditorWindow.cs` is deprecated and retained only for Unity `.meta` stability.
|
||||
|
||||
Menu items:
|
||||
|
||||
- `Tools/Oss 编辑器`
|
||||
- `Tools/Steam 上传流程测试器`
|
||||
- `Tools/OSS 导出 JSON (Dashboard)`
|
||||
|
||||
Editor capabilities:
|
||||
@ -321,6 +374,9 @@ Editor capabilities:
|
||||
- Deserialize collect `.dat` files with MemoryPack for statistics.
|
||||
- Export collect data JSON for the dashboard under `Tools/OSS/Data/JsonExport`.
|
||||
- Test STS request, upload success, wrong object key rejection, and oversized upload rejection.
|
||||
- Run the Steam-authenticated end-to-end upload sequence 1-5: `action=steamauth`, `type=ossdata`, `type=bugreport`, `type=multilingualreport`, and `type=questionnaire`.
|
||||
|
||||
`Tools/Steam 上传流程测试器` requires Play Mode with Steam initialized and a valid AuthTicket. Its questionnaire test constructs a small `th1.questionnaire-answer.v1` JSON payload, requests `type=questionnaire`, verifies the returned object key is under `questionnaire/` and ends in `.json`, then uploads with `application/json` and the 512 KB guard. This tester does not cover `collectdata`; keep using `Tools/Oss 编辑器` for collect upload policy checks.
|
||||
|
||||
`OssDownloadService` uses OSS REST V1 HMAC-SHA1 signing. Its `ListObjectsAsync` handles continuation tokens and `DownloadObjectAsync` encodes each object-key path segment while preserving slashes.
|
||||
|
||||
@ -344,6 +400,8 @@ Interpretation:
|
||||
- Cache issues: stale version/type/objectKey almost always mean cache normalization changed on only one side of read/write.
|
||||
- Slow upload credential requests after Steam is ready: verify the client is calling `action=steamauth`, Tablestore has `{steamId}#steamauth`, and `authExpireAt` is still in the future.
|
||||
- Player bug report upload overwrites: `bugreport` should bypass STS cache and object keys should include a random suffix.
|
||||
- Multilingual report upload overwrites: `multilingualreport` should bypass STS cache and object keys should include a random suffix.
|
||||
- Questionnaire upload failure after STS success: verify JSON is under 512 KB, content type is `application/json`, object key is `questionnaire/*.json`, and service/client are both using `type=questionnaire`.
|
||||
- Player bug viewer restore problems: inspect `manifest.json` archive `zipEntry`/`sourceFileName` fields and the configured local Config path.
|
||||
|
||||
## Deployment And Validation
|
||||
@ -351,10 +409,14 @@ Interpretation:
|
||||
Local code checks:
|
||||
|
||||
- In `Tools/OSS/game-upload-function`, run `npm install` only when dependencies are missing or changed.
|
||||
- In `Tools/OSS/game-upload-function`, run `npm run check` for the static upload contract check. It includes `node -c index.js` and verifies upload-type/path/limit policy snippets, including `questionnaire`.
|
||||
- There is no meaningful `npm test` currently; `package.json` has the default failing test stub.
|
||||
- A local server boot requires all required env vars; without them, HTTP requests should return a controlled `500` listing missing variables.
|
||||
- Backend syntax check: `node -c Tools/OSS/game-upload-function/index.js`.
|
||||
- Backend syntax/check command: `npm run check` from `Tools/OSS/game-upload-function`.
|
||||
- Player bug viewer syntax check: `python -m py_compile Tools/PlayerBugViewer/player_bug_viewer.py`.
|
||||
- Multilingual viewer syntax check: `python -m py_compile Tools/PlayerMultilingualReportViewer/player_multilingual_report_viewer.py`.
|
||||
- Questionnaire viewer syntax check: `python -m py_compile Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py`.
|
||||
- Unity editor upload tester compile check: `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore`.
|
||||
|
||||
Smoke checks when credentials are available:
|
||||
|
||||
@ -363,6 +425,7 @@ Smoke checks when credentials are available:
|
||||
- Test both `type: "ossdata"` and `type: "collectdata"`.
|
||||
- Test `action: "steamauth"` and confirm later uploads can skip realtime Steam API while cache is valid.
|
||||
- Test `type: "bugreport"` and confirm a unique `.zip` key under `bugreport/`.
|
||||
- Test `type: "multilingualreport"` and confirm a unique `.zip` key under `multilingualreport/`.
|
||||
- Test `type: "questionnaire"` and confirm a unique `.json` key under `questionnaire/`.
|
||||
- Attempt wrong objectKey upload with existing credential and confirm OSS rejects it.
|
||||
- Attempt oversized upload and confirm client/backend policy rejects it: 3 MB standard, 10 MB bugreport, 512 KB questionnaire.
|
||||
|
||||
@ -55,6 +55,7 @@ AI 不是一次性规划完整回合,而是重复执行“读局势,选一
|
||||
```text
|
||||
如果城市危险,它去救城。
|
||||
否则如果眼前能打有价值目标,它攻击。
|
||||
否则如果还没有稳定二城,且附近有可占城中心,它优先去扩张。
|
||||
否则如果脚下或附近有占领、遗迹、资源、恢复、升级机会,它做机会动作。
|
||||
否则如果它空闲,它向防守、进攻或发展战线移动。
|
||||
否则它交给内政或兜底逻辑。
|
||||
@ -87,17 +88,19 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
|
||||
|---|---|---|
|
||||
| 1 | Emergency | 阻止立即丢城、被偷家、关键单位暴毙 |
|
||||
| 2 | HeroManagement | 处理选英雄、英雄任务、出场和复活节奏 |
|
||||
| 3 | HeroPlaybook | 发挥英雄机制和阵营特色 |
|
||||
| 4 | Tactic | 处理当前可攻击或近距离交战 |
|
||||
| 5 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
|
||||
| 6 | Front | 把空闲单位送往正确战略方向 |
|
||||
| 7 | Growth | 推进城市、地块、科技、文化、外交 |
|
||||
| 8 | Fallback | 防止规则缺口导致 AI 停摆 |
|
||||
| 3 | PriorityTactic | 只让击杀、打英雄、打城市威胁等确定战果抢在扩张前 |
|
||||
| 4 | Expansion | 二城前和早期高价值扩张优先抢可占城中心 |
|
||||
| 5 | HeroPlaybook | 发挥英雄机制和阵营特色 |
|
||||
| 6 | Tactic | 处理当前可攻击或近距离交战 |
|
||||
| 7 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
|
||||
| 8 | Front | 把空闲单位送往正确战略方向 |
|
||||
| 9 | Growth | 推进城市、地块、科技、文化、外交 |
|
||||
| 10 | Fallback | 防止规则缺口导致 AI 停摆 |
|
||||
|
||||
车道顺序的核心含义:
|
||||
|
||||
```text
|
||||
活命 > 英雄体系 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
|
||||
活命 > 英雄体系 > 确定战果 > 早期扩张 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
|
||||
```
|
||||
|
||||
---
|
||||
@ -144,6 +147,8 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
|
||||
- 不保存跨多回合的大型黑板式复杂状态。
|
||||
- 不建立永久军团状态机。
|
||||
- 大范围搜索只保留 TopN 目标。
|
||||
- 同一玩家同一回合内,已经执行过的同一个 action stableKey 不再进入 Director 候选,避免单位移动、城市训练等动作反复被选中。
|
||||
- 同一玩家同一回合内,扩张、回防、前线移动会记录目标意图;同类目标意图达到预算后不再重复派兵,避免所有单位反复奔向同一个目标。
|
||||
|
||||
---
|
||||
|
||||
@ -163,18 +168,21 @@ Development
|
||||
进入 Defense 的典型条件:
|
||||
|
||||
- 有城市 `CityThreat` 达到危险。
|
||||
- 敌方单位进入己方领土。
|
||||
- 敌方单位进入某座己方城市自己的领土,或短期可威胁该城市。
|
||||
- 敌军压力显著高于附近守军。
|
||||
- 敌军下回合可能威胁城市中心。
|
||||
- 多个敌国同时压境。
|
||||
|
||||
Defense 的行为倾向:
|
||||
|
||||
- Emergency 车道优先回防。
|
||||
- 城市优先训练防守单位、建城墙、移动占城格单位。
|
||||
- Emergency 车道优先阻止丢城;危险城市如果空防或只剩唯一守军,先补兵/城墙,再考虑攻击和外派。
|
||||
- 城市优先训练防守单位、建城墙、保留占城格单位。
|
||||
- 如果危险城市中心为空,城市生产要优先补可站城/守城单位;扩张变强后不能让空城威胁长期升高。
|
||||
- 科技优先防御、基础兵种、移动和克制。
|
||||
- 英雄优先治疗、保护、控场、守城。
|
||||
|
||||
城市威胁必须绑定到具体城市。远处敌人踩到我方另一座城市的领土,不能让所有城市都进入 Emergency;否则 AI 会过度防守,扩张和成长动作会被挤掉。
|
||||
|
||||
### 5.2 Expansion
|
||||
|
||||
进入 Expansion 的典型条件:
|
||||
@ -186,7 +194,15 @@ Defense 的行为倾向:
|
||||
|
||||
Expansion 的行为倾向:
|
||||
|
||||
- UnitOpportunity 优先占领和探索。
|
||||
- 二城前,非严重城市威胁下,可占城中心优先于普通战线移动。
|
||||
- 可占城中心包括 `ResourceType.CityCenter` 村庄、无归属城市,也包括当前地图数据中表现为非同盟 owner 的空城/村点。
|
||||
- 早期扩张只追可在有限回合内转化的目标,避免全地图远距离追城导致移动量很大但二城率不变。
|
||||
- Expansion 只扫描已排序的少量高价值扩张目标;扩大城市数不能让每次决策退化成“所有目标 × 所有单位”的全量搜索。
|
||||
- 单位移动优先满足“本回合能占”或“下回合能站到可占点旁边”,远距离目标只保留作低优先级战线。
|
||||
- 扩张目标必须考虑占后守备风险;如果目标格或占领单位附近威胁很高、当前已有严重城市威胁、或己方单位数不足以覆盖城市数,则降低占领/靠近优先级。普通城市威胁只影响危险目标,不应把安全二城扩张整体压掉。
|
||||
- 二城前如果存在严重城市威胁,Expansion 不完全关闭,只保留距离 3 格内、目标格无即时威胁、且可快速转化的村庄/空城目标;其他远距离或危险扩张继续让位给 Emergency。
|
||||
- 危险城市的唯一守军不能外派扩张;非唯一守军可以参与近距离、安全、能快速转化的二城扩张,避免防守过度导致城市数停滞。
|
||||
- Expansion 车道优先处理占领和向目标靠近;UnitOpportunity 负责脚下的占领、遗迹和采集补漏。
|
||||
- Development Front 指向村庄、遗迹、资源和边界。
|
||||
- 城市优先增长和基础建设。
|
||||
- 科技优先资源开发、移动能力和道路。
|
||||
@ -204,6 +220,7 @@ Attack 的行为倾向:
|
||||
|
||||
- Front 指向进攻目标城市。
|
||||
- Tactic 优先击杀高价值目标和守城单位。
|
||||
- PriorityTactic 只处理确定战果:击杀、攻击英雄、攻击正在威胁城市的单位,或极低反击风险的高分攻击。
|
||||
- 城市优先训练克制兵种和攻城相关单位。
|
||||
- 科技优先军事克制、攻击、移动、海战和攻城。
|
||||
|
||||
@ -327,7 +344,22 @@ Development 的行为倾向:
|
||||
|
||||
## 9. 英雄策略
|
||||
|
||||
英雄不走普通单位公式。英雄由 HeroPlaybook 驱动。
|
||||
英雄不走普通单位公式。英雄分两层处理:
|
||||
|
||||
- HeroManagement 负责英雄体系节奏:选英雄、让已选择英雄尽快出场、再推进任务。
|
||||
- HeroPlaybook 负责场上英雄怎么发挥机制:治疗、保护、控场、地面攻击、主动技能、收割、站位。
|
||||
|
||||
HeroManagement 的节奏:
|
||||
|
||||
```text
|
||||
没有选择英雄
|
||||
→ 先选择最适合当前阵营和局势的英雄
|
||||
→ 如果已选择英雄数量已经占满当前英雄槽位,先买下一英雄槽位
|
||||
→ 已选择但未上场时,优先在合法城市训练/复活英雄
|
||||
→ 英雄已经能参与战局后,再推进可强制完成的英雄任务
|
||||
```
|
||||
|
||||
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会因为第二、第三英雄槽位没开而卡住英雄体系。
|
||||
|
||||
HeroPlaybook 的判断顺序:
|
||||
|
||||
@ -409,6 +441,7 @@ HeroPlaybook 的判断顺序:
|
||||
- 按英雄等级和技能入口识别可用 Action。
|
||||
- 治疗和保护类动作优先给残血英雄和高价值友军。
|
||||
- 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。
|
||||
- 刺客和先锋英雄在有合法有效攻击目标时,攻击优先级略高于普通 MoveToFront;否则已上场英雄容易只移动、不形成个人威胁。
|
||||
- 自身主动类动作只在满足局部价值时使用。
|
||||
- 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。
|
||||
|
||||
@ -459,11 +492,15 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。
|
||||
|---|---|
|
||||
| 单位战斗 | UnitAttack、UnitAttackAlly、UnitAttackGround |
|
||||
| 单位移动 | UnitMove |
|
||||
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 |
|
||||
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、ShipUpgrade、AbsorbMarker、英雄主动 |
|
||||
| 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder |
|
||||
| 地块行为 | Gain、Build、GridMisc |
|
||||
| 玩家行为 | LearnTech、BuyCultureCard、PlayerAction |
|
||||
| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 |
|
||||
| 玩家行为 | LearnTech、PlayerAction;BuyCultureCard 只开放英雄槽位卡,不开放普通文化卡 |
|
||||
| 英雄管理 | SelectHero、TrainUnit:Giant 出场/复活、SecondHero/ThirdHero 槽位卡、FinishHeroTask |
|
||||
|
||||
UnitAttackAlly 只由 HeroPlaybook、支援战术或明确的友军互动规则使用,不进入 Fallback。友军目标动作如果落到兜底,通常说明它缺少英雄或支援规则。
|
||||
|
||||
普通 UnitAttack 必须有可预期收益。基础伤害为 0 的纯普通攻击不进入 Emergency、Tactic 或 GenericHero 攻击候选,避免防守时把行动浪费在“合法但不掉血”的目标上。带推击、龙船撞击、Reisen 协同或 Kaguya 狼等攻击附加效果的动作可以例外,因为它们可能通过位移、追击、标记或额外伤害产生收益。
|
||||
|
||||
危险动作默认不进入普通 AI 选择:
|
||||
|
||||
@ -507,10 +544,16 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|
||||
性能原则:
|
||||
|
||||
- 城市威胁只看城市周围有限范围。
|
||||
- 城市领土威胁只看当前城市自己的 `Territory`,不使用全玩家领土做全局标记。
|
||||
- 局部战斗只看可接触范围。
|
||||
- Front 只保留少量高价值目标。
|
||||
- DevelopmentTarget 只保留 TopN。
|
||||
- Expansion 从 DevelopmentTarget 中只扫描前 N 个扩张目标。
|
||||
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、自城、局部战斗收集移动锚点,每个单位只保留最靠近锚点的少量移动候选。
|
||||
- 同一次决策内,单位到目标格的最佳移动结果可以缓存,避免 Expansion、Emergency、Front、HeroPlaybook 重复扫描同一单位的移动候选。
|
||||
- 行动候选只生成一次。
|
||||
- 同一玩家回合内已经执行过的 stableKey 不再参与下一次候选选择。
|
||||
- 扩张移动、回防移动、前线移动有每回合意图预算;预算耗尽后交给其他车道,避免单一目标吞掉整回合。
|
||||
- 车道只查缓存和动作池。
|
||||
|
||||
如果卡顿,优先削减:
|
||||
@ -534,12 +577,51 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|
||||
| 城市防守 | 城市危险时优先攻击威胁或回防 |
|
||||
| 占领扩张 | 能占村、占城、开遗迹时不会长期无视 |
|
||||
| 局部战斗 | 能打高价值目标、残血目标和威胁城市目标 |
|
||||
| 高价值战术 | 击杀、打英雄、解除城市威胁时能插队,但普通蹭血不压过早期扩张 |
|
||||
| 英雄表现 | 英雄会治疗、保护、地面攻击、自爆、坐镇或控场 |
|
||||
| 战线移动 | 空闲单位能向防守、进攻、发展目标移动 |
|
||||
| 城市发展 | 安全城市能生产、升级、建设、科技文化 |
|
||||
| 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 |
|
||||
| 性能 | 单个 AI 动作决策无明显卡顿 |
|
||||
|
||||
当前批跑调优采用以下基线指标,不用单局主观感觉判断 AI 是否变聪明。指标只用于诊断和调参,不反向要求 AI 套统一万能公式。
|
||||
|
||||
| 指标组 | 观察项 | 目标 |
|
||||
|---|---|---|
|
||||
| 正确性 | failedGames、noEffect、repeated、maxActions/playerTurn | 必须为 0 或远低于强制停止线 |
|
||||
| 扩张 | aliveAvgCities、alive>=2、alive>=3、maxCities | 二城率和三城率稳定提升,不能靠单个高滚玩家掩盖整体弱扩张 |
|
||||
| 战斗 | UnitAttack 数、kills、actingUnitDeaths、PriorityTactic/Tactic 占比 | 有战果且不过度白送,城市威胁能被处理 |
|
||||
| 防守 | cityLost、capitalThreatTurns、emptyThreatenedCityTurns、resolved/worsened、emergencyResponse、defenderReturn | 少丢城,首都少长期受压,危险城市有回防或生产响应 |
|
||||
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、heroPersonalExpression、styleBuckets | 已选择英雄应尽快上场;不同英雄的攻击、治疗、控场、召唤、经济和防守表达要能区分 |
|
||||
| 小兵定位 | unitRoleExpression、unitTypeExpression、kills、selfDeaths、damage、taken、captures、moves | 机动兵要推进和占领,近战要站线和收割,远程/攻城要打出输出,防守兵要少送死并解决威胁 |
|
||||
| 特色单位 | unitSkillActions、unitSkillChangedActions、actorSkillSignatures、skillSignatureExpression | 新阵营小兵和特殊单位要通过技能签名、特殊动作、状态变化展示存在感 |
|
||||
| Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 |
|
||||
| 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 |
|
||||
|
||||
防守指标解释:
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| cityLost | 同一玩家两次回合开始之间城市数减少,代表上一轮循环没守住 |
|
||||
| capitalThreatTurns | 回合开始时首都处于城市威胁范围 |
|
||||
| emptyThreatenedCityTurns | 回合开始时有受威胁城市中心没有己方或同盟单位驻守 |
|
||||
| resolved/worsened | 某个行动后城市威胁数量、危急威胁或 dangerScore 改善/恶化 |
|
||||
| emergencyResponse | 有防守机会时 Emergency 决策的覆盖率 |
|
||||
| defenderReturn | Emergency 中实际执行回防移动的次数 |
|
||||
|
||||
英雄和特色单位指标解释:
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| styleBuckets | 把英雄 reason/action 归为 Defense、Recovery、Burst、Control、Summon、Economy、Mobility、HeroLifecycle、General |
|
||||
| heroDefensiveUse | 英雄在防守、治疗、保护或 Emergency 中参与的次数 |
|
||||
| heroPersonalExpression | 按英雄身份统计真实执行次数、击杀、伤害、治疗、移动、技能变化和 top actions,判断个人机制是否真的被使用 |
|
||||
| unitRoleExpression | 按 Melee、Mobility、Ranged、Siege、Defender、Naval、Special、Summon 统计真实行为,判断小兵定位是否健康 |
|
||||
| unitTypeExpression | 按具体 UnitType 统计行为,用来发现某个特色小兵只被生产但不行动、只移动不输出、或死亡过高 |
|
||||
| unitSkillActions | 带技能签名的单位实际执行了哪些动作 |
|
||||
| unitSkillChangedActions | 动作前后改变了单位或目标技能签名的动作 |
|
||||
| actorSkillSignatures | 最常参与行动的技能组合,辅助判断新阵营小兵是否真的被用到 |
|
||||
|
||||
问题定位:
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
@ -548,6 +630,8 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|
||||
| 能占不占 | UnitOpportunity、DevelopmentTarget |
|
||||
| 英雄不放技能 | HeroState、HeroPlaybook、ActionPool |
|
||||
| 单位乱走 | Front、GridThreat、MoveTarget |
|
||||
| 过度防守 | CityThreat 是否把全玩家领土误算到每座城市 |
|
||||
| Fallback 偏高 | Recover、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybook;UnitAttackAlly 不应出现在 Fallback |
|
||||
| 城市不发展 | CityPlan、Growth、ActionPool |
|
||||
| 科技乱学 | StrategicPosture、TechScore |
|
||||
| 回合慢 | ActionPool、移动候选、局部搜索半径 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,19 @@ AI 测试必须回答四个问题:
|
||||
| 评分错误 | 车道对了但目标很蠢 | 修同类动作排序 |
|
||||
| 执行失败 | `executed=false` | 查 `CompleteExecute`、参数刷新、网络状态 |
|
||||
|
||||
本轮自循环统一使用一组可比较指标,避免只凭单局观感判断聪明程度:
|
||||
|
||||
| 指标组 | 字段 | 判断方式 |
|
||||
|---|---|---|
|
||||
| 正确性 | `failedGames`、`noEffect`、`repeated`、`maxActions/playerTurn` | 先保证没有错误、死循环和无收益动作 |
|
||||
| 扩张 | `aliveAvgCities`、`alive>=2`、`alive>=3`、`maxCities` | 看整体城市数和二城/三城率是否提升 |
|
||||
| 战斗 | `UnitAttack`、`kills`、`actingUnitDeaths`、`PriorityTactic/Tactic` | 看是否有战果,是否过度送兵 |
|
||||
| 英雄 | `selected`、`spawned`、`HeroManagement`、`HeroPlaybook`、英雄 reason/action | 看英雄是否上场、是否用个人机制 |
|
||||
| 防守 | `cityLost`、`capitalThreatTurns`、`emptyThreatenedCityTurns`、`resolved/worsened`、`emergencyResponse` | 看是否少丢城、是否响应危险城市 |
|
||||
| 特色单位 | `unitSkillActions`、`unitSkillChangedActions`、`actorSkillSignatures` | 看英雄/小兵技能是否真的参与行动 |
|
||||
| Fallback | Fallback 总数、actionType、no-effect | 用来发现规则缺口 |
|
||||
| 性能 | `actions/sec`、`avgGame`、`decision avg/p95/max` | 只处理明显尖峰,不牺牲聪明度做极端优化 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 诊断开关
|
||||
@ -269,7 +282,7 @@ playerGrowth
|
||||
资源:coin / techPoint / culture / cultureCardCount
|
||||
规模:cityCount / unitCount / heroCount
|
||||
军力:selfMilitary / enemyMilitary
|
||||
威胁:criticalCityThreatCount / cityThreatCount / maxCityDangerScore
|
||||
威胁:criticalCityThreatCount / cityThreatCount / capitalThreatCount / emptyThreatenedCityCount / maxCityDangerScore
|
||||
关键对象:单位血量、位置、死亡,城市归属、等级
|
||||
```
|
||||
|
||||
@ -278,11 +291,13 @@ playerGrowth
|
||||
```text
|
||||
netActionDelta
|
||||
coinDelta / techPointDelta / cultureDelta
|
||||
cityDelta / unitDelta / heroDelta
|
||||
cityDelta / cityLostDelta / cityGainedDelta / unitDelta / heroDelta
|
||||
selfMilitaryDelta / enemyMilitaryDelta
|
||||
criticalCityThreatDelta / cityThreatDelta
|
||||
criticalCityThreatDelta / cityThreatDelta / capitalThreatDelta / emptyThreatenedCityDelta
|
||||
cityThreatResolved / cityThreatWorsened
|
||||
unitMoved / unitDied / targetUnitDied
|
||||
cityOwnerChanged / targetCityOwnerChanged
|
||||
unitSkillSignatureChanged / targetUnitSkillSignatureChanged
|
||||
```
|
||||
|
||||
重点看:
|
||||
@ -294,9 +309,29 @@ netActionDelta 是否推进
|
||||
delta 是否符合动作意图
|
||||
攻击是否造成 targetUnitHealthDelta 或 targetUnitDied
|
||||
回防是否降低 criticalCityThreatDelta 或 maxCityDangerScoreDelta
|
||||
防守行动是否让 cityThreatResolved=true,或至少不让 cityThreatWorsened=true
|
||||
占领是否造成 cityOwnerChanged 或 cityDelta
|
||||
特色单位行动是否带有 unitSkillSignature,并在需要时改变 skill signature
|
||||
```
|
||||
|
||||
### 5.7 batch diagnostics
|
||||
|
||||
`batch_summary.json` 的 `diagnostics` 会聚合单局 JSONL,重点字段:
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| cityLostCount / cityGainedCount | 同一玩家两次 TurnStart 之间的城市减少/增加 |
|
||||
| capitalThreatTurnCount | 首都处于威胁中的回合开始次数 |
|
||||
| emptyThreatenedCityTurnCount | 受威胁且城市中心无人驻守的回合开始次数 |
|
||||
| cityThreatResolvedCount / cityThreatWorsenedCount | 行动后城市威胁改善/恶化次数 |
|
||||
| emergencyResponseRate | 防守机会中 Emergency 决策覆盖率 |
|
||||
| defenderReturnCount | Emergency 中执行回防移动的次数 |
|
||||
| heroDefensiveUse | 英雄参与防守、保护、治疗或 Emergency 的次数 |
|
||||
| defenseScore | 防守综合分,用于同参数批跑前后对比 |
|
||||
| topHeroStyleBuckets | 英雄风格分布 |
|
||||
| topUnitSkillActionTypes | 带技能签名单位执行的动作 |
|
||||
| topActorSkillSignatures | 最常行动的技能组合 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 单回合阅读流程
|
||||
@ -571,9 +606,14 @@ ActionPool 最大数量
|
||||
| 执行失败率 | execution.executed | 参数或同步问题 |
|
||||
| 候选生成量 | actionPool.all | 性能风险 |
|
||||
| 城市威胁变化 | execution.delta.criticalCityThreatDelta | 防守效果 |
|
||||
| 城市丢失 | 相邻 TurnStart 的 cityCount / cityIdsSignature | 防守结果 |
|
||||
| 首都压力 | turnSummary.capitalThreatCount | 防守风险 |
|
||||
| 空城压力 | turnSummary.emptyThreatenedCityCount | 驻防质量 |
|
||||
| 军力交换 | selfMilitaryDelta / enemyMilitaryDelta | 战斗收益 |
|
||||
| 扩张收益 | cityDelta / cityOwnerChanged | 占领能力 |
|
||||
| 英雄存活 | heroDelta / unitDied | 英雄保命 |
|
||||
| 英雄风格 | Hero reason/action -> styleBuckets | 英雄个性表达 |
|
||||
| 特色单位表达 | unitSkillSignature / unitSkillChangedActions | 小兵技能是否发挥 |
|
||||
| 重复动作 | action.stableKey | 循环风险 |
|
||||
|
||||
自动归因规则:
|
||||
@ -597,6 +637,12 @@ NoAction率低但想要的动作不在 lanes
|
||||
Emergency 后 criticalCityThreatDelta 不下降
|
||||
=> 防守动作没有真正解决问题,调整 Emergency 行动优先级
|
||||
|
||||
cityLost 高或 capitalThreatTurns 长期高
|
||||
=> 城市威胁识别、Emergency 响应、城市生产和回防目标优先级需要调整
|
||||
|
||||
emptyThreatenedCityTurns 高
|
||||
=> AI 可能只扩张不驻防,检查 Front/Hold、EmergencyMove、城市训练防守兵
|
||||
|
||||
Tactic 后 enemyMilitaryDelta 不下降且 selfMilitaryDelta 下降
|
||||
=> 攻击评分低估反击或高估输出
|
||||
|
||||
@ -606,6 +652,12 @@ UnitOpportunity 长期不触发 Capture/Examine/Gather
|
||||
HeroPlaybook 候选少或长期无效
|
||||
=> 英雄规则条件或目标策略需要补
|
||||
|
||||
styleBuckets 长期只有 General
|
||||
=> 英雄 Playbook 缺专属 reason/action,或分类规则缺失,需要补英雄机制表达
|
||||
|
||||
unitSkillActions 低
|
||||
=> 特色小兵没进入行动主体,检查训练、Front、UnitOpportunity 和 Tactic 对该兵种的使用
|
||||
|
||||
decideMs 尖峰且 actionPool.all 高
|
||||
=> 限制候选数量、Front/DevelopmentTarget TopN 或移动枚举
|
||||
```
|
||||
@ -613,16 +665,24 @@ decideMs 尖峰且 actionPool.all 高
|
||||
自循环的固定流程:
|
||||
|
||||
```text
|
||||
1. 固定 10 个 seed、地图、阵营组合。
|
||||
2. 跑一批 JSONL。
|
||||
3. 聚合上面的指标。
|
||||
1. 固定 Seed、地图尺寸、玩家数、回合数、难度、动作预算。
|
||||
2. 用 Editor batch runner 跑一批 JSONL 和 batch_summary.json。
|
||||
3. 先用 analyze_ai_batch_quality.py 聚合指标,不直接打开大日志。
|
||||
4. 找异常最高的 3 类问题。
|
||||
5. 对每类问题抽 3-5 条 eventSequence 做人工复核。
|
||||
6. 判断改 18、19、代码还是 Action 生成。
|
||||
7. 修改后用同一批 seed 回归。
|
||||
7. 修改后用同一批 Seed 和同一批参数回归。
|
||||
8. 对比指标是否改善。
|
||||
```
|
||||
|
||||
固定 Seed 的含义:
|
||||
|
||||
```text
|
||||
Tools/RunAIDirectorBatch.ps1 -Seed 424242 ...
|
||||
```
|
||||
|
||||
BatchRunner 会在 Editor/batch 环境把 `MapData.Net.RandomSeed`、Unity 随机种子和地图高度噪声种子固定住。多局批跑时使用 `Seed + gameIndex`。`batch_summary.json` 中的 `diagnosticsLogPath` 是分析器匹配 JSONL 的权威路径,不用再按时间戳猜日志。
|
||||
|
||||
---
|
||||
|
||||
## 10. 问题回写流程
|
||||
@ -682,6 +742,13 @@ delta证据:
|
||||
开始调整 18/19 的策略思路
|
||||
```
|
||||
|
||||
推荐命令:
|
||||
|
||||
```powershell
|
||||
Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 17 -Width 30 -Height 30 -Turns 12 -Seed 424242 -TimeoutSeconds 420 -MaxActions 9000 -MaxActionsPerPlayerTurn 120 -Difficulty LUNATIC -KeepGoing
|
||||
python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py --batch Unity/Logs/AI_Batch/<timestamp>/batch_summary.json --top 12
|
||||
```
|
||||
|
||||
第四轮:
|
||||
|
||||
```text
|
||||
|
||||
@ -9,9 +9,13 @@ param(
|
||||
[int]$TimeoutSeconds = 1800,
|
||||
[int]$MaxActions = 20000,
|
||||
[int]$MaxActionsPerPlayerTurn = 260,
|
||||
[int]$Seed = 0,
|
||||
[string]$OutDir,
|
||||
[string]$Difficulty = "LUNATIC",
|
||||
[switch]$KeepGoing,
|
||||
[switch]$FullDiagnostics,
|
||||
[switch]$AllSight,
|
||||
[switch]$StopOnGameEnd,
|
||||
[switch]$AllowProjectAlreadyOpen,
|
||||
[switch]$DryRun
|
||||
)
|
||||
@ -96,6 +100,9 @@ function Quote-Argument([string]$Value) {
|
||||
|
||||
$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,
|
||||
@ -109,8 +116,12 @@ $args = @(
|
||||
"-aiBatchTimeoutSeconds", $TimeoutSeconds,
|
||||
"-aiBatchMaxActions", $MaxActions,
|
||||
"-aiBatchMaxActionsPerPlayerTurn", $MaxActionsPerPlayerTurn,
|
||||
"-aiBatchSeed", $Seed,
|
||||
"-aiBatchDifficulty", $Difficulty,
|
||||
"-aiBatchFailFast", $failFast,
|
||||
"-aiBatchCompactDiagnostics", $compactDiagnostics,
|
||||
"-aiBatchAllSight", $allSightValue,
|
||||
"-aiBatchStopOnGameEnd", $stopOnGameEndValue,
|
||||
"-aiBatchOut", $OutDir
|
||||
)
|
||||
|
||||
|
||||
@ -2301,10 +2301,7 @@ namespace RuntimeData
|
||||
// 购买文化卡
|
||||
public bool TryBuyCultureCard(MapData map, PlayerData player, CultureCardType cardType)
|
||||
{
|
||||
if (!Table.Instance.CultureCardDataAssets.GetCultureCardInfo(cardType, out var info)) return false;
|
||||
if (!info.CheckEmpireCanUseCard(player.Empire)) return false;
|
||||
if (!info.CheckPrerequisiteCardsOwned(player)) return false;
|
||||
if (!info.CheckPlayerCanAfford(player)) return false;
|
||||
if (!CheckCanBuyCultureCard(map, player, cardType, out var info)) return false;
|
||||
info.SpendCost(player);
|
||||
CultureCardList.Add(cardType);
|
||||
var card = CultureCardFactory.GetCultureCardBase(cardType);
|
||||
@ -2316,12 +2313,35 @@ namespace RuntimeData
|
||||
// 能否购买文化卡
|
||||
public bool CheckCanBuyCultureCard(MapData map, PlayerData player, CultureCardType cardType)
|
||||
{
|
||||
if (!Table.Instance.CultureCardDataAssets.GetCultureCardInfo(cardType, out var info)) return false;
|
||||
return CheckCanBuyCultureCard(map, player, cardType, out _);
|
||||
}
|
||||
|
||||
private bool CheckCanBuyCultureCard(MapData map, PlayerData player, CultureCardType cardType, out CultureCardInfo info)
|
||||
{
|
||||
info = null;
|
||||
var dataAssets = Table.Instance?.CultureCardDataAssets;
|
||||
if (dataAssets == null || !dataAssets.GetCultureCardInfo(cardType, out info)) return false;
|
||||
if (player == null) return false;
|
||||
if (info.NotShow) return false;
|
||||
var ownedCount = GetCultureCardOwnedCount(cardType);
|
||||
if (info.MaxCount > 0 && ownedCount >= info.MaxCount) return false;
|
||||
if (!info.CheckEmpireCanUseCard(player.Empire)) return false;
|
||||
if (!info.CheckPrerequisiteCardsOwned(player)) return false;
|
||||
if (!info.CheckPlayerCanAfford(player)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private int GetCultureCardOwnedCount(CultureCardType cardType)
|
||||
{
|
||||
var count = 0;
|
||||
if (CultureCardList == null) return count;
|
||||
foreach (var ownedCardType in CultureCardList)
|
||||
{
|
||||
if (ownedCardType == cardType) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public void OnTurnStart(MapData map, PlayerData player)
|
||||
{
|
||||
|
||||
@ -231,6 +231,8 @@ namespace Logic.AI
|
||||
public CommonActionParams TargetParam;
|
||||
public Strategy TargetStrategy;
|
||||
public List<uint> TargetList;
|
||||
public bool UseTargetListAsMoveAnchors;
|
||||
public int MaxMoveActionsPerUnit;
|
||||
|
||||
public HashSet<string> Marks;
|
||||
|
||||
@ -328,6 +330,8 @@ namespace Logic.AI
|
||||
ForeachLegion = new List<uint>();
|
||||
ForeachCity = new List<CityData>();
|
||||
TargetList = new List<uint>();
|
||||
UseTargetListAsMoveAnchors = false;
|
||||
MaxMoveActionsPerUnit = 0;
|
||||
AroundGridBuffer = new List<GridData>();
|
||||
TmpUnitSetBuffer = new HashSet<UnitData>();
|
||||
TmpCityListBuffer = new List<CityData>();
|
||||
@ -446,6 +450,8 @@ namespace Logic.AI
|
||||
ForeachLegion.Clear();
|
||||
ForeachCity.Clear();
|
||||
TargetList.Clear();
|
||||
UseTargetListAsMoveAnchors = false;
|
||||
MaxMoveActionsPerUnit = 0;
|
||||
|
||||
CityStrategy.Clear();
|
||||
FreeUnitStrategy.Clear();
|
||||
|
||||
@ -292,6 +292,7 @@ namespace Logic.AI
|
||||
GeneratorActionIds(data, CommonActionType.LearnTech);
|
||||
GeneratorActionIds(data, CommonActionType.StartWonder);
|
||||
GeneratorActionIds(data, CommonActionType.PlayerAction);
|
||||
GeneratorActionIds(data, CommonActionType.BuyCultureCard);
|
||||
foreach (var city in selfCities)
|
||||
{
|
||||
data.TargetParam.CityData = city;
|
||||
@ -346,6 +347,71 @@ namespace Logic.AI
|
||||
|
||||
return data.AIActions;
|
||||
}
|
||||
|
||||
public static List<AIActionBase> GeneratorDirectorActionIdsForUse(
|
||||
MapData map,
|
||||
PlayerData selfPlayer,
|
||||
IEnumerable<uint> moveAnchorGridIds,
|
||||
int maxMoveActionsPerUnit)
|
||||
{
|
||||
var data = new AICalculatorData();
|
||||
data.Map = map;
|
||||
data.Player = selfPlayer;
|
||||
data.UseTargetListAsMoveAnchors = true;
|
||||
data.MaxMoveActionsPerUnit = maxMoveActionsPerUnit;
|
||||
if (moveAnchorGridIds != null)
|
||||
{
|
||||
foreach (var gridId in moveAnchorGridIds)
|
||||
{
|
||||
if (!data.TargetList.Contains(gridId)) data.TargetList.Add(gridId);
|
||||
}
|
||||
}
|
||||
|
||||
using var pooledSelfUnits = THCollectionPool.GetHashSetHandle<UnitData>(out var selfUnits);
|
||||
map.GetUnitDataListByPlayerId(selfPlayer.Id, selfUnits);
|
||||
using var pooledSelfCities = THCollectionPool.GetHashSetHandle<CityData>(out var selfCities);
|
||||
map.GetCityDataListByPlayerId(selfPlayer.Id, selfCities);
|
||||
|
||||
data.TargetParam.MapData = map;
|
||||
data.TargetParam.PlayerData = selfPlayer;
|
||||
GeneratorActionIds(data, CommonActionType.LearnTech);
|
||||
GeneratorActionIds(data, CommonActionType.StartWonder);
|
||||
GeneratorActionIds(data, CommonActionType.PlayerAction);
|
||||
GeneratorActionIds(data, CommonActionType.BuyCultureCard);
|
||||
foreach (var city in selfCities)
|
||||
{
|
||||
data.TargetParam.CityData = city;
|
||||
GeneratorActionIds(data, CommonActionType.Gain);
|
||||
GeneratorActionIds(data, CommonActionType.Build);
|
||||
GeneratorActionIds(data, CommonActionType.BuildWonder);
|
||||
GeneratorActionIds(data, CommonActionType.GridMisc);
|
||||
GeneratorActionIds(data, CommonActionType.TrainUnit);
|
||||
GeneratorActionIds(data, CommonActionType.CityLevelUpAction);
|
||||
}
|
||||
|
||||
foreach (var unit in selfUnits)
|
||||
{
|
||||
data.TargetParam.UnitData = unit;
|
||||
GeneratorActionIds(data, CommonActionType.UnitAction);
|
||||
GeneratorActionIds(data, CommonActionType.UnitSkill);
|
||||
GeneratorActionIds(data, CommonActionType.UnitMove);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttack);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttackAlly);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttackGround);
|
||||
}
|
||||
|
||||
for (int i = data.AIActions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var id = data.AIActions[i].ActionLogic.ActionId;
|
||||
if (id.UnitActionType is UnitActionType.Disband or UnitActionType.ForceDisband or UnitActionType.Demolish or UnitActionType.Disperse
|
||||
|| id.GridMiscActionType == GridMiscActionType.Destroy)
|
||||
{
|
||||
data.AIActions.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
return data.AIActions;
|
||||
}
|
||||
|
||||
public static List<AIActionBase> GeneratorAllActionIds(MapData map, PlayerData selfPlayer)
|
||||
{
|
||||
@ -389,9 +455,6 @@ namespace Logic.AI
|
||||
|
||||
public static void GeneratorActionIds(AICalculatorData data, CommonActionType type)
|
||||
{
|
||||
// AI暂不购买文化卡,避免隐藏/里程碑卡被当成普通候选行为反复执行。
|
||||
if (type == CommonActionType.BuyCultureCard) return;
|
||||
|
||||
var actions = ActionLogicFactory.GetActionLogicByType(type);
|
||||
if (actions == null || actions.Count == 0) return;
|
||||
|
||||
@ -505,6 +568,11 @@ namespace Logic.AI
|
||||
|
||||
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
|
||||
Main.UnitLogic.CalcUnitMoveInfo(data.Map, data.TargetParam.UnitData.Id);
|
||||
if (data.UseTargetListAsMoveAnchors)
|
||||
{
|
||||
GenerateMoveActionsTowardAnchors(data, actions, unitGrid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.TargetList.Count > 0)
|
||||
{
|
||||
@ -572,6 +640,34 @@ namespace Logic.AI
|
||||
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
|
||||
|
||||
Main.UnitLogic.CalcUnitMoveInfo(data.Map, data.TargetParam.UnitData.Id);
|
||||
if (data.UseTargetListAsMoveAnchors && data.TargetList.Count > 0)
|
||||
{
|
||||
foreach (var gridId in data.TargetList)
|
||||
{
|
||||
if (!data.Map.GridMap.GetGridDataByGid(gridId, out var grid)) continue;
|
||||
if (!grid.VisibleUnit(data.Map, data.TargetParam.PlayerData, out var targetUnit)) continue;
|
||||
var result = Main.UnitLogic.CheckUnitCanMoveOrAttack(data.Map, data.TargetParam.UnitData, grid);
|
||||
if (result != MoveAttackType.Attack) continue;
|
||||
|
||||
data.TargetParam.GridData = grid;
|
||||
data.TargetParam.TargetUnitData = targetUnit;
|
||||
data.TargetParam.OnParamChanged();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (!action.CheckCan(data.TargetParam)) continue;
|
||||
var param = data.TargetParam.GetCopyParam();
|
||||
param.CityData = null;
|
||||
param.TargetGridData = null;
|
||||
param.TargetPlayerData = null;
|
||||
param.OnParamChanged();
|
||||
data.AIActions.Add(new AIActionBase(param, action));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var grid in data.Map.GridMap.GridList)
|
||||
{
|
||||
//TODO check playerData right
|
||||
@ -735,6 +831,7 @@ namespace Logic.AI
|
||||
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (!IsAIHeroSlotCultureCard(data.TargetParam.PlayerData, action.ActionId.CultureCardType)) continue;
|
||||
if (!action.CheckCan(data.TargetParam)) continue;
|
||||
var param = data.TargetParam.GetCopyParam();
|
||||
param.UnitData = null;
|
||||
@ -748,5 +845,88 @@ namespace Logic.AI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsAIHeroSlotCultureCard(PlayerData player, CultureCardType cardType)
|
||||
{
|
||||
var heroData = player?.PlayerHeroData;
|
||||
var cultureInfo = player?.PlayerCultureInfo;
|
||||
if (heroData == null || cultureInfo == null) return false;
|
||||
if (cultureInfo.CultureCardList != null && cultureInfo.CultureCardList.Contains(cardType)) return false;
|
||||
|
||||
return cardType switch
|
||||
{
|
||||
CultureCardType.SecondHero => heroData.MaxHeroCount == 1 && heroData.HeroCount >= 1,
|
||||
CultureCardType.ThirdHero => heroData.MaxHeroCount == 2
|
||||
&& heroData.HeroCount >= 2
|
||||
&& cultureInfo.CultureCardList != null
|
||||
&& cultureInfo.CultureCardList.Contains(CultureCardType.SecondHero),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static void GenerateMoveActionsTowardAnchors(
|
||||
AICalculatorData data,
|
||||
List<ActionLogicBase> actions,
|
||||
GridData unitGrid)
|
||||
{
|
||||
if (data == null || actions == null || unitGrid == null) return;
|
||||
|
||||
data.AroundGridBuffer.Clear();
|
||||
Main.UnitLogic.CollectMoveAttackCandidateGrids(data.Map, data.TargetParam.UnitData, data.AroundGridBuffer);
|
||||
if (data.AroundGridBuffer.Count == 0) return;
|
||||
|
||||
data.AroundGridBuffer.Sort((a, b) =>
|
||||
{
|
||||
var scoreCompare = ScoreDirectorMoveGrid(data, unitGrid, b)
|
||||
.CompareTo(ScoreDirectorMoveGrid(data, unitGrid, a));
|
||||
if (scoreCompare != 0) return scoreCompare;
|
||||
return a.Id.CompareTo(b.Id);
|
||||
});
|
||||
|
||||
var generated = 0;
|
||||
var limit = data.MaxMoveActionsPerUnit <= 0 ? int.MaxValue : data.MaxMoveActionsPerUnit;
|
||||
foreach (var grid in data.AroundGridBuffer)
|
||||
{
|
||||
var result = Main.UnitLogic.CheckUnitCanMoveOrAttack(data.Map, data.TargetParam.UnitData, grid);
|
||||
if (result != MoveAttackType.Move && result != MoveAttackType.MoveToPort && result != MoveAttackType.MoveAshore && result != MoveAttackType.MoveTeleport) continue;
|
||||
|
||||
data.TargetParam.GridData = grid;
|
||||
data.TargetParam.OnParamChanged();
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (!action.CheckCan(data.TargetParam)) continue;
|
||||
var param = data.TargetParam.GetCopyParam();
|
||||
param.CityData = null;
|
||||
param.TargetUnitData = null;
|
||||
param.TargetGridData = null;
|
||||
param.TargetPlayerData = null;
|
||||
param.OnParamChanged();
|
||||
data.AIActions.Add(new AIActionBase(param, action));
|
||||
generated++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (generated >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ScoreDirectorMoveGrid(AICalculatorData data, GridData unitGrid, GridData moveGrid)
|
||||
{
|
||||
if (data == null || moveGrid == null) return int.MinValue;
|
||||
var bestDistance = int.MaxValue;
|
||||
foreach (var targetGridId in data.TargetList)
|
||||
{
|
||||
if (!data.Map.GridMap.GetGridDataByGid(targetGridId, out var targetGrid)) continue;
|
||||
var distance = data.Map.GridMap.CalcDistance(moveGrid, targetGrid);
|
||||
if (distance < bestDistance) bestDistance = distance;
|
||||
}
|
||||
|
||||
if (bestDistance == int.MaxValue) bestDistance = data.Map.GridMap.CalcDistance(unitGrid, moveGrid);
|
||||
var score = -bestDistance * 100;
|
||||
if (moveGrid.CityOnGrid(data.Map, out _)) score += 80;
|
||||
if (moveGrid.VisibleUnit(data.Map, data.Player, out _)) score -= 120;
|
||||
if (data.Player?.Sight != null && data.Player.Sight.CheckIsInSight(moveGrid.Id)) score += 10;
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,7 +106,10 @@ namespace Logic.AI
|
||||
if (AILogicState == AILogicState.Pausing && (AIDirectorBatchRuntime.SkipPresentationWait || !PresentationManager.Busy))
|
||||
#endif
|
||||
{
|
||||
_targetTime -= Time.deltaTime;
|
||||
if (AIDirectorBatchRuntime.SkipPresentationWait)
|
||||
_targetTime = 0f;
|
||||
else
|
||||
_targetTime -= Time.deltaTime;
|
||||
if (_targetTime <= 0) AILogicState = AILogicState.Playing;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ using System.Collections.Generic;
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Action;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
@ -26,13 +25,18 @@ namespace Logic.AI.Director
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _attackGroundByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _cityActionsByCity = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _gridActionsByGrid = new();
|
||||
private readonly Dictionary<(uint unitId, uint targetGridId), AIActionBase> _bestMoveCache = new();
|
||||
|
||||
public static AIDirectorActionIndex Build(AIDirectorContext ctx)
|
||||
{
|
||||
var index = new AIDirectorActionIndex();
|
||||
if (ctx?.Map == null || ctx.Player == null) return index;
|
||||
|
||||
var generated = AIActionGenerator.GeneratorAllActionIdsForUse(ctx.Map, ctx.Player);
|
||||
var generated = AIActionGenerator.GeneratorDirectorActionIdsForUse(
|
||||
ctx.Map,
|
||||
ctx.Player,
|
||||
BuildMoveAnchorGridIds(ctx),
|
||||
ctx.Config.MaxMoveActionsPerUnit);
|
||||
if (generated == null) return index;
|
||||
|
||||
var limit = ctx.Config.MaxGeneratedActions <= 0 ? int.MaxValue : ctx.Config.MaxGeneratedActions;
|
||||
@ -42,6 +46,7 @@ namespace Logic.AI.Director
|
||||
if (index.AllActions.Count >= limit) break;
|
||||
var copied = CopyAction(action);
|
||||
if (copied == null) continue;
|
||||
if (ctx.BlockedActionKeys != null && ctx.BlockedActionKeys.Contains(StableActionKey(copied))) continue;
|
||||
if (!copied.ActionLogic.CheckCan(copied.Param)) continue;
|
||||
if (IsDangerousAction(copied)) continue;
|
||||
index.Add(copied);
|
||||
@ -52,6 +57,57 @@ namespace Logic.AI.Director
|
||||
return index;
|
||||
}
|
||||
|
||||
private static List<uint> BuildMoveAnchorGridIds(AIDirectorContext ctx)
|
||||
{
|
||||
var result = new List<uint>();
|
||||
if (ctx?.Cache == null) return result;
|
||||
|
||||
void Add(GridData grid)
|
||||
{
|
||||
if (grid == null || result.Contains(grid.Id)) return;
|
||||
result.Add(grid.Id);
|
||||
}
|
||||
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
Add(threat?.CityGrid);
|
||||
if (threat?.EnemyUnits == null) continue;
|
||||
foreach (var enemy in threat.EnemyUnits)
|
||||
{
|
||||
if (enemy != null && ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) Add(enemyGrid);
|
||||
}
|
||||
}
|
||||
|
||||
var expansionAnchorCount = 0;
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
{
|
||||
if (target?.TargetType is not (AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)) continue;
|
||||
if (ctx.Config.MaxExpansionTargetScanCount > 0 && expansionAnchorCount >= ctx.Config.MaxExpansionTargetScanCount) break;
|
||||
Add(target.Grid);
|
||||
expansionAnchorCount++;
|
||||
}
|
||||
|
||||
foreach (var front in ctx.Cache.Fronts)
|
||||
{
|
||||
if (front?.FrontType == AIDirectorFrontType.Development) continue;
|
||||
Add(front?.TargetGrid);
|
||||
Add(front?.AnchorGrid);
|
||||
}
|
||||
|
||||
foreach (var city in ctx.Cache.SelfCities)
|
||||
{
|
||||
if (city != null && ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) Add(cityGrid);
|
||||
}
|
||||
|
||||
foreach (var battle in ctx.Cache.LocalBattles)
|
||||
{
|
||||
Add(battle?.EnemyGrid);
|
||||
Add(battle?.SelfGrid);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public AIDirectorActionCandidate Candidate(AIActionBase action, AIDirectorLane lane, string reason, float priority, bool fallback = false)
|
||||
{
|
||||
if (action == null) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
|
||||
@ -91,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;
|
||||
@ -135,6 +198,23 @@ namespace Logic.AI.Director
|
||||
return null;
|
||||
}
|
||||
|
||||
public AIActionBase FindHeroSlotCultureCard(PlayerData player)
|
||||
{
|
||||
AIActionBase best = null;
|
||||
var bestType = CultureCardType.Max;
|
||||
foreach (var action in HeroManagementActions)
|
||||
{
|
||||
var id = action.ActionLogic.ActionId;
|
||||
if (id.ActionType != CommonActionType.BuyCultureCard) continue;
|
||||
if (!AIActionGenerator.IsAIHeroSlotCultureCard(player, id.CultureCardType)) continue;
|
||||
if (id.CultureCardType >= bestType) continue;
|
||||
bestType = id.CultureCardType;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public IEnumerable<AIActionBase> GetCityActions(CityData city)
|
||||
{
|
||||
if (city == null) yield break;
|
||||
@ -173,6 +253,8 @@ namespace Logic.AI.Director
|
||||
if (unit == null) return null;
|
||||
if (!_movesByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
if (targetGrid == null) return actions.Count > 0 ? actions[0] : null;
|
||||
var cacheKey = (unit.Id, targetGrid.Id);
|
||||
if (_bestMoveCache.TryGetValue(cacheKey, out var cached)) return cached;
|
||||
|
||||
AIActionBase best = null;
|
||||
var bestDistance = int.MaxValue;
|
||||
@ -186,6 +268,7 @@ namespace Logic.AI.Director
|
||||
best = action;
|
||||
}
|
||||
|
||||
_bestMoveCache[cacheKey] = best;
|
||||
return best;
|
||||
}
|
||||
|
||||
@ -202,12 +285,11 @@ namespace Logic.AI.Director
|
||||
public AIActionBase FindBestFallback()
|
||||
{
|
||||
return FindFirstFallbackAction(AttackActions)
|
||||
?? FindFirstFallbackAction(MoveActions)
|
||||
?? FindFirstFallbackAction(AttackGroundActions)
|
||||
?? FindFirstFallbackAction(CityActions)
|
||||
?? FindFirstFallbackAction(GridActions)
|
||||
?? FindFirstFallbackAction(PlayerActions)
|
||||
?? FindFirstFallbackAction(UnitActions)
|
||||
?? FindFirstFallbackAction(AllActions);
|
||||
?? FindFirstFallbackAction(UnitActions);
|
||||
}
|
||||
|
||||
private void Add(AIActionBase action)
|
||||
@ -263,9 +345,14 @@ namespace Logic.AI.Director
|
||||
PlayerActions.Add(action);
|
||||
break;
|
||||
case CommonActionType.LearnTech:
|
||||
case CommonActionType.BuyCultureCard:
|
||||
PlayerActions.Add(action);
|
||||
break;
|
||||
case CommonActionType.BuyCultureCard:
|
||||
if (AIActionGenerator.IsAIHeroSlotCultureCard(action.Param.PlayerData, id.CultureCardType))
|
||||
{
|
||||
HeroManagementActions.Add(action);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -402,6 +489,11 @@ namespace Logic.AI.Director
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null) return false;
|
||||
|
||||
if (id.ActionType == CommonActionType.UnitAttackAlly)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.ActionType == CommonActionType.UnitAction
|
||||
&& id.UnitActionType is UnitActionType.Examine
|
||||
or UnitActionType.KANAKOSIT
|
||||
|
||||
@ -13,7 +13,7 @@ namespace Logic.AI.Director
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
public static class AIDirectorDiagnostics
|
||||
{
|
||||
private const string SchemaVersion = "1.0";
|
||||
private const string SchemaVersion = "1.4";
|
||||
private const int MaxItemsPerSection = 32;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
@ -29,6 +29,7 @@ namespace Logic.AI.Director
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
public static string CurrentLogPath => EnsureSession();
|
||||
public static string CurrentLogPathOrEmpty => _currentLogPath ?? string.Empty;
|
||||
|
||||
public static void SetEnabled(bool enabled)
|
||||
{
|
||||
@ -49,7 +50,6 @@ namespace Logic.AI.Director
|
||||
public static void BeginNewSession()
|
||||
{
|
||||
ResetSession();
|
||||
if (_enabled) EnsureSession();
|
||||
}
|
||||
|
||||
private static void ResetSession()
|
||||
@ -217,7 +217,7 @@ namespace Logic.AI.Director
|
||||
priority = diagnostic.priority,
|
||||
lane = candidate?.Lane.ToString() ?? AIDirectorLane.None.ToString(),
|
||||
reason = candidate?.Reason ?? string.Empty,
|
||||
action = BuildActionSnapshot(candidate?.AIAction),
|
||||
action = BuildActionSnapshot(candidate?.AIAction),
|
||||
scoreTerms = BuildScoreTerms(candidate?.ScoreTerms)
|
||||
};
|
||||
}
|
||||
@ -294,7 +294,7 @@ namespace Logic.AI.Director
|
||||
if (action?.ActionLogic?.ActionId == null) return null;
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var param = action.Param;
|
||||
return new ActionSnapshot
|
||||
var snapshot = new ActionSnapshot
|
||||
{
|
||||
stableKey = AIDirectorActionIndex.StableActionKey(action),
|
||||
actionType = id.ActionType.ToString(),
|
||||
@ -320,6 +320,28 @@ namespace Logic.AI.Director
|
||||
targetGridId = param?.TargetGridId ?? 0,
|
||||
targetPlayerId = param?.TargetPlayerId ?? 0
|
||||
};
|
||||
FillActionUnitTypeSnapshot(param?.UnitData, true, snapshot);
|
||||
FillActionUnitTypeSnapshot(param?.TargetUnitData, false, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void FillActionUnitTypeSnapshot(UnitData unit, bool self, ActionSnapshot snapshot)
|
||||
{
|
||||
if (unit == null || snapshot == null) return;
|
||||
if (self)
|
||||
{
|
||||
snapshot.actorUnitType = unit.UnitType.ToString();
|
||||
snapshot.actorGiantType = unit.GiantType.ToString();
|
||||
snapshot.actorUnitLevel = unit.UnitLevel;
|
||||
snapshot.actorChessType = unit.ChessType.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot.targetActorUnitType = unit.UnitType.ToString();
|
||||
snapshot.targetActorGiantType = unit.GiantType.ToString();
|
||||
snapshot.targetActorUnitLevel = unit.UnitLevel;
|
||||
snapshot.targetActorChessType = unit.ChessType.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CityThreatSnapshot> BuildCityThreats(List<AIDirectorCityThreat> threats)
|
||||
@ -571,14 +593,18 @@ namespace Logic.AI.Director
|
||||
PlayerTechPoint = player?.PlayerTechPoint ?? 0,
|
||||
PlayerCulture = player?.PlayerCultureInfo?.PlayerCulture ?? 0,
|
||||
CultureCardCount = player?.PlayerCultureInfo?.CultureCardList?.Count ?? 0,
|
||||
PlayerScore = player?.PlayerScore ?? 0
|
||||
PlayerScore = player?.PlayerScore ?? 0,
|
||||
SightGridCount = player?.Sight?.SightGidSet?.Count ?? 0
|
||||
};
|
||||
|
||||
if (map == null || player == null) return probe;
|
||||
|
||||
FillHeroOutcomeProbe(map, player, probe);
|
||||
|
||||
using var cityHandle = THCollectionPool.GetListHandle<CityData>(out var cities);
|
||||
map.GetCityDataListByPlayerId(player.Id, cities);
|
||||
probe.CityCount = cities.Count;
|
||||
FillCitySignatureProbe(cities, probe);
|
||||
|
||||
using var unitHandle = THCollectionPool.GetListHandle<UnitData>(out var units);
|
||||
map.GetUnitDataListByPlayerId(player.Id, units);
|
||||
@ -605,6 +631,12 @@ namespace Logic.AI.Director
|
||||
var enemyCount = 0;
|
||||
var enemyPower = 0f;
|
||||
var defenderPower = 0f;
|
||||
var hasCityCenterDefender = cityGrid.RealUnit(map, out var cityCenterUnit)
|
||||
&& cityCenterUnit != null
|
||||
&& cityCenterUnit.IsAlive()
|
||||
&& map.GetPlayerDataByUnitId(cityCenterUnit.Id, out var cityCenterOwner)
|
||||
&& cityCenterOwner != null
|
||||
&& map.SameUnion(player.Id, cityCenterOwner.Id);
|
||||
|
||||
if (map.UnitMap?.UnitList != null)
|
||||
{
|
||||
@ -628,23 +660,90 @@ namespace Logic.AI.Director
|
||||
|
||||
if (enemyCount <= 0) continue;
|
||||
probe.CityThreatCount++;
|
||||
AddUniqueId(probe.ThreatenedCityIds, city.Id);
|
||||
if (city.IsCapital) probe.CapitalThreatCount++;
|
||||
if (!hasCityCenterDefender) probe.EmptyThreatenedCityCount++;
|
||||
var danger = enemyPower - defenderPower;
|
||||
if (danger > probe.MaxCityDangerScore) probe.MaxCityDangerScore = danger;
|
||||
if (danger > 0f) probe.CriticalCityThreatCount++;
|
||||
if (danger > 0f)
|
||||
{
|
||||
probe.CriticalCityThreatCount++;
|
||||
AddUniqueId(probe.CriticalThreatenedCityIds, city.Id);
|
||||
if (city.IsCapital) probe.CriticalCapitalThreatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
probe.ThreatenedCityIdsSignature = BuildIdSignature(probe.ThreatenedCityIds);
|
||||
probe.CriticalThreatenedCityIdsSignature = BuildIdSignature(probe.CriticalThreatenedCityIds);
|
||||
|
||||
if (probe.CriticalCityThreatCount > 0) probe.StrategicPosture = AIDirectorStrategicPosture.Defense.ToString();
|
||||
else if (probe.EnemyMilitary > 0f && probe.SelfMilitary >= probe.EnemyMilitary) probe.StrategicPosture = AIDirectorStrategicPosture.Attack.ToString();
|
||||
else probe.StrategicPosture = AIDirectorStrategicPosture.Development.ToString();
|
||||
return probe;
|
||||
}
|
||||
|
||||
private static void FillCitySignatureProbe(List<CityData> cities, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
if (probe == null) return;
|
||||
using var ownedHandle = THCollectionPool.GetListHandle<uint>(out var ownedIds);
|
||||
using var capitalHandle = THCollectionPool.GetListHandle<uint>(out var capitalIds);
|
||||
if (cities != null)
|
||||
{
|
||||
foreach (var city in cities)
|
||||
{
|
||||
if (city == null) continue;
|
||||
ownedIds.Add(city.Id);
|
||||
if (city.IsCapital) capitalIds.Add(city.Id);
|
||||
}
|
||||
}
|
||||
|
||||
probe.OwnedCityIdsSignature = BuildIdSignature(ownedIds);
|
||||
probe.CapitalCityIdsSignature = BuildIdSignature(capitalIds);
|
||||
}
|
||||
|
||||
private static void AddUniqueId(List<uint> values, uint id)
|
||||
{
|
||||
if (id == 0 || values == null || values.Contains(id)) return;
|
||||
values.Add(id);
|
||||
}
|
||||
|
||||
private static string BuildIdSignature(List<uint> values)
|
||||
{
|
||||
if (values == null || values.Count <= 0) return string.Empty;
|
||||
values.Sort();
|
||||
using var handle = THCollectionPool.GetListHandle<string>(out var parts);
|
||||
foreach (var value in values) parts.Add(value.ToString());
|
||||
return string.Join("|", parts);
|
||||
}
|
||||
|
||||
private static void FillHeroOutcomeProbe(MapData map, PlayerData player, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
var heroData = player?.PlayerHeroData;
|
||||
if (map == null || player == null || heroData == null || probe == null) return;
|
||||
|
||||
probe.SelectedHeroCount = heroData.HeroList?.Count ?? 0;
|
||||
probe.MaxHeroCount = heroData.MaxHeroCount;
|
||||
probe.HeroTaskCount = heroData.HeroTaskDict?.Count ?? 0;
|
||||
|
||||
if (heroData.HeroTaskDict == null) return;
|
||||
foreach (var kv in heroData.HeroTaskDict)
|
||||
{
|
||||
var task = kv.Value;
|
||||
if (task == null) continue;
|
||||
if (task.CheckFinished(map, player)) probe.ReadyHeroTaskCount++;
|
||||
if (task.IsForceFinished) probe.ForcedHeroTaskCount++;
|
||||
probe.HeroTaskProgress += task.Level > 100000 ? 100000 : (int)task.Level;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillActionObjectProbe(MapData map, AIActionBase action, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
if (map == null || action?.Param == null || probe == null) return;
|
||||
var param = action.Param;
|
||||
FillUnitProbe(map, param.UnitId, true, probe);
|
||||
FillUnitProbe(map, param.TargetUnitId, false, probe);
|
||||
FillGridProbe(map, param.GridId, false, true, probe);
|
||||
FillGridProbe(map, param.TargetGridId, false, false, probe);
|
||||
FillCityProbe(map, param.CityId, true, probe);
|
||||
var targetCityId = 0u;
|
||||
if (param.TargetGridId != 0 && map.GetCityDataByGid(param.TargetGridId, out var targetCity) && targetCity != null) targetCityId = targetCity.Id;
|
||||
@ -665,19 +764,110 @@ namespace Logic.AI.Director
|
||||
&& unit.IsAlive();
|
||||
var health = alive ? unit.Health : 0;
|
||||
var gridId = 0u;
|
||||
if (alive && map.GetGridDataByUnitId(unitId, out var grid) && grid != null) gridId = grid.Id;
|
||||
GridData unitGrid = null;
|
||||
if (alive && map.GetGridDataByUnitId(unitId, out var grid) && grid != null)
|
||||
{
|
||||
unitGrid = grid;
|
||||
gridId = grid.Id;
|
||||
}
|
||||
|
||||
if (self)
|
||||
{
|
||||
probe.UnitAlive = alive;
|
||||
probe.UnitHealth = health;
|
||||
probe.UnitGridId = gridId;
|
||||
FillSkillProbe(unit, true, probe);
|
||||
FillGridProbe(unitGrid, false, true, probe);
|
||||
}
|
||||
else
|
||||
{
|
||||
probe.TargetUnitAlive = alive;
|
||||
probe.TargetUnitHealth = health;
|
||||
probe.TargetUnitGridId = gridId;
|
||||
FillSkillProbe(unit, false, probe);
|
||||
FillGridProbe(unitGrid, false, false, probe);
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillSkillProbe(UnitData unit, bool self, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
if (unit == null || probe == null) return;
|
||||
var count = unit.Skills?.Count ?? 0;
|
||||
var signature = BuildSkillSignature(unit);
|
||||
if (self)
|
||||
{
|
||||
probe.UnitSkillCount = count;
|
||||
probe.UnitSkillSignature = signature;
|
||||
}
|
||||
else
|
||||
{
|
||||
probe.TargetUnitSkillCount = count;
|
||||
probe.TargetUnitSkillSignature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSkillSignature(UnitData unit)
|
||||
{
|
||||
if (unit?.Skills == null || unit.Skills.Count <= 0) return string.Empty;
|
||||
using var handle = THCollectionPool.GetListHandle<string>(out var entries);
|
||||
foreach (var skill in unit.Skills)
|
||||
{
|
||||
if (skill == null) continue;
|
||||
entries.Add($"{skill.GetSkillType()}:{skill.Level}");
|
||||
if (entries.Count >= MaxItemsPerSection) break;
|
||||
}
|
||||
|
||||
entries.Sort(StringComparer.Ordinal);
|
||||
return string.Join("|", entries);
|
||||
}
|
||||
|
||||
private static void FillGridProbe(MapData map, uint gridId, bool unitGrid, bool self, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
if (gridId == 0 || map == null || probe == null) return;
|
||||
if (map.GridMap == null || !map.GridMap.GetGridDataByGid(gridId, out var grid) || grid == null) return;
|
||||
FillGridProbe(grid, unitGrid, self, probe);
|
||||
}
|
||||
|
||||
private static void FillGridProbe(GridData grid, bool unitGrid, bool self, AIDirectorOutcomeProbe probe)
|
||||
{
|
||||
if (grid == null || probe == null) return;
|
||||
if (unitGrid)
|
||||
{
|
||||
if (self)
|
||||
{
|
||||
probe.UnitGridId = grid.Id;
|
||||
probe.UnitGridResource = grid.Resource.ToString();
|
||||
probe.UnitGridResourceUnderBuilding = grid.ResourceUnderBuilding.ToString();
|
||||
probe.UnitGridBuildingLevel = grid.buildingLevel;
|
||||
probe.UnitGridSpType = grid.GetOnlyOneSpType().ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
probe.TargetUnitGridId = grid.Id;
|
||||
probe.TargetUnitGridResource = grid.Resource.ToString();
|
||||
probe.TargetUnitGridResourceUnderBuilding = grid.ResourceUnderBuilding.ToString();
|
||||
probe.TargetUnitGridBuildingLevel = grid.buildingLevel;
|
||||
probe.TargetUnitGridSpType = grid.GetOnlyOneSpType().ToString();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (self)
|
||||
{
|
||||
probe.ActionGridId = grid.Id;
|
||||
probe.ActionGridResource = grid.Resource.ToString();
|
||||
probe.ActionGridResourceUnderBuilding = grid.ResourceUnderBuilding.ToString();
|
||||
probe.ActionGridBuildingLevel = grid.buildingLevel;
|
||||
probe.ActionGridSpType = grid.GetOnlyOneSpType().ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
probe.TargetActionGridId = grid.Id;
|
||||
probe.TargetActionGridResource = grid.Resource.ToString();
|
||||
probe.TargetActionGridResourceUnderBuilding = grid.ResourceUnderBuilding.ToString();
|
||||
probe.TargetActionGridBuildingLevel = grid.buildingLevel;
|
||||
probe.TargetActionGridSpType = grid.GetOnlyOneSpType().ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -692,12 +882,24 @@ namespace Logic.AI.Director
|
||||
probe.CityId = cityId;
|
||||
probe.CityOwnerId = ownerId;
|
||||
probe.CityLevel = city.Level;
|
||||
probe.CityLevelExp = city.LevelExp;
|
||||
probe.CityLevelUpPoint = city.CityLevelUpPoint;
|
||||
probe.CityParkCount = city.ParkCount;
|
||||
probe.CityWorkshop = city.Workshop;
|
||||
probe.CityWall = city.CityWall;
|
||||
probe.CityTerritoryCount = city.Territory?.TerritoryArea?.Count ?? 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
probe.TargetCityId = cityId;
|
||||
probe.TargetCityOwnerId = ownerId;
|
||||
probe.TargetCityLevel = city.Level;
|
||||
probe.TargetCityLevelExp = city.LevelExp;
|
||||
probe.TargetCityLevelUpPoint = city.CityLevelUpPoint;
|
||||
probe.TargetCityParkCount = city.ParkCount;
|
||||
probe.TargetCityWorkshop = city.Workshop;
|
||||
probe.TargetCityWall = city.CityWall;
|
||||
probe.TargetCityTerritoryCount = city.Territory?.TerritoryArea?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -714,29 +916,77 @@ namespace Logic.AI.Director
|
||||
playerCulture = probe.PlayerCulture,
|
||||
cultureCardCount = probe.CultureCardCount,
|
||||
playerScore = probe.PlayerScore,
|
||||
sightGridCount = probe.SightGridCount,
|
||||
cityCount = probe.CityCount,
|
||||
ownedCityIdsSignature = probe.OwnedCityIdsSignature,
|
||||
capitalCityIdsSignature = probe.CapitalCityIdsSignature,
|
||||
unitCount = probe.UnitCount,
|
||||
heroCount = probe.HeroCount,
|
||||
selectedHeroCount = probe.SelectedHeroCount,
|
||||
maxHeroCount = probe.MaxHeroCount,
|
||||
heroTaskCount = probe.HeroTaskCount,
|
||||
readyHeroTaskCount = probe.ReadyHeroTaskCount,
|
||||
forcedHeroTaskCount = probe.ForcedHeroTaskCount,
|
||||
heroTaskProgress = probe.HeroTaskProgress,
|
||||
selfMilitary = probe.SelfMilitary,
|
||||
enemyMilitary = probe.EnemyMilitary,
|
||||
criticalCityThreatCount = probe.CriticalCityThreatCount,
|
||||
cityThreatCount = probe.CityThreatCount,
|
||||
capitalThreatCount = probe.CapitalThreatCount,
|
||||
criticalCapitalThreatCount = probe.CriticalCapitalThreatCount,
|
||||
emptyThreatenedCityCount = probe.EmptyThreatenedCityCount,
|
||||
threatenedCityIdsSignature = probe.ThreatenedCityIdsSignature,
|
||||
criticalThreatenedCityIdsSignature = probe.CriticalThreatenedCityIdsSignature,
|
||||
maxCityDangerScore = probe.MaxCityDangerScore,
|
||||
strategicPosture = probe.StrategicPosture,
|
||||
unitId = probe.UnitId,
|
||||
unitAlive = probe.UnitAlive,
|
||||
unitHealth = probe.UnitHealth,
|
||||
unitGridId = probe.UnitGridId,
|
||||
unitSkillCount = probe.UnitSkillCount,
|
||||
unitSkillSignature = probe.UnitSkillSignature,
|
||||
unitGridResource = probe.UnitGridResource,
|
||||
unitGridResourceUnderBuilding = probe.UnitGridResourceUnderBuilding,
|
||||
unitGridBuildingLevel = probe.UnitGridBuildingLevel,
|
||||
unitGridSpType = probe.UnitGridSpType,
|
||||
targetUnitId = probe.TargetUnitId,
|
||||
targetUnitAlive = probe.TargetUnitAlive,
|
||||
targetUnitHealth = probe.TargetUnitHealth,
|
||||
targetUnitGridId = probe.TargetUnitGridId,
|
||||
targetUnitSkillCount = probe.TargetUnitSkillCount,
|
||||
targetUnitSkillSignature = probe.TargetUnitSkillSignature,
|
||||
targetUnitGridResource = probe.TargetUnitGridResource,
|
||||
targetUnitGridResourceUnderBuilding = probe.TargetUnitGridResourceUnderBuilding,
|
||||
targetUnitGridBuildingLevel = probe.TargetUnitGridBuildingLevel,
|
||||
targetUnitGridSpType = probe.TargetUnitGridSpType,
|
||||
actionGridId = probe.ActionGridId,
|
||||
actionGridResource = probe.ActionGridResource,
|
||||
actionGridResourceUnderBuilding = probe.ActionGridResourceUnderBuilding,
|
||||
actionGridBuildingLevel = probe.ActionGridBuildingLevel,
|
||||
actionGridSpType = probe.ActionGridSpType,
|
||||
targetActionGridId = probe.TargetActionGridId,
|
||||
targetActionGridResource = probe.TargetActionGridResource,
|
||||
targetActionGridResourceUnderBuilding = probe.TargetActionGridResourceUnderBuilding,
|
||||
targetActionGridBuildingLevel = probe.TargetActionGridBuildingLevel,
|
||||
targetActionGridSpType = probe.TargetActionGridSpType,
|
||||
cityId = probe.CityId,
|
||||
cityOwnerId = probe.CityOwnerId,
|
||||
cityLevel = probe.CityLevel,
|
||||
cityLevelExp = probe.CityLevelExp,
|
||||
cityLevelUpPoint = probe.CityLevelUpPoint,
|
||||
cityParkCount = probe.CityParkCount,
|
||||
cityWorkshop = probe.CityWorkshop,
|
||||
cityWall = probe.CityWall,
|
||||
cityTerritoryCount = probe.CityTerritoryCount,
|
||||
targetCityId = probe.TargetCityId,
|
||||
targetCityOwnerId = probe.TargetCityOwnerId,
|
||||
targetCityLevel = probe.TargetCityLevel
|
||||
targetCityLevel = probe.TargetCityLevel,
|
||||
targetCityLevelExp = probe.TargetCityLevelExp,
|
||||
targetCityLevelUpPoint = probe.TargetCityLevelUpPoint,
|
||||
targetCityParkCount = probe.TargetCityParkCount,
|
||||
targetCityWorkshop = probe.TargetCityWorkshop,
|
||||
targetCityWall = probe.TargetCityWall,
|
||||
targetCityTerritoryCount = probe.TargetCityTerritoryCount
|
||||
};
|
||||
}
|
||||
|
||||
@ -751,23 +1001,79 @@ namespace Logic.AI.Director
|
||||
cultureDelta = after.PlayerCulture - before.PlayerCulture,
|
||||
cultureCardDelta = after.CultureCardCount - before.CultureCardCount,
|
||||
scoreDelta = after.PlayerScore - before.PlayerScore,
|
||||
sightGridDelta = after.SightGridCount - before.SightGridCount,
|
||||
cityDelta = after.CityCount - before.CityCount,
|
||||
cityLostDelta = Mathf.Max(0, before.CityCount - after.CityCount),
|
||||
cityGainedDelta = Mathf.Max(0, after.CityCount - before.CityCount),
|
||||
cityOwnershipSignatureChanged = before.OwnedCityIdsSignature != after.OwnedCityIdsSignature,
|
||||
capitalOwnershipSignatureChanged = before.CapitalCityIdsSignature != after.CapitalCityIdsSignature,
|
||||
unitDelta = after.UnitCount - before.UnitCount,
|
||||
heroDelta = after.HeroCount - before.HeroCount,
|
||||
selectedHeroDelta = after.SelectedHeroCount - before.SelectedHeroCount,
|
||||
maxHeroDelta = after.MaxHeroCount - before.MaxHeroCount,
|
||||
heroTaskDelta = after.HeroTaskCount - before.HeroTaskCount,
|
||||
readyHeroTaskDelta = after.ReadyHeroTaskCount - before.ReadyHeroTaskCount,
|
||||
forcedHeroTaskDelta = after.ForcedHeroTaskCount - before.ForcedHeroTaskCount,
|
||||
heroTaskProgressDelta = after.HeroTaskProgress - before.HeroTaskProgress,
|
||||
selfMilitaryDelta = after.SelfMilitary - before.SelfMilitary,
|
||||
enemyMilitaryDelta = after.EnemyMilitary - before.EnemyMilitary,
|
||||
criticalCityThreatDelta = after.CriticalCityThreatCount - before.CriticalCityThreatCount,
|
||||
cityThreatDelta = after.CityThreatCount - before.CityThreatCount,
|
||||
capitalThreatDelta = after.CapitalThreatCount - before.CapitalThreatCount,
|
||||
criticalCapitalThreatDelta = after.CriticalCapitalThreatCount - before.CriticalCapitalThreatCount,
|
||||
emptyThreatenedCityDelta = after.EmptyThreatenedCityCount - before.EmptyThreatenedCityCount,
|
||||
cityThreatSignatureChanged = before.ThreatenedCityIdsSignature != after.ThreatenedCityIdsSignature,
|
||||
criticalCityThreatSignatureChanged = before.CriticalThreatenedCityIdsSignature != after.CriticalThreatenedCityIdsSignature,
|
||||
cityThreatResolved = before.CityThreatCount > 0
|
||||
&& (after.CriticalCityThreatCount < before.CriticalCityThreatCount
|
||||
|| after.CityThreatCount < before.CityThreatCount
|
||||
|| after.MaxCityDangerScore + 0.001f < before.MaxCityDangerScore),
|
||||
cityThreatWorsened = after.CriticalCityThreatCount > before.CriticalCityThreatCount
|
||||
|| after.CityThreatCount > before.CityThreatCount
|
||||
|| after.EmptyThreatenedCityCount > before.EmptyThreatenedCityCount
|
||||
|| after.MaxCityDangerScore > before.MaxCityDangerScore + 0.001f,
|
||||
maxCityDangerScoreDelta = after.MaxCityDangerScore - before.MaxCityDangerScore,
|
||||
unitMoved = before.UnitGridId != after.UnitGridId,
|
||||
unitHealthDelta = after.UnitHealth - before.UnitHealth,
|
||||
unitDied = before.UnitAlive && !after.UnitAlive,
|
||||
unitSkillDelta = after.UnitSkillCount - before.UnitSkillCount,
|
||||
unitSkillSignatureChanged = before.UnitSkillSignature != after.UnitSkillSignature,
|
||||
unitGridResourceChanged = before.UnitGridResource != after.UnitGridResource,
|
||||
unitGridResourceUnderBuildingChanged = before.UnitGridResourceUnderBuilding != after.UnitGridResourceUnderBuilding,
|
||||
unitGridBuildingLevelDelta = after.UnitGridBuildingLevel - before.UnitGridBuildingLevel,
|
||||
unitGridSpTypeChanged = before.UnitGridSpType != after.UnitGridSpType,
|
||||
targetUnitHealthDelta = after.TargetUnitHealth - before.TargetUnitHealth,
|
||||
targetUnitDied = before.TargetUnitAlive && !after.TargetUnitAlive,
|
||||
targetUnitSkillDelta = after.TargetUnitSkillCount - before.TargetUnitSkillCount,
|
||||
targetUnitSkillSignatureChanged = before.TargetUnitSkillSignature != after.TargetUnitSkillSignature,
|
||||
targetUnitGridResourceChanged = before.TargetUnitGridResource != after.TargetUnitGridResource,
|
||||
targetUnitGridResourceUnderBuildingChanged = before.TargetUnitGridResourceUnderBuilding != after.TargetUnitGridResourceUnderBuilding,
|
||||
targetUnitGridBuildingLevelDelta = after.TargetUnitGridBuildingLevel - before.TargetUnitGridBuildingLevel,
|
||||
targetUnitGridSpTypeChanged = before.TargetUnitGridSpType != after.TargetUnitGridSpType,
|
||||
actionGridResourceChanged = before.ActionGridResource != after.ActionGridResource,
|
||||
actionGridResourceUnderBuildingChanged = before.ActionGridResourceUnderBuilding != after.ActionGridResourceUnderBuilding,
|
||||
actionGridBuildingLevelDelta = after.ActionGridBuildingLevel - before.ActionGridBuildingLevel,
|
||||
actionGridSpTypeChanged = before.ActionGridSpType != after.ActionGridSpType,
|
||||
targetActionGridResourceChanged = before.TargetActionGridResource != after.TargetActionGridResource,
|
||||
targetActionGridResourceUnderBuildingChanged = before.TargetActionGridResourceUnderBuilding != after.TargetActionGridResourceUnderBuilding,
|
||||
targetActionGridBuildingLevelDelta = after.TargetActionGridBuildingLevel - before.TargetActionGridBuildingLevel,
|
||||
targetActionGridSpTypeChanged = before.TargetActionGridSpType != after.TargetActionGridSpType,
|
||||
cityOwnerChanged = before.CityOwnerId != after.CityOwnerId,
|
||||
targetCityOwnerChanged = before.TargetCityOwnerId != after.TargetCityOwnerId,
|
||||
cityLevelDelta = after.CityLevel - before.CityLevel,
|
||||
targetCityLevelDelta = after.TargetCityLevel - before.TargetCityLevel
|
||||
cityLevelExpDelta = after.CityLevelExp - before.CityLevelExp,
|
||||
cityLevelUpPointDelta = after.CityLevelUpPoint - before.CityLevelUpPoint,
|
||||
cityParkDelta = after.CityParkCount - before.CityParkCount,
|
||||
cityWorkshopChanged = before.CityWorkshop != after.CityWorkshop,
|
||||
cityWallChanged = before.CityWall != after.CityWall,
|
||||
cityTerritoryDelta = after.CityTerritoryCount - before.CityTerritoryCount,
|
||||
targetCityLevelDelta = after.TargetCityLevel - before.TargetCityLevel,
|
||||
targetCityLevelExpDelta = after.TargetCityLevelExp - before.TargetCityLevelExp,
|
||||
targetCityLevelUpPointDelta = after.TargetCityLevelUpPoint - before.TargetCityLevelUpPoint,
|
||||
targetCityParkDelta = after.TargetCityParkCount - before.TargetCityParkCount,
|
||||
targetCityWorkshopChanged = before.TargetCityWorkshop != after.TargetCityWorkshop,
|
||||
targetCityWallChanged = before.TargetCityWall != after.TargetCityWall,
|
||||
targetCityTerritoryDelta = after.TargetCityTerritoryCount - before.TargetCityTerritoryCount
|
||||
};
|
||||
}
|
||||
|
||||
@ -888,29 +1194,77 @@ namespace Logic.AI.Director
|
||||
public int playerCulture;
|
||||
public int cultureCardCount;
|
||||
public int playerScore;
|
||||
public int sightGridCount;
|
||||
public int cityCount;
|
||||
public string ownedCityIdsSignature;
|
||||
public string capitalCityIdsSignature;
|
||||
public int unitCount;
|
||||
public int heroCount;
|
||||
public int selectedHeroCount;
|
||||
public int maxHeroCount;
|
||||
public int heroTaskCount;
|
||||
public int readyHeroTaskCount;
|
||||
public int forcedHeroTaskCount;
|
||||
public int heroTaskProgress;
|
||||
public float selfMilitary;
|
||||
public float enemyMilitary;
|
||||
public int criticalCityThreatCount;
|
||||
public int cityThreatCount;
|
||||
public int capitalThreatCount;
|
||||
public int criticalCapitalThreatCount;
|
||||
public int emptyThreatenedCityCount;
|
||||
public string threatenedCityIdsSignature;
|
||||
public string criticalThreatenedCityIdsSignature;
|
||||
public float maxCityDangerScore;
|
||||
public string strategicPosture;
|
||||
public uint unitId;
|
||||
public bool unitAlive;
|
||||
public int unitHealth;
|
||||
public uint unitGridId;
|
||||
public int unitSkillCount;
|
||||
public string unitSkillSignature;
|
||||
public string unitGridResource;
|
||||
public string unitGridResourceUnderBuilding;
|
||||
public int unitGridBuildingLevel;
|
||||
public string unitGridSpType;
|
||||
public uint targetUnitId;
|
||||
public bool targetUnitAlive;
|
||||
public int targetUnitHealth;
|
||||
public uint targetUnitGridId;
|
||||
public int targetUnitSkillCount;
|
||||
public string targetUnitSkillSignature;
|
||||
public string targetUnitGridResource;
|
||||
public string targetUnitGridResourceUnderBuilding;
|
||||
public int targetUnitGridBuildingLevel;
|
||||
public string targetUnitGridSpType;
|
||||
public uint actionGridId;
|
||||
public string actionGridResource;
|
||||
public string actionGridResourceUnderBuilding;
|
||||
public int actionGridBuildingLevel;
|
||||
public string actionGridSpType;
|
||||
public uint targetActionGridId;
|
||||
public string targetActionGridResource;
|
||||
public string targetActionGridResourceUnderBuilding;
|
||||
public int targetActionGridBuildingLevel;
|
||||
public string targetActionGridSpType;
|
||||
public uint cityId;
|
||||
public uint cityOwnerId;
|
||||
public int cityLevel;
|
||||
public int cityLevelExp;
|
||||
public int cityLevelUpPoint;
|
||||
public int cityParkCount;
|
||||
public bool cityWorkshop;
|
||||
public bool cityWall;
|
||||
public int cityTerritoryCount;
|
||||
public uint targetCityId;
|
||||
public uint targetCityOwnerId;
|
||||
public int targetCityLevel;
|
||||
public int targetCityLevelExp;
|
||||
public int targetCityLevelUpPoint;
|
||||
public int targetCityParkCount;
|
||||
public bool targetCityWorkshop;
|
||||
public bool targetCityWall;
|
||||
public int targetCityTerritoryCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@ -922,23 +1276,73 @@ namespace Logic.AI.Director
|
||||
public int cultureDelta;
|
||||
public int cultureCardDelta;
|
||||
public int scoreDelta;
|
||||
public int sightGridDelta;
|
||||
public int cityDelta;
|
||||
public int cityLostDelta;
|
||||
public int cityGainedDelta;
|
||||
public bool cityOwnershipSignatureChanged;
|
||||
public bool capitalOwnershipSignatureChanged;
|
||||
public int unitDelta;
|
||||
public int heroDelta;
|
||||
public int selectedHeroDelta;
|
||||
public int maxHeroDelta;
|
||||
public int heroTaskDelta;
|
||||
public int readyHeroTaskDelta;
|
||||
public int forcedHeroTaskDelta;
|
||||
public int heroTaskProgressDelta;
|
||||
public float selfMilitaryDelta;
|
||||
public float enemyMilitaryDelta;
|
||||
public int criticalCityThreatDelta;
|
||||
public int cityThreatDelta;
|
||||
public int capitalThreatDelta;
|
||||
public int criticalCapitalThreatDelta;
|
||||
public int emptyThreatenedCityDelta;
|
||||
public bool cityThreatSignatureChanged;
|
||||
public bool criticalCityThreatSignatureChanged;
|
||||
public bool cityThreatResolved;
|
||||
public bool cityThreatWorsened;
|
||||
public float maxCityDangerScoreDelta;
|
||||
public bool unitMoved;
|
||||
public int unitHealthDelta;
|
||||
public bool unitDied;
|
||||
public int unitSkillDelta;
|
||||
public bool unitSkillSignatureChanged;
|
||||
public bool unitGridResourceChanged;
|
||||
public bool unitGridResourceUnderBuildingChanged;
|
||||
public int unitGridBuildingLevelDelta;
|
||||
public bool unitGridSpTypeChanged;
|
||||
public int targetUnitHealthDelta;
|
||||
public bool targetUnitDied;
|
||||
public int targetUnitSkillDelta;
|
||||
public bool targetUnitSkillSignatureChanged;
|
||||
public bool targetUnitGridResourceChanged;
|
||||
public bool targetUnitGridResourceUnderBuildingChanged;
|
||||
public int targetUnitGridBuildingLevelDelta;
|
||||
public bool targetUnitGridSpTypeChanged;
|
||||
public bool actionGridResourceChanged;
|
||||
public bool actionGridResourceUnderBuildingChanged;
|
||||
public int actionGridBuildingLevelDelta;
|
||||
public bool actionGridSpTypeChanged;
|
||||
public bool targetActionGridResourceChanged;
|
||||
public bool targetActionGridResourceUnderBuildingChanged;
|
||||
public int targetActionGridBuildingLevelDelta;
|
||||
public bool targetActionGridSpTypeChanged;
|
||||
public bool cityOwnerChanged;
|
||||
public bool targetCityOwnerChanged;
|
||||
public int cityLevelDelta;
|
||||
public int cityLevelExpDelta;
|
||||
public int cityLevelUpPointDelta;
|
||||
public int cityParkDelta;
|
||||
public bool cityWorkshopChanged;
|
||||
public bool cityWallChanged;
|
||||
public int cityTerritoryDelta;
|
||||
public int targetCityLevelDelta;
|
||||
public int targetCityLevelExpDelta;
|
||||
public int targetCityLevelUpPointDelta;
|
||||
public int targetCityParkDelta;
|
||||
public bool targetCityWorkshopChanged;
|
||||
public bool targetCityWallChanged;
|
||||
public int targetCityTerritoryDelta;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@ -1001,6 +1405,14 @@ namespace Logic.AI.Director
|
||||
public uint unitLevel;
|
||||
public string wonderType;
|
||||
public string skillType;
|
||||
public string actorUnitType;
|
||||
public string actorGiantType;
|
||||
public uint actorUnitLevel;
|
||||
public string actorChessType;
|
||||
public string targetActorUnitType;
|
||||
public string targetActorGiantType;
|
||||
public uint targetActorUnitLevel;
|
||||
public string targetActorChessType;
|
||||
public string mainObjectType;
|
||||
public uint playerId;
|
||||
public uint unitId;
|
||||
@ -1120,6 +1532,7 @@ namespace Logic.AI.Director
|
||||
{
|
||||
public static bool Enabled => false;
|
||||
public static string CurrentLogPath => string.Empty;
|
||||
public static string CurrentLogPathOrEmpty => string.Empty;
|
||||
public static void SetEnabled(bool enabled) { }
|
||||
public static void Enable() { }
|
||||
public static void Disable() { }
|
||||
|
||||
@ -111,7 +111,7 @@ namespace Logic.AI.Director
|
||||
}
|
||||
|
||||
var priority = rule.Priority + GetContextPriorityBonus(ctx, state, rule, targetUnit);
|
||||
return ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroPlaybook, $"{state.GiantType}:{rule.Reason}", priority);
|
||||
return ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroPlaybook, $"{state.GiantType}:{rule.RuleId}", priority);
|
||||
}
|
||||
|
||||
private float GetContextPriorityBonus(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule, UnitData target)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using TH1_Logic.Action;
|
||||
using UnityEngine;
|
||||
@ -11,7 +12,12 @@ namespace Logic.AI.Director
|
||||
private readonly AIDirectorWorldCacheBuilder _cacheBuilder = new();
|
||||
private readonly AIDirectorHeroRuleEvaluator _heroEvaluator = new();
|
||||
|
||||
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null)
|
||||
public AIDirectorDecision Decide(
|
||||
MapData map,
|
||||
PlayerData player,
|
||||
AIDirectorConfig config = null,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var decision = new AIDirectorDecision();
|
||||
@ -23,7 +29,7 @@ namespace Logic.AI.Director
|
||||
return decision;
|
||||
}
|
||||
|
||||
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault());
|
||||
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys, blockedIntentKeys);
|
||||
ctx.Cache = _cacheBuilder.Build(ctx);
|
||||
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
|
||||
_cacheBuilder.BuildUnitOpportunities(ctx);
|
||||
@ -44,8 +50,10 @@ namespace Logic.AI.Director
|
||||
|
||||
if (TryEmergencyLane(ctx, decision, out var candidate)
|
||||
|| TryHeroManagementLane(ctx, decision, out candidate)
|
||||
|| TryTacticLane(ctx, decision, out candidate, true)
|
||||
|| TryExpansionLane(ctx, decision, out candidate)
|
||||
|| TryHeroPlaybookLane(ctx, decision, out candidate)
|
||||
|| TryTacticLane(ctx, decision, out candidate)
|
||||
|| TryTacticLane(ctx, decision, out candidate, false)
|
||||
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|
||||
|| TryFrontLane(ctx, decision, out candidate)
|
||||
|| TryGrowthLane(ctx, decision, out candidate)
|
||||
@ -65,9 +73,15 @@ namespace Logic.AI.Director
|
||||
return decision;
|
||||
}
|
||||
|
||||
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
|
||||
public bool TryDecide(
|
||||
MapData map,
|
||||
PlayerData player,
|
||||
out AIDirectorActionCandidate candidate,
|
||||
AIDirectorConfig config = null,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
{
|
||||
var decision = Decide(map, player, config);
|
||||
var decision = Decide(map, player, config, blockedActionKeys, blockedIntentKeys);
|
||||
candidate = decision.Candidate;
|
||||
return decision.HasAction;
|
||||
}
|
||||
@ -86,7 +100,15 @@ 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 urgentCityAction = TryEmergencyCityAction(ctx, decision, threat);
|
||||
if (IsUrgentCityDefenseAction(urgentCityAction))
|
||||
{
|
||||
candidate = urgentCityAction;
|
||||
decision.AddTrace($"Emergency: urgent city action for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var attack = TryEmergencyAttack(ctx, decision, threat);
|
||||
if (attack.IsValid)
|
||||
@ -116,16 +138,129 @@ 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;
|
||||
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++;
|
||||
|
||||
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 targetRiskPenalty = ExpansionTargetRiskPenalty(ctx, target, grid);
|
||||
var score = ScoreExpansionTarget(ctx, target) + 420f - targetRiskPenalty;
|
||||
if (score <= 0f) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Capture.{target.TargetType}", score);
|
||||
AddTerms(current, ("target", ScoreExpansionTarget(ctx, target)), ("capture", 420f), ("targetRisk", -targetRiskPenalty));
|
||||
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;
|
||||
if (IsIntentBudgetReached(ctx, AIDirectorLane.Expansion, ctx.Config.MaxExpansionMoveIntentsPerTurn)) return best;
|
||||
var intentKey = BuildIntentKey(AIDirectorLane.Expansion, $"Move.{target.TargetType}", target?.Grid?.Id ?? 0);
|
||||
if (IsIntentBlocked(ctx, intentKey)) return best;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var startGrid)) continue;
|
||||
if (target?.Grid == null || startGrid.Id == target.Grid.Id) continue;
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target.Grid);
|
||||
if (ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance)) continue;
|
||||
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, target.Grid);
|
||||
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
|
||||
if (endGrid == null) continue;
|
||||
|
||||
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, target.Grid);
|
||||
if (endDistance >= startDistance && startDistance > 1) continue;
|
||||
|
||||
var targetScore = ScoreExpansionTarget(ctx, target);
|
||||
var isUrgentSecondCity = ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold;
|
||||
var progressScore = Mathf.Max(0, startDistance - endDistance) * (isUrgentSecondCity ? 105f : 90f);
|
||||
var reachBonus = endDistance == 0 ? 380f : endDistance == 1 ? 190f : 0f;
|
||||
var nearConversionBonus = startDistance <= 2 ? 220f : startDistance <= 3 ? 120f : isUrgentSecondCity && startDistance <= 4 ? 60f : 0f;
|
||||
var mobilityBonus = UnitMobilityExpansionBonus(unit);
|
||||
var longDragPenalty = startDistance >= 5 && startDistance - endDistance <= 1 ? 160f : 0f;
|
||||
var safetyPenalty = GridThreat(ctx, endGrid) * (target.TargetType == AIDirectorDevelopmentTargetType.Village ? 0.25f : 0.5f);
|
||||
var targetRiskPenalty = ExpansionTargetRiskPenalty(ctx, target, endGrid);
|
||||
var score = targetScore + progressScore + reachBonus + nearConversionBonus + mobilityBonus - longDragPenalty - endDistance * 28f - safetyPenalty - targetRiskPenalty;
|
||||
if (score <= 0f) continue;
|
||||
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Move.{target.TargetType}", score);
|
||||
AddTerms(
|
||||
current,
|
||||
("target", targetScore),
|
||||
("progress", progressScore),
|
||||
("reach", reachBonus),
|
||||
("nearConversion", nearConversionBonus),
|
||||
("mobility", mobilityBonus),
|
||||
("longDrag", -longDragPenalty),
|
||||
("distance", -endDistance * 28f),
|
||||
("threat", -safetyPenalty),
|
||||
("targetRisk", -targetRiskPenalty));
|
||||
current.Unit = current.Unit ?? unit;
|
||||
current.TargetGrid = current.TargetGrid ?? target.Grid;
|
||||
current.IntentKey = intentKey;
|
||||
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;
|
||||
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, "守军攻击城市威胁", score);
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.AttackCityThreat", score);
|
||||
AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue));
|
||||
candidate.Unit = candidate.Unit ?? defender;
|
||||
candidate.TargetUnit = candidate.TargetUnit ?? target;
|
||||
@ -141,6 +276,9 @@ namespace Logic.AI.Director
|
||||
private AIDirectorActionCandidate TryEmergencyMove(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
|
||||
{
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
if (IsIntentBudgetReached(ctx, AIDirectorLane.Emergency, ctx.Config.MaxEmergencyMoveIntentsPerTurn)) return best;
|
||||
var intentKey = BuildIntentKey(AIDirectorLane.Emergency, "MoveToCity", threat?.City?.Id ?? threat?.CityGrid?.Id ?? 0);
|
||||
if (IsIntentBlocked(ctx, intentKey)) return best;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
|
||||
@ -148,15 +286,21 @@ namespace Logic.AI.Director
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
|
||||
var unitGrid = unit.Grid(ctx.Map);
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, unitGrid, threat.CityGrid);
|
||||
if (startDistance > ctx.Config.MaxEmergencyRescueDistance && !threat.IsCritical) continue;
|
||||
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
|
||||
if (!IsUsefulMoveToward(ctx, unitGrid, endGrid, threat.CityGrid)) continue;
|
||||
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, threat.CityGrid);
|
||||
var unitPower = AIDirectorMath.UnitPower(unit);
|
||||
var distancePenalty = -startDistance * 12f;
|
||||
var progressBonus = Mathf.Max(0, startDistance - endDistance) * 55f;
|
||||
var distancePenalty = -endDistance * 14f;
|
||||
var heroBonus = unit.TreatedAsHero(ctx.Map, unit) ? 60f : 0f;
|
||||
var score = 900f + threat.DangerScore * 20f + unitPower + distancePenalty + heroBonus;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "回防城市", score);
|
||||
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("distance", distancePenalty), ("hero", heroBonus));
|
||||
var score = 900f + threat.DangerScore * 20f + unitPower + progressBonus + distancePenalty + heroBonus;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.MoveToCity", score);
|
||||
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("progress", progressBonus), ("distance", distancePenalty), ("hero", heroBonus));
|
||||
candidate.Unit = candidate.Unit ?? unit;
|
||||
candidate.City = candidate.City ?? threat.City;
|
||||
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
|
||||
candidate.IntentKey = intentKey;
|
||||
RecordCandidate(ctx, decision, "EmergencyMove", candidate, action == null ? "NoMoveAction" : (candidate.IsValid ? null : "CheckCanFailed"));
|
||||
best = MaxCandidate(best, candidate);
|
||||
}
|
||||
@ -173,7 +317,7 @@ namespace Logic.AI.Director
|
||||
{
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
if (score <= 0f) continue;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "危险城市生产或防御", score + 250f);
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.CityAction", score + 250f);
|
||||
AddTerms(candidate, ("cityGrowth", score), ("emergencyBonus", 250f));
|
||||
candidate.City = candidate.City ?? threat.City;
|
||||
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
|
||||
@ -189,7 +333,7 @@ namespace Logic.AI.Director
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
|
||||
var selectHero = ctx.ActionIndex.FindHeroManagementAction(PlayerActionType.SelectHero);
|
||||
candidate = ctx.ActionIndex.Candidate(selectHero, AIDirectorLane.HeroManagement, "选择英雄", 890f);
|
||||
candidate = ctx.ActionIndex.Candidate(selectHero, AIDirectorLane.HeroManagement, "HeroManagement.SelectHero", 890f);
|
||||
AddTerms(candidate, ("base", 890f));
|
||||
RecordCandidate(ctx, decision, "SelectHero", candidate, selectHero == null ? "NoAction" : null);
|
||||
if (candidate.IsValid)
|
||||
@ -198,8 +342,31 @@ 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)
|
||||
{
|
||||
candidate = heroSpawn;
|
||||
decision.AddTrace($"HeroManagement: spawn hero {candidate.AIAction?.ActionLogic?.ActionId?.GiantType}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (heroSlotCard.IsValid)
|
||||
{
|
||||
candidate = heroSlotCard;
|
||||
decision.AddTrace($"HeroManagement: buy hero slot card {candidate.ActionId?.CultureCardType}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var finishTask = ctx.ActionIndex.FindBestHeroTaskFinish(ctx.Player);
|
||||
candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "推进最低等级英雄任务", 870f);
|
||||
candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "HeroManagement.FinishLowestTask", 870f);
|
||||
AddTerms(candidate, ("base", 870f));
|
||||
RecordCandidate(ctx, decision, "FinishHeroTask", candidate, finishTask == null ? "NoAction" : null);
|
||||
if (candidate.IsValid)
|
||||
@ -211,6 +378,77 @@ namespace Logic.AI.Director
|
||||
return false;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate TryHeroSlotCultureCard(AIDirectorContext ctx, AIDirectorDecision decision)
|
||||
{
|
||||
var action = ctx.ActionIndex.FindHeroSlotCultureCard(ctx.Player);
|
||||
var score = ScoreCultureCard(ctx, action?.ActionLogic?.ActionId?.CultureCardType ?? CultureCardType.None);
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.BuyHeroSlotCard", score);
|
||||
AddTerms(candidate, ("heroSlotCard", score));
|
||||
RecordCandidate(ctx, decision, "BuyHeroSlotCard", candidate, action == null ? "NoAction" : (candidate.IsValid ? null : "CheckCanFailed"));
|
||||
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;
|
||||
foreach (var plan in ctx.Cache.CityPlans)
|
||||
{
|
||||
if (plan?.City == null) continue;
|
||||
foreach (var action in ctx.ActionIndex.GetCityActions(plan.City))
|
||||
{
|
||||
if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue;
|
||||
|
||||
var cityPlanBonus = (plan.Priority > 0f ? plan.Priority : 0f) * 0.08f;
|
||||
var roleBonus = plan.Kind switch
|
||||
{
|
||||
AIDirectorCityPlanKind.EmergencyDefense => 40f,
|
||||
AIDirectorCityPlanKind.Mobilize => 30f,
|
||||
AIDirectorCityPlanKind.Frontline => 25f,
|
||||
_ => 0f
|
||||
};
|
||||
var priority = 880f + cityPlanBonus + roleBonus;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", priority);
|
||||
AddTerms(current, ("base", 880f), ("cityPlan", cityPlanBonus), ("role", roleBonus));
|
||||
current.City = current.City ?? plan.City;
|
||||
current.TargetGrid = current.TargetGrid ?? plan.CityGrid;
|
||||
RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed");
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
}
|
||||
|
||||
if (best.IsValid) return best;
|
||||
|
||||
foreach (var action in ctx.ActionIndex.CityActions)
|
||||
{
|
||||
if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", 880f);
|
||||
AddTerms(current, ("base", 880f));
|
||||
RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed");
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool IsSelectedHeroSpawnAction(PlayerData player, AIActionBase action)
|
||||
{
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null) return false;
|
||||
return id.ActionType == CommonActionType.TrainUnit
|
||||
&& id.UnitType == UnitType.Giant
|
||||
&& id.GiantType != GiantType.None
|
||||
&& player?.PlayerHeroData != null
|
||||
&& player.PlayerHeroData.HasHero(id.GiantType);
|
||||
}
|
||||
|
||||
private bool TryHeroPlaybookLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
@ -241,43 +479,49 @@ namespace Logic.AI.Director
|
||||
if (state.HealthRatio <= ctx.Config.HeroLowHealthRatio)
|
||||
{
|
||||
var recover = ctx.ActionIndex.FindUnitAction(state.Hero, UnitActionType.Recover);
|
||||
var recoverCandidate = ctx.ActionIndex.Candidate(recover, AIDirectorLane.HeroPlaybook, "英雄低血恢复", 840f);
|
||||
var recoverCandidate = ctx.ActionIndex.Candidate(recover, AIDirectorLane.HeroPlaybook, "HeroPlaybook.LowHpRecover", 840f);
|
||||
AddTerms(recoverCandidate, ("base", 840f));
|
||||
if (recoverCandidate.IsValid) return recoverCandidate;
|
||||
|
||||
var retreatTarget = FindSafestSelfCityGrid(ctx, state.Hero);
|
||||
var move = ctx.ActionIndex.FindBestMove(state.Hero, retreatTarget);
|
||||
var moveCandidate = ctx.ActionIndex.Candidate(move, AIDirectorLane.HeroPlaybook, "英雄低血撤退", 820f);
|
||||
var moveCandidate = ctx.ActionIndex.Candidate(move, AIDirectorLane.HeroPlaybook, "HeroPlaybook.LowHpRetreat", 820f);
|
||||
AddTerms(moveCandidate, ("base", 820f));
|
||||
if (moveCandidate.IsValid) return moveCandidate;
|
||||
}
|
||||
|
||||
var attack = ctx.ActionIndex.FindBestAttack(state.Hero);
|
||||
var targetValue = UnitTargetValue(ctx, attack?.Param?.TargetUnitData);
|
||||
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "英雄通用高价值攻击", 760f + targetValue);
|
||||
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue));
|
||||
if (attackCandidate.IsValid) return attackCandidate;
|
||||
var attack = FindBestUsefulAttack(ctx, state.Hero);
|
||||
var attackScore = ScoreAttackAction(ctx, attack);
|
||||
var targetValue = attackScore > 0f ? UnitTargetValue(ctx, attack?.Param?.TargetUnitData) : 0f;
|
||||
var roleAttackBonus = state.Role is AIDirectorHeroRole.Assassin or AIDirectorHeroRole.Vanguard ? 90f : 0f;
|
||||
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericHighValueAttack", 760f + targetValue + roleAttackBonus);
|
||||
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("roleAttack", roleAttackBonus), ("attackScore", attackScore));
|
||||
if (attackCandidate.IsValid && attackScore > 0f) return attackCandidate;
|
||||
|
||||
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
|
||||
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
|
||||
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "英雄站位到战线", 650f);
|
||||
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "HeroPlaybook.MoveToFront", 650f);
|
||||
AddTerms(frontCandidate, ("base", 650f));
|
||||
return frontCandidate;
|
||||
}
|
||||
|
||||
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate, bool priorityOnly)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
var minScore = priorityOnly ? ctx.Config.PriorityTacticScore : 0f;
|
||||
var reasonPrefix = priorityOnly ? "PriorityTactic" : "Tactic";
|
||||
|
||||
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, "局部战斗攻击", score);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.LocalBattleAttack", score);
|
||||
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
|
||||
if (priorityOnly && (score < minScore || !IsPriorityTacticAction(ctx, action, attackScore))) continue;
|
||||
RecordCandidate(ctx, decision, "LocalBattle", current, action == null ? "NoAttackAction" : null);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
@ -285,8 +529,10 @@ namespace Logic.AI.Director
|
||||
foreach (var action in ctx.ActionIndex.AttackActions)
|
||||
{
|
||||
var score = ScoreAttackAction(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "普通攻击收益", score);
|
||||
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;
|
||||
RecordCandidate(ctx, decision, "AttackAction", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
@ -317,19 +563,28 @@ namespace Logic.AI.Director
|
||||
private bool TryFrontLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
if (IsIntentBudgetReached(ctx, AIDirectorLane.Front, ctx.Config.MaxFrontMoveIntentsPerTurn)) return false;
|
||||
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var front in ctx.Cache.Fronts)
|
||||
{
|
||||
if (front.FrontType == AIDirectorFrontType.Development) continue;
|
||||
var target = ResolveFrontTarget(front);
|
||||
if (target == null) continue;
|
||||
var intentKey = BuildIntentKey(AIDirectorLane.Front, front.FrontType.ToString(), target.Id);
|
||||
if (IsIntentBlocked(ctx, intentKey)) continue;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (ShouldSkipFrontMove(ctx, unit, front)) continue;
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, target);
|
||||
var startGrid = unit.Grid(ctx.Map);
|
||||
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
|
||||
if (!IsUsefulMoveToward(ctx, startGrid, endGrid, target)) continue;
|
||||
var score = ScoreFrontMove(ctx, unit, action, front);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Front, $"{front.FrontType} 战线移动", score);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Front, $"Front.{front.FrontType}.Move", score);
|
||||
AddTerms(current, ("frontMove", score));
|
||||
current.IntentKey = intentKey;
|
||||
RecordCandidate(ctx, decision, front.FrontType.ToString(), current, action == null ? "NoMoveAction" : null);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
@ -350,7 +605,7 @@ namespace Logic.AI.Director
|
||||
{
|
||||
var plan = FindCityPlanByAction(ctx, action);
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "城市发展", score);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.City", score);
|
||||
AddTerms(current, ("cityGrowth", score));
|
||||
RecordCandidate(ctx, decision, "CityGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
@ -359,7 +614,7 @@ namespace Logic.AI.Director
|
||||
foreach (var action in ctx.ActionIndex.GridActions)
|
||||
{
|
||||
var score = ScoreGridGrowth(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "地块发展", score);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.Grid", score);
|
||||
AddTerms(current, ("gridGrowth", score));
|
||||
RecordCandidate(ctx, decision, "GridGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
@ -368,7 +623,7 @@ namespace Logic.AI.Director
|
||||
foreach (var action in ctx.ActionIndex.PlayerActions)
|
||||
{
|
||||
var score = ScorePlayerGrowth(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "玩家发展", score);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.Player", score);
|
||||
AddTerms(current, ("playerGrowth", score));
|
||||
RecordCandidate(ctx, decision, "PlayerGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
@ -383,7 +638,7 @@ namespace Logic.AI.Director
|
||||
private bool TryFallback(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
var action = ctx.ActionIndex.FindBestFallback();
|
||||
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, "兜底合法动作", 1f, true);
|
||||
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, "Fallback.LegalAction", 1f, true);
|
||||
AddTerms(candidate, ("base", 1f));
|
||||
RecordCandidate(ctx, decision, "Fallback", candidate, action == null ? "NoFallbackAction" : null);
|
||||
if (!candidate.IsValid) return false;
|
||||
@ -397,6 +652,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);
|
||||
@ -408,6 +664,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;
|
||||
@ -437,12 +726,20 @@ namespace Logic.AI.Director
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var city = action.Param.CityData;
|
||||
var score = 360f + (plan?.Priority ?? 0f) * 0.2f;
|
||||
var threat = plan?.Threat;
|
||||
|
||||
var kind = plan?.Kind ?? AIDirectorCityPlanKind.BacklineGrowth;
|
||||
if (kind == AIDirectorCityPlanKind.EmergencyDefense)
|
||||
{
|
||||
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, plan.Threat);
|
||||
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, threat);
|
||||
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 220f;
|
||||
if (threat != null)
|
||||
{
|
||||
if (threat.DefenderPower <= 0f && id.ActionType == CommonActionType.TrainUnit) score += 240f;
|
||||
if (threat.DefenderPower <= 0f && id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 180f;
|
||||
if (threat.IsCapital && id.ActionType == CommonActionType.TrainUnit) score += 120f;
|
||||
if (threat.CanBeThreatenedNextTurn && id.ActionType == CommonActionType.TrainUnit) score += 100f;
|
||||
}
|
||||
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score -= 300f;
|
||||
}
|
||||
else if (kind is AIDirectorCityPlanKind.Mobilize or AIDirectorCityPlanKind.Frontline)
|
||||
@ -454,7 +751,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)
|
||||
@ -519,10 +820,12 @@ namespace Logic.AI.Director
|
||||
|
||||
private float ScoreCultureCard(AIDirectorContext ctx, CultureCardType card)
|
||||
{
|
||||
var score = 300f;
|
||||
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Defense && card == CultureCardType.AdvancedMilitaryEnhance) score += 120f;
|
||||
if (ctx.Cache.SelfHeroes.Count >= 1 && card is CultureCardType.SecondHero or CultureCardType.ThirdHero or CultureCardType.AdvancedHeroEnhance) score += 100f;
|
||||
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Development && card == CultureCardType.AdvancedEconomyEnhance) score += 80f;
|
||||
if (!AIActionGenerator.IsAIHeroSlotCultureCard(ctx.Player, card)) return 0f;
|
||||
var heroData = ctx.Player?.PlayerHeroData;
|
||||
var score = 860f;
|
||||
if (card == CultureCardType.SecondHero && heroData?.MaxHeroCount == 1) score += 80f;
|
||||
if (card == CultureCardType.ThirdHero && heroData?.MaxHeroCount == 2) score += 70f;
|
||||
if (ctx.Cache.SelfHeroes.Count <= 0) score -= 120f;
|
||||
return score;
|
||||
}
|
||||
|
||||
@ -625,17 +928,177 @@ namespace Logic.AI.Director
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) return true;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit) && front.FrontType != AIDirectorFrontType.Defense) return true;
|
||||
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.LowHealthRatio && front.FrontType == AIDirectorFrontType.Attack) return true;
|
||||
if (IsStandingOnCriticalCityCenter(ctx, unit)) return true;
|
||||
if (UnitIsCriticalCityDefender(ctx, unit)) return true;
|
||||
var target = ResolveFrontTarget(front);
|
||||
var startGrid = unit.Grid(ctx.Map);
|
||||
if (target != null
|
||||
&& startGrid != null
|
||||
&& ctx.Config.MaxFrontMoveDistance > 0
|
||||
&& AIDirectorMath.Distance(ctx.Map, startGrid, target) > ctx.Config.MaxFrontMoveDistance
|
||||
&& front.FrontType != AIDirectorFrontType.Defense)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsStandingOnCriticalCityCenter(AIDirectorContext ctx, UnitData unit)
|
||||
private bool IsUrgentCityDefenseAction(AIDirectorActionCandidate candidate)
|
||||
{
|
||||
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
|
||||
if (candidate == null || !candidate.IsValid || candidate.ActionId == null) return false;
|
||||
var id = candidate.ActionId;
|
||||
if (id.ActionType == CommonActionType.TrainUnit) return true;
|
||||
return id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall;
|
||||
}
|
||||
|
||||
private float ExpansionTargetRiskPenalty(AIDirectorContext ctx, AIDirectorDevelopmentTarget target, GridData actingGrid)
|
||||
{
|
||||
if (ctx?.Cache == null || target?.Grid == null) return 0f;
|
||||
var gridThreat = GridThreat(ctx, target.Grid);
|
||||
var localThreat = actingGrid != null ? GridThreat(ctx, actingGrid) * 0.25f : 0f;
|
||||
var penalty = gridThreat * 0.75f + localThreat;
|
||||
if (target.TargetType == AIDirectorDevelopmentTargetType.EnemyEmptyCity) penalty += gridThreat * 0.35f;
|
||||
if (HasSevereCityThreat(ctx))
|
||||
{
|
||||
penalty += ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold
|
||||
&& target.TargetType is AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity
|
||||
? 80f
|
||||
: 180f;
|
||||
}
|
||||
if (ctx.Cache.SelfUnits.Count <= ctx.Cache.SelfCities.Count + 1 && gridThreat > 0f) penalty += 120f;
|
||||
return penalty;
|
||||
}
|
||||
|
||||
private bool ShouldSkipExpansionUnit(
|
||||
AIDirectorContext ctx,
|
||||
UnitData unit,
|
||||
AIDirectorDevelopmentTarget target,
|
||||
GridData startGrid,
|
||||
int startDistance)
|
||||
{
|
||||
if (ctx == null || unit == null || target?.Grid == null || startGrid == null) return true;
|
||||
if (startDistance > ctx.Config.DevelopmentSearchRange + 2) return true;
|
||||
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.CriticalHealthRatio && GridThreat(ctx, startGrid) > 0f) return true;
|
||||
if (!UnitIsCriticalCityDefender(ctx, unit)) return false;
|
||||
if (UnitIsOnlyCriticalCityDefender(ctx, unit)) return true;
|
||||
if (ctx.Cache.SelfUnits.Count <= ctx.Cache.SelfCities.Count + 1) return true;
|
||||
return GridThreat(ctx, target.Grid) > 0f;
|
||||
}
|
||||
|
||||
private bool IsOnlyDefenderForThreat(AIDirectorCityThreat threat, UnitData unit)
|
||||
{
|
||||
if (threat == null || unit == null) return false;
|
||||
var count = 0;
|
||||
foreach (var defender in threat.Defenders)
|
||||
{
|
||||
if (defender == null) continue;
|
||||
count++;
|
||||
}
|
||||
|
||||
return count <= 1 && threat.Defenders.Contains(unit);
|
||||
}
|
||||
|
||||
private bool UnitIsOnlyCriticalCityDefender(AIDirectorContext ctx, UnitData unit)
|
||||
{
|
||||
if (unit == null || ctx?.Cache == null) return false;
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (!threat.IsCritical) continue;
|
||||
if (threat.CityGrid?.Id == grid.Id) return true;
|
||||
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
|
||||
if (IsOnlyDefenderForThreat(threat, unit)) return true;
|
||||
}
|
||||
|
||||
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) 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);
|
||||
}
|
||||
|
||||
private bool HasSevereCityThreat(AIDirectorContext ctx)
|
||||
{
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (ShouldUseEmergency(threat, ctx.Config)) return true;
|
||||
}
|
||||
|
||||
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 > 4) return false;
|
||||
return GridThreat(ctx, target.Grid) <= 0f;
|
||||
}
|
||||
|
||||
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;
|
||||
if (target.Distance > ctx.Config.DevelopmentSearchRange) return false;
|
||||
if (ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) return true;
|
||||
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;
|
||||
if (IsOnlyDefenderForThreat(threat, unit)) return true;
|
||||
foreach (var defender in threat.Defenders)
|
||||
{
|
||||
if (defender?.Id == unit.Id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -655,6 +1118,22 @@ namespace Logic.AI.Director
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsPriorityTacticAction(AIDirectorContext ctx, AIActionBase action, float attackScore)
|
||||
{
|
||||
if (action?.Param?.UnitData == null || action.Param.TargetUnitData == null) return false;
|
||||
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;
|
||||
|
||||
var targetValue = UnitTargetValue(ctx, target);
|
||||
var counterThreat = CounterThreat(ctx, attacker, target);
|
||||
return attackScore >= ctx.Config.PriorityTacticScore + 60f && counterThreat <= targetValue * 0.15f;
|
||||
}
|
||||
|
||||
private bool TargetOwnerFeelingHighAndNotWar(AIDirectorContext ctx, UnitData target)
|
||||
{
|
||||
if (target == null || !ctx.Map.GetPlayerDataByUnitId(target.Id, out var owner) || owner == null) return false;
|
||||
@ -702,6 +1181,40 @@ namespace Logic.AI.Director
|
||||
return AIDirectorMath.Distance(ctx.Map, endGrid, target) <= unit.GetAttackRange(ctx.Map) + unit.GetActionPoint(ActionPointType.Move);
|
||||
}
|
||||
|
||||
private static bool IsUsefulMoveToward(AIDirectorContext ctx, GridData startGrid, GridData endGrid, GridData targetGrid)
|
||||
{
|
||||
if (ctx?.Map == null || startGrid == null || endGrid == null || targetGrid == null) return false;
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, targetGrid);
|
||||
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, targetGrid);
|
||||
return endDistance < startDistance || endDistance <= 1;
|
||||
}
|
||||
|
||||
private static string BuildIntentKey(AIDirectorLane lane, string kind, uint targetId)
|
||||
{
|
||||
if (targetId == 0) return string.Empty;
|
||||
return $"{lane}:{kind}:{targetId}";
|
||||
}
|
||||
|
||||
private static bool IsIntentBlocked(AIDirectorContext ctx, string intentKey)
|
||||
{
|
||||
return !string.IsNullOrEmpty(intentKey)
|
||||
&& ctx?.BlockedIntentKeys != null
|
||||
&& ctx.BlockedIntentKeys.Contains(intentKey);
|
||||
}
|
||||
|
||||
private static bool IsIntentBudgetReached(AIDirectorContext ctx, AIDirectorLane lane, int maxCount)
|
||||
{
|
||||
if (maxCount <= 0 || ctx?.BlockedIntentKeys == null) return false;
|
||||
var prefix = $"{lane}:";
|
||||
var count = 0;
|
||||
foreach (var key in ctx.BlockedIntentKeys)
|
||||
{
|
||||
if (key != null && key.StartsWith(prefix, System.StringComparison.Ordinal)) count++;
|
||||
}
|
||||
|
||||
return count >= maxCount;
|
||||
}
|
||||
|
||||
private float GridThreat(AIDirectorContext ctx, GridData grid)
|
||||
{
|
||||
if (grid == null) return 0f;
|
||||
@ -722,10 +1235,16 @@ namespace Logic.AI.Director
|
||||
var score = UnitBaseMilitaryValue(id.UnitType);
|
||||
if (threat != null)
|
||||
{
|
||||
if (!CityCenterHasFriendlyGuard(ctx, threat.CityGrid))
|
||||
{
|
||||
score += IsDefenderUnit(id.UnitType) ? 100f : IsMeleeUnit(id.UnitType) ? 60f : 35f;
|
||||
}
|
||||
|
||||
foreach (var enemy in threat.EnemyUnits)
|
||||
{
|
||||
score += id.UnitType == UnitType.Defender ? 45f : 20f;
|
||||
if (enemy.GetAttackRange(ctx.Map) > 1 && id.UnitType == UnitType.Rider) score += 30f;
|
||||
score += IsDefenderUnit(id.UnitType) ? 45f : 20f;
|
||||
if (enemy.GetAttackRange(ctx.Map) > 1 && IsMobilityUnit(id.UnitType)) score += 35f;
|
||||
if (enemy.UnitType == UnitType.Defender && IsSiegeUnit(id.UnitType)) score += 50f;
|
||||
}
|
||||
}
|
||||
|
||||
@ -736,25 +1255,85 @@ 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;
|
||||
if (IsMobilityUnit(id.UnitType) || IsSiegeUnit(id.UnitType)) score += 45f;
|
||||
if (IsRangedUnit(id.UnitType) && ctx.Cache.HasAnyEnemyContact) score += 35f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float TrainUnitExpansionValue(UnitType unitType)
|
||||
{
|
||||
return unitType switch
|
||||
{
|
||||
UnitType.Rider or UnitType.MoriyaRider or UnitType.KomeijiIndianRider or UnitType.HakureiValkyrie => 170f,
|
||||
UnitType.Warrior or UnitType.Swordsman or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker => 105f,
|
||||
UnitType.Knights or UnitType.MoriyaKnight or UnitType.KomeijiIndianKnight => 100f,
|
||||
UnitType.Archer or UnitType.KomeijiIndianArcher => 30f,
|
||||
UnitType.Defender or UnitType.HakureiRoundShieldman => -45f,
|
||||
_ => 20f
|
||||
};
|
||||
}
|
||||
|
||||
private float UnitBaseMilitaryValue(UnitType unitType)
|
||||
{
|
||||
return unitType switch
|
||||
{
|
||||
UnitType.Defender => 90f,
|
||||
UnitType.Warrior => 70f,
|
||||
UnitType.Archer => 75f,
|
||||
UnitType.Rider or UnitType.Knights => 85f,
|
||||
UnitType.Catapult => 100f,
|
||||
UnitType.Defender or UnitType.HakureiRoundShieldman => 90f,
|
||||
UnitType.Warrior or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker => 70f,
|
||||
UnitType.Swordsman => 95f,
|
||||
UnitType.Archer or UnitType.KomeijiIndianArcher => 78f,
|
||||
UnitType.Rider or UnitType.Knights or UnitType.MoriyaRider or UnitType.MoriyaKnight or UnitType.KomeijiIndianRider or UnitType.KomeijiIndianKnight or UnitType.HakureiValkyrie => 88f,
|
||||
UnitType.Catapult or UnitType.KaguyaFrenchCatapult or UnitType.KomeijiIndianCatapult => 105f,
|
||||
UnitType.Giant or UnitType.GiantJuggernaut => 140f,
|
||||
_ => 50f
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsMobilityUnit(UnitType unitType)
|
||||
{
|
||||
return unitType is UnitType.Rider or UnitType.Knights or UnitType.MoriyaRider or UnitType.MoriyaKnight
|
||||
or UnitType.KomeijiIndianRider or UnitType.KomeijiIndianKnight or UnitType.HakureiValkyrie;
|
||||
}
|
||||
|
||||
private static bool IsMeleeUnit(UnitType unitType)
|
||||
{
|
||||
return unitType is UnitType.Warrior or UnitType.Swordsman or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior
|
||||
or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker;
|
||||
}
|
||||
|
||||
private static bool IsRangedUnit(UnitType unitType)
|
||||
{
|
||||
return unitType is UnitType.Archer or UnitType.KomeijiIndianArcher;
|
||||
}
|
||||
|
||||
private static bool IsSiegeUnit(UnitType unitType)
|
||||
{
|
||||
return unitType is UnitType.Catapult or UnitType.KaguyaFrenchCatapult or UnitType.KomeijiIndianCatapult;
|
||||
}
|
||||
|
||||
private static bool IsDefenderUnit(UnitType unitType)
|
||||
{
|
||||
return unitType is UnitType.Defender or UnitType.HakureiRoundShieldman;
|
||||
}
|
||||
|
||||
private static float UnitMobilityExpansionBonus(UnitData unit)
|
||||
{
|
||||
if (unit == null) return 0f;
|
||||
if (IsMobilityUnit(unit.UnitType)) return 170f;
|
||||
return unit.GetActionPoint(ActionPointType.Move) >= 2 ? 120f : 0f;
|
||||
}
|
||||
|
||||
private static bool CityCenterHasFriendlyGuard(AIDirectorContext ctx, GridData cityGrid)
|
||||
{
|
||||
if (ctx?.Map == null || ctx.Player == null || cityGrid == null) return false;
|
||||
return cityGrid.RealUnit(ctx.Map, out var unit)
|
||||
&& unit != null
|
||||
&& ctx.Map.GetPlayerDataByUnitId(unit.Id, out var owner)
|
||||
&& owner != null
|
||||
&& ctx.Map.SameUnion(ctx.Player.Id, owner.Id);
|
||||
}
|
||||
|
||||
private bool NeedStandingArmy(AIDirectorContext ctx, CityData city)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) return true;
|
||||
@ -867,9 +1446,9 @@ namespace Logic.AI.Director
|
||||
|
||||
private bool TechLooksMobility(TechType tech)
|
||||
{
|
||||
return tech is TechType.Climbing or TechType.Roads or TechType.Sailing or TechType.Navigation or TechType.FreeSpirit
|
||||
or TechType.KaguyaRoad or TechType.KanakoClimbing or TechType.KanakoRoads or TechType.KanakoNavigation
|
||||
or TechType.KomeijiIndianSailing or TechType.KomeijiIndianNavigation or TechType.HakureiFishing;
|
||||
return tech is TechType.Climbing or TechType.Riding or TechType.Roads or TechType.Sailing or TechType.Navigation or TechType.FreeSpirit
|
||||
or TechType.KaguyaRoad or TechType.KanakoClimbing or TechType.KanakoRiding or TechType.KanakoRoads or TechType.KanakoNavigation
|
||||
or TechType.KomeijiIndianRiding or TechType.KomeijiIndianSailing or TechType.KomeijiIndianNavigation or TechType.HakureiFishing;
|
||||
}
|
||||
|
||||
private bool TechLooksEconomic(TechType tech)
|
||||
|
||||
@ -10,6 +10,7 @@ namespace Logic.AI.Director
|
||||
{
|
||||
None,
|
||||
Emergency,
|
||||
Expansion,
|
||||
HeroManagement,
|
||||
HeroPlaybook,
|
||||
Tactic,
|
||||
@ -64,7 +65,9 @@ namespace Logic.AI.Director
|
||||
HeroUpgrade,
|
||||
Upgrade,
|
||||
CultureUnitUpgrade,
|
||||
Recover
|
||||
Recover,
|
||||
ShipUpgrade,
|
||||
AbsorbMarker
|
||||
}
|
||||
|
||||
public enum AIDirectorHeroRole
|
||||
@ -130,9 +133,20 @@ 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 float PriorityTacticScore = 780f;
|
||||
public int MaxGeneratedActions = 4096;
|
||||
public int MaxFrontCount = 12;
|
||||
public int MaxDevelopmentTargetCount = 20;
|
||||
public int MaxExpansionTargetScanCount = 6;
|
||||
public int MaxMoveActionsPerUnit = 5;
|
||||
public int MaxExpansionMoveIntentsPerTurn = 3;
|
||||
public int MaxEmergencyMoveIntentsPerTurn = 2;
|
||||
public int MaxFrontMoveIntentsPerTurn = 1;
|
||||
public int MaxEmergencyRescueDistance = 6;
|
||||
public int MaxFrontMoveDistance = 8;
|
||||
public float LowHealthRatio = 0.45f;
|
||||
public float CriticalHealthRatio = 0.25f;
|
||||
public float HeroLowHealthRatio = 0.6f;
|
||||
@ -164,12 +178,21 @@ namespace Logic.AI.Director
|
||||
public readonly AIDirectorConfig Config;
|
||||
public AIDirectorWorldCache Cache;
|
||||
public AIDirectorActionIndex ActionIndex;
|
||||
public readonly HashSet<string> BlockedActionKeys;
|
||||
public readonly HashSet<string> BlockedIntentKeys;
|
||||
|
||||
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config)
|
||||
public AIDirectorContext(
|
||||
MapData map,
|
||||
PlayerData player,
|
||||
AIDirectorConfig config,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
{
|
||||
Map = map;
|
||||
Player = player;
|
||||
Config = config ?? AIDirectorConfig.CreateDefault();
|
||||
BlockedActionKeys = blockedActionKeys;
|
||||
BlockedIntentKeys = blockedIntentKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@ -319,6 +342,7 @@ namespace Logic.AI.Director
|
||||
public GridData Grid;
|
||||
public GridData TargetGrid;
|
||||
public string Reason;
|
||||
public string IntentKey;
|
||||
public float Priority;
|
||||
public bool IsFallback;
|
||||
public readonly List<AIDirectorScoreTerm> ScoreTerms = new();
|
||||
@ -510,29 +534,79 @@ namespace Logic.AI.Director
|
||||
public int PlayerCulture;
|
||||
public int CultureCardCount;
|
||||
public int PlayerScore;
|
||||
public int SightGridCount;
|
||||
public int CityCount;
|
||||
public string OwnedCityIdsSignature;
|
||||
public string CapitalCityIdsSignature;
|
||||
public readonly List<uint> ThreatenedCityIds = new();
|
||||
public readonly List<uint> CriticalThreatenedCityIds = new();
|
||||
public int UnitCount;
|
||||
public int HeroCount;
|
||||
public int SelectedHeroCount;
|
||||
public int MaxHeroCount;
|
||||
public int HeroTaskCount;
|
||||
public int ReadyHeroTaskCount;
|
||||
public int ForcedHeroTaskCount;
|
||||
public int HeroTaskProgress;
|
||||
public float SelfMilitary;
|
||||
public float EnemyMilitary;
|
||||
public int CriticalCityThreatCount;
|
||||
public int CityThreatCount;
|
||||
public int CapitalThreatCount;
|
||||
public int CriticalCapitalThreatCount;
|
||||
public int EmptyThreatenedCityCount;
|
||||
public string ThreatenedCityIdsSignature;
|
||||
public string CriticalThreatenedCityIdsSignature;
|
||||
public float MaxCityDangerScore;
|
||||
public string StrategicPosture;
|
||||
public uint UnitId;
|
||||
public bool UnitAlive;
|
||||
public int UnitHealth;
|
||||
public uint UnitGridId;
|
||||
public int UnitSkillCount;
|
||||
public string UnitSkillSignature;
|
||||
public string UnitGridResource;
|
||||
public string UnitGridResourceUnderBuilding;
|
||||
public int UnitGridBuildingLevel;
|
||||
public string UnitGridSpType;
|
||||
public uint TargetUnitId;
|
||||
public bool TargetUnitAlive;
|
||||
public int TargetUnitHealth;
|
||||
public uint TargetUnitGridId;
|
||||
public int TargetUnitSkillCount;
|
||||
public string TargetUnitSkillSignature;
|
||||
public string TargetUnitGridResource;
|
||||
public string TargetUnitGridResourceUnderBuilding;
|
||||
public int TargetUnitGridBuildingLevel;
|
||||
public string TargetUnitGridSpType;
|
||||
public uint ActionGridId;
|
||||
public string ActionGridResource;
|
||||
public string ActionGridResourceUnderBuilding;
|
||||
public int ActionGridBuildingLevel;
|
||||
public string ActionGridSpType;
|
||||
public uint TargetActionGridId;
|
||||
public string TargetActionGridResource;
|
||||
public string TargetActionGridResourceUnderBuilding;
|
||||
public int TargetActionGridBuildingLevel;
|
||||
public string TargetActionGridSpType;
|
||||
public uint CityId;
|
||||
public uint CityOwnerId;
|
||||
public int CityLevel;
|
||||
public int CityLevelExp;
|
||||
public int CityLevelUpPoint;
|
||||
public int CityParkCount;
|
||||
public bool CityWorkshop;
|
||||
public bool CityWall;
|
||||
public int CityTerritoryCount;
|
||||
public uint TargetCityId;
|
||||
public uint TargetCityOwnerId;
|
||||
public int TargetCityLevel;
|
||||
public int TargetCityLevelExp;
|
||||
public int TargetCityLevelUpPoint;
|
||||
public int TargetCityParkCount;
|
||||
public bool TargetCityWorkshop;
|
||||
public bool TargetCityWall;
|
||||
public int TargetCityTerritoryCount;
|
||||
}
|
||||
|
||||
internal static class AIDirectorMath
|
||||
|
||||
@ -39,7 +39,7 @@ namespace Logic.AI.Director
|
||||
foreach (var action in actions.UnitActions)
|
||||
{
|
||||
if (action?.Param?.UnitData == null || action.ActionLogic?.ActionId == null) continue;
|
||||
var type = GetOpportunityType(action.ActionLogic.ActionId.UnitActionType);
|
||||
var type = GetOpportunityType(action);
|
||||
if (type == AIDirectorUnitOpportunityType.None) continue;
|
||||
var value = ScoreUnitOpportunity(ctx, action, type);
|
||||
if (value <= 0f) continue;
|
||||
@ -161,14 +161,15 @@ namespace Logic.AI.Director
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, cityGrid, enemyGrid);
|
||||
var reach = ctx.Config.EmergencyEnemySearchRange + enemy.GetAttackRange(ctx.Map);
|
||||
if (distance <= reach)
|
||||
var isInCityTerritory = city.CheckIsInTerritory(enemyGrid.Id);
|
||||
if (distance <= reach || isInCityTerritory)
|
||||
{
|
||||
threat.EnemyUnits.Add(enemy);
|
||||
threat.EnemyPower += AIDirectorMath.UnitPower(enemy) / Mathf.Max(1, distance);
|
||||
threat.NearestEnemyDistance = Mathf.Min(threat.NearestEnemyDistance, distance);
|
||||
}
|
||||
|
||||
if (cache.SelfTerritoryGridIds.Contains(enemyGrid.Id)) threat.HasEnemyOnTerritory = true;
|
||||
if (isInCityTerritory) threat.HasEnemyOnTerritory = true;
|
||||
if (CanThreatenCityNextTurn(ctx, enemy, cityGrid)) threat.CanBeThreatenedNextTurn = true;
|
||||
}
|
||||
|
||||
@ -310,7 +311,7 @@ namespace Logic.AI.Director
|
||||
if (grid == null) continue;
|
||||
var target = TryBuildDevelopmentTarget(ctx, cache, grid);
|
||||
if (target == null) continue;
|
||||
if (target.Distance > ctx.Config.DevelopmentSearchRange && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
|
||||
if (target.Distance > ctx.Config.DevelopmentSearchRange) continue;
|
||||
if (GridThreat(ctx, cache, grid) > 160f && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
|
||||
targets.Add(target);
|
||||
}
|
||||
@ -368,6 +369,12 @@ namespace Logic.AI.Director
|
||||
foreach (var target in cache.DevelopmentTargets)
|
||||
{
|
||||
if (target.Grid == null) continue;
|
||||
if (cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold
|
||||
&& target.TargetType is AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cache.Fronts.Add(new AIDirectorFront
|
||||
{
|
||||
FrontType = AIDirectorFrontType.Development,
|
||||
@ -466,12 +473,22 @@ 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.CityCenter)
|
||||
{
|
||||
type = AIDirectorDevelopmentTargetType.Village;
|
||||
value = 1220f + (ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn ? 220f : 0f);
|
||||
}
|
||||
else if (grid.Resource != ResourceType.None && !grid.HasBuilding())
|
||||
{
|
||||
type = AIDirectorDevelopmentTargetType.Resource;
|
||||
@ -494,9 +511,13 @@ namespace Logic.AI.Director
|
||||
};
|
||||
}
|
||||
|
||||
private AIDirectorUnitOpportunityType GetOpportunityType(UnitActionType type)
|
||||
private AIDirectorUnitOpportunityType GetOpportunityType(AIActionBase action)
|
||||
{
|
||||
return type switch
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null) return AIDirectorUnitOpportunityType.None;
|
||||
if (UnitActionUpgradeHelper.IsShipUpgradeTarget(id.UnitType)) return AIDirectorUnitOpportunityType.ShipUpgrade;
|
||||
|
||||
return id.UnitActionType switch
|
||||
{
|
||||
UnitActionType.Capture => AIDirectorUnitOpportunityType.Capture,
|
||||
UnitActionType.Examine => AIDirectorUnitOpportunityType.Examine,
|
||||
@ -505,6 +526,7 @@ namespace Logic.AI.Director
|
||||
UnitActionType.Upgrade => AIDirectorUnitOpportunityType.Upgrade,
|
||||
UnitActionType.CultureUnitUpgrade => AIDirectorUnitOpportunityType.CultureUnitUpgrade,
|
||||
UnitActionType.Recover => AIDirectorUnitOpportunityType.Recover,
|
||||
UnitActionType.AbsorbRedMist or UnitActionType.HakureiAbsorbRune => AIDirectorUnitOpportunityType.AbsorbMarker,
|
||||
_ => AIDirectorUnitOpportunityType.None
|
||||
};
|
||||
}
|
||||
@ -515,19 +537,27 @@ 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,
|
||||
AIDirectorUnitOpportunityType.Upgrade => 610f,
|
||||
AIDirectorUnitOpportunityType.CultureUnitUpgrade => 600f,
|
||||
AIDirectorUnitOpportunityType.Recover => ScoreRecover(ctx, unit),
|
||||
AIDirectorUnitOpportunityType.ShipUpgrade => ScoreShipUpgrade(ctx, action, unit),
|
||||
AIDirectorUnitOpportunityType.AbsorbMarker => ScoreAbsorbMarker(ctx, unit),
|
||||
_ => 0f
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -539,12 +569,37 @@ namespace Logic.AI.Director
|
||||
{
|
||||
if (unit == null) return 0f;
|
||||
var hp = AIDirectorMath.HealthRatio(unit);
|
||||
if (hp > ctx.Config.LowHealthRatio) return 0f;
|
||||
var score = 560f + (1f - hp) * 240f;
|
||||
var hasNegativeState = unit.GetSkill(SkillType.KomeijiFear, out _) || unit.GetSkill(SkillType.SAKUYATIRED, out _);
|
||||
if (hp > ctx.Config.LowHealthRatio && !hasNegativeState) return 0f;
|
||||
var score = 520f + (1f - hp) * 220f;
|
||||
if (hasNegativeState) score += 160f;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit)) score += 120f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreShipUpgrade(AIDirectorContext ctx, AIActionBase action, UnitData unit)
|
||||
{
|
||||
if (unit == null || action?.ActionLogic?.ActionId == null) return 0f;
|
||||
if (!UnitActionUpgradeHelper.TryGetShipUpgradeCost(unit, action.ActionLogic.ActionId.UnitType, out var cost)) return 0f;
|
||||
if (ctx.Player.PlayerCoin < cost) return 0f;
|
||||
|
||||
var score = 560f + AIDirectorMath.UnitPower(unit) * 0.2f;
|
||||
if (cost == 0) score += 120f;
|
||||
else score -= cost * 18f;
|
||||
if (ctx.Cache.StrategicPosture is AIDirectorStrategicPosture.Expansion or AIDirectorStrategicPosture.Attack) score += 90f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreAbsorbMarker(AIDirectorContext ctx, UnitData unit)
|
||||
{
|
||||
if (unit == null) return 0f;
|
||||
var missing = 1f - AIDirectorMath.HealthRatio(unit);
|
||||
if (missing <= 0.05f) return 0f;
|
||||
var score = 500f + missing * 260f;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit)) score += 100f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private bool ExistsHighValueDevelopmentTargetNearCity(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var target in cache.DevelopmentTargets)
|
||||
@ -752,12 +807,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)
|
||||
|
||||
@ -21,10 +21,14 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using Logic.AI.Director;
|
||||
using Logic.CrashSight;
|
||||
using RuntimeData;
|
||||
using System.Collections.Generic;
|
||||
using TH1_Logic.Action;
|
||||
|
||||
namespace Logic.AI
|
||||
@ -8,6 +9,8 @@ namespace Logic.AI
|
||||
public sealed class DirectorAIKernel : IAIKernel
|
||||
{
|
||||
private readonly AIDirectorLogic _director = new();
|
||||
private readonly HashSet<string> _executedActionKeysThisTurn = new();
|
||||
private readonly HashSet<string> _executedIntentKeysThisTurn = new();
|
||||
private MapData _mapData;
|
||||
private PlayerData _playerData;
|
||||
|
||||
@ -21,6 +24,8 @@ namespace Logic.AI
|
||||
{
|
||||
_mapData = mapData;
|
||||
_playerData = playerData;
|
||||
_executedActionKeysThisTurn.Clear();
|
||||
_executedIntentKeysThisTurn.Clear();
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordTurnStart(_mapData, _playerData);
|
||||
#endif
|
||||
@ -29,7 +34,7 @@ namespace Logic.AI
|
||||
public AIKernelUpdate Update()
|
||||
{
|
||||
if (_mapData == null || _playerData == null) return AIKernelUpdate.Finished;
|
||||
var decision = _director.Decide(_mapData, _playerData);
|
||||
var decision = _director.Decide(_mapData, _playerData, null, _executedActionKeysThisTurn, _executedIntentKeysThisTurn);
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
|
||||
#endif
|
||||
@ -43,6 +48,8 @@ namespace Logic.AI
|
||||
if (action.IsInSight) action.ActionLogic.CameraControl(action.Param);
|
||||
if (action.ActionLogic.ActionId.PlayerActionType == PlayerActionType.OfferAlly)
|
||||
LogSystem.LogInfo("AI 发起结盟");
|
||||
_executedActionKeysThisTurn.Add(AIDirectorActionIndex.StableActionKey(action));
|
||||
if (!string.IsNullOrEmpty(candidate.IntentKey)) _executedIntentKeysThisTurn.Add(candidate.IntentKey);
|
||||
return AIKernelUpdate.ActionReady(action);
|
||||
}
|
||||
|
||||
@ -53,6 +60,8 @@ namespace Logic.AI
|
||||
#endif
|
||||
_mapData = null;
|
||||
_playerData = null;
|
||||
_executedActionKeysThisTurn.Clear();
|
||||
_executedIntentKeysThisTurn.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -242,6 +242,13 @@ namespace TH1_Logic.Core
|
||||
{
|
||||
MapData = new MapData(MapConfig, NetMode.Single);
|
||||
MapData.Net.Mode = NetMode.Single;
|
||||
#if UNITY_EDITOR
|
||||
if (AIDirectorBatchRuntime.RandomSeedOverride != 0)
|
||||
{
|
||||
MapData.Net.RandomSeed = AIDirectorBatchRuntime.RandomSeedOverride;
|
||||
UnityEngine.Random.InitState(AIDirectorBatchRuntime.RandomSeedOverride);
|
||||
}
|
||||
#endif
|
||||
|
||||
//step #1 初始化Audio
|
||||
InitGameAudio();
|
||||
|
||||
@ -7,7 +7,9 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using Logic;
|
||||
using Logic.AI;
|
||||
using Logic.AI.Director;
|
||||
using Logic.CrashSight;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using RuntimeData;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.Core;
|
||||
@ -134,6 +136,8 @@ namespace TH1_Logic.Editor
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
||||
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
||||
#endif
|
||||
CompleteResult(result, gameDirectory);
|
||||
results.Add(result);
|
||||
@ -163,13 +167,19 @@ namespace TH1_Logic.Editor
|
||||
AILogic.UseDirectorKernel();
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = true;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = 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();
|
||||
#endif
|
||||
|
||||
var main = Main.Instance;
|
||||
main.MapConfig = BuildMapConfig(options, gameIndex);
|
||||
result.playerCount = (int)main.MapConfig.PlayerCount;
|
||||
result.width = (int)main.MapConfig.Width;
|
||||
result.height = (int)main.MapConfig.Height;
|
||||
result.randomSeed = AIDirectorBatchRuntime.RandomSeedOverride;
|
||||
result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString();
|
||||
|
||||
Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}");
|
||||
@ -220,6 +230,8 @@ namespace TH1_Logic.Editor
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
||||
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
||||
#endif
|
||||
try
|
||||
{
|
||||
@ -313,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";
|
||||
@ -391,10 +410,7 @@ namespace TH1_Logic.Editor
|
||||
{
|
||||
var civId = PickCivId(i + gameIndex, used);
|
||||
used.Add(civId);
|
||||
if (!config.SetSinglePlayerSlotCiv(i, civId, civId))
|
||||
{
|
||||
config.SetPlayerSlotRandomCiv(i, NetMode.Single);
|
||||
}
|
||||
config.SetSinglePlayerSlotCiv(i, civId, civId);
|
||||
}
|
||||
|
||||
config.EnsurePlayerSlots(NetMode.Single);
|
||||
@ -427,7 +443,7 @@ namespace TH1_Logic.Editor
|
||||
if (main != null)
|
||||
{
|
||||
main.NoAI = false;
|
||||
main.FullSight = true;
|
||||
main.FullSight = options.AllSight;
|
||||
main.AIActionTime = 0f;
|
||||
main.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
|
||||
main.DebugMode = true;
|
||||
@ -436,7 +452,7 @@ namespace TH1_Logic.Editor
|
||||
|
||||
if (DebugCenter.Instance == null) return;
|
||||
DebugCenter.Instance.DebugNoAI = false;
|
||||
DebugCenter.Instance.DebugSelfPlayerAllSight = true;
|
||||
DebugCenter.Instance.DebugSelfPlayerAllSight = options.AllSight;
|
||||
DebugCenter.Instance.DebugAIActionTime = 0f;
|
||||
DebugCenter.Instance.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
|
||||
DebugCenter.Instance.DebugMode = true;
|
||||
@ -457,8 +473,12 @@ namespace TH1_Logic.Editor
|
||||
options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn);
|
||||
options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit);
|
||||
options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed);
|
||||
options.Seed = GetIntArg("-aiBatchSeed", options.Seed);
|
||||
options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast);
|
||||
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;
|
||||
@ -536,6 +556,10 @@ namespace TH1_Logic.Editor
|
||||
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
|
||||
result.survivingPlayers = CountSurvivingPlayers(map);
|
||||
result.players = BuildPlayerResults(map);
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPathOrEmpty;
|
||||
#endif
|
||||
result.diagnostics = BuildDiagnosticsSummary(result.diagnosticsLogPath);
|
||||
}
|
||||
|
||||
private static List<BatchPlayerResult> BuildPlayerResults(MapData map)
|
||||
@ -559,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
|
||||
});
|
||||
}
|
||||
@ -590,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;
|
||||
@ -639,6 +689,441 @@ namespace TH1_Logic.Editor
|
||||
File.WriteAllText(path, JsonUtility.ToJson(summary, true), Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static BatchDiagnosticSummary BuildDiagnosticsSummary(string diagnosticsLogPath)
|
||||
{
|
||||
var summary = new BatchDiagnosticSummary();
|
||||
if (string.IsNullOrWhiteSpace(diagnosticsLogPath) || !File.Exists(diagnosticsLogPath)) return summary;
|
||||
|
||||
summary.logPath = diagnosticsLogPath.Replace('\\', '/');
|
||||
var decisionMs = new List<float>();
|
||||
var actionPoolAll = new List<int>();
|
||||
var actionPoolMoves = new List<int>();
|
||||
var actionCountByPlayerTurn = new Dictionary<string, int>();
|
||||
var executedStableKeysByPlayerTurn = new HashSet<string>();
|
||||
var lanes = new Dictionary<string, int>();
|
||||
var reasons = new Dictionary<string, int>();
|
||||
var selectedActionTypes = new Dictionary<string, int>();
|
||||
var executedActionTypes = new Dictionary<string, int>();
|
||||
var laneActionTypes = new Dictionary<string, int>();
|
||||
var noEffectActionTypes = new Dictionary<string, int>();
|
||||
var heroReasons = new Dictionary<string, int>();
|
||||
var heroActionTypes = new Dictionary<string, int>();
|
||||
var heroExecutedActionTypes = new Dictionary<string, int>();
|
||||
var fallbackActionTypes = new Dictionary<string, int>();
|
||||
var fallbackReasons = new Dictionary<string, int>();
|
||||
var fallbackExecutedActionTypes = new Dictionary<string, int>();
|
||||
var fallbackNoEffectActionTypes = new Dictionary<string, int>();
|
||||
var defenseReasons = new Dictionary<string, int>();
|
||||
var defenseActionTypes = new Dictionary<string, int>();
|
||||
var heroStyleBuckets = new Dictionary<string, int>();
|
||||
var heroStyleReasons = new Dictionary<string, int>();
|
||||
var heroStyleActionTypes = new Dictionary<string, int>();
|
||||
var unitSkillActionTypes = new Dictionary<string, int>();
|
||||
var unitSkillChangedActionTypes = new Dictionary<string, int>();
|
||||
var actorSkillSignatures = new Dictionary<string, int>();
|
||||
var decisionLaneByAction = new Dictionary<string, string>();
|
||||
var decisionReasonByAction = new Dictionary<string, string>();
|
||||
var lastTurnSummaryByPlayer = new Dictionary<uint, JToken>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadLines(diagnosticsLogPath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var record = JObject.Parse(line);
|
||||
var eventType = record.Value<string>("eventType");
|
||||
if (eventType == "Decision")
|
||||
{
|
||||
summary.decisions++;
|
||||
var decision = record["decision"];
|
||||
if (decision == null) continue;
|
||||
|
||||
decisionMs.Add(decision.Value<float?>("decideMs") ?? 0f);
|
||||
var pool = record["actionPool"];
|
||||
if (pool != null)
|
||||
{
|
||||
actionPoolAll.Add(pool.Value<int?>("all") ?? 0);
|
||||
actionPoolMoves.Add(pool.Value<int?>("moves") ?? 0);
|
||||
}
|
||||
|
||||
if (!(decision.Value<bool?>("hasAction") ?? false))
|
||||
{
|
||||
summary.noActionDecisions++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var lane = decision.Value<string>("lane") ?? string.Empty;
|
||||
var reason = decision.Value<string>("reason") ?? string.Empty;
|
||||
var action = decision["action"];
|
||||
var actionType = action?.Value<string>("actionType") ?? string.Empty;
|
||||
var actionKey = BuildActionMetricKey(action);
|
||||
var turnActionKey = BuildTurnActionKey(record, action);
|
||||
Increment(lanes, lane);
|
||||
Increment(reasons, reason);
|
||||
Increment(selectedActionTypes, actionType);
|
||||
Increment(laneActionTypes, $"{lane}:{actionType}");
|
||||
if (!string.IsNullOrEmpty(turnActionKey))
|
||||
{
|
||||
decisionLaneByAction[turnActionKey] = lane;
|
||||
decisionReasonByAction[turnActionKey] = reason;
|
||||
}
|
||||
|
||||
if (lane == "Emergency")
|
||||
{
|
||||
summary.emergencyDecisions++;
|
||||
Increment(defenseReasons, reason);
|
||||
Increment(defenseActionTypes, actionKey);
|
||||
}
|
||||
|
||||
if (lane == "HeroManagement")
|
||||
{
|
||||
summary.heroManagementDecisions++;
|
||||
Increment(heroReasons, reason);
|
||||
Increment(heroActionTypes, actionKey);
|
||||
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
|
||||
}
|
||||
else if (lane == "HeroPlaybook")
|
||||
{
|
||||
summary.heroPlaybookDecisions++;
|
||||
Increment(heroReasons, reason);
|
||||
Increment(heroActionTypes, actionKey);
|
||||
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
|
||||
if (reason.StartsWith("HeroPlaybook.", StringComparison.Ordinal)) summary.genericHeroDecisions++;
|
||||
else summary.heroRuleDecisions++;
|
||||
}
|
||||
|
||||
if (decision.Value<bool?>("isFallback") ?? false)
|
||||
{
|
||||
summary.fallbackDecisions++;
|
||||
Increment(fallbackReasons, reason);
|
||||
Increment(fallbackActionTypes, actionKey);
|
||||
}
|
||||
}
|
||||
else if (eventType == "TurnStart")
|
||||
{
|
||||
var turnSummary = record["turnSummary"];
|
||||
if (turnSummary == null) continue;
|
||||
|
||||
var playerId = record.Value<uint?>("playerId") ?? 0;
|
||||
if ((turnSummary.Value<int?>("criticalCityThreatCount") ?? 0) > 0) summary.criticalCityThreatTurnCount++;
|
||||
if ((turnSummary.Value<int?>("capitalThreatCount") ?? 0) > 0) summary.capitalThreatTurnCount++;
|
||||
var emptyThreatenedCityCount = turnSummary.Value<int?>("emptyThreatenedCityCount") ?? 0;
|
||||
if (emptyThreatenedCityCount > 0)
|
||||
{
|
||||
summary.emptyThreatenedCityTurnCount++;
|
||||
summary.emptyThreatenedCityTotal += emptyThreatenedCityCount;
|
||||
}
|
||||
|
||||
if (lastTurnSummaryByPlayer.TryGetValue(playerId, out var previous))
|
||||
{
|
||||
var previousCityCount = previous.Value<int?>("cityCount") ?? 0;
|
||||
var currentCityCount = turnSummary.Value<int?>("cityCount") ?? 0;
|
||||
if (currentCityCount < previousCityCount) summary.cityLostCount += previousCityCount - currentCityCount;
|
||||
if (currentCityCount > previousCityCount) summary.cityGainedCount += currentCityCount - previousCityCount;
|
||||
|
||||
var previousCapital = previous.Value<string>("capitalCityIdsSignature") ?? string.Empty;
|
||||
var currentCapital = turnSummary.Value<string>("capitalCityIdsSignature") ?? string.Empty;
|
||||
if (!string.Equals(previousCapital, currentCapital, StringComparison.Ordinal)) summary.capitalOwnershipChangedCount++;
|
||||
}
|
||||
|
||||
lastTurnSummaryByPlayer[playerId] = turnSummary;
|
||||
}
|
||||
else if (eventType == "Execution")
|
||||
{
|
||||
summary.executions++;
|
||||
var execution = record["execution"];
|
||||
var action = execution?["action"];
|
||||
var actionType = action?.Value<string>("actionType") ?? string.Empty;
|
||||
var actionKey = BuildActionMetricKey(action);
|
||||
Increment(executedActionTypes, actionType);
|
||||
|
||||
var playerTurnKey = $"{record.Value<uint?>("playerId") ?? 0}:{record.Value<uint?>("playerTurn") ?? 0}";
|
||||
actionCountByPlayerTurn.TryGetValue(playerTurnKey, out var actionCount);
|
||||
actionCountByPlayerTurn[playerTurnKey] = actionCount + 1;
|
||||
|
||||
var stableKey = action?.Value<string>("stableKey");
|
||||
if (!string.IsNullOrEmpty(stableKey))
|
||||
{
|
||||
var stableKeyInTurn = $"{playerTurnKey}:{stableKey}";
|
||||
if (!executedStableKeysByPlayerTurn.Add(stableKeyInTurn)) summary.repeatedExecutions++;
|
||||
}
|
||||
|
||||
var turnActionKey = BuildTurnActionKey(record, action);
|
||||
decisionLaneByAction.TryGetValue(turnActionKey, out var executedLane);
|
||||
decisionReasonByAction.TryGetValue(turnActionKey, out var executedReason);
|
||||
if (executedLane == "HeroManagement" || executedLane == "HeroPlaybook")
|
||||
{
|
||||
Increment(heroExecutedActionTypes, actionKey);
|
||||
}
|
||||
else if (executedLane == "Fallback")
|
||||
{
|
||||
Increment(fallbackExecutedActionTypes, actionKey);
|
||||
}
|
||||
|
||||
var delta = execution?["delta"];
|
||||
if (execution?.Value<bool?>("executed") ?? false)
|
||||
{
|
||||
summary.heroDelta += delta?.Value<int?>("heroDelta") ?? 0;
|
||||
summary.selectedHeroDelta += delta?.Value<int?>("selectedHeroDelta") ?? 0;
|
||||
summary.heroTaskDelta += delta?.Value<int?>("heroTaskDelta") ?? 0;
|
||||
summary.readyHeroTaskDelta += delta?.Value<int?>("readyHeroTaskDelta") ?? 0;
|
||||
summary.forcedHeroTaskDelta += delta?.Value<int?>("forcedHeroTaskDelta") ?? 0;
|
||||
summary.heroTaskProgressDelta += delta?.Value<int?>("heroTaskProgressDelta") ?? 0;
|
||||
summary.cityThreatResolvedCount += delta?.Value<bool?>("cityThreatResolved") == true ? 1 : 0;
|
||||
summary.cityThreatWorsenedCount += delta?.Value<bool?>("cityThreatWorsened") == true ? 1 : 0;
|
||||
if (executedLane == "Emergency") summary.emergencyExecutions++;
|
||||
if (executedLane == "Emergency" && actionKey == "UnitMove")
|
||||
{
|
||||
summary.defenderReturnCount++;
|
||||
}
|
||||
|
||||
if (IsHeroAction(action))
|
||||
{
|
||||
var bucket = ClassifyStyleBucket(executedReason, actionKey);
|
||||
if (IsDefensiveStyle(bucket) || executedLane == "Emergency") summary.heroDefensiveUse++;
|
||||
}
|
||||
|
||||
var before = execution?["before"];
|
||||
var skillSignature = before?.Value<string>("unitSkillSignature") ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(skillSignature))
|
||||
{
|
||||
Increment(unitSkillActionTypes, actionKey);
|
||||
Increment(actorSkillSignatures, CompactSkillSignature(skillSignature));
|
||||
if (delta?.Value<bool?>("unitSkillSignatureChanged") == true
|
||||
|| delta?.Value<bool?>("targetUnitSkillSignatureChanged") == true
|
||||
|| (delta?.Value<int?>("unitSkillDelta") ?? 0) != 0
|
||||
|| (delta?.Value<int?>("targetUnitSkillDelta") ?? 0) != 0)
|
||||
{
|
||||
Increment(unitSkillChangedActionTypes, actionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(delta))
|
||||
{
|
||||
summary.noEffectExecutions++;
|
||||
Increment(noEffectActionTypes, actionType);
|
||||
if (executedLane == "Fallback") Increment(fallbackNoEffectActionTypes, actionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
summary.error = e.Message;
|
||||
}
|
||||
|
||||
FillFloatStats(decisionMs, out summary.avgDecideMs, out summary.p95DecideMs, out summary.maxDecideMs);
|
||||
FillIntStats(actionPoolAll, out summary.avgActionPoolAll, out summary.p95ActionPoolAll, out summary.maxActionPoolAll);
|
||||
FillIntStats(actionPoolMoves, out summary.avgActionPoolMoves, out summary.p95ActionPoolMoves, out summary.maxActionPoolMoves);
|
||||
FillIntStats(actionCountByPlayerTurn.Values.ToList(), out summary.avgActionsPerPlayerTurn, out summary.p95ActionsPerPlayerTurn, out summary.maxActionsPerPlayerTurn);
|
||||
summary.topLanes = TopCounts(lanes, 12);
|
||||
summary.topReasons = TopCounts(reasons, 20);
|
||||
summary.topSelectedActionTypes = TopCounts(selectedActionTypes, 12);
|
||||
summary.topExecutedActionTypes = TopCounts(executedActionTypes, 12);
|
||||
summary.topLaneActionTypes = TopCounts(laneActionTypes, 20);
|
||||
summary.noEffectActionTypes = TopCounts(noEffectActionTypes, 12);
|
||||
summary.topHeroReasons = TopCounts(heroReasons, 16);
|
||||
summary.topHeroActionTypes = TopCounts(heroActionTypes, 12);
|
||||
summary.topHeroExecutedActionTypes = TopCounts(heroExecutedActionTypes, 12);
|
||||
summary.topDefenseReasons = TopCounts(defenseReasons, 12);
|
||||
summary.topDefenseActionTypes = TopCounts(defenseActionTypes, 12);
|
||||
summary.topHeroStyleBuckets = TopCounts(heroStyleBuckets, 12);
|
||||
summary.topHeroStyleReasons = TopCounts(heroStyleReasons, 16);
|
||||
summary.topHeroStyleActionTypes = TopCounts(heroStyleActionTypes, 12);
|
||||
summary.topUnitSkillActionTypes = TopCounts(unitSkillActionTypes, 12);
|
||||
summary.topUnitSkillChangedActionTypes = TopCounts(unitSkillChangedActionTypes, 12);
|
||||
summary.topActorSkillSignatures = TopCounts(actorSkillSignatures, 12);
|
||||
summary.topFallbackReasons = TopCounts(fallbackReasons, 12);
|
||||
summary.topFallbackActionTypes = TopCounts(fallbackActionTypes, 12);
|
||||
summary.topFallbackExecutedActionTypes = TopCounts(fallbackExecutedActionTypes, 12);
|
||||
summary.fallbackNoEffectActionTypes = TopCounts(fallbackNoEffectActionTypes, 12);
|
||||
summary.defenseOpportunityTurns = summary.criticalCityThreatTurnCount + summary.emptyThreatenedCityTurnCount;
|
||||
summary.emergencyResponseRate = summary.defenseOpportunityTurns <= 0
|
||||
? 0f
|
||||
: Mathf.Clamp01((float)summary.emergencyDecisions / summary.defenseOpportunityTurns);
|
||||
summary.defenseScore = summary.cityThreatResolvedCount * 3f
|
||||
+ summary.emergencyExecutions
|
||||
+ summary.defenderReturnCount
|
||||
+ summary.heroDefensiveUse * 2f
|
||||
- summary.cityLostCount * 8f
|
||||
- summary.capitalThreatTurnCount * 2f
|
||||
- summary.emptyThreatenedCityTurnCount * 2f
|
||||
- summary.cityThreatWorsenedCount * 2f;
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static string BuildTurnActionKey(JToken record, JToken action)
|
||||
{
|
||||
var stableKey = action?.Value<string>("stableKey");
|
||||
if (string.IsNullOrEmpty(stableKey)) return string.Empty;
|
||||
return $"{record?.Value<uint?>("playerId") ?? 0}:{record?.Value<uint?>("playerTurn") ?? 0}:{stableKey}";
|
||||
}
|
||||
|
||||
private static string BuildActionMetricKey(JToken action)
|
||||
{
|
||||
if (action == null) return string.Empty;
|
||||
var actionType = action.Value<string>("actionType") ?? string.Empty;
|
||||
var subType = actionType switch
|
||||
{
|
||||
"UnitAction" => action.Value<string>("unitActionType") ?? string.Empty,
|
||||
"PlayerAction" => action.Value<string>("playerActionType") ?? string.Empty,
|
||||
"CityAction" => action.Value<string>("cityActionType") ?? string.Empty,
|
||||
"CityLevelUpAction" => action.Value<string>("cityLevelUpActionType") ?? string.Empty,
|
||||
"GridMisc" => action.Value<string>("gridMiscActionType") ?? string.Empty,
|
||||
"LearnTech" => action.Value<string>("techType") ?? string.Empty,
|
||||
"BuyCultureCard" => action.Value<string>("cultureCardType") ?? string.Empty,
|
||||
"Gain" => action.Value<string>("resourceType") ?? string.Empty,
|
||||
"TrainUnit" => action.Value<string>("unitType") ?? string.Empty,
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return string.IsNullOrEmpty(subType) || subType == "None"
|
||||
? actionType
|
||||
: $"{actionType}:{subType}";
|
||||
}
|
||||
|
||||
private static void IncrementHeroStyle(
|
||||
Dictionary<string, int> buckets,
|
||||
Dictionary<string, int> reasons,
|
||||
Dictionary<string, int> actions,
|
||||
string reason,
|
||||
string actionKey)
|
||||
{
|
||||
var bucket = ClassifyStyleBucket(reason, actionKey);
|
||||
Increment(buckets, bucket);
|
||||
Increment(reasons, $"{bucket}:{reason}");
|
||||
Increment(actions, $"{bucket}:{actionKey}");
|
||||
}
|
||||
|
||||
private static string ClassifyStyleBucket(string reason, string actionKey)
|
||||
{
|
||||
var text = $"{reason ?? string.Empty} {actionKey ?? string.Empty}";
|
||||
if (ContainsAny(text, "LowHp", "Recover", "Heal", "Eirin", "Sanae", "Patchouli", "Absorb", "Revive"))
|
||||
return "Recovery";
|
||||
if (ContainsAny(text, "Defense", "Defend", "Protect", "Guard", "MoveToCity", "Aunn", "Meiling", "Sakuya", "Reimu", "ShakeOff", "Unsit"))
|
||||
return "Defense";
|
||||
if (ContainsAny(text, "Kill", "Flandre", "Assassin", "AttackValue", "LocalBattle", "Boom", "Mokou", "Reisen", "Yuugi"))
|
||||
return "Burst";
|
||||
if (ContainsAny(text, "Ban", "Fear", "Control", "Satori", "Koishi", "Sumireko", "Orb", "Ground"))
|
||||
return "Control";
|
||||
if (ContainsAny(text, "Summon", "CreateMini", "Suwako", "Snake", "BonePile", "Mini"))
|
||||
return "Summon";
|
||||
if (ContainsAny(text, "Economy", "CityExp", "Corpse", "KanakoSit", "Rin", "Tewi", "Kaguya"))
|
||||
return "Economy";
|
||||
if (ContainsAny(text, "MoveAgain", "MoveToFront", "Retreat", "Mobility", "Aya", "Kasen"))
|
||||
return "Mobility";
|
||||
if (ContainsAny(text, "SelectHero", "SpawnHero", "FinishLowestTask"))
|
||||
return "HeroLifecycle";
|
||||
return "General";
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string value, params string[] tokens)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || tokens == null) return false;
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(token) && value.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsDefensiveStyle(string bucket)
|
||||
{
|
||||
return bucket == "Defense" || bucket == "Recovery";
|
||||
}
|
||||
|
||||
private static bool IsHeroAction(JToken action)
|
||||
{
|
||||
if (action == null) return false;
|
||||
var actorUnitType = action.Value<string>("actorUnitType") ?? string.Empty;
|
||||
var actorGiantType = action.Value<string>("actorGiantType") ?? string.Empty;
|
||||
var giantType = action.Value<string>("giantType") ?? string.Empty;
|
||||
return actorUnitType == "Giant"
|
||||
|| (!string.IsNullOrEmpty(actorGiantType) && actorGiantType != "None")
|
||||
|| (!string.IsNullOrEmpty(giantType) && giantType != "None");
|
||||
}
|
||||
|
||||
private static string CompactSkillSignature(string signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature)) return string.Empty;
|
||||
var parts = signature.Split('|');
|
||||
if (parts.Length <= 4) return signature;
|
||||
return string.Join("|", parts.Take(4)) + "|...";
|
||||
}
|
||||
|
||||
private static bool HasMeaningfulDelta(JToken delta)
|
||||
{
|
||||
if (delta == null) return false;
|
||||
foreach (var property in delta.Children<JProperty>())
|
||||
{
|
||||
if (property.Name == "netActionDelta") continue;
|
||||
var value = property.Value;
|
||||
switch (value.Type)
|
||||
{
|
||||
case JTokenType.Boolean:
|
||||
if (value.Value<bool>()) return true;
|
||||
break;
|
||||
case JTokenType.Integer:
|
||||
case JTokenType.Float:
|
||||
if (Math.Abs(value.Value<float>()) > 0.0001f) return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Increment(Dictionary<string, int> values, string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) key = "(empty)";
|
||||
values.TryGetValue(key, out var count);
|
||||
values[key] = count + 1;
|
||||
}
|
||||
|
||||
private static List<BatchCountMetric> TopCounts(Dictionary<string, int> values, int maxCount)
|
||||
{
|
||||
return values
|
||||
.OrderByDescending(item => item.Value)
|
||||
.ThenBy(item => item.Key, StringComparer.Ordinal)
|
||||
.Take(maxCount)
|
||||
.Select(item => new BatchCountMetric { key = item.Key, count = item.Value })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void FillFloatStats(List<float> values, out float average, out float p95, out float max)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
average = 0f;
|
||||
p95 = 0f;
|
||||
max = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
values.Sort();
|
||||
average = values.Average();
|
||||
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
|
||||
max = values[^1];
|
||||
}
|
||||
|
||||
private static void FillIntStats(List<int> values, out float average, out int p95, out int max)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
average = 0f;
|
||||
p95 = 0;
|
||||
max = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
values.Sort();
|
||||
average = (float)values.Average();
|
||||
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
|
||||
max = values[^1];
|
||||
}
|
||||
|
||||
private static void OpenStartupScene(string scenePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
@ -729,6 +1214,10 @@ namespace TH1_Logic.Editor
|
||||
public float AnimationSpeed = 100f;
|
||||
public bool FailFast = true;
|
||||
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;
|
||||
public AIDifficult Difficulty = AIDifficult.LUNATIC;
|
||||
@ -747,6 +1236,7 @@ namespace TH1_Logic.Editor
|
||||
MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn);
|
||||
StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit);
|
||||
AnimationSpeed = Mathf.Max(1f, AnimationSpeed);
|
||||
Seed = Mathf.Max(0, Seed);
|
||||
if (string.IsNullOrWhiteSpace(OutputDirectory))
|
||||
{
|
||||
var root = Directory.GetParent(Application.dataPath).FullName;
|
||||
@ -788,12 +1278,15 @@ namespace TH1_Logic.Editor
|
||||
public int playerCount;
|
||||
public int width;
|
||||
public int height;
|
||||
public int randomSeed;
|
||||
public int frames;
|
||||
public int netActions;
|
||||
public uint maxPlayerTurn;
|
||||
public uint curPlayerId;
|
||||
public uint curPlayerTurn;
|
||||
public int survivingPlayers;
|
||||
public string diagnosticsLogPath;
|
||||
public BatchDiagnosticSummary diagnostics;
|
||||
public List<BatchPlayerResult> players = new();
|
||||
}
|
||||
|
||||
@ -811,5 +1304,88 @@ 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]
|
||||
public class BatchDiagnosticSummary
|
||||
{
|
||||
public string logPath;
|
||||
public string error;
|
||||
public int decisions;
|
||||
public int noActionDecisions;
|
||||
public int fallbackDecisions;
|
||||
public int heroManagementDecisions;
|
||||
public int heroPlaybookDecisions;
|
||||
public int heroRuleDecisions;
|
||||
public int genericHeroDecisions;
|
||||
public int executions;
|
||||
public int noEffectExecutions;
|
||||
public int repeatedExecutions;
|
||||
public int heroDelta;
|
||||
public int selectedHeroDelta;
|
||||
public int heroTaskDelta;
|
||||
public int readyHeroTaskDelta;
|
||||
public int forcedHeroTaskDelta;
|
||||
public int heroTaskProgressDelta;
|
||||
public int criticalCityThreatTurnCount;
|
||||
public int capitalThreatTurnCount;
|
||||
public int emptyThreatenedCityTurnCount;
|
||||
public int emptyThreatenedCityTotal;
|
||||
public int defenseOpportunityTurns;
|
||||
public int cityThreatResolvedCount;
|
||||
public int cityThreatWorsenedCount;
|
||||
public int cityLostCount;
|
||||
public int cityGainedCount;
|
||||
public int capitalOwnershipChangedCount;
|
||||
public int emergencyDecisions;
|
||||
public int emergencyExecutions;
|
||||
public int defenderReturnCount;
|
||||
public int heroDefensiveUse;
|
||||
public float emergencyResponseRate;
|
||||
public float defenseScore;
|
||||
public float avgDecideMs;
|
||||
public float p95DecideMs;
|
||||
public float maxDecideMs;
|
||||
public float avgActionPoolAll;
|
||||
public int p95ActionPoolAll;
|
||||
public int maxActionPoolAll;
|
||||
public float avgActionPoolMoves;
|
||||
public int p95ActionPoolMoves;
|
||||
public int maxActionPoolMoves;
|
||||
public float avgActionsPerPlayerTurn;
|
||||
public int p95ActionsPerPlayerTurn;
|
||||
public int maxActionsPerPlayerTurn;
|
||||
public List<BatchCountMetric> topLanes = new();
|
||||
public List<BatchCountMetric> topReasons = new();
|
||||
public List<BatchCountMetric> topSelectedActionTypes = new();
|
||||
public List<BatchCountMetric> topExecutedActionTypes = new();
|
||||
public List<BatchCountMetric> topLaneActionTypes = new();
|
||||
public List<BatchCountMetric> noEffectActionTypes = new();
|
||||
public List<BatchCountMetric> topHeroReasons = new();
|
||||
public List<BatchCountMetric> topHeroActionTypes = new();
|
||||
public List<BatchCountMetric> topHeroExecutedActionTypes = new();
|
||||
public List<BatchCountMetric> topDefenseReasons = new();
|
||||
public List<BatchCountMetric> topDefenseActionTypes = new();
|
||||
public List<BatchCountMetric> topHeroStyleBuckets = new();
|
||||
public List<BatchCountMetric> topHeroStyleReasons = new();
|
||||
public List<BatchCountMetric> topHeroStyleActionTypes = new();
|
||||
public List<BatchCountMetric> topUnitSkillActionTypes = new();
|
||||
public List<BatchCountMetric> topUnitSkillChangedActionTypes = new();
|
||||
public List<BatchCountMetric> topActorSkillSignatures = new();
|
||||
public List<BatchCountMetric> topFallbackReasons = new();
|
||||
public List<BatchCountMetric> topFallbackActionTypes = new();
|
||||
public List<BatchCountMetric> topFallbackExecutedActionTypes = new();
|
||||
public List<BatchCountMetric> fallbackNoEffectActionTypes = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchCountMetric
|
||||
{
|
||||
public string key;
|
||||
public int count;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Logic.Pool;
|
||||
using Logic.AI;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Core;
|
||||
using Unity.VisualScripting;
|
||||
@ -156,12 +157,17 @@ namespace Logic
|
||||
//海陆层使用的地形高度数据
|
||||
private void GenerateHeightMap()
|
||||
{
|
||||
System.Random batchRandom = null;
|
||||
#if UNITY_EDITOR
|
||||
if (AIDirectorBatchRuntime.RandomSeedOverride != 0)
|
||||
batchRandom = new System.Random(AIDirectorBatchRuntime.RandomSeedOverride ^ 0x5F3759DF);
|
||||
#endif
|
||||
for (var x = 0; x < _width; x++)
|
||||
{
|
||||
for (var y = 0; y < _height; y++)
|
||||
{
|
||||
float seda = new System.Random().Next(1, 30);
|
||||
float sedb = new System.Random().Next(2, 40);
|
||||
float seda = batchRandom?.Next(1, 30) ?? new System.Random().Next(1, 30);
|
||||
float sedb = batchRandom?.Next(2, 40) ?? new System.Random().Next(2, 40);
|
||||
// 使用种子值来改变噪声的生成,保证每次生成的噪声不同
|
||||
var xCoord = (float)x / _width * Scale / seda;
|
||||
var yCoord = (float)y / _height * Scale / sedb;
|
||||
|
||||
@ -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