ai plan v2
This commit is contained in:
+110
-231
@@ -17,21 +17,18 @@ namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal sealed class AIAnalyze
|
||||
{
|
||||
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
|
||||
public sealed record ChatMessage(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
|
||||
|
||||
public static string GetSystemPrompt(
|
||||
Replay replay,
|
||||
ImmutableSortedDictionary<int, Player> players,
|
||||
AiPromptSettings? promptSettings = null)
|
||||
{
|
||||
var defaultPrompt = BuildDefaultSystemPrompt(replay, players);
|
||||
|
||||
// Try loading from knowledge_{mod}.md file (generated by tools/expand_knowledge.py).
|
||||
// If the file exists, use it instead of the built-in string.
|
||||
var modName = replay.Mod.ModName?.ToLowerInvariant();
|
||||
var knowledgeModName = modName switch
|
||||
{
|
||||
"corona" => "corona",
|
||||
_ => "default",
|
||||
};
|
||||
string defaultPrompt;
|
||||
var knowledgeModName = GetKnowledgeModName(replay);
|
||||
var knowledgePath = Path.Combine(AppContext.BaseDirectory, $"knowledge_{knowledgeModName}.md");
|
||||
if (File.Exists(knowledgePath))
|
||||
{
|
||||
@@ -43,10 +40,25 @@ namespace AnotherReplayReader.Utils
|
||||
var knowledge = KnowledgeSet.ForMod(knowledgeModName, AppContext.BaseDirectory);
|
||||
defaultPrompt = knowledge.RenderAsPrompt(factionNames, mapId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 旧路径:知识文件缺失时的回退(副作用 MessageBox 只在这里执行)
|
||||
defaultPrompt = BuildDefaultSystemPrompt(replay, players);
|
||||
}
|
||||
|
||||
return ComposeSystemPrompt(defaultPrompt, promptSettings);
|
||||
}
|
||||
|
||||
/// <summary>根据回放 mod 名得到知识文件名(default/corona)。</summary>
|
||||
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;
|
||||
@@ -106,33 +118,27 @@ namespace AnotherReplayReader.Utils
|
||||
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
|
||||
- 操作参数或操作对象,例如:`[UnitId]239`
|
||||
典型的玩家操作流程
|
||||
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
|
||||
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
|
||||
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
|
||||
例外:
|
||||
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
|
||||
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
|
||||
|
||||
# 输出要求
|
||||
## 1. 初始阶段
|
||||
触发条件:用户输入包含:""判断是否需要分段处理数据""
|
||||
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
|
||||
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
|
||||
- 输出示例:
|
||||
```
|
||||
[分段列表]
|
||||
#1 [0:00.0]~[0:55.4] 开局
|
||||
#2 [0:50.1]~[3:02.1] 开局(第二部分)
|
||||
#3 [3:00]~[5:16] 前中期
|
||||
#4 [5:10]~[10:13.12] 中期
|
||||
```
|
||||
- 输出示例(假如判断不需要分段):
|
||||
```
|
||||
[分段列表]
|
||||
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
|
||||
```
|
||||
## 1. 总览阶段
|
||||
触发条件:用户输入包含:""请先对整局进行总览""
|
||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
|
||||
- 你的任务:
|
||||
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
|
||||
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
||||
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
|
||||
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
|
||||
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
||||
|
||||
## 2. 分段分析、推理阶段
|
||||
触发条件:用户输入类似于:""请分析第N段([BEGIN]至[END])的数据""
|
||||
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END])""
|
||||
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
||||
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
||||
- 选取该阶段的主要事件,以及和它们的上下文
|
||||
- 也可以选择数个其他有分析价值的事件
|
||||
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
|
||||
@@ -143,6 +149,7 @@ namespace AnotherReplayReader.Utils
|
||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
||||
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
|
||||
|
||||
## 3. 最终总结阶段
|
||||
触发条件:用户输入包含:""请对以上内容进行总结""
|
||||
@@ -193,8 +200,9 @@ namespace AnotherReplayReader.Utils
|
||||
- `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|时间|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 里写注释。
|
||||
|
||||
@@ -1010,71 +1018,56 @@ PlayerA: 开始出兵
|
||||
return sb.ToString().Replace("\r", "");
|
||||
}
|
||||
|
||||
public static string BuildUserPrompt(
|
||||
Mod mod,
|
||||
ImmutableSortedDictionary<int, Player> players,
|
||||
string commandData,
|
||||
out string prefix
|
||||
)
|
||||
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
|
||||
{
|
||||
var playerNamesForAI = PlayerNamesForAI(mod, players);
|
||||
var playerData = players.Select(kv =>
|
||||
{
|
||||
var id = kv.Key;
|
||||
var player = kv.Value;
|
||||
var playerNameForAI = playerNamesForAI[id];
|
||||
var prefix = player.IsComputer ? "电脑" : "玩家";
|
||||
var name = player.IsComputer ? $"[{player.PlayerName}AI]" : player.PlayerName;
|
||||
var factionName = ModData.GetFaction(mod, player.FactionId).Name;
|
||||
if (player.Team >= 0)
|
||||
{
|
||||
return $"{prefix}#{id} {name} ({playerNameForAI}),{factionName},队伍{player.Team}";
|
||||
}
|
||||
return $"{prefix}#{id} {name} ({playerNameForAI}),{factionName}";
|
||||
});
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("请你阅读并分析以下数据,首先进行初步分析,然后判断是否需要分段处理数据");
|
||||
sb.AppendLine("玩家列表:");
|
||||
sb.AppendLine(string.Join("\n", playerData));
|
||||
sb.AppendLine("数据:");
|
||||
prefix = sb.ToString();
|
||||
sb.AppendLine(commandData);
|
||||
sb.AppendLine("请先对整局进行总览。");
|
||||
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
|
||||
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 BuildSegmentUserPrompt(IReadOnlyList<Segment> segments, int currentSegmentIndex, int eventCount)
|
||||
public static string BuildSegmentUserPromptV2(
|
||||
int segmentIndex,
|
||||
int totalSegments,
|
||||
ReplaySlice slice,
|
||||
int eventCount,
|
||||
string? title)
|
||||
{
|
||||
if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Current segment index is out of range.");
|
||||
}
|
||||
var segment = segments[currentSegmentIndex];
|
||||
var beginText = currentSegmentIndex <= 0
|
||||
? "游戏开始"
|
||||
: segment.Start.ToString();
|
||||
var endText = currentSegmentIndex >= segments.Count - 1
|
||||
? "游戏结束"
|
||||
: segment.End.ToString();
|
||||
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 instruction = @$"
|
||||
请分析第{currentSegmentIndex + 1}段({beginText}至{endText})的数据。
|
||||
本阶段一共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接把{eventCount}条操作信息都列出来。
|
||||
也不要把每一条操作信息都视作一个单独事件。
|
||||
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件。
|
||||
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
|
||||
请重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
|
||||
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
|
||||
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实。
|
||||
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
|
||||
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
|
||||
|
||||
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
||||
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
|
||||
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
|
||||
";
|
||||
return instruction.Trim().Replace("\r", "");
|
||||
}
|
||||
|
||||
public static string BuildFinalUserPrompt(int totalEventCount)
|
||||
public static string BuildSummaryUserPromptV2(int totalEventCount)
|
||||
{
|
||||
var instruction = $@"
|
||||
请对以上内容进行总结。
|
||||
基于:
|
||||
- 你对于各阶段的推理分析
|
||||
- 由我在对话刚开始时提供的原始数据(约{totalEventCount}个操作信息)。
|
||||
- 整局总览与对局摘要
|
||||
- 各分段的推理分析
|
||||
- 各分段的已发现事实
|
||||
(原始操作记录约{totalEventCount}条,未全部提供;如需要核实某个具体时间段,可以在总结中说明,由程序另行提供。)
|
||||
请对各阶段的推理分析进行汇总。
|
||||
判断是否需要对之前的分析进行补充,例如检查之前是否遗漏了某些重要的操作信息?
|
||||
判断是否需要对之前的分析进行修正。
|
||||
@@ -1084,6 +1077,36 @@ PlayerA: 开始出兵
|
||||
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);
|
||||
@@ -1143,31 +1166,9 @@ PlayerA: 开始出兵
|
||||
public string Text;
|
||||
}
|
||||
|
||||
public record Segment(TimeSpan Start, TimeSpan End, string Description);
|
||||
|
||||
public record State(ImmutableList<object> Messages, ImmutableList<Segment> Segments, int CurrentSegment)
|
||||
{
|
||||
public static State Initial => new(ImmutableList<object>.Empty, ImmutableList<Segment>.Empty, -1);
|
||||
public State AppendNewMessage(string role, string content)
|
||||
{
|
||||
var newMessages = Messages.Add(new
|
||||
{
|
||||
role,
|
||||
content
|
||||
});
|
||||
return this with { Messages = newMessages };
|
||||
}
|
||||
public State AppendNewSegment(Segment segment)
|
||||
{
|
||||
var newSegments = Segments.Add(segment);
|
||||
return this with { Segments = newSegments };
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result
|
||||
{
|
||||
public string Response;
|
||||
public State State;
|
||||
public int? PromptTokens;
|
||||
public int? CompletionTokens;
|
||||
public int? TotalTokens;
|
||||
@@ -1175,9 +1176,6 @@ PlayerA: 开始出兵
|
||||
}
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private State _state = State.Initial;
|
||||
|
||||
public State LastSuccessfulState => _state;
|
||||
|
||||
public AIAnalyze()
|
||||
{
|
||||
@@ -1187,98 +1185,27 @@ PlayerA: 开始出兵
|
||||
};
|
||||
}
|
||||
|
||||
public void SetState(State state)
|
||||
{
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task<Result> AnalyzeAsync(
|
||||
string instruction,
|
||||
string text,
|
||||
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
|
||||
public async Task<Result> CompleteAsync(
|
||||
ImmutableList<ChatMessage> messages,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputState = State.Initial
|
||||
.AppendNewMessage("system", instruction)
|
||||
.AppendNewMessage("user", text);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
var resultState = result.State;
|
||||
|
||||
var splitted = result.Response.Split('\n').ToList();
|
||||
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
|
||||
if (titleIndex == -1)
|
||||
{
|
||||
throw new Exception("AI分析失败");
|
||||
}
|
||||
|
||||
// regex match two timespan in "[0:00.0]~[0:55.4]"
|
||||
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
|
||||
for (var i = titleIndex + 1; i < splitted.Count; ++i)
|
||||
{
|
||||
var line = splitted[i];
|
||||
var match = timeSpanRegex.Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var startTimeText = match.Groups[1].Value;
|
||||
var endTimeText = match.Groups[2].Value;
|
||||
var start = ParseAITimeSpan(startTimeText);
|
||||
var end = ParseAITimeSpan(endTimeText);
|
||||
var description = line.Substring(match.Index + match.Length).Trim();
|
||||
resultState = resultState.AppendNewSegment(new(start, end, description));
|
||||
}
|
||||
}
|
||||
|
||||
result.State = resultState with { CurrentSegment = 0 };
|
||||
_state = result.State;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Result> ContinueAnalyzeAsync(
|
||||
string instruction,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_state.CurrentSegment < 0 || _state.CurrentSegment >= _state.Segments.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Current segment index is out of range.");
|
||||
}
|
||||
var inputState = _state.AppendNewMessage("user", instruction);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
|
||||
result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
|
||||
_state = result.State;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Result> FinishAnalyzeAsync(
|
||||
string instruction,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_state.CurrentSegment != _state.Segments.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Current segment index is out of range.");
|
||||
}
|
||||
|
||||
var inputState = _state.AppendNewMessage("user", instruction);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
|
||||
_state = result.State;
|
||||
return result;
|
||||
return await Task.Run(
|
||||
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<Result> DoRequest(
|
||||
HttpClient http,
|
||||
State state,
|
||||
ImmutableList<ChatMessage> messages,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var provider = requestContext.Provider;
|
||||
var requestParams = ProcessRequestParams(state, requestContext.BuildRequestParams());
|
||||
var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
|
||||
var isStream = requestContext.Model.IsStream;
|
||||
var inputJson = JsonSerializer.Serialize(requestParams);
|
||||
|
||||
@@ -1351,7 +1278,6 @@ PlayerA: 开始出兵
|
||||
}
|
||||
|
||||
var resultText = fullBuilder.ToString();
|
||||
result.State = state.AppendNewMessage("assistant", resultText);
|
||||
result.Response = resultText;
|
||||
return result;
|
||||
}
|
||||
@@ -1454,12 +1380,12 @@ PlayerA: 开始出兵
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ProcessRequestParams(
|
||||
State state,
|
||||
ImmutableList<ChatMessage> messages,
|
||||
Dictionary<string, object> inputRequestParams)
|
||||
{
|
||||
return new Dictionary<string, object>(inputRequestParams)
|
||||
{
|
||||
["messages"] = state.Messages.ToArray(),
|
||||
["messages"] = messages.ToArray(),
|
||||
["stream"] = true,
|
||||
["stream_options"] = new
|
||||
{
|
||||
@@ -1468,52 +1394,5 @@ PlayerA: 开始出兵
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan ParseAITimeSpan(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
throw new FormatException("Empty input");
|
||||
}
|
||||
|
||||
input = input.Trim();
|
||||
|
||||
|
||||
var parts = input.Split(':');
|
||||
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
throw new FormatException("Invalid format");
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// 1. 解析最后一段:seconds + fraction
|
||||
// -----------------------------
|
||||
if (!float.TryParse(parts.Last(), out float floatSeconds))
|
||||
{
|
||||
throw new FormatException("Invalid seconds");
|
||||
}
|
||||
int seconds = (int)floatSeconds;
|
||||
int milliseconds = (int)Math.Round((floatSeconds - seconds) * 1000);
|
||||
|
||||
// -----------------------------
|
||||
// 2. 累加前面的部分(从右往左)
|
||||
// -----------------------------
|
||||
long totalSeconds = seconds;
|
||||
long multiplier = 60; // 每层递进:秒->分->时->天...
|
||||
|
||||
for (int i = parts.Length - 2; i >= 0; i--)
|
||||
{
|
||||
if (!long.TryParse(parts[i], out long value))
|
||||
throw new FormatException($"Invalid number: {parts[i]}");
|
||||
|
||||
totalSeconds += value * multiplier;
|
||||
multiplier *= 60;
|
||||
}
|
||||
|
||||
var result = TimeSpan.FromSeconds(totalSeconds)
|
||||
+ TimeSpan.FromMilliseconds(milliseconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user