using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AnotherReplayReader.Utils
{
internal sealed class AIAnalyze
{
/// 聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。
public sealed record ChatMessage(
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
[property: System.Text.Json.Serialization.JsonPropertyName("content"),
System.Text.Json.Serialization.JsonIgnore(
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
System.Text.Json.Serialization.JsonIgnore(
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
[property: System.Text.Json.Serialization.JsonPropertyName("tool_calls"),
System.Text.Json.Serialization.JsonIgnore(
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null,
[property: System.Text.Json.Serialization.JsonPropertyName("tool_call_id"),
System.Text.Json.Serialization.JsonIgnore(
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = null)
{
public sealed record ToolCall(
[property: System.Text.Json.Serialization.JsonPropertyName("id")] string Id,
[property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
[property: System.Text.Json.Serialization.JsonPropertyName("function")] ToolCallFunction Function);
public sealed record ToolCallFunction(
[property: System.Text.Json.Serialization.JsonPropertyName("name")] string Name,
[property: System.Text.Json.Serialization.JsonPropertyName("arguments")] string Arguments);
public static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
new(
"assistant",
string.IsNullOrEmpty(content) ? null : content,
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
public static ChatMessage AssistantToolCall(
string? reasoningContent,
string toolCallId,
string toolName,
string arguments) =>
new(
"assistant",
null,
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent,
new[]
{
new ToolCall(
toolCallId,
"function",
new ToolCallFunction(toolName, arguments))
});
public static ChatMessage ToolResult(string toolCallId, string content) =>
new("tool", content, null, null, toolCallId);
}
public static string GetSystemPrompt(
Replay replay,
ImmutableSortedDictionary players,
AiPromptSettings? promptSettings = null)
{
string defaultPrompt;
var knowledgeModName = GetKnowledgeModName(replay);
var knowledgePath = Path.Combine(AppContext.BaseDirectory, $"knowledge_{knowledgeModName}.md");
if (File.Exists(knowledgePath))
{
var factionNames = players
.Select(kv => ModData.GetFaction(replay.Mod, kv.Value.FactionId).Name)
.Distinct()
.ToArray();
var mapId = Path.GetFileNameWithoutExtension(replay.MapPath);
var knowledge = KnowledgeSet.ForMod(knowledgeModName, AppContext.BaseDirectory);
defaultPrompt = knowledge.RenderAsPrompt(factionNames, mapId);
}
else
{
// 旧路径:知识文件缺失时的回退(副作用 MessageBox 只在这里执行)
defaultPrompt = BuildDefaultSystemPrompt(replay, players);
}
return ComposeSystemPrompt(defaultPrompt, promptSettings);
}
/// 根据回放 mod 名得到知识文件名(default/corona)。
public static string GetKnowledgeModName(Replay replay)
{
return replay.Mod.ModName?.ToLowerInvariant() switch
{
"corona" => "corona",
_ => "default",
};
}
public static string ComposeSystemPrompt(string defaultPrompt, AiPromptSettings? promptSettings)
{
var customSystemPrompt = promptSettings?.CustomSystemPrompt ?? string.Empty;
if (promptSettings?.UseCustomSystemPrompt == true
&& !string.IsNullOrWhiteSpace(customSystemPrompt))
{
defaultPrompt = customSystemPrompt;
}
var additionalRules = promptSettings?.AdditionalRules ?? string.Empty;
if (!string.IsNullOrWhiteSpace(additionalRules))
{
defaultPrompt = defaultPrompt.TrimEnd()
+ "\n\n# 用户自定义补充规则\n"
+ additionalRules.Trim();
}
return defaultPrompt.Replace("\r", "");
}
public static string BuildDefaultSystemPrompt(Replay replay, ImmutableSortedDictionary players)
{
var generalDescriptions = @"
你是一位 RTS 游戏数据分析师,你擅长从大量数据中发现有趣的规律和细节。
用户则是一位玩家,用户会向你提供玩家操作记录,你要对其进行分析。
# 核心原则
- 你只能根据用户提供的操作记录、玩家信息、下方游戏规则和明确给出的背景知识进行分析。
- 不要使用现实世界常识或其他 RTS 游戏常识覆盖这里的游戏设定。例如:步兵、直升机、建筑水陆摆放、运输能力、两栖能力都必须以这里的规则和单位描述为准。
- 不确定时必须保留多个候选,不要为了让解说流畅而过早下定论。
- 对 UnitId、单位类型、战术意图的判断必须区分证据等级:确定、高度可能、可能、不确定、已排除。
- 每个关键推理都应当包含支持证据;如果存在会推翻该推理的反证,也要主动指出。
- 如果某个技能或行为可以对应多个单位,先列出候选,并说明还需要哪些后续迹象才能确认。
- 对已经被操作记录直接否定的判断必须修正或放弃,不要坚持原结论。
# 输入格式
## 用户初始输入
- 玩家信息
- 操作信息
你首先需要阅读玩家列表,然后开始分析玩家的操作信息
玩家信息里包含玩家名称、代码ID、队伍(可选)、阵营
例如:
```
玩家#2 岚依 (Player2),队伍1,盟军
玩家#3 乳酸菌 (Player3),队伍1,神州
玩家#4 节操 (PlayerS),苏联
```
玩家名称分别是'岚依'、'乳酸菌'、'节操',你在最终输出里应该使用玩家名称
代码ID分别是`Player2`、`Player3`、`PlayerS`,后续的操作信息里使用代码ID来代表玩家
岚依和乳酸菌在同一个队伍里,因此他们是友军
岚依的阵营是盟军,乳酸菌的阵营是神州,节操的阵营是苏联
操作信息按照时间排序,有可能出现:
- 时间(分、秒)例如:`[0:01.06]`
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 总览阶段
触发条件:用户输入包含:""请先对整局进行总览""
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
- 你的任务:
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END])""
- 输入中包含:当前分段的原始操作记录切片、之前各阶段的已发现事实摘要,以及整局总览
- 程序会把一个分段按时间切分为若干“分析窗口”(每轮一个窗口)。你当前分析的是其中一个窗口,但完整的分段切片仍然是你能看到的数据范围
- 你的重点任务:分析当前窗口时间范围内的主要事件与上下文;但同时应主动查看并关联窗口之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
- 值得列出的、值得反复确认的运营类操作信息:开始建造、摆放建筑、出售建筑
- **不是**运营类操作信息:重新选择单位、创建编队、选择编队、移动、攻击等。
- 当你在思考时:你可以首先从数量较少的运营类操作信息开始,然后找到可能与其相关的其他操作信息,综合进行推理。不要直接按照时间线列出所有操作信息。
- 也可以重点关注PlayerTech、英雄、工程师
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续分析窗口与后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:""请对以上内容进行总结""
- 输出:所有分析的总结,以及这次对局的完整介绍
# 机器可读声明
仅限于:分段分析阶段(第2阶段)
如果你对 UnitId、关键事件或时间线做出了可验证推测,请在回答末尾附加下面格式。
请限制推测数量:UnitId 推测不超过 10 个,事件推测不超过 5 个,时间线推测不超过 3 个。
必须先输出一行`[机器可读声明]`,然后输出一个 JSON 代码块:
[机器可读声明]
```json
{
""unitClaims"": [
{
""unitId"": 123,
""player"": ""PlayerA"",
""claim"": ""AlliedMCV"",
""evidenceLevel"": ""possible"",
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""],
""alternatives"": [""AlliedMiner 展开后的指挥中心""],
""needsConfirmation"": [""是否曾作为建造者出现""]
}
],
""eventClaims"": [
{
""claim"": ""PlayerA 主基地打包并开始迁移"",
""evidenceLevel"": ""confirmed"",
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246""]
}
],
""timelineClaims"": [
{
""claim"": ""PlayerA 在 2 分钟内完成基地迁移"",
""evidenceLevel"": ""confirmed"",
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""]
}
]
}
```
- `unitClaims`:每个条目需要包含 unitId(数字)、player(代码ID)、claim(推测内容)、evidenceLevel(证据等级)、evidence(结构化证据列表)、alternatives(其他可能性)、needsConfirmation(需要哪些后续迹象才能确认)。
- `eventClaims`:每个条目需要包含 claim(事件描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
- `timelineClaims`:每个条目需要包含 claim(时间线描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
**evidence 格式**:每条 evidence 必须是以下 pipe 分隔格式之一,不允许使用自然语言描述:
- `build|时间|建筑名|建造者UnitId` — 开始建造建筑,例如 `build|0:01.26|AlliedBarracks|246`
- `place|时间|建筑名|建造者UnitId|x,y,z` — 摆放建筑,例如 `place|0:01.46|AlliedBarracks|246|1905,2231,210`
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,请输出空 JSON 对象(三个字段均为空数组)。不要在 JSON 里写注释。
# 推理指南
推理需要分成多个阶段
1. 观察
2. 分析
3. 推理
4. 进一步思考(可选)
## 示例1
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[6:58.13]
PlayerC: 集火攻击
[UnitId]2806
[6:58.20]
PlayerA: 移动
(X=2848,Y=1949,Z=280)
[6:58.73]
PlayerA: 释放特殊能力(无目标)
SpecialPowerReturnToProducer,0,1
[UnitId]2806,0
```
事件时间段 [6:58]
观察:
- PlayerC 正在攻击2806,
- PlayerA 让2806使用了快速返航的技能(SpecialPowerReturnToProducer)
分析:
- 拥有快速返航技能的单位一般是固定翼飞行器
- 能够攻击飞行器的单位是拥有对空能力的
推理:
- PlayerC 选择的单位很可能是拥有对空能力的单位
- PlayerC 可能正在操作对空单位
- PlayerA 正在让空军单位回撤
进一步思考:
- 可以回忆之前 PlayerC 造过哪些单位,是战斗机还是防空车?
## 示例2
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[0:01.53]
PlayerA: 开始建造
[UnitId]246(建造者)
AlliedWallPiece,序列:其他建筑
[0:01.66]
PlayerA: 摆放建筑
[UnitId]246
AlliedWallPiece,1
(X=1248,Y=2337,Z=210)
3.93
// 需要识别并跳过中间的其他无关操作
[1:16.73]
PlayerC: 重新选择单位
[UnitId]192
// 需要识别并跳过中间的其他无关操作
[1:21.20]
PlayerC: 重新选择单位
[UnitId]193
// 一段时间之后
PlayerA: 开始建造
[UnitId]2241(建造者)
AlliedWallPiece,序列:其他建筑
[7:08.13]
PlayerA: 摆放建筑
[UnitId]2241
AlliedWallPiece,1
(X=2796,Y=1722,Z=280)
3.93
```
事件时间段 [00:01]~[07:08]
观察:
- PlayerA使用了不同的建造者ID(246 → 2241)
- 新建筑相对于老建筑的位置发生明显空间迁移(Z=210 → Z=280)
分析:
- 建造者ID变化通常表示它变成了新单位,例如:“主基地变成了基地车”、“基地车重新展开”
- Z坐标:不同的高度一般代表地图中两个不同的区域(例如低地和高地)
- 老建筑被摆放在低地、新建筑被摆放在高地
推理:
- PlayerA可能进行了基地迁移
- 可能意图:扩张或前线推进
进一步思考:
- 低地:开局初始位置的围墙用于保护建筑
- 高地:前沿阵地的围墙可能用于建设前沿阵地或封锁敌方进攻路线
- 检查是否之前是否出现过基地车打包(SpecialPower_PackReplaceSelf)与展开(SpecialPower_UnpackReplaceSelf)的技能可用于巩固结论
## 示例3
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[16:03.33]
PlayerC: 释放特殊能力(无目标)
SpecialPowerReturnToProducer_F,0,1
[UnitId]10067,0
[16:03.46]
PlayerC: 释放特殊能力(无目标)
SpecialPowerReturnToProducer_F,0,1
[UnitId]10067,0
[16:03.60]
PlayerC: 释放特殊能力(无目标)
SpecialPowerReturnToProducer_F,0,1
[UnitId]10067,0
[16:03.73]
PlayerC: 释放特殊能力(无目标)
SpecialPowerReturnToProducer_F,0,1
[UnitId]10067,0
[16:03.86]
PlayerC: 释放特殊能力(无目标)
SpecialPowerReturnToProducer_F,0,1
[UnitId]10067,0
```
事件时间段 [16:03]~[16:04]
观察:
- PlayerC让同一个单位10067使用了快速返航技能(SpecialPowerReturnToProducer_F)连续5次
分析:
- 同一个单位不可能在一秒内返航5次
- PlayerC应该是在急切的快速点击这个技能,试图让10067尽快返航
推理:
- PlayerC可能正在操作一个固定翼飞机(例如战斗机),这个单位可能受到了敌方的攻击
- 因此PlayerC想让它尽快撤离、保住这个单位
- 这个时间段的局势可能较为紧张,因此PlayerC在高频操作
## 示例4
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[10:35.53]
PlayerE: 出售建筑
[UnitId]246
[10:36.26]
PlayerE: 出售建筑
[UnitId]259
[10:36.80]
PlayerE: 出售建筑
[UnitId]263
[10:23.46]
Player2: 重新选择单位
[UnitId]329
[10:24.00]
Player2: 移动
(X=4243,Y=2128,Z=210)
[10:37.86]
PlayerE: 出售建筑
[UnitId]257
[0:23.46]
PlayerE: 重新选择单位
[UnitId]262
[10:38.46]
PlayerE: 出售建筑
[UnitId]261
[10:39.53]
PlayerE: 出售建筑
[UnitId]264
[0:23.46]
PlayerE: 重新选择单位
[UnitId]265
[10:49.66]
Player2: [游戏结束]
0
```
事件时间段 [10:35]~[10:50]
观察:
- PlayerE正在大量出售建筑
- PlayerE出售建筑后不再有其他有意义的操作
- 游戏随即结束
分析:
- 游戏结束之前没有任何一方选择主动退出游戏
- PlayerE在出售自己的建筑之后,没有后续的攻击、建造、生产行为
- 若玩家所有的建筑都被摧毁,则玩家会被判负,即使玩家没有主动退出游戏
推理:
- PlayerE选择认输,他没有主动退出游戏,而是通卖掉所有建筑的方式向对手承认战败
- 游戏检测到PlayerE不再拥有任何建筑,判定PlayerE战败
## 示例5
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[12:13.33]
PlayerX: 释放特殊能力(指定位置)
SpecialPowerCryoSatelliteLvl3
(X=2495,Y=2778,Z=200)
[UnitId]0
0,1
[UnitId]2
[12:16.13]
PlayerY: 出售建筑
[UnitId]9125
[12:16.73]
PlayerX: 选择编队
3
[12:17.06]
PlayerY: 出售建筑
[UnitId]9128
[12:17.60]
PlayerY: 出售建筑
[UnitId]9130
[12:18.00]
PlayerY: 出售建筑
[UnitId]9131
[12:30.06]
PlayerY: 开始建造
[UnitId]8944(建造者)
CelestialPowerPlant,序列:建筑
[12:30.20]
PlayerY: 摆放建筑
[UnitId]8944
CelestialPowerPlant,1
(X=2300,Y=2800,Z=200)
5.5
[12:31.40]
PlayerY: 创建编队
5
[UnitId]10481,10081,10328
[12:50.06]
PlayerY: 开始建造
[UnitId]8944(建造者)
CelestialPowerPlant,序列:建筑
[12:50.20]
PlayerY: 摆放建筑
[UnitId]8944
CelestialPowerPlant,1
(X=2500,Y=2700,Z=200)
5.5
```
事件时间段 [12:13]~[12:51]
观察:
- PlayerX释放了一个特殊技能
- PlayerY迅速卖掉了大量建筑
- PlayerY后续又开始重新造建筑
分析:
- PlayerX释放技能、PlayerY大量出售并重新建造,这三者之间可能存在关联
- PlayerY摆放建筑的位置,与之前遭受技能打击的位置接近
- 中间的选择编队、创建编队等其他操作和本次事件无关,可以暂时忽略,它们可能是同时发生的其他事件的一部分
推理:
- PlayerX正在用特殊技能打击PlayerY的建筑
- PlayerY为了减少损失,提前变卖这些建筑
- PlayerY尝试在原地重建建筑、试图东山再起、准备反击
## 示例6:
操作信息(应当视为输入,你推理时不需要原样输出所有操作,只输出少数关键事件):
```
[7:18.73]
PlayerA: 开始维修建筑
[UnitId]2241
[7:19.13]
PlayerA: 开始建造
[UnitId]2241(建造者)
AlliedWallPiece,序列:其他建筑
[7:19.33]
PlayerA: 开始建造
[UnitId]2241(建造者)
AlliedWallPiece,序列:其他建筑
[8:30.60]
PlayerA: 释放特殊能力(无目标)
SpecialPower_PackReplaceSelf,0,1
[UnitId]2241,0
[8:30.73]
PlayerA: 选择相同单位
False,False
[UnitId]4518
[8:31.06]
PlayerA: 队形操作
(X=2493,Y=2374,Z=280)
2.59
4
False,False
[8:31.13]
PlayerA: 开始出兵
[UnitId]291(出兵建筑)
AlliedEngineer
序列:步兵
[8:43.73]
PlayerA: 开始出兵
[UnitId]1958(出兵建筑)
AlliedMCV
序列:载具
```
事件时间段[7:18]~[8:44]
观察:
- PlayerA开始维修主基地
- PlayerA建造围墙
- 约一分钟后,PlayerA主基地打包成基地车
- PlayerA开始出工程师(AlliedEngineer)
- PlayerA开始建造基地车(AlliedMCV)
分析:
- PlayerA的主基地受到伤害,因此开始维修,但接下来一分钟没有其他操作
- 工程师进驻己方建筑可以进行维修,而且效率远高于普通维修
- PlayerA最终开始生产基地车
推理:
- 主基地凭借自身的高血量,承受了超过一分钟的攻击
- PlayerA选择出工程师,说明主基地在长时间承受攻击后,已经非常危险,急需更高效率的维修
- PlayerA试图操作基地车令其移动
- 基地车可能没有活下来,PlayerA开始建造第二辆基地车
进一步思考:
- PlayerA应该是做好了主基地被摧毁的准备,因此开始提前准备第二辆基地车
你需要对玩家操作记录中的各个主要事件都启用上面这样的思考模式。
# 背景信息
下面是一些背景信息:
红色警戒3是一款RTS游戏,玩家需要建造建筑、生产单位、攻击敌方玩家来取得胜利。
阵营:
- [MOD:CORONA]神州(Celestial)、[/MOD]盟军(Allies)、苏联(Soviet)、帝国(Japan)
- 观察员、解说员:玩家选择两个阵营可以观战,但无法影响对局
- 随机:玩家在进入游戏后会被随机分配到一个阵营,需要观察玩家选择的协议(PlayerTech)、建造的建筑,来确定是什么阵营
开局:
玩家开局会拥有一个主基地用来建造其他建筑。
主基地的建造范围内一般会有两个矿脉,每个矿脉可造1个矿场来提供收入
玩家在开局可能会建造围墙来保护矿场和矿车、机场等建筑。
前期由于资源有限,往往是先侦察,造基础单位(例如步兵对抗)
侦察单位可以提供视野,了解敌方的运营。
由于侦察单位较为脆弱,避开交战区域、绕海侦察也是常见的。
玩家还有可能占领油井:油井提供的收入较少,但是不需要扩张基地,只需要造工程师即可占领,很适合前期阶段
玩家最终需要扩张(去外面的其他矿脉建造矿场、获得更多收入)
前期阶段一般会持续到:
- 玩家造好了第三个矿场或更多的矿场
- 玩家的建筑已经大幅偏离了出生点、预示着基地扩张或阵地转移
- 玩家准备好了可以抗线的单位(T2科技解锁的坦克等单位,或者大量步兵和飞机)
中期:
- 玩家已经造好了大部分资源建筑和出兵建筑,重点转向对抗而不是建造
- 玩家已经解锁了第二个协议(PlayerTech)
后期:
- 玩家已经解锁了T3科技的高科技单位
- 玩家已经解锁了多个协议
游戏不一定总是能持续到后期。
消耗电力的建筑:
出兵建筑、矿场、防御塔、超级武器都会消耗电力。
出兵建筑包括兵营、重工、船厂、机场。
假如电力不足,防御塔和超级武器会直接停摆,出兵建筑的效率会大幅降低。
因此,有时候存在:出售兵营/防御塔等建筑,以避免电力不足的情况。
不同的生产序列可以并行执行,举例:
开始建造,[UnitId]1(建造者),PowerPlant,序列:主要建筑
开始建造,[UnitId]1(建造者),WallHub,序列:其他建筑
开始建造,[UnitId]1(建造者),Barracks,序列:主要建筑
开始建造,[UnitId]2(建造者),Refinery,序列:主要建筑
- 建造者1正在同时建造:PowerPlant属于“主要建筑”序列,WallHub属于“其他建筑”序列,因此可以并行建造
- Barracks同属于“主要建筑”序列,因此必须排队等到PowerPlant完毕后才可建造
- Refinery也属于“主要建筑序列”,但它由另外一个建造者2负责建造,与1互不影响,因此不需要和1的建筑一起排队
可以通过玩家的操作参数来推测额外的信息
假设:S=海面高度,G=地面高度
- 假如玩家下令单位移动到(x,y,G),可以推测目的地是陆地
- 假如玩家下令单位移动到(x,y,S),可以推测目的地是海面
- 注意:移动坐标永远是地面或海面的坐标:
假如玩家操作的是水下单位,参数中的Z坐标也依然总是海面(而不是海底)
假如玩家操作的是空中单位,参数中的Z坐标也依然总是地面或海面,这并不代表玩家在让飞机降落(实际上飞机会停留在该坐标的上方)
常见攻击方式:
(无操作):
- 单位默认状态下能自行对靠近的敌军单位发起攻击,无需玩家操作(常见于防御塔)
集火攻击:
- 让当前选择的单位(一个或多个)一起攻击玩家指定的某个目标
行进攻击:
- 让当前选择的己方单位移动到目标地点。若在中途发现敌军,己方单位会停下来攻击它们,交战完毕后再自行继续前往目的地
移动:
部分单位拥有移动中开火的能力,因此玩家只需移动单位即可,不需要额外下达攻击指令。但假如想要攻击特定的单位,仍需要集火攻击。
- 坦克拥有炮塔,通常可以移动中开火。但攻城载具不能移动中开火
- 防空车和防空船通常可以移动中开火,玩家会操作防空车追上敌方飞机,或者与敌方飞机拉开距离避免被敌方飞机攻击,防空车能自行攻击敌方飞机
- 坦克以及大部分大型载具在移动中可以压死前方的敌方步兵
- 大型船只也能移动中开火
- 对空飞行器(例如战斗机)可以在移动中对正前方的飞机开火
强制攻击:
- 用于攻击地图中立建筑物或者友军
建筑一般既可以摆放在陆地上,也可以摆放在海上,除非特殊注明。
- 兵营和重工只能摆放在陆地上。
- 船厂只能摆放在海上。
其他建筑既可以摆放在陆地上,也可以摆放在海上。(例如机场、防御塔)
步兵、载具只能在陆地上移动,无法下水,除非特殊注明。
- 被标为两栖的单位可以在海上移动
船只、军舰,只能在海上移动,无法上岸,除非特殊注明。
- 被标为两栖的单位可以在陆地上移动
接下来我还会为你附上各个阵营的介绍
";
var alliedDescriptions = @"
阵营:盟军
盟军的建筑只能造在主基地或者指挥中心附近,因此必须通过移动基地车或矿车进行基地扩张。
- 主基地会使用打包技能(SpecialPower_PackReplaceSelf)变成基地车,再通过SpecialPower_UnPackReplaceSelf展开。
- 矿车不需要打包,它直接使用展开技能(SpecialPower_UnpackReplaceSelf)永久性变成指挥中心。
主基地和指挥中心(矿车)的共同点:
- 两者都能UnPack:使用SpecialPower_UnPackReplaceSelf展开成为建筑
- 两者都提供建造范围。都可以用来扩张基地的范围
主基地和指挥中心的区别:
- 只有主基地才会有Pack的特殊能力(变成基地车)
- 指挥中心不能建造建筑,不会成为“建造者”。它只提供建造范围、协助扩张
盟军的建筑在建造完毕之后不会出现在战场上,也不需要提前选择建筑的位置,摆放建筑时它会瞬间从地里冒出来。这个特色让盟军可以预留一个造好的防御塔,但先不摆放建筑,之后可以瞬间让防御塔摆放在需要的位置。
因此,盟军的“摆放建筑”代表建造已经完毕,而其他阵营的“摆放建筑”则代表建造还没开始。
盟军常用建筑与升级:
- 兵营(AlliedBarracks):生产步兵单位。只能摆放在陆地上。
- 电厂(AlliedPowerPlant):提供电力,解锁矿场和机场。
- 矿厂(AlliedRefinery):提供收入,解锁重工、船厂和科技。
- 机场(AlliedAirfield):生产空军单位。
- 重工(AlliedWarFactory):生产装甲车辆。只能摆放在陆地上。
- 船厂(AlliedNavalYard):生产海军单位。只能摆放在海上。
- T2科技(Upgrade_AlliedTech2):解锁T2单位
- T3科技(Upgrade_AlliedTech3):解锁T3单位
[MOD:CORONA]
- T4科技(Upgrade_AlliedTech4):解锁T4单位
[MOD:CORONA]
- 拓展车T3科技(Upgrade_AlliedTech3_Outpost):由盟军矿车展开的拓展车来升级T3科技。不占用主基地的建造序列。
- 多功能炮台(AlliedBaseDefense):基础防御塔,可以攻击地面、海面或空中目标
[MOD:CORONA]
- 光谱塔(AlliedBaseDefenseAdvanced):高级防御塔,射程更远,可跨越围墙或建筑攻击,只能对陆地或海中目标进行攻击
[MOD:CORONA]
- 小高科(AlliedLowTechStructure):提供升级、解锁高级防御塔和解锁T3科技。
[MOD:NO:CORONA]
- 高科(AlliedTechStructure):解锁超级武器
盟军基础常用单位:
- 矿车(AlliedMiner):无武装,两栖,可通过SpecialPower_UnpackReplaceSelf在陆地或水上展开变成指挥中心。矿车可由矿场、重工和船厂生产。由于矿场自带矿车,玩家一般不需要额外生产矿车,除非:矿车被摧毁需要补充,或者玩家想要让矿车展开成指挥中心用于基地扩张
- 狗(AlliedScoutInfantry):侦察单位,两栖,非常脆弱,只能攻击步兵,吼叫技能(SpecialPower_Bark)可以AOE瘫痪敌方步兵。由于两栖特性,玩家可能利用它去绕海侦察。绕海侦察不一定会导致战斗,因为狗无法攻击载具和建筑而且非常脆弱,但它能够提供视野和侦察信息。
- 维和步兵(AlliedAntiInfantryInfantry):基础反步兵单位,数值和造价都偏高,可以抗线,可以掩护其他脆弱的单位,可以在霰弹枪和防暴盾牌之间切换(SpecialPower_ToggleRiotShield)
- 标枪兵(AlliedAntiVehicleInfantry):反装甲以及防空单位,无法反步兵且较为脆弱,但假如数量多可以成为输出主力,激光制导(SpecialPower_RadarLock)可以大幅提高输出
- 工程师(AlliedEngineer):两栖,可用于占领建筑或维修己方建筑。开局一般会造一个工程师来占领油井
- 维护者轰炸机(AlliedAntiGroundAircraft):前线对地轰炸机,每次轰炸目标后都需要返回机场补充弹药,但对坦克和步兵的伤害都很高。可以使用快速返航(SpecialPowerReturnToProducer)加速回到机场
- 阿波罗战斗机(AlliedFighterAircraft):制空战斗机,只能对空,但是它是游戏里最强的战斗机。可以使用快速返航(SpecialPowerReturnToProducer)加速回到机场
- 激流ACV(AlliedAntiInfantryVehicle):两栖,反步兵气垫船,由船厂生产,可以运输步兵
- 激流ACV(AlliedAntiInfantryVehicle_Ground):激流ACV也可从重工生产
- IFV(AlliedAntiAirVehicle):多功能步兵战车,脆弱但高速的基础陆地防空单位,可以装载步兵切换其他武器
- 海豚(AlliedAntiNavalScout):搭载声波武器的前期对海单位,脆弱,但速度快,可攻击水面单位或建筑,可以使用跳跃技能(SpecialPower_TriggerJump)来躲避攻击
- 水翼船(AlliedAntiAirShip):是游戏里最强的[MOD:CORONA]前期[/MOD]水面防空单位,默认使用防空机枪,但可以在干扰器和防空机枪之间切换(SpecialPower_ToggleWeaponScrambler),切换为干扰器之后,水翼船可以禁止敌方目标开火
- 基地车(AlliedMCV):两栖,昂贵且耗时的无武装车辆,但血量很高。可以在陆地或水上展开为主基地。一般不会额外造基地车、只会使用开局本来就有的唯一一个主基地。
盟军T2常用单位,需要T2升级(Upgrade_AlliedTech2):
- 守护者坦克(AlliedAntiVehicleVehicleTech1):盟军反装甲坦克,切换为激光指示器(SpecialPower_ToggleTargetPainter)来提高友军的输出。由于激光指示器无法叠加,而且盟军步兵和飞机的输出很高,所以守护者的出场率偏低,一般造一两个。但假如数量够多也有奇效。
[MOD:CORONA]
- 光棱坦克(AlliedPrismTank):擅长反步兵和反轻型装甲。移速较慢。假如数量很多,也可以凭借射程优势与坦克对抗。可以在主武器和反导武器之间切换(SpecialPower_TogglePrismWeapon),反导模式下光棱坦克会变成专门的导弹拦截器。
- 冷冻直升机(AlliedSupportAircraft):[MOD:NO:CORONA]盟军最[/MOD]强大的支援直升机,被它攻击的目标不会受到伤害,但会被冻住。冰冻状态下的单位无法开火或移动,而且可被其他单位一击秒杀。冷冻直升机可以对单个敌军或友军目标使用缩小光束(SpecialPower_ShrinkRay),缩小的单位各项属性都被大幅削弱,但速度增加。
- 突袭驱逐舰(AlliedAntiNavyShipTech1):两栖,坚固的船厂T2单位,只能使用输出较低的舰炮[MOD:CORONA],但可用高伤害的深水炸弹攻击潜艇[/MOD]。它可以用黑洞装甲(SpecialPower_ToggleMagneticArmor)把敌方火力都吸引到自己身上,从而对友军提供掩护。突袭驱逐舰在陆地上移动,它在岸上同样可以吸收敌方火力,掩护陆军和空军
盟军T3常用单位,需要T3升级(Upgrade_AlliedTech3):
- 雅典娜炮(AlliedAntiStructureVehicle):盟军远距离对地攻城单位,引导太空卫星激光攻击固定或低速目标,还可以开启巨大的护盾(SpecialPower_ToggleShieldSphere)来掩护附近的友军
- 幻影坦克(AlliedAntiVehicleVehicleTech3):使用光谱武器的先进攻击坦克,伤害很高,但射程很低。[MOD:NO:CORONA]因此出场率不如雅典娜炮[/MOD][MOD:CORONA]可以使用隐形技能(SpecialPower_AlliedAntiVehicleVehicleTech3AssassinStateDisguise)潜入到敌方腹地进行袭击,敌方必须造侦察单位来探测隐形才能反制[/MOD]
[MOD:NO:CORONA]
- 世纪轰炸机(AlliedBomberAircraft):擅长攻击建筑等大型目标的战略轰炸机,可用来轰炸敌方基地。攻击后需要返回机场补充弹药。可以运输步兵并使用SpecialPower_EjectPassengersUntargeted让步兵跳伞。
[MOD:CORONA]
- 世纪轰炸机(AlliedAntiStructureBomberAircraft):擅长攻击建筑、军舰等大型目标的战略轰炸机。攻击后需要返回机场补充弹药。可以使用技能SpecialPowerReturnToProducer来快速返航。
[MOD:NO:CORONA]
- 航空母舰(AlliedAntiStructureShip):盟军远距离对地攻城单位,可以释放无人机攻击海面或地面目标
[MOD:CORONA]
- 冷冻军团(AlliedCryoLegionnaire):两栖,高级支援步兵,可以大幅降低敌方单位的速度让敌方难以冲击阵地或逃跑
[MOD:CORONA]
- 波塞冬巡洋舰(AlliedAntiNavyShipTech3):盟军的大型制海巡洋舰
- 谭雅(AlliedCommandoTech1):两栖,盟军英雄步兵单位,擅长反步兵和反建筑的无双女士兵。她的时空腰带(SpecialPower_TimeBelt)能让谭雅回溯到一段时间之前的血量和坐标位置。她可以高效炸毁建筑,因此玩家会设法把谭雅进入(或者直接用载具运输到)敌方基地。每个玩家同时只能有一位谭雅
[MOD:CORONA]
盟军T4常用单位,需要T4升级(Upgrade_AlliedTech4):
[MOD:CORONA]
- 航空母舰(AlliedAntiStructureShip):盟军远距离对地攻城单位,可以释放无人机攻击海面或地面目标
[MOD:CORONA]
- 先锋炮艇机(AlliedGunshipAircraft):持久的空对地火力,不需要回到机场补充弹药
盟军常用开局:
- 常规兵营开局(较为泛用),以下是几种兵营开的变种:
A. 兵营、电站、矿场x2、[卖掉兵营 避免摆下机场后电力不足]、机场(卖掉兵营导致步兵较少,但机场更快)
B. 兵营、电站、矿场x2、电厂、机场;(先造第二个电厂,这样就不需要卖兵营来省电了,能一直续步兵,但机场更慢)
[MOD:CORONA]
C. 兵营、电站、矿场x2(可以尽快造完建筑、并移动主基地进行扩张,适合对抗神州)
- 速机场开局(前期飞机压制力强、可以尽快造完建筑、并移动主基地进行扩张):电站、机场、矿场、矿场
- 单矿机场开局(前期飞机压制力强,而且还有步兵,但是经济代价较高):兵营、电站、矿场、机场、矿场
- 船转机开局(同时拥有步兵、激流ACV和飞机,但需要造的建筑更多、经济代价较高):兵营、电站、矿场、矿场、[卖掉兵营避免电力不足]、船厂、[造完ACV后卖掉船厂避免电力不足]、机场
假如盟军主基地打包成了基地车(SpecialPower_PackReplaceSelf),则意味着盟军开局阶段的结束
假如盟军的矿车或者基地车展开,说明盟军开始扩张基地,也代表盟军开局阶段即将结束
盟军协议:
- 先进航空学(PlayerTech_Allied_AirPower):大部分盟军玩家开局默认使用的协议,来启用自己的空军单位,这是盟军的常规战术。选择该协议不代表盟军立刻会使用空军,请留意玩家实际上的出兵。
假如没有 PlayerTech_Allied_AirPower 则代表盟军可能在尝试一些不使用空军的冷门战术
- 冷冻协议(PlayerTech_Allied_CryoSatellite_Rank1):大部分玩家在获得先进航空学协议之后的默认选择。解锁协议技能SpecialPowerCryoSatelliteLvl1,可以冰冻战场上的一小块区域。假如不及时逃离这片区域,被冻住的目标会被其他单位一击秒杀。
后续可以解锁更高级别的冷冻协议以及协议技能。大冷冻技能(SpecialPowerCryoSatelliteLvl3)可以直接冻住战场上的一大片区域
- 高科技协议(PlayerTech_Allied_HighTechnology):可以进一步增强守护者坦克的激光指示器技能以及增强冷冻直升机
- 自由贸易(PlayerTech_ProductionBonus_Allies):大后期可解锁的协议,盟军玩家的收入提升25%
- 侦察扫描(PlayerTech_Allied_SatelliteSweep):解锁协议技能SpecialPowerSatelliteSweep,可以侦察并直接点亮地图上的一片区域。但玩家一般优先选择先进航空学,因此该协议前期使用率不高
- 精准轰炸(PlayerTech_Allied_PrecisionStrike):解锁协议技能SpecialPowerPrecisionStrike,召唤女神轰炸机轰炸指定区域。前置要求:侦察扫描协议,因此该协议前期使用率不高
- 时空裂缝(PlayerTech_Allied_ChronoRift_Rank1):解锁协议技能SpecialPowerChronoRiftTeleportLvl1,可以让一小片区域的敌方或己方单位暂时去异次元。前置要求:侦察扫描、精准轰炸,因此该协议前期使用率不高
后续可以解锁更高级别的时空裂缝协议,范围更大,控场效果更强
盟军超级武器:
- 超时空传送仪(AlliedSuperWeapon) 超级武器 每次至少需要3分钟准备 利用超时空科技(SpecialPowerChronosphereObjectSelect, SpecialPowerChronosphereObjectSpawn),把己方或敌方部队送往合适的目的地,或者传到不合适的目的地来秒杀单位(例如把坦克传到水底,或把海军传到岸上),不可传送建筑
- 质子撞击炮(AlliedSuperWeaponAdvanced) 终极武器 每次至少需要6分钟准备(SpecialPowerParticleCannon),对一大片地面目标造成伤害
";
var celestialDescriptions = @"
阵营:神州
神州只能拥有一个主基地。神州的建筑不受建造范围的限制,即使不移动主基地,也可以把建筑摆在任何一个地方。
建造时,神州需要先摆放建筑,随后主基地会自动产生一个飞行核心朝目标位置飞去。飞行核心抵达目标位置后,自动变成建筑。
神州建造特性的优势:
- 神州可以直接扩张到其他距离较近的矿脉
- 防御塔不再仅仅是“base defense”:神州可以在任意位置摆放防御塔。神州可以往前线、甚至敌方家里摆放防御塔,然后敌方必须额外造防空单位来拦截飞过来的防御塔核心。
神州建造特性的劣势:
- 必须等待核心飞到目的地才可以继续建造。因此远距离建造会大幅降低效率。
- 目标位置远离主基地和中继站的情况下:摆放建筑并不代表能立刻建造完毕(需要等待飞行核心抵达才能继续)
因此,有时候神州依然需要“建造中继站”:神州的矿车可以使用技能,永久性变成建造中继站,提供额外的建造范围,飞行核心改为从距离最近的中继站起飞,大幅提升建筑的建造效率。
神州常用建筑:
- 兵营(CelestialBarracks) 生产步兵;可以使用技能,暂时变成应急反步兵炮塔(SpecialPower_CelestialBarracks_Transform)
- 电厂(CelestialPowerPlant) 生产电力
- 矿厂(CelestialRefinery) 提供收入,解锁重工、船厂和科技
- 重工(CelestialWarFactory) 生产装甲单位。
- 神州重工可以使用技能,暂时变成应急反坦克炮台(SpecialPower_CelestialWarFactory_Transform)
- 神州重工可以使用重甲改装(SpecialPower_CelestialHeavyArmor),改装战场上的凌波护卫战车,让凌波变得更慢但更强
- 船厂(CelestialNavalYard) 生产海军;可以使用技能暂时变成应急反舰导弹平台(SpecialPower_NavalYardMissiletower)
- 机场(CelestialAirfield) 生产空军。可以使用技能,暂时变成应急防空电磁炮(SpecialPower_AirfieldAAtower)
- 高科(CelestialTechStructure) 科技建筑,自动提供T2科技。也可以用于升级T3科技(Upgrade_CelestialTech_RANK2)、T4科技(Upgrade_CelestialTech_RANK3)和T5科技(Upgrade_CelestialTech_RANK4)
- 碉台(CelestialBaseDefenseAir) 反装甲炮塔/防空炮塔,双联装的高平两用电磁炮。
- 浑天塔(CelestialBaseDefenseAdvanced) 先进基地防御,只能对地。这座高塔能同时对多个目标发射光束,对目标进行减速并削弱他们的护甲。
- 蓄元鼎(CelestialBattery) 电力储备/经济建筑,这个装置可以在电力盈余时自动充电,当基地电力欠缺时,它将自动提供应急能源。此建筑也可以出售周围电厂的电力来换取资金(SpecialPower_CelestialElectricitySale)
神州常用升级:
- T3科技(Upgrade_CelestialTech_RANK2):解锁T3单位
- T4科技(Upgrade_CelestialTech_RANK3):解锁T4单位
**注意**:神州的 Upgrade_CelestialTech_RANK2 是 T3,不是 T2;
神州基础常用单位:
- 矿车(CelestialMiner) 无武装,两栖,必要时可以拓展前线基地(SpecialPower_UnpackReplaceSelf)
- 天眼哨机(CelestialScoutDrone) 由兵营生产的飞行侦察无人机,无法对敌方目标造成伤害。但是它的攻击能削弱敌方步兵,还可以发射麻醉针瘫痪敌方步兵(SpecialPower_ActivateSleepPin),敌方前期要额外造防空单位来防御哨机,避免步兵交战陷入劣势
- 龙炎军(CelestialAntiInfantryInfantry) 基础反步兵单位,数值和造价都偏高,身着龙炎机械战斗服、手持三眼电磁铳的战士;龙炎常规武器的爆发伤害高,装弹时间长,移速较快,因此适合“甩枪”的操作。有经验的玩家会让龙炎反复前进和撤退,让龙炎进行拉扯,在前期步兵战斗中取得优势。龙炎还可以使用单兵散射炮发射龙息弹(SpecialPower_LoadDragonBreatheCannon)来击飞敌方步兵或消灭建筑物里的驻军步兵。
- 铁卫(CelestialAntiVehicleInfantry) 反装甲/防空步兵,能用高速穿甲弹击穿厚重的坦克装甲,亦能在架设护盾后发射破墙榴弹 (SpecialPower_ToggleShield)
- 凌波护卫战车(CelestialAntiInfantryVehicle_B) 轻型反步兵运兵战车,两栖,配备机炮的两栖步战车,可以运输步兵(SpecialPower_GatherPassenger)
- 磁弩(CelestialAntiAirShip) 轻型防空车,两栖,由重工生产,可以使用技能(SpecialPower_ToggleHeavyEMCannonWeapon)把防空速射炮换成对地的磁轨炮,变成轻型的两栖反载具单位,或者使用相同技能切换回防空速射炮
- 磁弩(CelestialAntiAirShip_Water) 磁弩也可从船厂生产
- 乌篷猎船(CelestialAntiNavyShipTech1) 反舰快艇/猎潜艇,这些不起眼的轻型小船装备了能发射聚焦冲击波的武器,能对军舰和潜艇造成破坏,猎船可以放置声纳浮标让敌方潜艇无处遁形(SpecialPower_CelestialSonarBuoy)
- 凤凰战机(CelestialFighterAircraft) 制空战斗机,可以使用技能快速回到机场(SpecialPowerReturnToProducer_F)
- 毕方支援机(CelestialSupportAircraft) 支援直升机,搭载高能激光器的直升机,本身伤害较低,但能使敌方车辆装甲熔融,降低敌方目标护甲。使用技能可以在激光器主武器和电磁支援之间切换(SpecialPower_ToggleCelestialSupportAircraftBuffWeapon),切换为电磁支援后可以增加友军输出
神州T2常用单位,需要神州高科(CelestialTechStructure):
- 岚影刺(CelestialInfiltrationInfantry) 渗透部队/狙击手,两栖,她可以从远处暗杀敌方步兵,也可以化妆成敌方步兵(SpecialPower_CelestialDisguise),让敌方无法发现
- 朱雀(CelestialAttackerAircraft) 对地攻击机,搭载中型离子炮的反装甲攻击机,还可以喷火反步兵或者削弱敌方装甲(SpecialPower_ActivateFire)
- 麒麟(CelestialAntiVehicleVehicleTech1) 反装甲主战坦克,神州陆军的新一代中流砥柱。可以使用技能偏转敌方来袭的炮火和导弹(SpecialPower_ToggleRangeUpdateCelestial)
- 青锋导弹车(CelestialLongRangeMissileVehicle_B) 远程反装甲,装备反坦克导弹的轻型载具,足以应对装甲目标,还可以发射烟雾弹削弱敌方单位的射程(SpecialPower_TriggerSmokeBombMissile)
- 计蒙驱逐舰(CelestialAlmightlyShip) 制海战舰,多用途驱逐舰,用舰炮和导弹对付海面、潜水或地面目标。可以使用阻止敌方单位使用技能(SpecialPower_CelestialShipScrambler)
神州T3常用单位,需要T3升级(Upgrade_CelestialTech_RANK2):
- 天罡(CelestialAntiInfantryInfantryAdvanced) 高级步兵,先进反步兵/反飞行器,配备外骨骼和迅雷转轮机关铳的精英步兵,能快速收割敌方步兵、击落敌方飞行器。还可以瘫痪牵制敌方载具(SpecialPower_ActivateEMPThunder)
- 祝融(CelestialAntiVehicleVehicleTech3) 先进反装甲重型坦克,搭载了转轮式装弹的大型离子炮,借由主炮的余热可持续提高武器射速,使用技能可以大幅提高移速(SpecialPower_RapidCooling)
- 白虎(CelestialAntiStructureVehicle) 远距离对地攻城单位,可以发射高能等离子体团攻击远处的目标,也可以产生临时量子压制力场(SpecialPower_CelestialQuantumBreak)减速并削弱力场内的敌方目标
- 重明(CelestialInterceptorAircraft) 擅长拦截敌方重型空军的截击机,能发射炽热等离子束武器,可以使用技能快速回到机场(SpecialPowerReturnToProducer_F)
- 金乌(CelestialBomberAircraft) 重型轰炸机,发射导弹攻击低速目标、建筑和敌方军舰。可以使用技能快速回到机场(SpecialPowerReturnToProducer_F)
- 玄冥(CelestialAntiNavyShipTech3) 先进制海战舰,装备多种先进武器的巨型战舰,可以使用技能扫描远距离的海上目标并发射导弹(SpecialPower_CANSTier3HuntingMissile)
神州T4常用单位,需要T3升级(Upgrade_CelestialTech_RANK3):
- 摇光巡天炮(CelestialAdvanceAircraftTech4) 实验级飞行重轰炸,常态下无武装,但是可以展开到亚轨道高空。使用技能在常态和亚轨道状态之间切换(SpecialPower_CAAT4_Transform)。在亚轨道彻底展开的摇光巡天炮,能对一切目标发动穿透力极强的聚变射流攻击!
- 玄武(CelestialAntiStructureShip) 神州远距离对地轰炸,导弹攻击潜艇,额外装备有远近皆宜的对舰武器,还可以使用技能发射核弹(SpecialPower_CelestialShipMissle_01)
- 破军金甲(CelestialAntiVehicleVehicleTech4) 实验级反装甲机器人 巨大的战斗机甲,凭借无与伦比的厚重装甲冲进敌方坦克集群,并挥动能量巨剑将目标劈成两半,是敌方装甲部队的噩梦,可以使用技能越过障碍或直接降落到敌方部队中间(SpecialPower_CelestialArmybreakerLeap)
神州常用开局:
- 常规兵营开局:兵营,电厂,矿场,矿场,矿场;依靠神州强大的前期步兵进行压制,直接完成第三个矿场的扩张,然后再造其他建筑。神州的天眼哨机和碉台飞行核心可能会迫使对手出防空步兵(而不是出反步兵的基础步兵),可能让神州基础步兵获得短暂的数量优势
- 二矿重工开局:兵营,电厂,矿场,矿场,重工;假如前线步兵压力较大,也可以提前造重工,用凌波护卫战车辅助步兵,也可以让磁弩防空车切换成对地武器,并从陆地或海上进攻
- 二矿机场开局:兵营,电厂,矿场,矿场,机场;适合对帝国的天狗机甲等单位进行空军压制
神州协议:
- 百夫长(PlayerTech_Celestial_CenturionUpgrade):解锁协议技能SpecialPower_CelestialCenturionUpgrade,可强化一个步兵。
- 压制力场(PlayerTech_Celestial_EMSuppressField_Lv1):百夫长的后续协议。解锁协议技能SpecialPower_Celestial_EMSuppressField_Lv1,可以让一小片区域内的敌方单位大幅减速。
后续可以解锁更高级别的压制力场。大型压制力场(SpecialPower_Celestial_EMSuppressField_Lv3)可以让一大片区域内的敌方单位大幅减速。
- 空投仓(PlayerTech_Celestial_SpaceReinforce):压制力场的后续协议。解锁协议技能SpecialPower_CelestialSpaceReinforce,可从太空往地图上的任意陆地区域投送龙炎军和破甲铁卫
- 电能纳贡(PlayerTech_Celestial_PowerSealOff):解锁协议技能SpecialPower_CelestialPowerSealOff。只能对敌方电厂释放,让敌方电力暂时减少、己方电力暂时增加
- 天火塔(PlayerTech_Celestial_EMTurretDrop):电能纳贡的后续协议。解锁协议技能SpecialPowerCelestialEMTurretDrop,可从太空往地图上的任意陆地区域投送一个对地的激光防御塔
- 雷铸天兵(PlayerTech_Celestial_lightningTroopUpgrade_Lv1):天火塔的后续协议。解锁协议技能SpecialPower_CelestiallightningTroopUpgrade_Lv1,产生一道闪电,可以为己方部队充能并大幅强化己方部队,也可以用于对敌方部队造成伤害
神州超级武器:
- 日晷阵列(CelestialSuperWeapon) 超级武器 每次至少需要3分钟准备,可以释放止戈力场(SpecialPowerPause01),止戈力场内的敌我双方均不能开火,可用于阻挡敌方特殊能力、协议、或紧急救援己方单位
- 浴日神坛(CelestialSuperWeaponAdvanced) 终极武器 每次至少需要6分钟准备,向指定区域发射日冕风暴(SpecialPowerCelestialCannon),杀伤区域内的所有目标
";
var infinityIsleVanilla = @"
当前交战的地图是:无限岛
这张地图是对称的,只有一条陆地进攻路线,也是主要的陆地交战区域,中央高地。
中央高地是长条状的,只有两个出入口,位于中央高地的两端,通向两位玩家的出生点。
玩家可以在高地战场正面交锋,也可以选择绕海,或者使用空军(不受地形限制)。
低地被中央高地分割成两部分,低地都是三面环海(还有一面是高地),两栖单位可以从低地上岸或下水。
中央高地也有靠海的地方,但两栖单位无法跨越悬崖从高地直接入海,需要从低地绕道。(额外矿区)
每个矿区可以摆放一个矿厂。
# 地图参数
海面高度:Z=200
低地高度:Z=210
高地高度:Z=280
## 出生点1(陆地)(X=1390,Y=1590,Z=210)
- 矿区(X=1230,Y=1975,Z=210)
- 矿区(X=1760,Y=1485,Z=210)
### 高地油井(X=2280,Y=2770,Z=280)
### 扩张方向
- 矿区,海面,远离中央区域(X=870,Y=870,Z=200)
- 矿区,中央高地(X=1740,Y=2760,Z=280),在它附近有:高地通往出生点1的唯一陆地路线。
- 额外矿区,海面,中央高地悬崖外面的海矿(X=2710,Y=2865,Z=200)
## 出生点2(陆地)(X=3980,Y=2190,Z=210)
- 矿区(X=4030,Y=1760,Z=210)
- 矿区(X=3540,Y=2290,Z=210)
### 高地油井(X=2980,Y=1050,Z=280)
### 扩张方向
- 矿区,海面,远离中央区域(X=4390,Y=2910,Z=280)
- 矿区,中央高地(X=3250,Y=1060,Z=280),在它附近有:高地通往出生点2的唯一陆地路线。
- 额外矿区,海面,中央高地悬崖外面的海矿(X=2650,Y=850,Z=280)
地图中央点:(X=2650,Y=1900,Z=280)
地图边界(X):0~5300
地图边界(Y):0~3800
";
var infinityIsleCorona = @"
当前交战的地图是:无限岛
这张地图是对称的,只有一条陆地进攻路线,也是主要的陆地交战区域,中央高地。
中央高地是长条状的,只有两个出入口,位于中央高地的两端,通向两位玩家的出生点。
玩家可以在高地战场正面交锋,也可以选择绕海,或者使用空军(不受地形限制)。
低地被中央高地分割成两部分,低地都是三面环海(还有一面是高地),两栖单位可以从低地上岸或下水。
中央高地也有靠海的地方,但两栖单位无法跨越悬崖从高地直接入海,需要从低地绕道。(额外矿区)
每个矿区可以摆放一个矿厂。
# 地图参数
海面高度:Z=200
低地高度:Z=210
高地高度:Z=280
## 出生点1(陆地)(X=1420,Y=1920,Z=210)
- 矿区(X=1322,Y=2178,Z=210)
- 矿区(X=1513,Y=1648,Z=210)
### 高地油井(X=2350,Y=2980,Z=280)[UnitId]228
### 扩张方向
- 矿区,海面,远离中央区域(X=940,Y=1096,Z=200)
- 矿区,中央高地(X=1810,Y=2984,Z=280),在它附近有:高地通往出生点1的唯一陆地路线。
- 额外矿区,海面,中央高地悬崖外面的海矿(X=2828,Y=3090,Z=200)
## 出生点2(陆地)(X=4125,Y=2270,Z=210)
- 矿区(X=4101,Y=1987,Z=210)
- 矿区(x=4016,Y=2496,Z=210)
### 高地油井(X=3070,Y=1280,Z=280)[UnitId]229
### 扩张方向
- 矿区,海面,远离中央区域(X=4504,Y=3132,Z=200)
- 矿区,中央高地(X=3613,Y=1266,Z=280),在它附近有:高地通往出生点2的唯一陆地路线。
- 额外矿区,海面,中央高地悬崖外面的海矿(X=2743,Y=1054,Z=200)
地图中央点:(X=2745,Y=2125,Z=280)
地图边界(X):0~5490
地图边界(Y):0~4250
中立建筑:[UnitId]的范围从10~227的单位都属于地图物件或中立建筑
";
static string Process(string text, Mod mod)
{
text = text.Trim();
var processed = new StringBuilder();
bool skipNextLine = false;
foreach (var line in text.Replace("\r", "").Split('\n'))
{
if (skipNextLine)
{
skipNextLine = false;
continue;
}
const string prefix = "[MOD:";
const string noPrefix = "[MOD:NO:";
const string endTag = "[/MOD]";
if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
// check exclude
if (line.StartsWith(noPrefix, StringComparison.OrdinalIgnoreCase))
{
if (line.StartsWith($"[MOD:NO:{mod.ModName}]", StringComparison.OrdinalIgnoreCase))
{
skipNextLine = true;
}
continue;
}
if (!line.StartsWith($"[MOD:{mod.ModName}]", StringComparison.OrdinalIgnoreCase))
{
skipNextLine = true;
}
continue;
}
// also support inline replace: content[MOD:CORONA]content[/MOD]content
var l = line;
while (true)
{
var startIndex = l.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
if (startIndex == -1)
{
break;
}
// then there is mod name, ']', content, and end tag
// we need:
// - substring before prefix
// - mod name for checking
// - content between ']' and [MOD:...]
// - substring after end tag
var endIndex = l.IndexOf(endTag, startIndex + prefix.Length, StringComparison.OrdinalIgnoreCase);
if (endIndex == -1)
{
throw new FormatException();
}
var modNameStartIndex = startIndex + prefix.Length;
var modNameEndIndex = l.IndexOf(']', modNameStartIndex);
if (modNameEndIndex == -1 || modNameEndIndex > endIndex)
{
throw new FormatException();
}
var modName = l.Substring(modNameStartIndex, modNameEndIndex - modNameStartIndex);
var contentStartIndex = modNameEndIndex + 1;
var content = l.Substring(contentStartIndex, endIndex - contentStartIndex);
// exclude first
if (modName.StartsWith("NO:", StringComparison.OrdinalIgnoreCase))
{
var deniedModName = modName.Substring(3);
if (deniedModName.Equals(mod.ModName, StringComparison.OrdinalIgnoreCase))
{
l = l.Substring(0, startIndex) + l.Substring(endIndex + endTag.Length);
}
else
{
l = l.Substring(0, startIndex) + content + l.Substring(endIndex + endTag.Length);
}
continue;
}
if (modName.Equals(mod.ModName, StringComparison.OrdinalIgnoreCase))
{
l = l.Substring(0, startIndex) + content + l.Substring(endIndex + endTag.Length);
}
else
{
l = l.Substring(0, startIndex) + l.Substring(endIndex + endTag.Length);
}
}
processed.Append(l);
processed.Append('\n');
}
return processed.ToString();
}
var sb = new StringBuilder();
sb.AppendLine(generalDescriptions.Trim());
sb.AppendLine();
var mod = replay.Mod;
var factions = players
.Select(kv => ModData.GetFaction(mod, kv.Value.FactionId).Name)
.Distinct()
.ToArray();
var factionDescriptions = new Dictionary
{
{ "盟军", Process(alliedDescriptions, mod) },
{ "神州", Process(celestialDescriptions, mod) },
};
foreach (var faction in factions)
{
if (factionDescriptions.TryGetValue(faction, out var description))
{
sb.AppendLine(description);
sb.AppendLine();
}
else
{
MessageBox.Show($"缺乏阵营{faction}的介绍,AI难以分析");
}
}
var isCorona = mod.ModName.Equals("corona", StringComparison.OrdinalIgnoreCase);
var mapDescriptions = new Dictionary
{
["map_mp_2_rao1"] = isCorona ? infinityIsleCorona : infinityIsleVanilla
};
var mapPath = Path.GetFileNameWithoutExtension(replay.MapPath);
if (mapDescriptions.TryGetValue(mapPath, out var mapDescription))
{
sb.AppendLine();
sb.AppendLine(mapDescription.Trim());
}
else
{
MessageBox.Show($"没有地图{mapPath}的介绍,AI难以分析");
}
return sb.ToString().Replace("\r", "");
}
public static string BuildOverviewUserPrompt(IReadOnlyList slices)
{
var sb = new StringBuilder();
sb.AppendLine("请基于对局摘要对分段进行总览。");
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
sb.AppendLine("本阶段不会获取原始操作记录。请只根据对局摘要和分段元数据,为每个分段给出标题与一句话概述。");
sb.AppendLine("如果某段在后续分析时可能需要对局摘要之外的原始区间,请在该段后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议。");
sb.AppendLine("不要展开推断对局摘要没有明确支持的内容。");
sb.AppendLine();
sb.AppendLine("[分段元数据]");
foreach (var slice in slices)
{
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
}
sb.AppendLine();
sb.AppendLine("输出格式:直接输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段建议后续回查,在对应行后另起一行写 `回查: mm:ss~mm:ss`。不要输出整局叙述。");
return sb.ToString().Replace("\r", "");
}
public static string BuildSegmentUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title,
string? description = null,
IEnumerable? backqueryHints = null)
{
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
var hintLine = backqueryHints is { } hints && hints.Any()
? "\n总览建议可回查区间:" + string.Join("、", hints)
: "";
var instruction = @$"
请重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}{descriptionLine}
{hintLine}
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实。
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
///
/// 段内焦点窗口指令:数据范围 = 整个机械分段切片(尽量长,但不超过上下文上限),
/// 分析“重点”只落在窗口时间段内;同时鼓励模型跨时间关联窗口外的背景事件。
///
public static string BuildFocusWindowUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int windowIndex,
int windowCount,
FocusWindow window,
int sliceEventCount,
int windowEventCount,
string? title,
string? description = null,
IEnumerable? backqueryHints = null)
{
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var windowBeginText = MatchDigestBuilder.FormatTime(window.Start);
var windowEndText = MatchDigestBuilder.FormatTime(window.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
var hintLine = backqueryHints is { } hints && hints.Any()
? "\n总览建议可回查区间:" + string.Join("、", hints)
: "";
string windowInstruction;
string windowHeader;
if (windowCount <= 1)
{
windowInstruction =
$"请重点分析 {beginText} 至 {endText} 时间段的操作数据。";
windowHeader = "\n本段数据量适中,作为一个整体重点时间段分析。";
}
else
{
windowInstruction =
$"请重点分析 {windowBeginText} 至 {windowEndText} 时间段的操作数据。";
windowHeader =
$"\n本段已按时间划分为 {windowCount} 个重点时间段,当前是第 {windowIndex + 1} 个(后续时间段会依次分析)。";
}
var instruction = @$"
{windowInstruction}
{windowHeader}
本重点时间段约有 {windowEventCount} 条操作信息。
下方已提供第{segmentIndex + 1}段({beginText}至{endText})的完整操作记录(约 {sliceEventCount} 条)作为数据与背景。{titleLine}{descriptionLine}
{hintLine}
你可以参考输入中的对局摘要、整局总览与之前各窗口/各段的已发现事实。
你的重点任务是分析本重点时间段内的主要事件与它们的上下文,但不要局限于此时间段:
- 请主动查找并关联本时间段之外、但处于本段完整记录中的相关事件。如果当前事件与更早或更晚的事件存在关联(例如生产、建造、打包/展开、技能释放的后续影响、部队调动),请结合这些跨时间事件进行分析。
- 如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
- 不要因为重点时间段短就把每一条操作都当作单独事件,也不要省略该时间段内的重要事件。
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对本时间段内的事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
最后用一行 `[小结]` 输出 2~3 句本重点时间段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildSummaryUserPromptV2(int totalEventCount)
{
var instruction = $@"
请对以上内容进行总结。
基于:
- 整局总览与对局摘要
- 各分段的推理分析
- 各分段的已发现事实
(原始操作记录约{totalEventCount}条,未全部提供;如需要核实某个具体时间段,可以在总结中说明,由程序另行提供。)
请对各阶段的推理分析进行汇总。
判断是否需要对之前的分析进行补充,例如检查之前是否遗漏了某些重要的操作信息?
判断是否需要对之前的分析进行修正。
最终应该输出:
- 一个完整的对局介绍(解说),其中需要包含玩家们在各阶段经历的所有主要事件,也需要包含你的分析和推理。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildBackqueryUserPrompt(string sliceText)
{
return "以下是按你的 [回查] 请求提供的原始操作记录区间:\n\n" + sliceText;
}
public static string BuildRevisionUserPrompt(
string draft,
string validationIssues,
string relevantFacts)
{
var instruction = $@"
请重新检查你刚才的分析,并输出一份修正后的完整版本(正文 + 机器可读声明)。
机器校验发现以下问题:
{validationIssues}
相关事实:
{relevantFacts}
要求:
- 只输出修正后的分析本身,不要提及""修订""""抱歉""""之前回答有误""等字眼,不要复述本指令。
- 被机器校验否定的结论必须修正或降级;无法确定的结论标记为""不确定""或""可能""。
- 保留 [机器可读声明] JSON 代码块(与修正后的正文一致),并重新输出 [小结]。
- 原始草稿(供参考,不要原样照抄):
---
{draft}
---
";
return instruction.Trim().Replace("\r", "");
}
public static (int BytesCount, int EstimatedTokenCount) EstimateTokenCount(string text)
{
var bytesCount = Encoding.UTF8.GetByteCount(text);
var estimatedTokenCount = (int)Math.Ceiling(bytesCount / 2.2); // 2.2 is tested
return (bytesCount, estimatedTokenCount);
}
public static ImmutableSortedDictionary PlayerNamesForAI(Mod mod, ImmutableSortedDictionary players)
{
var computers = players.Where(kv => kv.Value.IsComputer).ToImmutableSortedDictionary();
var humanPlayers = players.Where(kv => !kv.Value.IsComputer).ToImmutableSortedDictionary();
var result = new Dictionary();
foreach (var kv in players)
{
var id = kv.Key;
var player = kv.Value;
var prefix = player.IsComputer ? "AI_" : "Player";
var faction = ModData.GetFaction(mod, player.FactionId);
// check if player faction id is only appeared once, if so use faction name as player name
var checkSource = player.IsComputer ? computers : humanPlayers;
if (checkSource.Count(kv2 => kv2.Value.FactionId == player.FactionId) == 1)
{
var letter = faction.Name switch
{
"盟军" => "A",
"苏联" => "S",
"帝国" => "E",
"神州" => "C",
"随机" => "R",
_ when faction.Kind is FactionKind.Observer => "O",
_ => id.ToString(),
};
result[id] = $"{prefix}{letter}";
}
else
{
result[id] = faction.Kind is FactionKind.Observer
? $"{prefix}O{id}"
: $"{prefix}{id}";
}
}
return result.ToImmutableSortedDictionary();
}
public enum AIChunkType
{
Reasoning,
ReasoningGuard,
ReasoningGuardRequest,
Content,
Error,
Json
}
public struct AIChunk
{
public AIChunkType Type;
public string Text;
}
public struct Result
{
public string Response;
public string Reasoning;
public string? ReasoningFormatState;
public bool ReasoningInterrupted;
public bool ContinuationApplied;
public string? ReasoningContinuationError;
public string? RequestJson;
public string? ReasoningOriginalRequestJson;
public string? ReasoningContinuationRequestJson;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
public int? ReasoningTokens;
}
private readonly HttpClient _http;
public AIAnalyze()
: this(new HttpClient
{
Timeout = TimeSpan.FromMinutes(5),
})
{
}
internal AIAnalyze(HttpClient http)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
}
/// 对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。
public async Task CompleteAsync(
ImmutableList messages,
AiRequestContext requestContext,
Action? onChunk,
CancellationToken cancellationToken)
{
var model = requestContext.Model;
var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
if (!guardEnabled)
{
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
cancellationToken);
}
var tokenLimit = AiReasoningGuard.GetEffectiveTokenLimit(
requestContext.Provider, model);
var firstResult = await Task.Run(
() => DoRequest(
_http,
messages,
requestContext,
onChunk,
cancellationToken,
tokenLimit),
cancellationToken);
if (!firstResult.ReasoningInterrupted)
{
return firstResult;
}
var continuationMessages = BuildReasoningContinuationMessages(
messages,
firstResult.Reasoning,
tokenLimit,
out var formatState);
firstResult.ReasoningFormatState = formatState;
var continuationContext = BuildReasoningToolContext(requestContext);
var originalRequestJson = firstResult.RequestJson;
var continuationRequestJson = SerializeRequestParams(
continuationMessages,
continuationContext,
writeIndented: true);
var continuationPromptTokens = 0;
foreach (var message in continuationMessages)
{
continuationPromptTokens += AiContextBudget.EstimateTokens(
(message.Content ?? string.Empty)
+ "\n"
+ (message.ReasoningContent ?? string.Empty));
}
var continuationCheck = AiContextBudget.CheckRequestUsage(
continuationPromptTokens,
requestContext.Provider,
continuationContext.Model);
var continuationBudgetWarning = string.Empty;
if (continuationCheck.Block)
{
var message = "续写请求未通过预算检查:" + continuationCheck.Message;
if (!string.IsNullOrEmpty(firstResult.Response))
{
firstResult.ReasoningContinuationError = message;
firstResult.ContinuationApplied = false;
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
firstResult.ReasoningContinuationRequestJson =
continuationRequestJson;
return firstResult;
}
throw new InvalidOperationException(message);
}
if (!continuationCheck.IsOk)
{
continuationBudgetWarning = continuationCheck.Message;
}
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.ReasoningGuardRequest,
Text = JsonSerializer.Serialize(
new
{
originalRequestJson,
continuationRequestJson
},
new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
});
Result secondResult = default;
var continuationError = string.Empty;
try
{
secondResult = await Task.Run(
() => DoRequest(
_http,
continuationMessages,
continuationContext,
onChunk,
cancellationToken,
tokenLimit),
cancellationToken);
}
catch (Exception ex) when (
!cancellationToken.IsCancellationRequested
&& ex is not OperationCanceledException)
{
continuationError = ex.Message;
}
if (!string.IsNullOrEmpty(continuationError))
{
if (!string.IsNullOrEmpty(firstResult.Response))
{
firstResult.ReasoningContinuationError =
"续写请求失败,已保留首次部分正文:" + continuationError;
firstResult.ContinuationApplied = false;
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
firstResult.ReasoningContinuationRequestJson =
continuationRequestJson;
return firstResult;
}
throw new InvalidOperationException(
"推理续写失败,且没有可用的部分回答:" + continuationError);
}
if (secondResult.ReasoningInterrupted)
{
if (!string.IsNullOrEmpty(secondResult.Response))
{
secondResult.ReasoningContinuationError =
"续写仍超过推理上限,已使用续写部分正文。";
secondResult.ContinuationApplied = false;
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
secondResult.ReasoningContinuationRequestJson =
continuationRequestJson;
return secondResult;
}
if (!string.IsNullOrEmpty(firstResult.Response))
{
firstResult.ReasoningContinuationError =
"续写仍超过推理上限,已回退到首次部分正文。";
firstResult.ContinuationApplied = false;
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
firstResult.ReasoningContinuationRequestJson =
continuationRequestJson;
return firstResult;
}
throw new InvalidOperationException(
"推理续写仍超过上限,且没有可用的部分回答。");
}
secondResult.ContinuationApplied = true;
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
secondResult.ReasoningContinuationRequestJson =
continuationRequestJson;
if (!string.IsNullOrEmpty(continuationBudgetWarning))
{
secondResult.ReasoningContinuationError = continuationBudgetWarning;
}
return secondResult;
}
private static ImmutableList BuildReasoningContinuationMessages(
ImmutableList messages,
string reasoning,
int tokenLimit,
out string formatState)
{
var prepared = AiReasoningGuard.PrepareReasoning(reasoning, tokenLimit);
formatState = prepared.FormatState;
var instruction = AiReasoningGuard.BuildToolInstruction();
var arguments = JsonSerializer.Serialize(new { instruction });
return messages
.Add(ChatMessage.AssistantToolCall(
prepared.Text,
AiReasoningGuard.ToolCallId,
AiReasoningGuard.ToolName,
arguments))
.Add(ChatMessage.ToolResult(
AiReasoningGuard.ToolCallId,
AiReasoningGuard.BuildToolResult()));
}
private static AiRequestContext BuildReasoningToolContext(
AiRequestContext context)
{
var model = new AiModel
{
ModelId = context.Model.ModelId,
DisplayName = context.Model.DisplayName,
IsStream = context.Model.IsStream,
ContextLength = context.Model.ContextLength,
ContextBudget = context.Model.ContextBudget,
ReasoningGuardEnabled = context.Model.ReasoningGuardEnabled,
ReasoningGuardTokenLimit = context.Model.ReasoningGuardTokenLimit,
ExtraParameters = new Dictionary(
context.Model.ExtraParameters)
};
model.ExtraParameters["tools"] =
new[] { AiReasoningGuard.BuildToolDefinition() };
model.ExtraParameters["tool_choice"] = "none";
return new AiRequestContext(context.Provider, model);
}
private static string SerializeRequestParams(
ImmutableList messages,
AiRequestContext context,
bool writeIndented = false)
{
var requestParams = ProcessRequestParams(
messages,
context.BuildRequestParams());
return JsonSerializer.Serialize(
requestParams,
writeIndented
? new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}
: null);
}
private static async Task DoRequest(
HttpClient http,
ImmutableList messages,
AiRequestContext requestContext,
Action? onChunk,
CancellationToken cancellationToken,
int? reasoningTokenLimit = null)
{
var provider = requestContext.Provider;
var isStream = requestContext.Model.IsStream;
var inputJson = SerializeRequestParams(messages, requestContext);
var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKey);
request.Content = new StringContent(inputJson, Encoding.UTF8, "application/json");
using var response = await http.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
using var responseStream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(responseStream);
var fullBuilder = new StringBuilder();
var reasoningBuilder = new StringBuilder();
var result = new Result();
result.RequestJson = inputJson;
var interrupted = false;
// 根据模式分别读取响应
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType != null)
{
isStream = contentType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase);
}
if (isStream)
{
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (!line.StartsWith("data: "))
{
continue;
}
var data = line.Substring(6);
if (data == "[DONE]")
{
break;
}
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Json,
Text = data
});
using var doc = JsonDocument.Parse(data);
ProcessJsonDocument(
doc,
isStream: true,
fullBuilder,
reasoningBuilder,
result,
onChunk);
if (reasoningTokenLimit is { } guardLimit
&& guardLimit > 0
&& AiContextBudget.EstimateTokens(reasoningBuilder.ToString())
>= guardLimit)
{
interrupted = true;
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.ReasoningGuard,
Text = "检测到推理内容超过保护阈值,正在请求 AI 尽快收尾..."
});
break;
}
}
}
else
{
var json = await reader.ReadToEndAsync();
using var doc = JsonDocument.Parse(json);
ProcessJsonDocument(
doc,
isStream: false,
fullBuilder,
reasoningBuilder,
result,
onChunk);
}
response.EnsureSuccessStatusCode();
result.Reasoning = reasoningBuilder.ToString();
result.ReasoningFormatState = AiReasoningGuard.DescribeFormatState(
result.Reasoning);
if (interrupted)
{
result.Response = fullBuilder.ToString();
result.ReasoningInterrupted = true;
return result;
}
if (fullBuilder.Length == 0)
{
throw new Exception("AI分析失败,返回内容为空");
}
var resultText = fullBuilder.ToString();
result.Response = resultText;
return result;
}
///
/// 处理单个 JSON 响应(既用于 stream 的每个 chunk,也用于 non‑stream 的完整响应)
///
private static void ProcessJsonDocument(
JsonDocument doc,
bool isStream,
StringBuilder fullBuilder,
StringBuilder reasoningBuilder,
Result result,
Action? onChunk)
{
// 提取 usage(如果存在)
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
static int? GetIntegerProperty(JsonElement @object, string field)
{
if (@object.TryGetProperty(field, out var value) && value.ValueKind is JsonValueKind.Number)
{
return value.GetInt32();
}
return null;
}
result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens") ?? result.PromptTokens;
result.TotalTokens = GetIntegerProperty(usage, "total_tokens") ?? result.TotalTokens;
result.CompletionTokens = GetIntegerProperty(usage, "completion_tokens") ?? result.CompletionTokens;
result.ReasoningTokens = GetIntegerProperty(usage, "reasoning_tokens") ?? result.ReasoningTokens;
}
// 提取 error(如果存在)
if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
{
if (error.TryGetProperty("message", out var message))
{
if (message.GetString() is string errorMessage && !string.IsNullOrWhiteSpace(errorMessage))
{
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Error,
Text = errorMessage
});
}
}
}
// 提取内容
if (doc.RootElement.TryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array
&& choices.GetArrayLength() > 0)
{
var choice = choices[0];
// stream 模式使用 delta,非 stream 模式使用 message
var contentObj = isStream
? choice.GetProperty("delta")
: choice.GetProperty("message");
ExtractContentFromObject(
contentObj,
fullBuilder,
reasoningBuilder,
onChunk);
}
}
///
/// 从 delta 或 message 对象中提取 content 和 reasoning_content
///
private static void ExtractContentFromObject(
JsonElement contentObj,
StringBuilder fullBuilder,
StringBuilder reasoningBuilder,
Action? onChunk)
{
// 普通内容
if (contentObj.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
{
fullBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
Text = text!
});
}
}
// 推理内容(可选,如 DeepSeek 等模型)
if (contentObj.TryGetProperty("reasoning_content", out var reasoning))
{
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
{
reasoningBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Reasoning,
Text = text!
});
}
}
}
private static Dictionary ProcessRequestParams(
ImmutableList messages,
Dictionary inputRequestParams)
{
var parameters = new Dictionary(inputRequestParams)
{
["messages"] = messages.ToArray()
};
// 尊重模型配置里的 IsStream;只有流式请求才附加 stream_options,
// 避免非 SSE 端点收到 stream=true 后失败。
if (inputRequestParams.TryGetValue("stream", out var streamValue)
&& streamValue is bool isStream
&& isStream)
{
parameters["stream_options"] = new
{
include_usage = true
};
}
return parameters;
}
}
}