Compare commits

..

15 Commits

27 changed files with 4881 additions and 229 deletions

View File

@ -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.

File diff suppressed because it is too large Load Diff

View File

@ -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']}"
)

View File

@ -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.

View File

@ -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."

View File

@ -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.

View File

@ -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、PlayerActionBuyCultureCard 只开放英雄槽位卡,不开放普通文化卡 |
| 英雄管理 | 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 或 HeroPlaybookUnitAttackAlly 不应出现在 Fallback |
| 城市不发展 | CityPlan、Growth、ActionPool |
| 科技乱学 | StrategicPosture、TechScore |
| 回合慢 | ActionPool、移动候选、局部搜索半径 |

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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
)

View File

@ -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)
{

View File

@ -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();

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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() { }

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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();
}
}
}

View File

@ -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;

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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;

View File

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