Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dae58bf42 | ||
|
|
acad70aaac | ||
|
|
579cd8e4b3 | ||
|
|
f33a6829e8 | ||
|
|
8e6d245c1c | ||
|
|
a2f0bcb371 | ||
|
|
9250528442 | ||
|
|
d06a93f759 | ||
|
|
9555423f51 | ||
|
|
00c67dd66a | ||
|
|
645189f21c | ||
|
|
2ada187ae0 | ||
|
|
241dc2b987 | ||
|
|
a3b4cc530d | ||
|
|
22cfb1f0a9 | ||
|
|
a942768c8d | ||
|
|
e16eefa166 | ||
|
|
66458e831c | ||
|
|
08700abd4f | ||
|
|
2a96f8efac | ||
|
|
0863b2fd71 | ||
|
|
78a7310a3b | ||
|
|
23207b9085 | ||
|
|
6f5c21aa8f | ||
|
|
888ddce4ef | ||
|
|
5b907309e0 | ||
|
|
c3a41c0d4e | ||
|
|
b92d1d90ad | ||
|
|
3f5070df77 | ||
|
|
6fd0bf0046 | ||
|
|
1f5f1c8e6c | ||
|
|
79d9bf4cba |
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"dotnet build": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<UserControl x:Class="AnotherReplayReader.AIChatPanel"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="350" d:DesignWidth="700">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="5">
|
||||||
|
<Button x:Name="_cancelButton"
|
||||||
|
Content="取消"
|
||||||
|
Click="OnCancelClick"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Margin="0,0,10,0"/>
|
||||||
|
<Button x:Name="_retryButton"
|
||||||
|
Content="重试当前段"
|
||||||
|
Click="OnRetryClick"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Margin="0,0,10,0"/>
|
||||||
|
<Button x:Name="_abortButton"
|
||||||
|
Content="放弃"
|
||||||
|
Click="OnAbortClick"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Margin="0,0,10,0"/>
|
||||||
|
|
||||||
|
<Separator Margin="5,0"/>
|
||||||
|
|
||||||
|
<TextBlock x:Name="_phaseText"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Margin="0,0,15,0"/>
|
||||||
|
|
||||||
|
<TextBlock x:Name="_timeText"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,15,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 主显示区 -->
|
||||||
|
<FlowDocumentScrollViewer x:Name="_outputViewer"
|
||||||
|
Grid.Row="1"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Margin="5,0">
|
||||||
|
<FlowDocument>
|
||||||
|
<Paragraph>
|
||||||
|
<Run Text="AI 分析结果将在此显示..." />
|
||||||
|
</Paragraph>
|
||||||
|
</FlowDocument>
|
||||||
|
</FlowDocumentScrollViewer>
|
||||||
|
|
||||||
|
<!-- 状态栏 -->
|
||||||
|
<StatusBar Grid.Row="2">
|
||||||
|
|
||||||
|
<StatusBarItem HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch">
|
||||||
|
<Grid VerticalAlignment="Stretch">
|
||||||
|
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Progress takes ALL remaining space -->
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
|
||||||
|
<!-- separators + fixed slots -->
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="2*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="3*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="6*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="7*"/>
|
||||||
|
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- ===================== -->
|
||||||
|
<!-- 1. CHAR PROGRESS -->
|
||||||
|
<!-- ===================== -->
|
||||||
|
<TextBlock x:Name="_charProgressText"
|
||||||
|
Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="6,0"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
|
||||||
|
<!-- separator 1 -->
|
||||||
|
<Border Grid.Column="1"
|
||||||
|
Width="1"
|
||||||
|
Margin="4,2"
|
||||||
|
Background="#80000000"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
|
||||||
|
<!-- instant speed -->
|
||||||
|
<TextBlock x:Name="_instantSpeedText"
|
||||||
|
Grid.Column="2"
|
||||||
|
TextAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
|
||||||
|
<!-- separator 2 -->
|
||||||
|
<Border Grid.Column="3"
|
||||||
|
Width="1"
|
||||||
|
Margin="4,2"
|
||||||
|
Background="#80000000"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
|
||||||
|
<!-- avg speed -->
|
||||||
|
<TextBlock x:Name="_avgSpeedText"
|
||||||
|
Grid.Column="4"
|
||||||
|
TextAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
|
||||||
|
<!-- separator 3 -->
|
||||||
|
<Border Grid.Column="5"
|
||||||
|
Width="1"
|
||||||
|
Margin="4,2"
|
||||||
|
Background="#80000000"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
|
||||||
|
<!-- current tokens -->
|
||||||
|
<TextBlock x:Name="_currentTokensText"
|
||||||
|
Grid.Column="6"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<!-- separator 4 -->
|
||||||
|
<Border Grid.Column="7"
|
||||||
|
Width="1"
|
||||||
|
Margin="4,2"
|
||||||
|
Background="#80000000"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
|
||||||
|
<!-- conversation tokens -->
|
||||||
|
<TextBlock x:Name="_conversationTokensText"
|
||||||
|
Grid.Column="8"
|
||||||
|
TextAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</Grid>
|
||||||
|
</StatusBarItem>
|
||||||
|
|
||||||
|
</StatusBar>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
+1644
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
|||||||
|
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="350" d:DesignWidth="650">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="180"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 左侧:Provider 列表 -->
|
||||||
|
<Grid Grid.Column="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ListBox x:Name="_providerListBox"
|
||||||
|
Grid.Row="0"
|
||||||
|
DisplayMemberPath="Name"
|
||||||
|
SelectionChanged="OnProviderSelectionChanged"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="新增" Click="OnAddProviderClick" Margin="2"/>
|
||||||
|
<Button Content="删除" Click="OnRemoveProviderClick" Margin="2"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 右侧:Provider 编辑 + 模型管理 -->
|
||||||
|
<Grid Grid.Column="1" Margin="10,0,0,0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Provider 基本信息 -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="80"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Label Grid.Row="0" Grid.Column="0" Content="名称"/>
|
||||||
|
<TextBox x:Name="_providerNameBox" Grid.Row="0" Grid.Column="1"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="1" Grid.Column="0" Content="Base URL"/>
|
||||||
|
<TextBox x:Name="_providerUrlBox" Grid.Row="1" Grid.Column="1"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="2" Grid.Column="0" Content="API Key"/>
|
||||||
|
<PasswordBox x:Name="_providerKeyBox" Grid.Row="2" Grid.Column="1"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,5">
|
||||||
|
<Label Content="Temperature" VerticalAlignment="Center"/>
|
||||||
|
<TextBox x:Name="_temperatureBox" Width="50" Margin="2"/>
|
||||||
|
<Label Content="TopP" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||||
|
<TextBox x:Name="_topPBox" Width="50" Margin="2"/>
|
||||||
|
<Label Content="MaxTokens" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||||
|
<TextBox x:Name="_maxTokensBox" Width="60" Margin="2"/>
|
||||||
|
<Button Content="应用" Click="OnApplyProviderClick" Margin="10,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Prompt 管理区域 -->
|
||||||
|
<GroupBox Header="提示词" Grid.Row="1" Margin="0,5,0,0">
|
||||||
|
<Grid Margin="5">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="100"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<CheckBox x:Name="_useCustomPromptCheck"
|
||||||
|
Grid.Row="0" Grid.Column="1"
|
||||||
|
Content="完全使用自定义 System Prompt"
|
||||||
|
Margin="0,0,0,5"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="1" Grid.Column="0" Content="System Prompt"/>
|
||||||
|
<TextBox x:Name="_customPromptBox"
|
||||||
|
Grid.Row="1" Grid.Column="1"
|
||||||
|
MinHeight="80"
|
||||||
|
MaxHeight="160"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" Grid.ColumnSpan="2" Margin="0,5,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="100"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Label Grid.Column="0" Content="补充规则"/>
|
||||||
|
<TextBox x:Name="_additionalRulesBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
MinHeight="50"
|
||||||
|
MaxHeight="100"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
<StackPanel Grid.Column="2" Margin="5,0,0,0">
|
||||||
|
<Button Content="应用提示词" Click="OnApplyPromptClick" Margin="2"/>
|
||||||
|
<Button Content="恢复默认" Click="OnResetPromptClick" Margin="2"/>
|
||||||
|
<Button Content="清空补充" Click="OnClearAdditionalRulesClick" Margin="2"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- 模型管理区域 -->
|
||||||
|
<GroupBox Header="模型" Grid.Row="2" Margin="0,5,0,0">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
|
<ComboBox x:Name="_modelComboBox"
|
||||||
|
Width="200"
|
||||||
|
DisplayMemberPath="DisplayText"
|
||||||
|
SelectionChanged="OnModelSelectionChanged"/>
|
||||||
|
<Button Content="获取模型列表" Click="OnFetchModelsClick" Margin="5,0"/>
|
||||||
|
<Button Content="添加自定义" Click="OnAddCustomModelClick" Margin="5,0"/>
|
||||||
|
<Button Content="删除模型" Click="OnRemoveModelClick" Margin="5,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 模型详情 -->
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="100"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Label Grid.Row="0" Grid.Column="0" Content="模型 ID"/>
|
||||||
|
<TextBox x:Name="_modelIdBox" Grid.Row="0" Grid.Column="1"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="1" Grid.Column="0" Content="上下文长度"/>
|
||||||
|
<TextBox x:Name="_contextLengthBox" Grid.Row="1" Grid.Column="1"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="2" Grid.Column="0" Content="上下文预算"/>
|
||||||
|
<TextBox x:Name="_contextBudgetBox" Grid.Row="2" Grid.Column="1"
|
||||||
|
ToolTip="一次请求总 token 软上限(含输出/推理余量)。0 = 按上下文长度自动:≥1M → 160K;200K~256K → 100K;更小 → 不支持长录像"/>
|
||||||
|
|
||||||
|
<CheckBox x:Name="_supportsSseCheck"
|
||||||
|
Grid.Row="3" Grid.Column="1"
|
||||||
|
Content="支持 SSE 流式输出"
|
||||||
|
Margin="0,5"/>
|
||||||
|
|
||||||
|
<Label Grid.Row="4" Grid.Column="0" Content="高级参数"/>
|
||||||
|
<TextBox x:Name="_extraParamsBox"
|
||||||
|
Grid.Row="4" Grid.Column="1"
|
||||||
|
MinHeight="80"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="应用模型修改" Click="OnApplyModelClick" Margin="2"/>
|
||||||
|
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock x:Name="_modelStatusText"
|
||||||
|
Grid.Row="6" Grid.ColumnSpan="2"
|
||||||
|
Foreground="Gray" Margin="0,5"/>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</GroupBox>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Web.Routing;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
public partial class AIProviderSettingsControl : UserControl
|
||||||
|
{
|
||||||
|
// ---------- fields ----------
|
||||||
|
private AiSettings _settings;
|
||||||
|
private AiProvider? _currentProvider;
|
||||||
|
private AiModel? _currentModel;
|
||||||
|
|
||||||
|
// 为了下拉框显示,内部包装
|
||||||
|
private record ModelDisplayItem(AiModel Model)
|
||||||
|
{
|
||||||
|
public string DisplayText =>
|
||||||
|
$"{Model.ModelId}{(Model.ContextLength == 0 ? " (未知性能)" : "")}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- constructor ----------
|
||||||
|
public AIProviderSettingsControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_settings = AiSettings.Load();
|
||||||
|
RefreshPromptFields();
|
||||||
|
RefreshProviderList();
|
||||||
|
if (_settings.Providers.Count > 0)
|
||||||
|
{
|
||||||
|
var lastSelection = _settings.ResolveLastSelection();
|
||||||
|
if (lastSelection is { } last)
|
||||||
|
{
|
||||||
|
// 恢复上次选中的 Provider 与模型。
|
||||||
|
// OnProviderSelectionChanged 会从持久化的 CurrentModelId 恢复模型;
|
||||||
|
// 显式调用 SelectModel 作为双保险(模型仍存在则精确恢复)。
|
||||||
|
_providerListBox.SelectedItem = last.Provider;
|
||||||
|
SelectModel(last.Model);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_providerListBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- public API ----------
|
||||||
|
public AiRequestContext? GetCurrentContext()
|
||||||
|
{
|
||||||
|
if (_currentProvider is null || _currentModel is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new AiRequestContext(_currentProvider, _currentModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiPromptSettings GetPromptSettings()
|
||||||
|
{
|
||||||
|
return _settings.Prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把当前选中的 Provider/模型保存到设置文件(供窗口关闭时调用,
|
||||||
|
/// 下次打开 AI 设置页时恢复上次选择)。
|
||||||
|
/// </summary>
|
||||||
|
public void SaveCurrentSelection()
|
||||||
|
{
|
||||||
|
if (_currentProvider is not null && _currentModel is not null)
|
||||||
|
{
|
||||||
|
_settings.SetCurrentSelection(_currentProvider, _currentModel);
|
||||||
|
_settings.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPromptFields()
|
||||||
|
{
|
||||||
|
_useCustomPromptCheck.IsChecked = _settings.Prompt.UseCustomSystemPrompt;
|
||||||
|
_customPromptBox.Text = _settings.Prompt.CustomSystemPrompt;
|
||||||
|
_additionalRulesBox.Text = _settings.Prompt.AdditionalRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApplyPromptClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_settings.Prompt.UseCustomSystemPrompt = _useCustomPromptCheck.IsChecked == true;
|
||||||
|
_settings.Prompt.CustomSystemPrompt = _customPromptBox.Text;
|
||||||
|
_settings.Prompt.AdditionalRules = _additionalRulesBox.Text;
|
||||||
|
_settings.Save();
|
||||||
|
MessageBox.Show("提示词配置已保存", "信息", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResetPromptClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
"确定要恢复内置 System Prompt 吗?自定义 System Prompt 会被清空,补充规则会保留。",
|
||||||
|
"恢复默认提示词",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
if (result != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Prompt.UseCustomSystemPrompt = false;
|
||||||
|
_settings.Prompt.CustomSystemPrompt = string.Empty;
|
||||||
|
_settings.Save();
|
||||||
|
RefreshPromptFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClearAdditionalRulesClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_settings.Prompt.AdditionalRules = string.Empty;
|
||||||
|
_settings.Save();
|
||||||
|
RefreshPromptFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 列表管理 ----------
|
||||||
|
private void RefreshProviderList()
|
||||||
|
{
|
||||||
|
_providerListBox.ItemsSource = new ObservableCollection<AiProvider>(_settings.Providers);
|
||||||
|
_providerListBox.DisplayMemberPath = "Name";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// 记住当前模型选择,切换 Provider 后若新 Provider 存在同名模型则保持选择
|
||||||
|
var previousModelId = _settings.CurrentModelId;
|
||||||
|
|
||||||
|
_currentProvider = _providerListBox.SelectedItem as AiProvider;
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_providerNameBox.Text = _currentProvider.Name;
|
||||||
|
_providerUrlBox.Text = _currentProvider.BaseUrl;
|
||||||
|
_providerKeyBox.Password = _currentProvider.ApiKey;
|
||||||
|
_temperatureBox.Text = _currentProvider.DefaultTemperature.ToString();
|
||||||
|
_topPBox.Text = _currentProvider.DefaultTopP.ToString();
|
||||||
|
_maxTokensBox.Text = _currentProvider.DefaultMaxTokens.ToString();
|
||||||
|
|
||||||
|
// 记录上次选中的 Provider(模型在 OnModelSelectionChanged 中记录)
|
||||||
|
_settings.CurrentProviderName = _currentProvider.Name;
|
||||||
|
RefreshModelList();
|
||||||
|
// 尝试恢复上一个选中的模型(启动恢复与 Provider 切换共用同一路径)
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousModelId))
|
||||||
|
{
|
||||||
|
var previousItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, previousModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (previousItem is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = previousItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无任何模型/无可恢复目标时回退到第一个模型
|
||||||
|
if (_currentModel is null && _modelComboBox.Items.Count > 0)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddProviderClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var newProvider = new AiProvider
|
||||||
|
{
|
||||||
|
Name = "新服务",
|
||||||
|
BaseUrl = "https://api.openai.com/v1"
|
||||||
|
};
|
||||||
|
_settings.Providers.Add(newProvider);
|
||||||
|
_settings.Save();
|
||||||
|
RefreshProviderList();
|
||||||
|
_providerListBox.SelectedItem = newProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRemoveProviderClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
$"确定要删除服务 \"{_currentProvider.Name}\" 吗?",
|
||||||
|
"确认删除", MessageBoxButton.YesNo);
|
||||||
|
if (result != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Providers.Remove(_currentProvider);
|
||||||
|
_settings.CurrentProviderName = null;
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
|
_settings.Save();
|
||||||
|
RefreshProviderList();
|
||||||
|
if (_settings.Providers.Count > 0)
|
||||||
|
{
|
||||||
|
_providerListBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentProvider = null;
|
||||||
|
_currentModel = null;
|
||||||
|
ClearProviderFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApplyProviderClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentProvider.Name = _providerNameBox.Text;
|
||||||
|
_currentProvider.BaseUrl = _providerUrlBox.Text;
|
||||||
|
_currentProvider.ApiKey = _providerKeyBox.Password;
|
||||||
|
double.TryParse(_temperatureBox.Text, out double temp);
|
||||||
|
_currentProvider.DefaultTemperature = temp;
|
||||||
|
double.TryParse(_topPBox.Text, out double topP);
|
||||||
|
_currentProvider.DefaultTopP = topP;
|
||||||
|
int.TryParse(_maxTokensBox.Text, out int maxTokens);
|
||||||
|
_currentProvider.DefaultMaxTokens = maxTokens;
|
||||||
|
|
||||||
|
_settings.CurrentProviderName = _currentProvider.Name;
|
||||||
|
_settings.Save();
|
||||||
|
RefreshProviderList();
|
||||||
|
_providerListBox.SelectedItem = _currentProvider;
|
||||||
|
MessageBox.Show("服务配置已保存", "信息", MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearProviderFields()
|
||||||
|
{
|
||||||
|
_providerNameBox.Text = "";
|
||||||
|
_providerUrlBox.Text = "";
|
||||||
|
_providerKeyBox.Password = "";
|
||||||
|
_temperatureBox.Text = "";
|
||||||
|
_topPBox.Text = "";
|
||||||
|
_maxTokensBox.Text = "";
|
||||||
|
_modelComboBox.ItemsSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 模型管理 ----------
|
||||||
|
private void RefreshModelList()
|
||||||
|
{
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
_modelComboBox.ItemsSource = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = _currentProvider.Models
|
||||||
|
.Select(m => new ModelDisplayItem(Model: m ))
|
||||||
|
.ToList();
|
||||||
|
_modelComboBox.ItemsSource = new ObservableCollection<ModelDisplayItem>(items);
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 当前 Provider 没有任何模型:清空当前模型,避免残留上一个 Provider 的模型
|
||||||
|
_currentModel = null;
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
|
ClearModelFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在模型下拉框中选中指定模型;找不到时回退到第一个模型。
|
||||||
|
/// 仅供启动恢复使用(避免刷新列表后触发首次默认选中)。
|
||||||
|
/// </summary>
|
||||||
|
private void SelectModel(AiModel model)
|
||||||
|
{
|
||||||
|
var item = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, model.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (item is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = item;
|
||||||
|
}
|
||||||
|
else if (_modelComboBox.Items.Count > 0)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnModelSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var selected = _modelComboBox.SelectedItem as ModelDisplayItem;
|
||||||
|
_currentModel = selected?.Model;
|
||||||
|
|
||||||
|
if (_currentModel is null)
|
||||||
|
{
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
|
ClearModelFields();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.CurrentModelId = _currentModel.ModelId;
|
||||||
|
|
||||||
|
_modelIdBox.Text = _currentModel.ModelId;
|
||||||
|
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
|
||||||
|
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
|
||||||
|
_supportsSseCheck.IsChecked = _currentModel.IsStream;
|
||||||
|
|
||||||
|
// 显示 ExtraParameters 为缩进 JSON
|
||||||
|
var json = JsonSerializer.Serialize(
|
||||||
|
_currentModel.ExtraParameters,
|
||||||
|
new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
_extraParamsBox.Text = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFetchModelsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show("请先选择一个服务");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(_currentProvider.BaseUrl))
|
||||||
|
{
|
||||||
|
MessageBox.Show("请先填写 Base URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(_currentProvider.ApiKey))
|
||||||
|
{
|
||||||
|
MessageBox.Show("请先填写 API Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步获取
|
||||||
|
Dispatcher.Invoke(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_modelStatusText.Text = "正在获取模型列表...";
|
||||||
|
var ids = await AiModelFetcher.FetchModelsAsync(
|
||||||
|
_currentProvider!.BaseUrl,
|
||||||
|
_currentProvider.ApiKey);
|
||||||
|
|
||||||
|
// 将获取的 ID 与现有模型合并,新 ID 若不存在则添加
|
||||||
|
var knownModels = KnownModels.GetAll();
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
if (_currentProvider.Models.All(m => m.ModelId != id))
|
||||||
|
{
|
||||||
|
var known = knownModels
|
||||||
|
.OrderBy(k => KnownModels.GetSimilarity(k.ModelId, id))
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (known != null && KnownModels.GetSimilarity(known.ModelId, id) >= KnownModels.SimilarityThreshold)
|
||||||
|
{
|
||||||
|
_currentProvider.Models.Add(new AiModel
|
||||||
|
{
|
||||||
|
ModelId = id,
|
||||||
|
DisplayName = id,
|
||||||
|
ContextLength = known.ContextLength,
|
||||||
|
ContextBudget = known.ContextBudget,
|
||||||
|
IsStream = known.IsStream,
|
||||||
|
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentProvider.Models.Add(new AiModel
|
||||||
|
{
|
||||||
|
ModelId = id,
|
||||||
|
ContextLength = 0,
|
||||||
|
IsStream = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Save();
|
||||||
|
RefreshModelList();
|
||||||
|
// 保持上次选中的模型(若仍存在),否则回退到第一个
|
||||||
|
if (_settings.CurrentModelId is { } lastModelId)
|
||||||
|
{
|
||||||
|
var lastItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, lastModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (lastItem is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = lastItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_modelStatusText.Text = $"获取成功,共 {ids.Count} 个模型";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_modelStatusText.Text = $"获取失败: {ex.Message}";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddCustomModelClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newModel = new AiModel
|
||||||
|
{
|
||||||
|
ModelId = "custom-model-id",
|
||||||
|
ContextLength = 0,
|
||||||
|
IsStream = false
|
||||||
|
};
|
||||||
|
_currentProvider.Models.Add(newModel);
|
||||||
|
_settings.Save();
|
||||||
|
RefreshModelList();
|
||||||
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.Last();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRemoveModelClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null || _currentModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
$"确定要删除模型 \"{_currentModel.ModelId}\" 吗?",
|
||||||
|
"确认删除", MessageBoxButton.YesNo);
|
||||||
|
if (result != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentProvider.Models.Remove(_currentModel);
|
||||||
|
_settings.Save();
|
||||||
|
// 被删除模型不再是上次选择;RefreshModelList 会回退到第一个模型
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
|
RefreshModelList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApplyModelClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentModel.ModelId = _modelIdBox.Text;
|
||||||
|
if (int.TryParse(_contextLengthBox.Text, out int ctxLen))
|
||||||
|
{
|
||||||
|
_currentModel.ContextLength = ctxLen;
|
||||||
|
}
|
||||||
|
if (int.TryParse(_contextBudgetBox.Text, out int budget) && budget > 0)
|
||||||
|
{
|
||||||
|
_currentModel.ContextBudget = budget;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentModel.ContextBudget = null;
|
||||||
|
}
|
||||||
|
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(
|
||||||
|
_extraParamsBox.Text);
|
||||||
|
if (dict is not null)
|
||||||
|
{
|
||||||
|
_currentModel.ExtraParameters = dict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
MessageBox.Show("高级参数格式错误,应为 JSON 键值对",
|
||||||
|
"错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Save();
|
||||||
|
// 模型 ID 已修改:更新持久化选择,避免下一次启动仍按旧 ID 解析
|
||||||
|
_settings.CurrentModelId = _currentModel.ModelId;
|
||||||
|
RefreshModelList();
|
||||||
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
MessageBox.Show("模型修改已保存", "信息", MessageBoxButton.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFillFromKnownModelsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentProvider is null || _currentModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var known = KnownModels.GetAll()
|
||||||
|
.OrderBy(k => KnownModels.GetSimilarity(k.ModelId, _currentModel.ModelId))
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (known is not null
|
||||||
|
&& KnownModels.GetSimilarity(known.ModelId, _currentModel.ModelId) >= KnownModels.SimilarityThreshold)
|
||||||
|
{
|
||||||
|
_currentModel.ExtraParameters = new Dictionary<string, object>(known.ExtraParameters);
|
||||||
|
_currentModel.IsStream = known.IsStream;
|
||||||
|
_currentModel.ContextLength = known.ContextLength;
|
||||||
|
_currentModel.ContextBudget = known.ContextBudget;
|
||||||
|
RefreshModelList();
|
||||||
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
MessageBox.Show("已从已知模板填充", "信息", MessageBoxButton.OK);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MessageBox.Show("未找到已知模板", "信息", MessageBoxButton.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearModelFields()
|
||||||
|
{
|
||||||
|
_modelIdBox.Text = "";
|
||||||
|
_contextLengthBox.Text = "";
|
||||||
|
_contextBudgetBox.Text = "";
|
||||||
|
_supportsSseCheck.IsChecked = false;
|
||||||
|
_extraParamsBox.Text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# AI reasoning_content 回传与“继续思考”实验研究报告
|
||||||
|
|
||||||
|
日期:2026-08-23
|
||||||
|
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
|
||||||
|
状态:实验性研究,未集成到主项目;主项目曾短暂集成推理保护,后因实测影响输出质量而移除(详见 `PLAN_ai_analysis_v2.md`)
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
本报告记录了为验证“将模型上一轮 `reasoning_content` 截断/修改后回传,模型是否能够继续合理思考”而执行的一系列实验。
|
||||||
|
|
||||||
|
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
|
||||||
|
|
||||||
|
与本研究相关的实验代码已从 `AiV2.Tests/Program.cs` 中移除,不再保留可执行实验入口。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
验证以下问题:
|
||||||
|
|
||||||
|
1. 模型能否在不追加新的 user 消息时,基于 assistant 的 `reasoning_content` 继续生成最终回答?
|
||||||
|
2. 截断后的 `reasoning_content` 是否仍然能被模型读取和引用?
|
||||||
|
3. 伪造 `tool_calls` + `tool` 结果历史是否比普通多轮对话更有效?
|
||||||
|
4. 工具结果中的措辞是否会显著影响模型对“目标思维链”的定位?
|
||||||
|
5. 标记在思维链中的位置是否影响模型的可召回性?
|
||||||
|
|
||||||
|
## 实验方法与基础设施
|
||||||
|
|
||||||
|
所有实验使用:
|
||||||
|
|
||||||
|
- 当前应用配置文件中的 OpenCodeGo / `deepseek-v4-flash`
|
||||||
|
- `stream=false` 的一次性 OpenAI 兼容请求
|
||||||
|
- 初始问题:
|
||||||
|
`A 比 B 高 20%,B 比 C 高 25%,那么 A 比 C 高多少?`
|
||||||
|
- 确定性记忆标记:
|
||||||
|
`TOKEN_MARK=731942`
|
||||||
|
|
||||||
|
专用测试位于 `AiV2.Tests/Program.cs`,默认跳过。
|
||||||
|
|
||||||
|
执行方式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build .\AiV2.Tests\AiV2.Tests.csproj /p:AiV2TestsBuilding=true
|
||||||
|
$env:ARR_AI_E2E = "1"
|
||||||
|
$env:ARR_AI_E2E_TOOL = "1"
|
||||||
|
$env:ARR_AI_TOOL_REPORT_PATH = "AI_reasoning_continuation_position_sweep_report.md"
|
||||||
|
& .\AiV2.Tests\bin\Debug\net461\AiV2.Tests.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 尝试过程
|
||||||
|
|
||||||
|
### 1. 普通多轮:assistant 推理 + 正文再回传
|
||||||
|
|
||||||
|
消息结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system → user → assistant(reasoning_content + content) → user
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:协议层接受 `reasoning_content`,模型通常会重新推导,但无法确认它真正延续了旧思维链。
|
||||||
|
|
||||||
|
### 2. 只发 assistant 推理,不追加 user
|
||||||
|
|
||||||
|
消息结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system → user → assistant(reasoning_content,content 不发送)
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:OpenCodeGo 接受最后一条 assistant 消息并生成完成,但新 reasoning 往往从题目重新开始。
|
||||||
|
|
||||||
|
### 3. 伪造 tool call 历史
|
||||||
|
|
||||||
|
消息结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
→ user
|
||||||
|
→ assistant(content 不发送, reasoning_content, tool_calls)
|
||||||
|
→ tool(tool_call_id, 工具结果)
|
||||||
|
```
|
||||||
|
|
||||||
|
请求同时声明 `tools`,并将 `tool_choice` 设置为 `"none"`。
|
||||||
|
|
||||||
|
工具结果中可以注入:
|
||||||
|
|
||||||
|
- “继续当前推理”指令
|
||||||
|
- 输出格式要求
|
||||||
|
- 需要引用的旧思维链内容
|
||||||
|
- “如果没有该内容,明确回答不存在”的边界条件
|
||||||
|
|
||||||
|
这一步开始观察到:**模型可能在部分运行中读取并引用 `reasoning_content`。**
|
||||||
|
|
||||||
|
### 4. 确定性记忆标记
|
||||||
|
|
||||||
|
为避免模型从工具结果中照抄标记,标记值只注入到 `reasoning_content`,`tool` 结果中不出现标记值。
|
||||||
|
|
||||||
|
### 5. 措辞 A/B
|
||||||
|
|
||||||
|
对比三种措辞:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A:上一轮思维链
|
||||||
|
B:当前推理
|
||||||
|
C:调用工具之前的思维链
|
||||||
|
D:本次 tool_calls 消息中携带的 reasoning_content
|
||||||
|
```
|
||||||
|
|
||||||
|
去掉“上一轮”“截断点”等歧义词后,B/D 的命中率明显更高。
|
||||||
|
|
||||||
|
### 6. 标记位置扫描
|
||||||
|
|
||||||
|
使用成功率最高的两种措辞:
|
||||||
|
|
||||||
|
- B:当前推理
|
||||||
|
- D:本次 tool_calls 消息中携带的 reasoning_content
|
||||||
|
|
||||||
|
标记插入完整思维链的 25%、50%、75%、末尾四个位置,每个组合重复 5 次。
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
### 措辞 A/B(每个组合 3 次)
|
||||||
|
|
||||||
|
| 工具提示措辞 | 正确写出标记 | 明确说标记不存在 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| A:上一轮思维链 | 2 / 3 | 1 / 3 |
|
||||||
|
| B:当前推理 | 3 / 3 | 0 / 3 |
|
||||||
|
| C:调用工具之前的思维链 | 2 / 3 | 1 / 3 |
|
||||||
|
| D:tool_calls 携带的 reasoning_content | 3 / 3 | 0 / 3 |
|
||||||
|
|
||||||
|
### 位置扫描(每个组合 5 次)
|
||||||
|
|
||||||
|
| 措辞 | 标记位置 | 正确写出标记 | 明确说没有 | 请求失败 |
|
||||||
|
|---|---|---:|---:|---:|
|
||||||
|
| B | 25% | 4 / 5 | 1 / 5 | 0 |
|
||||||
|
| B | 50% | 5 / 5 | 1 / 5 | 0 |
|
||||||
|
| B | 75% | 5 / 5 | 1 / 5 | 0 |
|
||||||
|
| B | 末尾 | 3 / 5 | 3 / 5 | 0 |
|
||||||
|
| D | 25% | 4 / 5 | 0 / 5 | 1(HTTP 503) |
|
||||||
|
| D | 50% | 5 / 5 | 0 / 5 | 0 |
|
||||||
|
| D | 75% | 5 / 5 | 2 / 5 | 0 |
|
||||||
|
| D | 末尾 | 4 / 5 | 2 / 5 | 0 |
|
||||||
|
|
||||||
|
## 关键发现
|
||||||
|
|
||||||
|
1. **tool call 是当前最有效的载体。**
|
||||||
|
|
||||||
|
把“继续推理、输出格式、引用要求”放进 tool 结果,模型会把这些内容当作任务输入,而不是普通 user 消息。
|
||||||
|
|
||||||
|
2. **措辞决定模型能否正确定位思维链。**
|
||||||
|
|
||||||
|
“当前推理”和“本次 tool_calls 消息中携带的 reasoning_content”比“上一轮思维链”更稳定。不要使用“上一轮”“截断点”这些容易引起歧义的词。
|
||||||
|
|
||||||
|
3. **标记在思维链开头/中部时最容易召回。**
|
||||||
|
|
||||||
|
50% 和 75% 位置均为 5/5;末尾位置明显下降。这说明不要把关键事实放在 `reasoning_content` 末尾。
|
||||||
|
|
||||||
|
4. **“正确写出标记”和“明确说没有”不是互斥的。**
|
||||||
|
|
||||||
|
模型有时先否定、后在同一输出中写出标记。统计时两者可能同时为真,人工审阅必须以完整 reasoning 和正文为准。
|
||||||
|
|
||||||
|
5. **服务端不稳定。**
|
||||||
|
|
||||||
|
D/25% 有一次 HTTP 503,属于服务端错误,不是模型失败。OpenCodeGo 未返回 `usage`,因此无法评估成本/token。
|
||||||
|
|
||||||
|
6. **主项目不应直接启用该机制。**
|
||||||
|
|
||||||
|
当前实验只能证明“部分情况下有效”,不足以作为生产依赖。
|
||||||
|
|
||||||
|
## 推荐方案
|
||||||
|
|
||||||
|
### 推荐消息结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
→ user(原始任务)
|
||||||
|
→ assistant(
|
||||||
|
content 不发送,
|
||||||
|
reasoning_content,
|
||||||
|
tool_calls: [{ id, type: "function", function: { name, arguments } }]
|
||||||
|
)
|
||||||
|
→ tool(
|
||||||
|
tool_call_id,
|
||||||
|
content: "工具结果:请继续你本次 tool_calls 消息中携带的 reasoning_content。
|
||||||
|
请原样写出其中的内部记忆标记;如果没有,请明确回答“标记不存在”。
|
||||||
|
请使用 Markdown 输出。"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
请求级配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "analysis_hint",
|
||||||
|
"description": "提供继续上一个内部推理的提示",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"instruction": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["instruction"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tool_choice": "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推荐提示词措辞
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请继续你当前正在进行的内部推理。
|
||||||
|
请继续你本次 tool_calls 消息中携带的 reasoning_content。
|
||||||
|
```
|
||||||
|
|
||||||
|
不推荐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
上一轮思维链在这里被截断,请继续。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 对主项目的建议
|
||||||
|
|
||||||
|
- 保留现有 UI 对 `reasoning_content` 的展示。
|
||||||
|
- 不要在生产管线中自动发送/截断/修改 `reasoning_content`。
|
||||||
|
- 如未来需要接入,优先使用 tool call 载体,并将关键指令、格式和引用要求放入 tool 结果。
|
||||||
|
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
||||||
|
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
||||||
|
|
||||||
|
## 主项目集成状态(2026-08-23,历史记录;该功能已全部移除)
|
||||||
|
|
||||||
|
> 以下内容是当时的集成状态,不代表当前代码。当前主项目已移除推理保护及所有相关代码/测试。
|
||||||
|
|
||||||
|
- 主项目曾落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
||||||
|
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
||||||
|
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
||||||
|
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
||||||
|
- 保留推理在其末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句;收尾句不插入中间 checkpoint,也不提及“token limit/被截断”等技术细节。
|
||||||
|
- 续写工具结果包含“进入收尾阶段、立即停止展开、直接输出最终结果”和“最后一句已经宣告收尾,请立即执行”等强化措辞。
|
||||||
|
- UI 在触发推理保护后提供两个可折叠日志:首次请求消息与续写请求完整消息;用户可展开查看完整 `messages`、`tools`、`tool_choice` 和收尾指令。
|
||||||
|
- UI 进一步改为阶段化时间线:推理保护事件与诊断在续写思考块之前输出;修订保留旧版本并折叠,最新版本展开;机器可读声明 JSON 与验证结果独立展示。
|
||||||
|
- UI 正文在成功提取机器可读声明后会移除 JSON 原文;推理保护诊断显示可读摘要(消息数变化、新增 assistant/tool 消息),完整续写 JSON 作为折叠附件;总览、修订和回查的 user prompt 会以折叠日志展示。
|
||||||
|
- 默认测试新增 `ReasoningGuard` 套件后总计 173 项通过;真实 API A/B 仍需要用户手工运行 `ARR_AI_E2E=1` 验证。
|
||||||
|
- 可选真实环境测试为 `OpenCodeGoGuardE2e`:设置 `ARR_AI_GUARD_E2E=1` 启用,报告路径可用 `ARR_AI_GUARD_REPORT_PATH` 指定。
|
||||||
|
|
||||||
|
## 专用测试状态
|
||||||
|
|
||||||
|
- 原测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
||||||
|
- 现状:已随推理保护一起移除,不再保留可执行实验入口。
|
||||||
|
|
||||||
|
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<Window x:Class="AnotherReplayReader.APM"
|
|
||||||
x:ClassModifier="internal"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
|
||||||
mc:Ignorable="d"
|
|
||||||
Title="APM"
|
|
||||||
Height="450"
|
|
||||||
Width="800">
|
|
||||||
<Grid>
|
|
||||||
<Label Margin="20,10,0,0"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Height="30"
|
|
||||||
VerticalAlignment="Top">
|
|
||||||
选择表格之后按 Ctrl+C 可以复制内容
|
|
||||||
</Label>
|
|
||||||
<Button x:Name="_setPlayerButton"
|
|
||||||
Content="设置玩家信息..."
|
|
||||||
Margin="0,19,22,0"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
Padding="5,2"
|
|
||||||
Visibility="Visible"
|
|
||||||
Click="OnSetPlayerButtonClick"
|
|
||||||
HorizontalAlignment="Right"/>
|
|
||||||
<DataGrid x:Name="_table"
|
|
||||||
Margin="20,40,22,19"
|
|
||||||
Grid.ColumnSpan="2"
|
|
||||||
CanUserSortColumns="True">
|
|
||||||
<DataGrid.Resources>
|
|
||||||
<Style TargetType="DataGridCell">
|
|
||||||
<EventSetter Event="MouseDoubleClick"
|
|
||||||
Handler="OnTableMouseDoubleClick" />
|
|
||||||
</Style>
|
|
||||||
</DataGrid.Resources>
|
|
||||||
<DataGrid.Columns>
|
|
||||||
<DataGridTextColumn Header="项"
|
|
||||||
Binding="{Binding Path=Name}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家1"
|
|
||||||
Binding="{Binding Path=Player1Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家2"
|
|
||||||
Binding="{Binding Path=Player2Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家3"
|
|
||||||
Binding="{Binding Path=Player3Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家4"
|
|
||||||
Binding="{Binding Path=Player4Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家5"
|
|
||||||
Binding="{Binding Path=Player5Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
<DataGridTextColumn Header="玩家6"
|
|
||||||
Binding="{Binding Path=Player6Value}"
|
|
||||||
IsReadOnly="True" />
|
|
||||||
</DataGrid.Columns>
|
|
||||||
</DataGrid>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</Window>
|
|
||||||
-230
@@ -1,230 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
|
||||||
{
|
|
||||||
internal class DataValue : IComparable<DataValue>, IComparable
|
|
||||||
{
|
|
||||||
public int? NumberValue { get; }
|
|
||||||
public string Value { get; }
|
|
||||||
|
|
||||||
public DataValue(string value)
|
|
||||||
{
|
|
||||||
Value = value;
|
|
||||||
if (int.TryParse(value, out var numberValue))
|
|
||||||
{
|
|
||||||
NumberValue = numberValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Value;
|
|
||||||
|
|
||||||
public int CompareTo(DataValue other)
|
|
||||||
{
|
|
||||||
if (NumberValue.HasValue == other.NumberValue.HasValue)
|
|
||||||
{
|
|
||||||
if (!NumberValue.HasValue)
|
|
||||||
{
|
|
||||||
return Value.CompareTo(other.Value);
|
|
||||||
}
|
|
||||||
return NumberValue.Value.CompareTo(other.NumberValue.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NumberValue.HasValue ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int CompareTo(object obj)
|
|
||||||
{
|
|
||||||
if (obj is DataValue other)
|
|
||||||
{
|
|
||||||
return CompareTo(other);
|
|
||||||
}
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class DataRow
|
|
||||||
{
|
|
||||||
public string Name { get; }
|
|
||||||
public DataValue Player1Value => _values[0];
|
|
||||||
public DataValue Player2Value => _values[1];
|
|
||||||
public DataValue Player3Value => _values[2];
|
|
||||||
public DataValue Player4Value => _values[3];
|
|
||||||
public DataValue Player5Value => _values[4];
|
|
||||||
public DataValue Player6Value => _values[5];
|
|
||||||
|
|
||||||
private readonly IReadOnlyList<DataValue> _values;
|
|
||||||
|
|
||||||
public DataRow(string name, IEnumerable<string> values)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
|
|
||||||
if (values.Count() < 6)
|
|
||||||
{
|
|
||||||
values = values.Concat(new string[6 - values.Count()]);
|
|
||||||
}
|
|
||||||
_values = values.Select(x => new DataValue(x)).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class DataTableFactory
|
|
||||||
{
|
|
||||||
static public List<DataRow> Get(Replay replay, PlayerIdentity identity)
|
|
||||||
{
|
|
||||||
var list = new List<byte>
|
|
||||||
{
|
|
||||||
0x0F,
|
|
||||||
0x5F,
|
|
||||||
0x12,
|
|
||||||
0x1B,
|
|
||||||
0x48,
|
|
||||||
0x52,
|
|
||||||
0xFC,
|
|
||||||
0xFD,
|
|
||||||
0x01,
|
|
||||||
0x21,
|
|
||||||
0x33,
|
|
||||||
0x34,
|
|
||||||
0x35,
|
|
||||||
0x37,
|
|
||||||
0x47,
|
|
||||||
0xF6,
|
|
||||||
0xF9,
|
|
||||||
0xF5,
|
|
||||||
0xF8,
|
|
||||||
0x2A,
|
|
||||||
0xFA,
|
|
||||||
0xFB,
|
|
||||||
0x07,
|
|
||||||
0x08,
|
|
||||||
0x05,
|
|
||||||
0x06,
|
|
||||||
0x09,
|
|
||||||
0x00,
|
|
||||||
0x0A,
|
|
||||||
0x03,
|
|
||||||
0x04,
|
|
||||||
0x28,
|
|
||||||
0x29,
|
|
||||||
0x0D,
|
|
||||||
0x0E,
|
|
||||||
0x15,
|
|
||||||
0x14,
|
|
||||||
0x36,
|
|
||||||
0x16,
|
|
||||||
0x2C,
|
|
||||||
0x1A,
|
|
||||||
0x4E,
|
|
||||||
0xFE,
|
|
||||||
0xFF,
|
|
||||||
0x32,
|
|
||||||
0x2E,
|
|
||||||
0x2F,
|
|
||||||
0x4B,
|
|
||||||
0x4C,
|
|
||||||
0x02,
|
|
||||||
0x0C,
|
|
||||||
0x10,
|
|
||||||
};
|
|
||||||
|
|
||||||
var dataList = new List<DataRow>
|
|
||||||
{
|
|
||||||
new DataRow("ID", replay.Players.Select(x => x.PlayerName))
|
|
||||||
};
|
|
||||||
if (replay.Type == ReplayType.Lan && identity.IsUsable)
|
|
||||||
{
|
|
||||||
dataList.Add(new DataRow("局域网IP", replay.Players.Select(x => identity.QueryRealNameAndIP(x.PlayerIP))));
|
|
||||||
}
|
|
||||||
|
|
||||||
var commandCounts = replay.GetCommandCounts();
|
|
||||||
foreach (var command in list)
|
|
||||||
{
|
|
||||||
var counts = commandCounts.TryGetValue(command, out var stored) ? stored : new int[replay.Players.Count];
|
|
||||||
dataList.Add(new DataRow(RA3Commands.GetCommandName(command), counts.Select(x => $"{x}")));
|
|
||||||
commandCounts.Remove(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var commandCount in commandCounts)
|
|
||||||
{
|
|
||||||
dataList.Add(new DataRow(RA3Commands.GetCommandName(commandCount.Key), commandCount.Value.Select(x => $"{x}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// <summary>
|
|
||||||
/// APM.xaml 的交互逻辑
|
|
||||||
/// </summary>
|
|
||||||
internal partial class APM : Window
|
|
||||||
{
|
|
||||||
private PlayerIdentity _identity;
|
|
||||||
|
|
||||||
public APM(Replay replay, PlayerIdentity identity)
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
_identity = identity;
|
|
||||||
|
|
||||||
if (_identity.IsUsable)
|
|
||||||
{
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
_setPlayerButton.IsEnabled = true;
|
|
||||||
_setPlayerButton.Visibility = Visibility.Visible;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
_setPlayerButton.IsEnabled = false;
|
|
||||||
_setPlayerButton.Visibility = Visibility.Hidden;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dataList = DataTableFactory.Get(replay, _identity);
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
_table.Items.Clear();
|
|
||||||
foreach (var row in dataList)
|
|
||||||
{
|
|
||||||
_table.Items.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
_table.Columns[1].Header = dataList[0].Player1Value;
|
|
||||||
_table.Columns[2].Header = dataList[0].Player2Value;
|
|
||||||
_table.Columns[3].Header = dataList[0].Player3Value;
|
|
||||||
_table.Columns[4].Header = dataList[0].Player4Value;
|
|
||||||
_table.Columns[5].Header = dataList[0].Player5Value;
|
|
||||||
_table.Columns[6].Header = dataList[0].Player6Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
MessageBox.Show($"加载录像信息失败:\r\n{e}");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSetPlayerButtonClick(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var window1 = new Window1(_identity);
|
|
||||||
window1.ShowDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTableMouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
_table.SelectAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+70
-21
@@ -1,4 +1,5 @@
|
|||||||
<Window x:Class="AnotherReplayReader.About"
|
<Window x:Class="AnotherReplayReader.About"
|
||||||
|
x:ClassModifier="internal"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
@@ -6,28 +7,76 @@
|
|||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="About"
|
Title="About"
|
||||||
Height="301.893"
|
Height="420"
|
||||||
Width="429">
|
Width="450"
|
||||||
<Grid Margin="0,0,-8,-59">
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Loaded="OnAboutWindowLoaded">
|
||||||
|
<Grid Margin="0,0,0,-50">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="280"/>
|
<RowDefinition Height="420" />
|
||||||
<RowDefinition Height="50"/>
|
<RowDefinition />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBox x:Name="_idBox" HorizontalAlignment="Left" Margin="54,23,0,10" TextWrapping="Wrap" Text="TextBox" Width="300" BorderBrush="White" RenderTransformOrigin="0.497,0.438" Grid.Row="1"/>
|
<StackPanel Grid.Row="0"
|
||||||
<TextBlock x:Name="textBlock" Margin="50,10,45,32" TextWrapping="Wrap" Grid.RowSpan="2">
|
Orientation="Vertical">
|
||||||
<Run Text="【自动录像机0.6】"/><LineBreak/>
|
<StackPanel x:Name="_updatePanel"
|
||||||
<Run Text="本工具目前额外支持以下mod的读取:AR、日冕、大蜗牛、Ins、FS、WOP、Eisenreich、TNW"/><LineBreak/>
|
Orientation="Vertical"
|
||||||
<Run Text="当mod增加新的阵营时,会出现未知阵营和阵营错乱现象"/><LineBreak/>
|
Visibility="Collapsed">
|
||||||
<Run Text="有任何问题可以先去找苏醒或节操"/><LineBreak/>
|
<TextBlock x:Name="_updateInfo"
|
||||||
<Run Text="解析录像的代码主要来源于louisdx的研究:"/><LineBreak/>
|
Margin="24,16">
|
||||||
<Hyperlink NavigateUri="https://github.com/louisdx/cnc-replayreaders"><Run Text="https://github.com/louisdx/cnc-replayreaders"/></Hyperlink><LineBreak/>
|
<Run FontWeight="Bold">已经有新版本了呢!</Run>
|
||||||
<Run Text="解析Big的代码来源于OpenSage:"/><LineBreak/>
|
<LineBreak />
|
||||||
<Hyperlink NavigateUri="https://github.com/OpenSAGE/OpenSAGE"><Run Text="https://github.com/OpenSAGE/OpenSAGE"/></Hyperlink><LineBreak/>
|
</TextBlock>
|
||||||
<Run Text="解析Tga的代码来源于Pfim:"/><LineBreak/>
|
<Separator />
|
||||||
<Hyperlink NavigateUri="https://github.com/nickbabcock/Pfim"><Run Text="https://github.com/nickbabcock/Pfim"/></Hyperlink><LineBreak/>
|
</StackPanel>
|
||||||
<Run Text="RA3吧:"/><LineBreak/>
|
<StackPanel Orientation="Vertical"
|
||||||
<Hyperlink NavigateUri="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3"><Run Text="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3"/></Hyperlink><LineBreak/>
|
Margin="24,16">
|
||||||
<Run Text="ARmod群号:656507961"/></TextBlock>
|
<TextBlock TextWrapping="Wrap">
|
||||||
<TextBlock x:Name="textBlock1" HorizontalAlignment="Left" Margin="10,23,0,11" TextWrapping="Wrap" Width="60" Grid.Row="1"><Run Text="ID"/><LineBreak/><Run/></TextBlock>
|
<Run Text="{Binding Source={x:Static local:App.NameWithVersion},
|
||||||
|
Mode=OneWay,
|
||||||
|
StringFormat={}【{0}】}" />
|
||||||
|
<LineBreak />
|
||||||
|
这个工具目前额外支持以下 Mod 的读取:AR、日冕、大蜗牛、Ins、FS、WOP、Eisenreich、TNW<LineBreak />
|
||||||
|
有任何问题可以先去找苏醒或节操问题(<LineBreak />
|
||||||
|
解析录像的代码主要来源于
|
||||||
|
<Hyperlink NavigateUri="https://www.gamereplays.org/community/index.php?showtopic=706067">R Schneider 的研究</Hyperlink>
|
||||||
|
以及 BoolBada 的
|
||||||
|
<Hyperlink NavigateUri="https://github.com/forcecore/KWReplayAutoSaver">KWReplayAutoSaver</Hyperlink>
|
||||||
|
<LineBreak />
|
||||||
|
解析 Big 的代码来源于
|
||||||
|
<Hyperlink NavigateUri="https://github.com/Qibbi">Jana Mohn</Hyperlink>
|
||||||
|
的 TechnologyAssembler<LineBreak />
|
||||||
|
解析 Tga 的代码来源于
|
||||||
|
<Hyperlink NavigateUri="https://github.com/nickbabcock/Pfim">Pfim</Hyperlink><LineBreak />
|
||||||
|
使用了
|
||||||
|
<Hyperlink NavigateUri="https://github.com/2881099/NPinyin">NPinyin</Hyperlink>
|
||||||
|
以支持按照拼音来查询信息<LineBreak />
|
||||||
|
APM 图表是通过
|
||||||
|
<Hyperlink NavigateUri="https://oxyplot.github.io/">OxyPlot</Hyperlink>
|
||||||
|
画出来的<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
欢迎来到红警3吧:
|
||||||
|
<Hyperlink NavigateUri="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3">https://tieba.baidu.com/ra3</Hyperlink><LineBreak />
|
||||||
|
<Run Text="ARMod 群号:161660710" />
|
||||||
|
</TextBlock>
|
||||||
|
<CheckBox x:Name="_checkForUpdates"
|
||||||
|
Margin="0,16,0,0"
|
||||||
|
Content="自动检查更新"
|
||||||
|
Checked="OnCheckForUpdatesCheckedChanged"
|
||||||
|
Unchecked="OnCheckForUpdatesCheckedChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<DockPanel x:Name="_bottom"
|
||||||
|
Grid.Row="1">
|
||||||
|
<Label DockPanel.Dock="Left"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Content="ID" />
|
||||||
|
<TextBox x:Name="_idBox"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="TextBox"
|
||||||
|
BorderBrush="White" />
|
||||||
|
</DockPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
+48
-14
@@ -1,34 +1,68 @@
|
|||||||
using System;
|
using AnotherReplayReader.Utils;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Data;
|
|
||||||
using System.Windows.Documents;
|
using System.Windows.Documents;
|
||||||
using System.Windows.Input;
|
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Shapes;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// About.xaml 的交互逻辑
|
/// About.xaml 的交互逻辑
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class About : Window
|
internal partial class About : Window
|
||||||
{
|
{
|
||||||
public About()
|
private readonly Cache _cache;
|
||||||
|
private readonly UpdateCheckerVersionData? _updateData;
|
||||||
|
|
||||||
|
public About(Cache cache)
|
||||||
{
|
{
|
||||||
|
_cache = cache;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_idBox.Text = Auth.ID;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
public About(Cache cache, UpdateCheckerVersionData updateData)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_updateData = updateData;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnAboutWindowLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var hyperlink in this.FindVisualChildren<Hyperlink>())
|
||||||
|
{
|
||||||
|
hyperlink.RequestNavigate += OnHyperlinkRequestNavigate;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cache.Initialization;
|
||||||
|
_checkForUpdates.IsChecked = _cache.GetOrDefault(UpdateChecker.CheckForUpdatesKey, false);
|
||||||
|
var data = _updateData
|
||||||
|
?? _cache.GetOrDefault<UpdateCheckerVersionData?>(UpdateChecker.CachedDataKey, null);
|
||||||
|
if (data is { } updateData && updateData.IsNewVersion())
|
||||||
|
{
|
||||||
|
_updatePanel.Visibility = Visibility.Visible;
|
||||||
|
_updateInfo.Inlines.Add(updateData.Description);
|
||||||
|
_updateInfo.Inlines.Add(new LineBreak());
|
||||||
|
updateData.Urls.Select(u =>
|
||||||
|
{
|
||||||
|
var h = new Hyperlink();
|
||||||
|
h.Inlines.Add(u);
|
||||||
|
h.NavigateUri = new(u);
|
||||||
|
h.RequestNavigate += OnHyperlinkRequestNavigate;
|
||||||
|
return h;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHyperlinkRequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||||
{
|
{
|
||||||
Process.Start(e.Uri.ToString());
|
Process.Start(e.Uri.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnCheckForUpdatesCheckedChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_cache.Set(UpdateChecker.CheckForUpdatesKey, _checkForUpdates.IsChecked is true);
|
||||||
|
await _cache.Save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net461</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>AiV2.Tests</AssemblyName>
|
||||||
|
<RootNamespace>AiV2.Tests</RootNamespace>
|
||||||
|
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AnotherReplayReader.csproj" AdditionalProperties="AiV2TestsBuilding=true" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\knowledge_units_default.json" Link="knowledge_units_default.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="..\knowledge_default.md" Link="knowledge_default.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
File diff suppressed because it is too large
Load Diff
+59
-169
@@ -1,193 +1,83 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
|
||||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
|
||||||
<ProjectGuid>{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}</ProjectGuid>
|
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<RootNamespace>AnotherReplayReader</RootNamespace>
|
<TargetFramework>net461</TargetFramework>
|
||||||
<AssemblyName>AnotherReplayReader</AssemblyName>
|
|
||||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
|
||||||
<FileAlignment>512</FileAlignment>
|
|
||||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
|
||||||
<WarningLevel>4</WarningLevel>
|
|
||||||
<Deterministic>true</Deterministic>
|
|
||||||
<TargetFrameworkProfile />
|
|
||||||
<IsWebBootstrapper>false</IsWebBootstrapper>
|
|
||||||
<PublishUrl>publish\</PublishUrl>
|
|
||||||
<Install>true</Install>
|
|
||||||
<InstallFrom>Disk</InstallFrom>
|
|
||||||
<UpdateEnabled>false</UpdateEnabled>
|
|
||||||
<UpdateMode>Foreground</UpdateMode>
|
|
||||||
<UpdateInterval>7</UpdateInterval>
|
|
||||||
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
|
|
||||||
<UpdatePeriodically>false</UpdatePeriodically>
|
|
||||||
<UpdateRequired>false</UpdateRequired>
|
|
||||||
<MapFileExtensions>true</MapFileExtensions>
|
|
||||||
<ApplicationRevision>1</ApplicationRevision>
|
|
||||||
<ApplicationVersion>0.0.1.%2a</ApplicationVersion>
|
|
||||||
<UseApplicationTrust>false</UseApplicationTrust>
|
|
||||||
<PublishWizardCompleted>true</PublishWizardCompleted>
|
|
||||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
|
||||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||||
|
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||||
|
<PublishUrl>publish\</PublishUrl>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<IncludePackageReferencesDuringMarkupCompilation>true</IncludePackageReferencesDuringMarkupCompilation>
|
||||||
|
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
<PropertyGroup>
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
<DebugSymbols>true</DebugSymbols>
|
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||||
<DebugType>full</DebugType>
|
<LangVersion>latest</LangVersion>
|
||||||
<Optimize>false</Optimize>
|
<Nullable>enable</Nullable>
|
||||||
<OutputPath>bin\Debug\</OutputPath>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
<WarningLevel>4</WarningLevel>
|
|
||||||
<Prefer32Bit>false</Prefer32Bit>
|
|
||||||
<LangVersion>7.2</LangVersion>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<OutputPath>bin\Release\</OutputPath>
|
|
||||||
<DefineConstants>TRACE</DefineConstants>
|
|
||||||
<ErrorReport>prompt</ErrorReport>
|
|
||||||
<WarningLevel>4</WarningLevel>
|
|
||||||
<Prefer32Bit>false</Prefer32Bit>
|
|
||||||
<LangVersion>7.2</LangVersion>
|
|
||||||
<DocumentationFile>
|
|
||||||
</DocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManifestCertificateThumbprint>DB7DFD435909EF54AE3424563219E8449DA47901</ManifestCertificateThumbprint>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManifestKeyFile>AnotherReplayReader_TemporaryKey.pfx</ManifestKeyFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<GenerateManifests>true</GenerateManifests>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<SignManifests>false</SignManifests>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup />
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="System" />
|
<Compile Remove="publish\**" />
|
||||||
<Reference Include="System.Data" />
|
<EmbeddedResource Remove="publish\**" />
|
||||||
|
<None Remove="publish\**" />
|
||||||
|
<Page Remove="publish\**" />
|
||||||
|
<Compile Remove="AiV2.Tests\**" />
|
||||||
|
<EmbeddedResource Remove="AiV2.Tests\**" />
|
||||||
|
<None Remove="AiV2.Tests\**" />
|
||||||
|
<Page Remove="AiV2.Tests\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
<Reference Include="System.Web" />
|
<Reference Include="System.Web" />
|
||||||
<Reference Include="System.Web.Extensions" />
|
<Reference Include="TechnologyAssembler.Core">
|
||||||
<Reference Include="System.Xaml" />
|
<HintPath>TechnologyAssembler.Core.dll</HintPath>
|
||||||
<Reference Include="System.Xml" />
|
<Private>true</Private>
|
||||||
<Reference Include="System.Core" />
|
</Reference>
|
||||||
<Reference Include="System.Xml.Linq" />
|
|
||||||
<Reference Include="System.Data.DataSetExtensions" />
|
|
||||||
<Reference Include="WindowsBase" />
|
|
||||||
<Reference Include="PresentationCore" />
|
|
||||||
<Reference Include="PresentationFramework" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ILMerge">
|
<PackageReference Include="NPinyin.Core" Version="3.0.0" />
|
||||||
<Version>3.0.29</Version>
|
<PackageReference Include="OxyPlot.Wpf" Version="2.1.0" />
|
||||||
</PackageReference>
|
<PackageReference Include="Pfim" Version="0.10.1" />
|
||||||
<PackageReference Include="ILMerge.MSBuild.Task">
|
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
|
||||||
<Version>1.0.7</Version>
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.0-preview.7.24405.7" />
|
||||||
</PackageReference>
|
<PackageReference Include="System.Text.Json" Version="5.0.2" />
|
||||||
<PackageReference Include="OpenSage.FileFormats.Big">
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Pfim">
|
|
||||||
<Version>0.7.0</Version>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ApplicationDefinition Include="App.xaml">
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</ApplicationDefinition>
|
|
||||||
<Compile Include="About.xaml.cs">
|
|
||||||
<DependentUpon>About.xaml</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="APM.xaml.cs">
|
|
||||||
<DependentUpon>APM.xaml</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="Auth.cs" />
|
|
||||||
<Compile Include="Cache.cs" />
|
|
||||||
<Compile Include="BigMinimapCache.cs" />
|
|
||||||
<Compile Include="Debug.xaml.cs">
|
|
||||||
<DependentUpon>Debug.xaml</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="MinimapReader.cs" />
|
|
||||||
<Compile Include="ModData.cs" />
|
|
||||||
<Compile Include="PlayerIdentity.cs" />
|
|
||||||
<Compile Include="Replay.cs" />
|
|
||||||
<Compile Include="Window1.xaml.cs">
|
|
||||||
<DependentUpon>Window1.xaml</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Page Include="About.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
</Page>
|
|
||||||
<Page Include="APM.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
</Page>
|
|
||||||
<Page Include="Debug.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
</Page>
|
|
||||||
<Page Include="MainWindow.xaml">
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</Page>
|
|
||||||
<Compile Include="App.xaml.cs">
|
|
||||||
<DependentUpon>App.xaml</DependentUpon>
|
|
||||||
<SubType>Code</SubType>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="CommandChunk.cs" />
|
|
||||||
<Compile Include="MainWindow.xaml.cs">
|
|
||||||
<DependentUpon>MainWindow.xaml</DependentUpon>
|
|
||||||
<SubType>Code</SubType>
|
|
||||||
</Compile>
|
|
||||||
<Page Include="Window1.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
</Page>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Properties\AssemblyInfo.cs">
|
<Compile Update="Properties\Settings.Designer.cs">
|
||||||
<SubType>Code</SubType>
|
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||||
</Compile>
|
|
||||||
<Compile Include="Properties\Resources.Designer.cs">
|
|
||||||
<AutoGen>True</AutoGen>
|
|
||||||
<DesignTime>True</DesignTime>
|
|
||||||
<DependentUpon>Resources.resx</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="Properties\Settings.Designer.cs">
|
|
||||||
<AutoGen>True</AutoGen>
|
<AutoGen>True</AutoGen>
|
||||||
<DependentUpon>Settings.settings</DependentUpon>
|
<DependentUpon>Settings.settings</DependentUpon>
|
||||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
|
||||||
</Compile>
|
</Compile>
|
||||||
<EmbeddedResource Include="Properties\Resources.resx">
|
</ItemGroup>
|
||||||
<Generator>ResXFileCodeGenerator</Generator>
|
<ItemGroup>
|
||||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
<None Update="Properties\Settings.settings">
|
||||||
</EmbeddedResource>
|
|
||||||
<None Include="app.config" />
|
|
||||||
<None Include="Properties\Settings.settings">
|
|
||||||
<Generator>SettingsSingleFileGenerator</Generator>
|
<Generator>SettingsSingleFileGenerator</Generator>
|
||||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<BootstrapperPackage Include=".NETFramework,Version=v4.6.1">
|
<Content Include="knowledge_default.md">
|
||||||
<Visible>False</Visible>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<ProductName>Microsoft .NET Framework 4.6.1 %28x86 和 x64%29</ProductName>
|
</Content>
|
||||||
<Install>true</Install>
|
<Content Include="knowledge_corona.md">
|
||||||
</BootstrapperPackage>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
|
</Content>
|
||||||
<Visible>False</Visible>
|
<Content Include="knowledge_units_default.json">
|
||||||
<ProductName>.NET Framework 3.5 SP1</ProductName>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<Install>false</Install>
|
</Content>
|
||||||
</BootstrapperPackage>
|
<!-- 临时快照:StringHashes 来自本地 RA3-MODSDK-X;后续应改为可配置路径或只打包需要的 hash 子集。 -->
|
||||||
|
<Content Include="Data\StringHashes.xml">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
<Target Name="CustomAfterBuild" AfterTargets="Build" Condition="'$(AiV2TestsBuilding)' != 'true'">
|
||||||
|
<ItemGroup>
|
||||||
|
<_FilesToMove Include="$(OutputPath)*.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Message Text="_FilesToMove: @(_FilesToMove->'%(Filename)%(Extension)')" Importance="high" />
|
||||||
|
<Message Text="DestFiles:
 @(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" Importance="high" />
|
||||||
|
<Move SourceFiles="@(_FilesToMove)" DestinationFiles="@(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 16
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 16.0.30907.101
|
VisualStudioVersion = 17.10.34928.147
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnotherReplayReader", "AnotherReplayReader.csproj", "{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnotherReplayReader", "AnotherReplayReader.csproj", "{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using OxyPlot;
|
||||||
|
using OxyPlot.Axes;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Apm
|
||||||
|
{
|
||||||
|
public class ApmPlotterFilterOptions
|
||||||
|
{
|
||||||
|
public bool CountUnknowns { get; }
|
||||||
|
public bool CountAutos { get; }
|
||||||
|
public bool CountClicks { get; }
|
||||||
|
|
||||||
|
public ApmPlotterFilterOptions(bool countUnknowns, bool countAutos, bool countClicks)
|
||||||
|
{
|
||||||
|
CountUnknowns = countUnknowns;
|
||||||
|
CountAutos = countAutos;
|
||||||
|
CountClicks = countClicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldSkip(int command)
|
||||||
|
{
|
||||||
|
if (!CountUnknowns && ApmPlotter.IsUnknown(command))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!CountAutos && ApmPlotter.IsAuto(command))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!CountClicks && ApmPlotter.IsClick(command))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ApmPlotter
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<int, byte> UnknownCommands;
|
||||||
|
private static readonly ConcurrentDictionary<int, byte> AutoCommands;
|
||||||
|
private readonly ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> _commands;
|
||||||
|
|
||||||
|
public Replay Replay { get; }
|
||||||
|
public ImmutableSortedDictionary<int, Player> PlayersMap { get; }
|
||||||
|
public ImmutableArray<Player> PlayersArray { get; }
|
||||||
|
public ImmutableArray<TimeSpan> PlayerLifes { get; }
|
||||||
|
public ImmutableArray<TimeSpan> StricterPlayerLifes { get; }
|
||||||
|
public TimeSpan ReplayLength { get; }
|
||||||
|
public ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands => _commands;
|
||||||
|
|
||||||
|
static ApmPlotter()
|
||||||
|
{
|
||||||
|
static KeyValuePair<int, byte> Create(int x) => new(x, default);
|
||||||
|
UnknownCommands = new(RA3Commands.UnknownCommands.Select(Create));
|
||||||
|
AutoCommands = new(RA3Commands.AutoCommands.Select(Create));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApmPlotter(Replay replay)
|
||||||
|
{
|
||||||
|
if (replay.Body is not { } body)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Replay must be fully parsed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Replay = replay;
|
||||||
|
|
||||||
|
var queryFull = from chunk in body
|
||||||
|
where chunk.Type is 1
|
||||||
|
from command in CommandChunk.Parse(chunk)
|
||||||
|
select command;
|
||||||
|
|
||||||
|
var playersIndices = queryFull
|
||||||
|
.Select(x => x.PlayerIndex)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// if length mismatch and last is post commentator, remove post commentator from replay players
|
||||||
|
var replayPlayers = replay.Players;
|
||||||
|
var postCommentatorIndex = replayPlayers.FindIndex(p => p.PlayerName == Replay.PostCommentator);
|
||||||
|
if (playersIndices.Length + 1 == replayPlayers.Length && postCommentatorIndex is int index)
|
||||||
|
{
|
||||||
|
replayPlayers = replayPlayers.RemoveAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playersIndices.Length != replayPlayers.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Player count mismatch. Indices={playersIndices.Length}, Players={replayPlayers.Length}");
|
||||||
|
}
|
||||||
|
PlayersMap = playersIndices
|
||||||
|
.Zip(replayPlayers, (index, player) => new KeyValuePair<int, Player>(index, player))
|
||||||
|
.ToImmutableSortedDictionary();
|
||||||
|
PlayersArray = PlayersMap.Values.ToImmutableArray();
|
||||||
|
|
||||||
|
|
||||||
|
//var queryFull = from chunk in body
|
||||||
|
// where chunk.Type is 1
|
||||||
|
// from command in CommandChunk.Parse(chunk)
|
||||||
|
// select (chunk.Time, command);
|
||||||
|
//var sb = new System.Text.StringBuilder();
|
||||||
|
//var stringHashes = System.IO.File.ReadAllText(@"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml");
|
||||||
|
//XDocument doc = XDocument.Parse(stringHashes);
|
||||||
|
//XNamespace ns = "uri:ea.com:eala:asset";
|
||||||
|
//var table = doc
|
||||||
|
// .Descendants(ns + "StringHashTable")
|
||||||
|
// .FirstOrDefault(x => (string?)x.Attribute("id") == "StringHashBin_INSTANCEID");
|
||||||
|
|
||||||
|
//if (table == null)
|
||||||
|
// throw new InvalidOperationException("StringHashBin_INSTANCEID not found.");
|
||||||
|
|
||||||
|
//Dictionary<uint, string> hashTable = table
|
||||||
|
// .Elements(ns + "StringAndHash")
|
||||||
|
// .ToDictionary(
|
||||||
|
// x => uint.Parse(x.Attribute("Hash")!.Value),
|
||||||
|
// x => x.Attribute("Text")!.Value);
|
||||||
|
|
||||||
|
//foreach (var (time, command) in queryFull)
|
||||||
|
//{
|
||||||
|
// if (IsUnknown(command.CommandId) || IsAuto(command.CommandId))
|
||||||
|
// {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// var name = RA3Commands.GetCommandName(command.CommandId);
|
||||||
|
// sb.Append($"[{time}] 玩家 {command.PlayerIndex},{name}\r\n");
|
||||||
|
// foreach (var kv in command.Data)
|
||||||
|
// {
|
||||||
|
// // try to convert int32 or uint32 to hashes and retrieve text
|
||||||
|
// var textFromHash = string.Empty;
|
||||||
|
// uint hashValue = kv.Key switch
|
||||||
|
// {
|
||||||
|
// CommandArgumentType.Int32 => (uint)(int)kv.Value,
|
||||||
|
// CommandArgumentType.UInt32 => (uint)kv.Value,
|
||||||
|
// CommandArgumentType.UInt32_2 => (uint)kv.Value,
|
||||||
|
// _ => 0
|
||||||
|
// };
|
||||||
|
// if (hashTable.TryGetValue(hashValue, out var text))
|
||||||
|
// {
|
||||||
|
// sb.Append($" {text}\r\n");
|
||||||
|
// }
|
||||||
|
// else
|
||||||
|
// {
|
||||||
|
// sb.Append($" {kv.Key}: {kv.Value}\r\n");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//流水账 = sb.ToString();
|
||||||
|
//System.IO.File.WriteAllText("流水账.txt", 流水账);
|
||||||
|
|
||||||
|
// get all commands
|
||||||
|
var query = from chunk in body
|
||||||
|
where chunk.Type is 1
|
||||||
|
select (chunk.Time, CommandChunk.Parse(chunk).ToImmutableArray());
|
||||||
|
_commands = query.ToImmutableArray();
|
||||||
|
|
||||||
|
var playerLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
|
||||||
|
var threeSeconds = TimeSpan.FromSeconds(3);
|
||||||
|
var stricterLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
|
||||||
|
foreach (var (time, commands) in _commands)
|
||||||
|
{
|
||||||
|
var estimatedTime = time + threeSeconds;
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
var commandId = command.CommandId;
|
||||||
|
var playerIndex = command.PlayerIndex;
|
||||||
|
if (playerIndex != -1)
|
||||||
|
{
|
||||||
|
if (commandId == 0x221)
|
||||||
|
{
|
||||||
|
if (playerLifes[playerIndex] < estimatedTime)
|
||||||
|
{
|
||||||
|
playerLifes[playerIndex] = estimatedTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!IsUnknown(commandId) && !IsAuto(commandId))
|
||||||
|
{
|
||||||
|
if (stricterLifes[playerIndex] < estimatedTime)
|
||||||
|
{
|
||||||
|
stricterLifes[playerIndex] = estimatedTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerLifes = playerLifes.Values.ToImmutableArray();
|
||||||
|
StricterPlayerLifes = stricterLifes.Values.ToImmutableArray();
|
||||||
|
ReplayLength = replay.Footer?.ReplayLength ?? PlayerLifes.Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataPoint[][] GetPoints(TimeSpan resolution, ApmPlotterFilterOptions options)
|
||||||
|
{
|
||||||
|
var lists = new SortedDictionary<int, List<int>>(PlayersMap.Keys.ToDictionary(x => x, _ => new List<int>()));
|
||||||
|
|
||||||
|
var currentTime = TimeSpan.Zero;
|
||||||
|
var currentIndex = 0;
|
||||||
|
foreach (var (time, commands) in _commands)
|
||||||
|
{
|
||||||
|
while (time >= currentTime + resolution)
|
||||||
|
{
|
||||||
|
currentTime += resolution;
|
||||||
|
++currentIndex;
|
||||||
|
}
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
if (options.ShouldSkip(command.CommandId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var list = lists[command.PlayerIndex];
|
||||||
|
var gap = currentIndex - list.Count;
|
||||||
|
if (gap >= 0)
|
||||||
|
{
|
||||||
|
list.AddRange(Enumerable.Repeat(0, gap + 1));
|
||||||
|
}
|
||||||
|
++lists[command.PlayerIndex][currentIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var apmMultiplier = 1 / resolution.TotalMinutes;
|
||||||
|
DataPoint Create(int v, int i) => new(TimeSpanAxis.ToDouble(resolution) * i,
|
||||||
|
v * apmMultiplier);
|
||||||
|
return [.. lists.Select(l => l.Value.Select(Create).ToArray())];
|
||||||
|
}
|
||||||
|
|
||||||
|
public double[] CalculateAverageApm(ApmPlotterFilterOptions options)
|
||||||
|
{
|
||||||
|
var apm = new SortedDictionary<int, double>(PlayersMap.Keys.ToDictionary(x => x, _ => 0.0));
|
||||||
|
foreach (var (_, commands) in _commands)
|
||||||
|
{
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
if (options.ShouldSkip(command.CommandId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++apm[command.PlayerIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [.. apm.Select((kv, i) => kv.Value / PlayerLifes[i].TotalMinutes)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double[] CalculateInstantApm(DataPoint[][] data, TimeSpan begin, TimeSpan end)
|
||||||
|
{
|
||||||
|
return [.. data.Select(playerData =>
|
||||||
|
{
|
||||||
|
return playerData
|
||||||
|
.SkipWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < begin)
|
||||||
|
.TakeWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < end)
|
||||||
|
.Average(p => new double?(p.Y)) ?? double.NaN;
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<int, int[]> GetCommandCounts(TimeSpan begin, TimeSpan end)
|
||||||
|
{
|
||||||
|
var playerCommands = new Dictionary<int, SortedDictionary<int, int>>();
|
||||||
|
|
||||||
|
foreach (var (time, commands) in _commands)
|
||||||
|
{
|
||||||
|
if (time < begin)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (time >= end)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
if (!playerCommands.TryGetValue(command.CommandId, out var commandCount))
|
||||||
|
{
|
||||||
|
commandCount = playerCommands[command.CommandId] = new(PlayersMap.Keys.ToDictionary(x => x, _ => 0));
|
||||||
|
}
|
||||||
|
commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerCommands.ToDictionary(kv => kv.Key, kv => kv.Value.Values.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsUnknown(int commandId)
|
||||||
|
{
|
||||||
|
if (UnknownCommands.ContainsKey(commandId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (RA3Commands.IsUnknownCommand(commandId))
|
||||||
|
{
|
||||||
|
UnknownCommands.TryAdd(commandId, default);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsAuto(int commandId) => AutoCommands.ContainsKey(commandId);
|
||||||
|
|
||||||
|
public static bool IsClick(int commandId) => commandId is 0x1F8 or 0x1F5;
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Apm
|
||||||
|
{
|
||||||
|
internal class DataRow
|
||||||
|
{
|
||||||
|
public const string AverageApmRow = "APM(整局游戏)";
|
||||||
|
public const string PartialApmRow = "APM(当前时间段)";
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
public bool IsDisabled { get; }
|
||||||
|
public DataValue Player1Value => _values[0];
|
||||||
|
public DataValue Player2Value => _values[1];
|
||||||
|
public DataValue Player3Value => _values[2];
|
||||||
|
public DataValue Player4Value => _values[3];
|
||||||
|
public DataValue Player5Value => _values[4];
|
||||||
|
public DataValue Player6Value => _values[5];
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<DataValue> _values;
|
||||||
|
|
||||||
|
public DataRow(string name,
|
||||||
|
IEnumerable<string> values,
|
||||||
|
bool isDisabled = false)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
IsDisabled = isDisabled;
|
||||||
|
|
||||||
|
if (values.Count() < 6)
|
||||||
|
{
|
||||||
|
values = values.Concat(new string[6 - values.Count()]);
|
||||||
|
}
|
||||||
|
_values = values.Select(x => new DataValue(x)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<DataRow> GetList(ApmPlotter plotter,
|
||||||
|
ApmPlotterFilterOptions options,
|
||||||
|
TimeSpan begin,
|
||||||
|
TimeSpan end,
|
||||||
|
out int apmRowIndex)
|
||||||
|
{
|
||||||
|
// these commands should appear in this order by default
|
||||||
|
var orderedCommands = new List<int>();
|
||||||
|
orderedCommands.AddRange(
|
||||||
|
[
|
||||||
|
0x1F5, // 选择
|
||||||
|
0x1F6,
|
||||||
|
0x1F8,
|
||||||
|
0x1F9,
|
||||||
|
0x22A,
|
||||||
|
0x1FA, // 编队
|
||||||
|
0x1FB,
|
||||||
|
0x1FC,
|
||||||
|
|
||||||
|
0x207, // 生产/建造
|
||||||
|
0x208,
|
||||||
|
0x205,
|
||||||
|
0x206,
|
||||||
|
0x209, // 摆放
|
||||||
|
|
||||||
|
0x20A, // 出售
|
||||||
|
|
||||||
|
0x203, // 升级
|
||||||
|
0x204,
|
||||||
|
|
||||||
|
0x20D, // 右键攻击
|
||||||
|
0x20E, // 强A
|
||||||
|
0x20F, // 强A
|
||||||
|
0x215, // 移动攻击
|
||||||
|
0x214, // 移动
|
||||||
|
0x236, // 倒车
|
||||||
|
0x216, // 碾压
|
||||||
|
0x22C, // 队形
|
||||||
|
0x21A, // 停止
|
||||||
|
0x21B, // 散开
|
||||||
|
|
||||||
|
0x24E, // 选择协议
|
||||||
|
|
||||||
|
0x1FE, // 释放特殊能力
|
||||||
|
0x1FF,
|
||||||
|
0x200,
|
||||||
|
0x201,
|
||||||
|
0x232,
|
||||||
|
|
||||||
|
0x22E, // 姿态
|
||||||
|
0x22F, // 计划模式
|
||||||
|
|
||||||
|
0x228, // 维修
|
||||||
|
0x229,
|
||||||
|
|
||||||
|
0x202, // 集结点
|
||||||
|
0x210, // 进驻建筑
|
||||||
|
0x20C, // 从建筑中撤出
|
||||||
|
|
||||||
|
0x248, // 采矿交矿
|
||||||
|
0x212,
|
||||||
|
|
||||||
|
0x24B, // 信标
|
||||||
|
0x24C,
|
||||||
|
0x24D,
|
||||||
|
|
||||||
|
0x1, // 退出地图
|
||||||
|
]);
|
||||||
|
orderedCommands.AddRange(RA3Commands.AutoCommands);
|
||||||
|
orderedCommands.AddRange(RA3Commands.UnknownCommands);
|
||||||
|
|
||||||
|
var dataList = new List<DataRow>
|
||||||
|
{
|
||||||
|
new("ID", plotter.PlayersArray.Select(x => x.PlayerName))
|
||||||
|
};
|
||||||
|
var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
|
||||||
|
if (isPartial)
|
||||||
|
{
|
||||||
|
string GetStatus(Player player, int i)
|
||||||
|
{
|
||||||
|
if (player.IsComputer)
|
||||||
|
{
|
||||||
|
return "这是 AI";
|
||||||
|
}
|
||||||
|
if (begin < plotter.StricterPlayerLifes[i])
|
||||||
|
{
|
||||||
|
return "存活";
|
||||||
|
}
|
||||||
|
return begin < plotter.PlayerLifes[i]
|
||||||
|
? "变身天眼帝国,或双手离开键盘"
|
||||||
|
: "可能已离开房间";
|
||||||
|
}
|
||||||
|
dataList.Add(new("存活状态(推测)", plotter.PlayersArray.Select(GetStatus)));
|
||||||
|
}
|
||||||
|
apmRowIndex = dataList.Count;
|
||||||
|
// kill-death ratio
|
||||||
|
if (plotter.Replay.Footer?.TryGetKillDeathRatios() is { } kdRatios)
|
||||||
|
{
|
||||||
|
var texts = kdRatios
|
||||||
|
.Take(plotter.PlayersArray.Length)
|
||||||
|
.Select(x => $"{x:0.##}");
|
||||||
|
dataList.Add(new("击杀阵亡比(存疑)", texts));
|
||||||
|
}
|
||||||
|
// get commands
|
||||||
|
var commandCounts = plotter.GetCommandCounts(begin, end);
|
||||||
|
// add commands in the order specified by the list
|
||||||
|
foreach (var command in orderedCommands)
|
||||||
|
{
|
||||||
|
var counts = commandCounts.TryGetValue(command, out var stored)
|
||||||
|
? stored
|
||||||
|
: new int[plotter.PlayersArray.Length];
|
||||||
|
if (!isPartial || counts.Any(x => x > 0))
|
||||||
|
{
|
||||||
|
dataList.Add(new(RA3Commands.GetCommandName(command),
|
||||||
|
counts.Select(x => $"{x}"),
|
||||||
|
options.ShouldSkip(command)));
|
||||||
|
}
|
||||||
|
commandCounts.Remove(command);
|
||||||
|
}
|
||||||
|
// add other commands
|
||||||
|
foreach (var commandCount in commandCounts)
|
||||||
|
{
|
||||||
|
dataList.Add(new(RA3Commands.GetCommandName(commandCount.Key),
|
||||||
|
commandCount.Value.Select(x => $"{x}"),
|
||||||
|
options.ShouldSkip(commandCount.Key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Apm
|
||||||
|
{
|
||||||
|
internal class DataValue : IComparable<DataValue>, IComparable
|
||||||
|
{
|
||||||
|
public int? NumberValue { get; }
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public DataValue(string value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
if (int.TryParse(value, out var numberValue))
|
||||||
|
{
|
||||||
|
NumberValue = numberValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Value;
|
||||||
|
|
||||||
|
public int CompareTo(DataValue other)
|
||||||
|
{
|
||||||
|
if (NumberValue.HasValue == other.NumberValue.HasValue)
|
||||||
|
{
|
||||||
|
if (!NumberValue.HasValue)
|
||||||
|
{
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
|
return NumberValue.Value.CompareTo(other.NumberValue!.Value);
|
||||||
|
}
|
||||||
|
return NumberValue.HasValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompareTo(object obj)
|
||||||
|
{
|
||||||
|
if (obj is DataValue other)
|
||||||
|
{
|
||||||
|
return CompareTo(other);
|
||||||
|
}
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
<Window x:Class="AnotherReplayReader.ApmWindow"
|
||||||
|
x:ClassModifier="internal"
|
||||||
|
x:Name="ApmWindowInstance"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
|
xmlns:ox="http://oxyplot.org/wpf"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="APM"
|
||||||
|
Height="550"
|
||||||
|
Width="800"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Loaded="OnApmWindowLoaded">
|
||||||
|
<Grid Margin="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="200*" />
|
||||||
|
<RowDefinition Height="300*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<GridSplitter Grid.Row="0"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Height="6"
|
||||||
|
BorderBrush="Gray"
|
||||||
|
BorderThickness="1" />
|
||||||
|
<DockPanel Grid.Row="0"
|
||||||
|
Margin="0,0,0,12">
|
||||||
|
<Grid DockPanel.Dock="Bottom"
|
||||||
|
Margin="16,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- LEFT SIDE CONTROLS -->
|
||||||
|
<StackPanel Grid.Column="0"
|
||||||
|
Orientation="Horizontal">
|
||||||
|
<Label Content="分辨率"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="_resolution"
|
||||||
|
Width="42"
|
||||||
|
TextAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
PreviewTextInput="OnResolutionPreviewTextInput"
|
||||||
|
TextChanged="OnResolutionTextChanged" />
|
||||||
|
<Label Content="秒"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<CheckBox x:Name="_skipUnknowns"
|
||||||
|
Margin="24,0,0,0"
|
||||||
|
Content="忽略未知操作"
|
||||||
|
IsChecked="True"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Checked="OnSkipUnknownsCheckedChanged"
|
||||||
|
Unchecked="OnSkipUnknownsCheckedChanged" />
|
||||||
|
|
||||||
|
<CheckBox x:Name="_skipAutos"
|
||||||
|
Margin="24,0,0,0"
|
||||||
|
Content="忽略自动操作"
|
||||||
|
IsChecked="True"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Checked="OnSkipAutosCheckedChanged"
|
||||||
|
Unchecked="OnSkipAutosCheckedChanged" />
|
||||||
|
|
||||||
|
<CheckBox x:Name="_skipClicks"
|
||||||
|
Margin="24,0,0,0"
|
||||||
|
Content="忽略左键点选"
|
||||||
|
IsChecked="False"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Checked="OnSkipClicksCheckedChanged"
|
||||||
|
Unchecked="OnSkipClicksCheckedChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- RIGHT SIDE BUTTON -->
|
||||||
|
<Button x:Name="_openEventDump"
|
||||||
|
Grid.Column="1"
|
||||||
|
Padding="8,2"
|
||||||
|
Content="打开流水账"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Click="OnOpenEventDumpClicked"/>
|
||||||
|
</Grid>
|
||||||
|
<ox:PlotView x:Name="_plot"
|
||||||
|
DataContext="{Binding ElementName=ApmWindowInstance, Path=PlotModel}"
|
||||||
|
Model="{Binding Model}" />
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Grid.Row="1"
|
||||||
|
Margin="16,8,16,16">
|
||||||
|
<DockPanel DockPanel.Dock="Top">
|
||||||
|
<Label x:Name="_label"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
选择表格之后按 Ctrl+C 可以复制内容
|
||||||
|
</Label>
|
||||||
|
</DockPanel>
|
||||||
|
<DataGrid x:Name="_table"
|
||||||
|
CanUserSortColumns="True"
|
||||||
|
VirtualizingPanel.ScrollUnit="Pixel">
|
||||||
|
<DataGrid.Resources>
|
||||||
|
<Style TargetType="DataGridCell">
|
||||||
|
<EventSetter Event="MouseDoubleClick"
|
||||||
|
Handler="OnTableMouseDoubleClick" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="DataGridRow">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Path=IsDisabled}"
|
||||||
|
Value="True">
|
||||||
|
<Setter Property="Foreground"
|
||||||
|
Value="Gray" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</DataGrid.Resources>
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="项"
|
||||||
|
Binding="{Binding Path=Name}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家1"
|
||||||
|
Binding="{Binding Path=Player1Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家2"
|
||||||
|
Binding="{Binding Path=Player2Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家3"
|
||||||
|
Binding="{Binding Path=Player3Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家4"
|
||||||
|
Binding="{Binding Path=Player4Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家5"
|
||||||
|
Binding="{Binding Path=Player5Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家6"
|
||||||
|
Binding="{Binding Path=Player6Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
using AnotherReplayReader.Apm;
|
||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using OxyPlot;
|
||||||
|
using OxyPlot.Annotations;
|
||||||
|
using OxyPlot.Axes;
|
||||||
|
using OxyPlot.Legends;
|
||||||
|
using OxyPlot.Series;
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// APM.xaml 的交互逻辑
|
||||||
|
/// </summary>
|
||||||
|
internal partial class ApmWindow : Window
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan DefaultResolution = TimeSpan.FromSeconds(15);
|
||||||
|
private readonly Regex _resolutionRegex = new(@"[^0-9]+");
|
||||||
|
private readonly Replay _replay;
|
||||||
|
private readonly Task<ApmPlotter> _plotter;
|
||||||
|
private readonly ApmWindowPlotController _plotController;
|
||||||
|
|
||||||
|
public ApmWindowPlotViewModel PlotModel { get; } = new();
|
||||||
|
public TimeSpan PlotResolution { get; private set; } = DefaultResolution;
|
||||||
|
public bool SkipUnknowns { get; private set; } = true;
|
||||||
|
public bool SkipAutos { get; private set; } = true;
|
||||||
|
public bool SkipClicks { get; private set; } = false;
|
||||||
|
|
||||||
|
public ApmWindow(Replay replay)
|
||||||
|
{
|
||||||
|
_replay = replay;
|
||||||
|
_plotter = Task.Run(() => new ApmPlotter(replay));
|
||||||
|
_plotController = new(this);
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void FilterData(TimeSpan begin, TimeSpan end, bool updatePlot)
|
||||||
|
{
|
||||||
|
await FilterDataAsync(begin, end, updatePlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnApmWindowLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_plot.Controller = _plotController;
|
||||||
|
_resolution.Text = PlotResolution.TotalSeconds.ToString();
|
||||||
|
_skipUnknowns.IsChecked = SkipUnknowns;
|
||||||
|
_skipAutos.IsChecked = SkipAutos;
|
||||||
|
_skipClicks.IsChecked = SkipClicks;
|
||||||
|
await InitializeApmWindowData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeApmWindowData()
|
||||||
|
{
|
||||||
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||||
|
_label.Content = "选择表格之后按 Ctrl+C 可以复制内容";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FilterDataAsync(TimeSpan begin, TimeSpan end, bool updatePlot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FilterDataAsyncThrowable(begin, end, updatePlot);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"加载录像信息失败:\r\n{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FilterDataAsyncThrowable(TimeSpan begin, TimeSpan end, bool updatePlot)
|
||||||
|
{
|
||||||
|
var resolution = PlotResolution;
|
||||||
|
var options = new ApmPlotterFilterOptions(!SkipUnknowns, !SkipAutos, !SkipClicks);
|
||||||
|
var (dataList, plotData, replayLength, isPartial) = await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var plotter = await _plotter.ConfigureAwait(false);
|
||||||
|
var list = DataRow.GetList(plotter, options, begin, end, out var apmIndex);
|
||||||
|
// avg apm
|
||||||
|
var avg = plotter.CalculateAverageApm(options);
|
||||||
|
// data for plotting and partial apm
|
||||||
|
var data = plotter.GetPoints(resolution, options);
|
||||||
|
// partial apm
|
||||||
|
var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
|
||||||
|
if (isPartial)
|
||||||
|
{
|
||||||
|
var instantApms = ApmPlotter.CalculateInstantApm(data, begin, end);
|
||||||
|
string PartialApmToString(double v, int i)
|
||||||
|
{
|
||||||
|
if (plotter.PlayersArray[i].IsComputer)
|
||||||
|
{
|
||||||
|
return "这是 AI";
|
||||||
|
}
|
||||||
|
return plotter.PlayerLifes[i] < begin
|
||||||
|
? "玩家已战败"
|
||||||
|
: $"{v:0.##}";
|
||||||
|
}
|
||||||
|
list.Insert(apmIndex, new(DataRow.PartialApmRow,
|
||||||
|
instantApms.Select(PartialApmToString)));
|
||||||
|
}
|
||||||
|
list.Insert(apmIndex, new(DataRow.AverageApmRow,
|
||||||
|
avg.Select(v => $"{v:0.##}")));
|
||||||
|
|
||||||
|
return (list, data, plotter.ReplayLength, isPartial);
|
||||||
|
});
|
||||||
|
if (updatePlot)
|
||||||
|
{
|
||||||
|
BuildPlot(plotData, replayLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
_table.Items.Clear();
|
||||||
|
foreach (var row in dataList)
|
||||||
|
{
|
||||||
|
_table.Items.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
_table.Columns[1].Header = dataList[0].Player1Value;
|
||||||
|
_table.Columns[2].Header = dataList[0].Player2Value;
|
||||||
|
_table.Columns[3].Header = dataList[0].Player3Value;
|
||||||
|
_table.Columns[4].Header = dataList[0].Player4Value;
|
||||||
|
_table.Columns[5].Header = dataList[0].Player5Value;
|
||||||
|
_table.Columns[6].Header = dataList[0].Player6Value;
|
||||||
|
if (isPartial)
|
||||||
|
{
|
||||||
|
_label.Content = $"{(ShortTimeSpan)begin} 至 {(ShortTimeSpan)end} 之间的 APM 数据";
|
||||||
|
_label.FontWeight = System.Windows.FontWeights.Bold;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_label.Content = "以下是整局游戏的 APM 数据:";
|
||||||
|
_label.FontWeight = System.Windows.FontWeights.Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildPlot(DataPoint[][] data, TimeSpan replayLength)
|
||||||
|
{
|
||||||
|
var model = new PlotModel();
|
||||||
|
model.Annotations.Add(new ApmWindowPlotTextAnnotation
|
||||||
|
{
|
||||||
|
Text = "鼠标左键拖拽选择,滚轮缩放,右键拖拽移动",
|
||||||
|
});
|
||||||
|
var maxApm = data.SelectMany(x => x).Max(x => x.Y);
|
||||||
|
var yaxis = new LinearAxis
|
||||||
|
{
|
||||||
|
Position = AxisPosition.Right,
|
||||||
|
IsZoomEnabled = false,
|
||||||
|
AbsoluteMinimum = -maxApm / 10,
|
||||||
|
AbsoluteMaximum = maxApm,
|
||||||
|
MajorStep = 50,
|
||||||
|
};
|
||||||
|
var lengthInSeconds = replayLength.TotalSeconds;
|
||||||
|
var limit = lengthInSeconds / 10;
|
||||||
|
var xaxis = new TimeSpanAxis
|
||||||
|
{
|
||||||
|
Position = AxisPosition.Bottom,
|
||||||
|
AbsoluteMinimum = -limit,
|
||||||
|
AbsoluteMaximum = lengthInSeconds + limit,
|
||||||
|
};
|
||||||
|
model.Axes.Add(yaxis);
|
||||||
|
model.Axes.Add(xaxis);
|
||||||
|
foreach (var (points, i) in data.Select((v, i) => (v, i)))
|
||||||
|
{
|
||||||
|
var series = new LineSeries
|
||||||
|
{
|
||||||
|
Title = _plotter.Result.PlayersArray[i].PlayerName,
|
||||||
|
};
|
||||||
|
series.Points.AddRange(points);
|
||||||
|
model.Series.Add(series);
|
||||||
|
model.Legends.Add(new Legend());
|
||||||
|
}
|
||||||
|
PlotModel.Model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTableMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
_table.SelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int NormalizeResolutionInput(string text)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(text, out var value))
|
||||||
|
{
|
||||||
|
value = (int)PlotResolution.TotalSeconds;
|
||||||
|
}
|
||||||
|
return Math.Min(Math.Max(1, value), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResolutionPreviewTextInput(object sender, TextCompositionEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = _resolutionRegex.IsMatch(e.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnResolutionTextChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var seconds = NormalizeResolutionInput(_resolution.Text);
|
||||||
|
if (Math.Abs(seconds - PlotResolution.TotalSeconds) >= 0.5)
|
||||||
|
{
|
||||||
|
PlotResolution = TimeSpan.FromSeconds(seconds);
|
||||||
|
_resolution.Text = seconds.ToString();
|
||||||
|
_plotController.DiscardRectangle();
|
||||||
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSkipUnknownsCheckedChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_skipUnknowns.IsChecked != SkipUnknowns)
|
||||||
|
{
|
||||||
|
SkipUnknowns = _skipUnknowns.IsChecked is true;
|
||||||
|
_plotController.DiscardRectangle();
|
||||||
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSkipAutosCheckedChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_skipAutos.IsChecked != SkipAutos)
|
||||||
|
{
|
||||||
|
SkipAutos = _skipAutos.IsChecked is true;
|
||||||
|
_plotController.DiscardRectangle();
|
||||||
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSkipClicksCheckedChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_skipClicks.IsChecked != SkipClicks)
|
||||||
|
{
|
||||||
|
SkipClicks = _skipClicks.IsChecked is true;
|
||||||
|
_plotController.DiscardRectangle();
|
||||||
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnOpenEventDumpClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var plotter = await _plotter;
|
||||||
|
var dump = new EventDump();
|
||||||
|
dump.LoadStringHashes();
|
||||||
|
dump.SetDumpData(plotter);
|
||||||
|
await dump.ShowPlainText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ApmWindowPlotViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private PlotModel _model = new();
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
public PlotModel Model
|
||||||
|
{
|
||||||
|
get => _model;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_model = value;
|
||||||
|
PropertyChanged?.Invoke(this, new(nameof(Model)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ApmWindowPlotController : PlotController
|
||||||
|
{
|
||||||
|
private readonly ApmWindow _window;
|
||||||
|
private RectangleAnnotation? _current;
|
||||||
|
private PlotModel Plot => _window.PlotModel.Model;
|
||||||
|
private double Resolution => _window.PlotResolution.TotalSeconds;
|
||||||
|
// private readonly Func<OxyMouseDownEventArgs>
|
||||||
|
|
||||||
|
public ApmWindowPlotController(ApmWindow window)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DiscardRectangle()
|
||||||
|
{
|
||||||
|
_current = null;
|
||||||
|
RemoveSelections();
|
||||||
|
Plot.InvalidatePlot(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool HandleMouseDown(IView view, OxyMouseDownEventArgs args)
|
||||||
|
{
|
||||||
|
TryBeginDragRectagle(args);
|
||||||
|
return base.HandleMouseDown(view, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool HandleMouseMove(IView view, OxyMouseEventArgs args)
|
||||||
|
{
|
||||||
|
TryDragRectangle(args);
|
||||||
|
return base.HandleMouseMove(view, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool HandleMouseUp(IView view, OxyMouseEventArgs args)
|
||||||
|
{
|
||||||
|
TryEndDragRectangle();
|
||||||
|
return base.HandleMouseUp(view, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSelections()
|
||||||
|
{
|
||||||
|
var list = Plot.Annotations
|
||||||
|
.Select((x, i) => (Item: x, Index: i))
|
||||||
|
.Where(t => t.Item is RectangleAnnotation)
|
||||||
|
.Reverse()
|
||||||
|
.ToArray();
|
||||||
|
foreach (var (_, index) in list)
|
||||||
|
{
|
||||||
|
Plot.Annotations.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryBeginDragRectagle(OxyMouseDownEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.ChangedButton == OxyMouseButton.Left)
|
||||||
|
{
|
||||||
|
var x = Plot.Axes[1].InverseTransform(args.Position.X);
|
||||||
|
x = Math.Round(x / Resolution) * Resolution;
|
||||||
|
RemoveSelections();
|
||||||
|
_current = new()
|
||||||
|
{
|
||||||
|
ClipByYAxis = true,
|
||||||
|
Fill = OxyColor.FromArgb(128, 0, 128, 255),
|
||||||
|
MinimumY = double.NegativeInfinity,
|
||||||
|
MaximumY = double.PositiveInfinity,
|
||||||
|
MinimumX = x,
|
||||||
|
MaximumX = x
|
||||||
|
};
|
||||||
|
Plot.Annotations.Add(_current);
|
||||||
|
Plot.InvalidatePlot(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryDragRectangle(OxyMouseEventArgs args)
|
||||||
|
{
|
||||||
|
if (_current is not null)
|
||||||
|
{
|
||||||
|
var x = Plot.Axes[1].InverseTransform(args.Position.X);
|
||||||
|
var offsetMultiplier = Math.Round((x - _current.MinimumX) / Resolution);
|
||||||
|
x = offsetMultiplier * Resolution;
|
||||||
|
_current.MaximumX = _current.MinimumX + x;
|
||||||
|
if (_current.MaximumX < _current.MinimumX)
|
||||||
|
{
|
||||||
|
var temp = _current.MinimumX;
|
||||||
|
_current.MinimumX = _current.MaximumX;
|
||||||
|
_current.MaximumX = temp;
|
||||||
|
}
|
||||||
|
Plot.InvalidatePlot(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryEndDragRectangle()
|
||||||
|
{
|
||||||
|
if (_current is not null)
|
||||||
|
{
|
||||||
|
if (Math.Abs(_current.MaximumX - _current.MinimumX) < Resolution)
|
||||||
|
{
|
||||||
|
RemoveSelections();
|
||||||
|
Plot.InvalidatePlot(false);
|
||||||
|
_window.FilterData(TimeSpan.MinValue, TimeSpan.MaxValue, false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_window.FilterData(TimeSpan.FromSeconds(_current.MinimumX),
|
||||||
|
TimeSpan.FromSeconds(_current.MaximumX),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
_current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ApmWindowPlotTextAnnotation : Annotation
|
||||||
|
{
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
public double X { get; set; } = 8;
|
||||||
|
public double Y { get; set; } = 8;
|
||||||
|
|
||||||
|
public override void Render(IRenderContext rc)
|
||||||
|
{
|
||||||
|
base.Render(rc);
|
||||||
|
double pX = PlotModel.PlotArea.Left + X;
|
||||||
|
double pY = PlotModel.PlotArea.Top + Y;
|
||||||
|
rc.DrawMultilineText(new(pX, pY),
|
||||||
|
Text,
|
||||||
|
PlotModel.TextColor,
|
||||||
|
PlotModel.DefaultFont,
|
||||||
|
PlotModel.DefaultFontSize,
|
||||||
|
PlotModel.SubtitleFontWeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
StartupUri="MainWindow.xaml">
|
StartupUri="MainWindow.xaml"
|
||||||
|
ShutdownMode="OnMainWindowClose">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
|
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|||||||
+63
-4
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.IO;
|
||||||
using System.Configuration;
|
using System.Reflection;
|
||||||
using System.Data;
|
using System.Runtime.InteropServices;
|
||||||
using System.Linq;
|
using System.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
@@ -12,5 +12,64 @@ namespace AnotherReplayReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
private int _isInException = 0;
|
||||||
|
|
||||||
|
public const string Version = "0.7";
|
||||||
|
public const string Name = "自动录像机";
|
||||||
|
public const string NameWithVersion = Name + " v" + Version;
|
||||||
|
|
||||||
|
private static readonly Lazy<string> _libsFolder = new(GetLibraryFolder, LazyThreadSafetyMode.PublicationOnly);
|
||||||
|
public static string LibsFolder => _libsFolder.Value;
|
||||||
|
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
static Assembly? LoadFromLibsFolder(object sender, ResolveEventArgs args)
|
||||||
|
{
|
||||||
|
var assemblyPath = Path.Combine(LibsFolder, new AssemblyName(args.Name).Name + ".dll");
|
||||||
|
return File.Exists(assemblyPath)
|
||||||
|
? Assembly.LoadFrom(assemblyPath)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(LoadFromLibsFolder);
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Increment(ref _isInException) > 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
const string message = "哎呀呀,出现了一些无法处理的问题,只能退出了。要不要尝试保存一下日志文件呢?";
|
||||||
|
var choice = MessageBox.Show($"{message}\r\n{eventArgs.ExceptionObject}", Name, MessageBoxButton.YesNo);
|
||||||
|
if (choice == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
Debug.Instance.RequestSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLibraryFolder() => Path.Combine(GetExecutableFolder(), nameof(AnotherReplayReader) + "Data");
|
||||||
|
|
||||||
|
private static string GetExecutableFolder()
|
||||||
|
{
|
||||||
|
char[]? buffer = null;
|
||||||
|
uint result;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
buffer = new char[(buffer?.Length ?? 128) * 2];
|
||||||
|
result = GetModuleFileNameW(IntPtr.Zero, buffer, buffer.Length);
|
||||||
|
if (result is 0)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to retrieve executable name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (result >= buffer.Length);
|
||||||
|
return Path.GetDirectoryName(new(buffer, 0, Array.IndexOf(buffer, '\0')));
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("Kernel32.dll", CallingConvention = CallingConvention.Winapi, CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||||
|
private static extern uint GetModuleFileNameW(IntPtr module, char[] fileName, int size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Microsoft.Win32;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
|
||||||
{
|
|
||||||
internal static class Auth
|
|
||||||
{
|
|
||||||
public static string ID { get; private set; }
|
|
||||||
|
|
||||||
static Auth()
|
|
||||||
{
|
|
||||||
ID = null;
|
|
||||||
|
|
||||||
var windowsID = null as string;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var view64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
|
||||||
using (var winNt = view64?.OpenSubKey(@"Software\Microsoft\Windows NT\CurrentVersion", false))
|
|
||||||
{
|
|
||||||
windowsID = winNt?.GetValue("ProductId") as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var randomKey = null as string;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var folderPath = Cache.CacheDirectory;
|
|
||||||
if (!Directory.Exists(folderPath))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(folderPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyPath = Path.Combine(folderPath, "id");
|
|
||||||
if(!File.Exists(keyPath))
|
|
||||||
{
|
|
||||||
File.WriteAllText(keyPath, Guid.NewGuid().ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
randomKey = File.ReadAllText(keyPath);
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(string.IsNullOrWhiteSpace(windowsID) || string.IsNullOrWhiteSpace(randomKey))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
using (var sha = SHA256.Create())
|
|
||||||
{
|
|
||||||
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(windowsID + randomKey));
|
|
||||||
ID = string.Concat(hash.Skip(3).Take(10).Select(x => $"{x:X2}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetKey()
|
|
||||||
{
|
|
||||||
if(ID == null)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var text = $"{ID}{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(text);
|
|
||||||
var pre = Encoding.UTF8.GetBytes("playertable!");
|
|
||||||
var salt = new byte[9];
|
|
||||||
using (var rng = new RNGCryptoServiceProvider())
|
|
||||||
{
|
|
||||||
rng.GetNonZeroBytes(salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(var i = 0; i < bytes.Length; ++i)
|
|
||||||
{
|
|
||||||
bytes[i] = (byte)(bytes[i] ^ salt[i % salt.Length]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Convert.ToBase64String(salt.Concat(bytes).Select((x, i) => (byte)(x ^ pre[i % pre.Length])).ToArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+74
-185
@@ -1,172 +1,36 @@
|
|||||||
using System;
|
using AnotherReplayReader.Utils;
|
||||||
using System.Collections.Generic;
|
using Microsoft.Win32;
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Win32;
|
using TechnologyAssembler.Core.IO;
|
||||||
using OpenSage.FileFormats.Big;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
internal sealed class BigMinimapCache
|
internal sealed class BigMinimapCache
|
||||||
{
|
{
|
||||||
private sealed class CacheAdapter
|
private readonly object _lock = new();
|
||||||
{
|
private SkuDefFileSystemProvider? _skudefFileSystem = null;
|
||||||
public List<string> Bigs { get; set; }
|
|
||||||
public Dictionary<string, string> MapsToBigs { get; set; }
|
|
||||||
|
|
||||||
public CacheAdapter()
|
public BigMinimapCache(string? ra3Directory)
|
||||||
{
|
{
|
||||||
Bigs = new List<string>();
|
Task.Run(() => Initialize(ra3Directory));
|
||||||
MapsToBigs = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//private Cache _cache;
|
public bool TryGetEntry(string path, out Stream? bigEntry)
|
||||||
private volatile IReadOnlyDictionary<string, string> _mapsToBigs = null;
|
|
||||||
|
|
||||||
public BigMinimapCache(Cache cache, string ra3Directory)
|
|
||||||
{
|
{
|
||||||
//_cache = cache;
|
bigEntry = null;
|
||||||
|
|
||||||
Task.Run(() =>
|
using var locker = new Lock(_lock);
|
||||||
{
|
if (_skudefFileSystem is not { } fs)
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(ra3Directory))
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Will not initialize BigMinimapCache because RA3Directory {ra3Directory} does not exist.\r\n";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bigSet = ParseSkudefs(Directory.EnumerateFiles(ra3Directory, "*.SkuDef"));
|
|
||||||
|
|
||||||
//var cached = _cache.GetOrDefault("bigsCache", new CacheAdapter());
|
|
||||||
var mapsToBigs = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
foreach (var bigPath in bigSet/*.Where(x => !cached.Bigs.Contains(x))*/)
|
|
||||||
{
|
|
||||||
if (!File.Exists(bigPath))
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Big {bigPath} does not exist.\r\n";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.Instance.DebugMessage += $"Trying to add Big {bigPath} to big minimap cache...\r\n";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var big = new BigArchive(bigPath))
|
|
||||||
{
|
|
||||||
foreach (var entry in big.Entries)
|
|
||||||
{
|
|
||||||
if (entry.FullName.EndsWith("_art.tga", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
mapsToBigs[entry.FullName] = bigPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception when reading big:\r\n {exception}\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//cached.Bigs = bigSet.ToList();
|
|
||||||
|
|
||||||
//_cache.Set("bigsCache", cached);
|
|
||||||
_mapsToBigs = mapsToBigs; //cached.MapsToBigs;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{exception}\r\n";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static HashSet<string> ParseSkudefs(IEnumerable<string> skudefs)
|
|
||||||
{
|
|
||||||
var skudefSet = new HashSet<string>(skudefs.Select(x => x.ToLowerInvariant()));
|
|
||||||
var unreadSkudefs = new HashSet<string>();
|
|
||||||
var bigSet = new HashSet<string>();
|
|
||||||
|
|
||||||
void ReadSkudefLine(string baseDirectory, string line, string expectedCommand, Action<string> action)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
char[] separators = { ' ', '\t' };
|
|
||||||
line = line.ToLowerInvariant();
|
|
||||||
var splitted = line.Split(separators, 2, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (splitted[0].Equals(expectedCommand))
|
|
||||||
{
|
|
||||||
var path = splitted[1];
|
|
||||||
if (!Path.IsPathRooted(path))
|
|
||||||
{
|
|
||||||
path = Path.Combine(baseDirectory, path);
|
|
||||||
}
|
|
||||||
action(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception when parsing skudef line:\r\n {exception}\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ReadSkudef(string fileName, Action<string, string> onBaseDirectoryAndLine)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var baseDirectory = Path.GetDirectoryName(fileName).ToLowerInvariant();
|
|
||||||
foreach (var line in File.ReadAllLines(fileName))
|
|
||||||
{
|
|
||||||
onBaseDirectoryAndLine(baseDirectory, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception when parsing skudef file:\r\n {exception}\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var skudef in skudefSet)
|
|
||||||
{
|
|
||||||
ReadSkudef(skudef, (baseDirectory, line) =>
|
|
||||||
{
|
|
||||||
ReadSkudefLine(baseDirectory, line, "add-config", x =>
|
|
||||||
{
|
|
||||||
if (!skudefSet.Contains(x))
|
|
||||||
{
|
|
||||||
unreadSkudefs.Add(x);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ReadSkudefLine(baseDirectory, line, "add-big", x => bigSet.Add(x));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var skudef in unreadSkudefs)
|
|
||||||
{
|
|
||||||
ReadSkudef(skudef, (baseDirectory, line) =>
|
|
||||||
{
|
|
||||||
ReadSkudefLine(baseDirectory, line, "add-big", x => bigSet.Add(x));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return bigSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryGetBigByEntryPath(string path, out BigArchive big)
|
|
||||||
{
|
|
||||||
big = null;
|
|
||||||
|
|
||||||
if (_mapsToBigs == null)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_mapsToBigs.ContainsKey(path))
|
if (!fs.FileExists(path))
|
||||||
{
|
{
|
||||||
Debug.Instance.DebugMessage += $"Cannot find big entry [{path}].\r\n";
|
Debug.Instance.DebugMessage += $"Cannot find big entry [{path}].\r\n";
|
||||||
return false;
|
return false;
|
||||||
@@ -174,58 +38,83 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bigPath = _mapsToBigs[path];
|
bigEntry = fs.OpenStream(path, VirtualFileModeType.Open);
|
||||||
big = new BigArchive(bigPath);
|
return true;
|
||||||
if(big.GetEntry(path) == null)
|
|
||||||
{
|
|
||||||
//_cache.Remove("bigsCache");
|
|
||||||
big.Dispose();
|
|
||||||
big = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
Debug.Instance.DebugMessage += $"Exception during query (entryStream) of BigMinimapCache: \r\n{exception}\r\n";
|
Debug.Instance.DebugMessage += $"Exception during query (entryStream) of BigMinimapCache: \r\n{exception}\r\n";
|
||||||
big = null;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] TryReadBytesFromBig(string path)
|
private void Initialize(string? ra3Directory)
|
||||||
{
|
{
|
||||||
if(_mapsToBigs == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!_mapsToBigs.ContainsKey(path))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bigPath = _mapsToBigs[path];
|
if (ra3Directory is null || !Directory.Exists(ra3Directory))
|
||||||
using (var big = new BigArchive(path))
|
|
||||||
{
|
{
|
||||||
var entry = big.GetEntry(path);
|
Debug.Instance.DebugMessage += $"Will not initialize BigMinimapCache because RA3Directory {ra3Directory} does not exist.\r\n";
|
||||||
using (var stream = entry.Open())
|
return;
|
||||||
using (var reader = new BinaryReader(stream))
|
}
|
||||||
|
|
||||||
|
var currentLanguage = RegistryUtils.RetrieveInRa3(RegistryHive.CurrentUser, "Language");
|
||||||
|
var currentLanguage_ = $"{currentLanguage}_";
|
||||||
|
double SkudefVersionSelector(string fullPath)
|
||||||
|
{
|
||||||
|
var value = -1.0;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return reader.ReadBytes((int)entry.Length);
|
const string skudefPrefix = "RA3";
|
||||||
|
const int prefix = 1;
|
||||||
|
const int language = 2;
|
||||||
|
const int majorVersion = 3;
|
||||||
|
const int minorVersion = 4;
|
||||||
|
const int majorVersionMultiplier = 10000;
|
||||||
|
const int correctLanguageBonus = 1000_0000;
|
||||||
|
|
||||||
|
value = 0;
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(fullPath);
|
||||||
|
var match = Regex.Match(stem, @"([^_]*)_([^0-9]*)([0-9]*)\.([0-9]*)");
|
||||||
|
if (!match.Success || match.Groups.Cast<Group>().Any(g => !g.Success))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
value += match.Groups[prefix].Value == skudefPrefix ? 0.1 : 0;
|
||||||
|
value += match.Groups[language].Value == currentLanguage_ ? correctLanguageBonus : 0;
|
||||||
|
value += int.Parse(match.Groups[majorVersion].Value) * majorVersionMultiplier;
|
||||||
|
value += int.Parse(match.Groups[minorVersion].Value);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Failed to retrieve skudef: {e}\r\n";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
var highestSkudef = (from p in Directory.EnumerateFiles(ra3Directory, "*.SkuDef")
|
||||||
|
orderby SkudefVersionSelector(p) descending
|
||||||
|
select p).First();
|
||||||
|
Debug.Instance.DebugMessage += $"Retrieved highest skudef: {highestSkudef}\r\n";
|
||||||
|
|
||||||
|
using var locker = new Lock(_lock);
|
||||||
|
DronePlatform.BuildTechnologyAssembler();
|
||||||
|
_skudefFileSystem = new SkuDefFileSystemProvider("config", highestSkudef);
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{e}\r\nLoader Exceptions: {e.LoaderExceptions.Length}";
|
||||||
|
foreach (var e2 in e.LoaderExceptions)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{e2}\r\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
Debug.Instance.DebugMessage += $"Exception during query (bytes) of BigMinimapCache: \r\n{exception}\r\n";
|
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{exception}\r\n";
|
||||||
//_cache.Remove("bigsCache");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# Context
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史术语表,部分定义已与 v2 管线不一致,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
### Operation Fact
|
||||||
|
A fact directly extracted from replay command data, such as command time, player, command name, UnitId, asset id, special power id, production queue, or target position.
|
||||||
|
|
||||||
|
### UnitId (ObjectId)
|
||||||
|
The numeric identifier of a unit instance in the replay. `ObjectId` in raw replay data and `UnitId` in the AI analysis context refer to the same thing: a number (e.g., `239`, `246`) that identifies a specific unit or building instance during the game session. The AI is tasked with guessing what game asset type (e.g., `AlliedBarracks`, `CelestialScoutDrone`) a given UnitId corresponds to.
|
||||||
|
|
||||||
|
### UnitId Guess
|
||||||
|
A hypothesis about what game object (asset type) a UnitId (numeric identifier) represents. A UnitId Guess is not a fact unless it is directly supported by replay data or game rules.
|
||||||
|
|
||||||
|
### Operation
|
||||||
|
Any player action recorded in the replay log, including selection (select unit, create/select control group), command (move, attack, ability use), production/construction (start building, place building, start producing), and miscellaneous actions (select protocol, stance switch, rally point set). All are operations; the distinction between "运营类" (economy/construction) and non-operational operations in the AI prompt is a pragmatic optimization to reduce reasoning cost, not a domain concept.
|
||||||
|
|
||||||
|
### Evidence Level
|
||||||
|
The confidence assigned to a UnitId Guess or tactical conclusion. Valid levels are: confirmed (确定), highly likely (高度可能), possible (可能), uncertain (不确定), and ruled out (已排除).
|
||||||
|
|
||||||
|
> **Note:** The Chinese prompt text uses "不确定" (not "待确认"/pending confirmation) to match the `Uncertain` enum value. There is no implied promise of future confirmation — uncertainty simply means the current evidence is insufficient for a stronger conclusion.
|
||||||
|
|
||||||
|
### Analysis Segment
|
||||||
|
A time-bounded section of the replay operations, chosen by the LLM during the initial assessment phase. Segments are defined by start/end timestamps and may overlap. The LLM decides the segmentation based on observed gameplay phases (e.g., opening, early-mid game, mid game). Each segment is analyzed in a separate AI request round.
|
||||||
|
|
||||||
|
### Validation Rule
|
||||||
|
A deterministic rule that checks LLM claims against Operation Facts and known game rules.
|
||||||
|
|
||||||
|
### Validation Issue
|
||||||
|
A machine-detected problem in an LLM claim, such as a direct contradiction, weak evidence, missing alternative, or impossible timeline.
|
||||||
|
|
||||||
|
### Game Knowledge
|
||||||
|
Domain knowledge about the game or mod that the AI may use during analysis, such as unit capabilities, faction rules, map geometry, build restrictions, and known exceptions. Game knowledge may be used both to shape prompt guidance and to power deterministic validation.
|
||||||
|
|
||||||
|
### Knowledge Scope
|
||||||
|
The applicability boundary of a piece of game knowledge. A mod is a game version and defines its own complete knowledge set. Within a mod, scope can be global (applies to all factions and maps on that mod), faction-specific, or map-specific. There is no separate "mod scope" because the mod IS the top-level scope selector — the replay's mod determines which knowledge set is loaded.
|
||||||
|
|
||||||
|
### Knowledge Set
|
||||||
|
A named collection of game knowledge entries for a specific game version (mod). Each knowledge set is self-contained and complete for its mod — there is no cross-set inheritance or conditional sharing. The set is organized hierarchically by scope: `global/` entries apply across all factions and maps; `factions/{name}/` entries are scoped to a faction; `maps/{id}/` entries are scoped to a map. The replay's mod name directly selects which knowledge set to load (e.g., `"default"` for base game, `"corona"` for the Corona mod).
|
||||||
|
|
||||||
|
### Knowledge Entry
|
||||||
|
The smallest reusable unit of game knowledge within a knowledge set. A knowledge entry has an identifier, a set of predefined tags, and a text description (markdown). It is the unified format used both for prompt rendering and for validation queries. Scope is inherited from the entry's path position within the knowledge set (global, faction, or map), not stored in the entry itself.
|
||||||
|
|
||||||
|
### Knowledge Tag
|
||||||
|
A predefined label attached to a `KnowledgeEntry` to enable querying by validation logic. Tags belong to a finite taxonomy covering capabilities (e.g., `builder`, `pack`, `unpack`, `amphibious`, `returnToProducer`), types (e.g., `infantry`, `vehicle`, `aircraft`, `naval`, `structure`), combat roles (e.g., `antiInfantry`, `antiVehicle`, `antiAir`, `antiNaval`, `antiStructure`), and special power references (e.g., `specialPower:PackReplaceSelf`).
|
||||||
|
|
||||||
|
### Revision Pass
|
||||||
|
(Partially implemented) A hidden LLM request that receives the prior draft, validation issues, and relevant Operation Facts, then produces a corrected analysis without exposing apology or correction chatter to the user. Currently, validation issues are detected and logged, but no automatic revision pass is triggered. The revision logic still needs to be wired into `AIChatPanel`.
|
||||||
@@ -3,59 +3,99 @@ using System.Collections.Concurrent;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Web.Script.Serialization;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using static System.Text.Json.JsonSerializer;
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
internal sealed class Cache
|
public sealed class Cache
|
||||||
{
|
{
|
||||||
public static string CacheDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RA3Bar.Lanyi.AnotherReplayReader");
|
public static string OldCacheDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RA3Bar.Lanyi.AnotherReplayReader");
|
||||||
|
public static string CacheDirectory => App.LibsFolder;
|
||||||
public static string CacheFilePath => Path.Combine(CacheDirectory, "AnotherReplayReader.cache");
|
public static string CacheFilePath => Path.Combine(CacheDirectory, "AnotherReplayReader.cache");
|
||||||
|
|
||||||
private ConcurrentDictionary<string, string> _storage;
|
private readonly ConcurrentDictionary<string, string> _storage = new();
|
||||||
|
|
||||||
|
public Task Initialization { get; }
|
||||||
|
|
||||||
public Cache()
|
public Cache()
|
||||||
{
|
{
|
||||||
try
|
Initialization = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(CacheDirectory))
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(CacheDirectory);
|
if (!Directory.Exists(CacheDirectory))
|
||||||
}
|
{
|
||||||
|
Directory.CreateDirectory(CacheDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
var serializer = new JavaScriptSerializer();
|
try
|
||||||
_storage = serializer.Deserialize<ConcurrentDictionary<string, string>>(File.ReadAllText(CacheFilePath));
|
{
|
||||||
}
|
var old = new DirectoryInfo(OldCacheDirectory);
|
||||||
catch
|
if (old.Exists)
|
||||||
{
|
{
|
||||||
_storage = new ConcurrentDictionary<string, string>();
|
foreach (var file in old.EnumerateFiles())
|
||||||
}
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
file.MoveTo(Path.Combine(CacheDirectory, file.Name));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
old.Delete();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
using var cacheStream = File.OpenRead(CacheFilePath);
|
||||||
|
var futureCache = DeserializeAsync<Dictionary<string, string>>(cacheStream).ConfigureAwait(false);
|
||||||
|
if (await futureCache is not { } cached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (var kv in cached)
|
||||||
|
{
|
||||||
|
_storage.TryAdd(kv.Key, kv.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public T GetOrDefault<T>(string key, in T defaultValue)
|
public T GetOrDefault<T>(string key, T defaultValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_storage.TryGetValue(key, out var valueString))
|
if (_storage.TryGetValue(key, out var valueString))
|
||||||
{
|
{
|
||||||
var serializer = new JavaScriptSerializer();
|
return Deserialize<T>(valueString) ?? defaultValue;
|
||||||
return serializer.Deserialize<T>(valueString);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Set<T>(string key, in T value)
|
public void Set<T>(string key, T value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var serializer = new JavaScriptSerializer();
|
_storage[key] = Serialize(value);
|
||||||
_storage[key] = serializer.Serialize(value);
|
}
|
||||||
Save();
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetValues(params (string Key, object Value)[] values)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in values)
|
||||||
|
{
|
||||||
|
_storage[key] = Serialize(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
@@ -63,15 +103,14 @@ namespace AnotherReplayReader
|
|||||||
public void Remove(string key)
|
public void Remove(string key)
|
||||||
{
|
{
|
||||||
_storage.TryRemove(key, out _);
|
_storage.TryRemove(key, out _);
|
||||||
Save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save()
|
public async Task Save()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var serializer = new JavaScriptSerializer();
|
using var cacheStream = File.Open(CacheFilePath, FileMode.Create, FileAccess.Write);
|
||||||
File.WriteAllText(CacheFilePath, serializer.Serialize(_storage));
|
await SerializeAsync(cacheStream, _storage).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ra3.BattleNet.Database.Utils;
|
||||||
|
|
||||||
|
public static class ChineseEncoding
|
||||||
|
{
|
||||||
|
private const int EncodedChineseTotalLength = 120;
|
||||||
|
private const int EncodedChineseChecksumLength = 3;
|
||||||
|
private const int EncodedChineseContentLength = EncodedChineseTotalLength - EncodedChineseChecksumLength;
|
||||||
|
private const int ChineseCodeSize = 13;
|
||||||
|
private const int ChineseCodeMask = 0b1111111111111;
|
||||||
|
private const int AsciiSectionSize = 0x80;
|
||||||
|
private const int Gb2312EucOffset = 0xA0;
|
||||||
|
private const int Gb2312RowWidth = 94;
|
||||||
|
private const int Gb2312LastRow = 87;
|
||||||
|
private const int Gb2312FirstUnassignedSectionBegin = 10;
|
||||||
|
private const int Gb2312FirstUnassignedSectionSize = 6;
|
||||||
|
private const int Gb2312SecondUnassignedSectionBegin = 88;
|
||||||
|
private const int Base64CodeSize = 6;
|
||||||
|
private const int Base64CodeMask = 0b111111;
|
||||||
|
private const string Base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
|
||||||
|
public static string DecodeChineseFromBase64(string original)
|
||||||
|
{
|
||||||
|
BigInteger bits = 0;
|
||||||
|
int index = 0;
|
||||||
|
foreach (var c in original)
|
||||||
|
{
|
||||||
|
BigInteger value = Base64Table.IndexOf(c);
|
||||||
|
if (value == -1)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid base64 string");
|
||||||
|
}
|
||||||
|
bits = bits | (value << index);
|
||||||
|
index += Base64CodeSize;
|
||||||
|
}
|
||||||
|
BigInteger checksum = 0;
|
||||||
|
var result = new List<byte>();
|
||||||
|
for (var bitIndex = 0; bitIndex < EncodedChineseContentLength; bitIndex += ChineseCodeSize)
|
||||||
|
{
|
||||||
|
int value = (int)((bits >> bitIndex) & ChineseCodeMask);
|
||||||
|
checksum += value;
|
||||||
|
if (value == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value < AsciiSectionSize)
|
||||||
|
{
|
||||||
|
if (value < 0x20 || value == 0x7F)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid ASCII");
|
||||||
|
}
|
||||||
|
result.Add((byte)value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value -= AsciiSectionSize;
|
||||||
|
var index1 = value / Gb2312RowWidth + 1;
|
||||||
|
var index2 = value % Gb2312RowWidth + 1;
|
||||||
|
if (index1 >= Gb2312FirstUnassignedSectionBegin)
|
||||||
|
{
|
||||||
|
index1 += Gb2312FirstUnassignedSectionSize;
|
||||||
|
}
|
||||||
|
if (index1 >= Gb2312SecondUnassignedSectionBegin)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid first byte");
|
||||||
|
}
|
||||||
|
if (index2 > Gb2312RowWidth)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid second byte");
|
||||||
|
}
|
||||||
|
index1 += Gb2312EucOffset;
|
||||||
|
index2 += Gb2312EucOffset;
|
||||||
|
result.Add((byte)index1);
|
||||||
|
result.Add((byte)index2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((bits >> EncodedChineseContentLength) != checksum % 8)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid checksum");
|
||||||
|
}
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
return Encoding.GetEncoding(936).GetString(result.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EncodeChineseToBase64(string original)
|
||||||
|
{
|
||||||
|
BigInteger bits = 0;
|
||||||
|
int index = 0;
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
var bytes = Encoding.GetEncoding(936).GetBytes(original);
|
||||||
|
BigInteger checksum = 0;
|
||||||
|
for (var i = 0; i < bytes.Length;)
|
||||||
|
{
|
||||||
|
BigInteger c = bytes[i];
|
||||||
|
if (c < AsciiSectionSize)
|
||||||
|
{
|
||||||
|
if (c < 0x20 || c == 0x7F)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid ASCII");
|
||||||
|
}
|
||||||
|
bits = bits | (c << index);
|
||||||
|
index += ChineseCodeSize;
|
||||||
|
checksum += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c <= Gb2312EucOffset || c > (Gb2312EucOffset + Gb2312LastRow))
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid first byte");
|
||||||
|
}
|
||||||
|
BigInteger c2 = bytes[i + 1];
|
||||||
|
if (c2 <= Gb2312EucOffset || c2 > (Gb2312EucOffset + Gb2312RowWidth))
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid second byte");
|
||||||
|
}
|
||||||
|
var index1 = c - Gb2312EucOffset;
|
||||||
|
if (index1 >= Gb2312FirstUnassignedSectionBegin)
|
||||||
|
{
|
||||||
|
var offset = index1 - Gb2312FirstUnassignedSectionBegin;
|
||||||
|
if (offset < Gb2312FirstUnassignedSectionSize)
|
||||||
|
{
|
||||||
|
throw new Exception("AA-AF user defined zone not supported");
|
||||||
|
}
|
||||||
|
index1 -= Gb2312FirstUnassignedSectionSize;
|
||||||
|
}
|
||||||
|
var index2 = c2 - Gb2312EucOffset;
|
||||||
|
if (index2 > Gb2312RowWidth)
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid second byte");
|
||||||
|
}
|
||||||
|
var value = (index1 - 1) * Gb2312RowWidth + (index2 - 1);
|
||||||
|
value = AsciiSectionSize + value;
|
||||||
|
bits = bits | (value << index);
|
||||||
|
index += ChineseCodeSize;
|
||||||
|
checksum += value;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bits = bits | ((checksum % 8) << EncodedChineseContentLength);
|
||||||
|
var result = "";
|
||||||
|
var bitsIndex = 0;
|
||||||
|
while (bitsIndex < EncodedChineseTotalLength)
|
||||||
|
{
|
||||||
|
int v = (int)((bits >> bitsIndex) & Base64CodeMask);
|
||||||
|
result += Base64Table[v];
|
||||||
|
bitsIndex += Base64CodeSize;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetPrettyName(string name)
|
||||||
|
{
|
||||||
|
if (name.Length != 20)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
foreach (var c in name)
|
||||||
|
{
|
||||||
|
if (!Base64Table.Contains(c))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DecodeChineseFromBase64(name);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-303
@@ -1,303 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
|
||||||
{
|
|
||||||
internal static class RA3Commands
|
|
||||||
{
|
|
||||||
//public static IReadOnlyDictionary<byte, Func<BinaryReader, string>> CommandParser { get; private set; }
|
|
||||||
|
|
||||||
public static IReadOnlyDictionary<byte, Action<BinaryReader>> CommandParser => _commandParser;
|
|
||||||
public static IReadOnlyDictionary<byte, string> CommandNames => _commandNames;
|
|
||||||
|
|
||||||
private static Dictionary<byte, Action<BinaryReader>> _commandParser;
|
|
||||||
private static Dictionary<byte, string> _commandNames;
|
|
||||||
|
|
||||||
static RA3Commands()
|
|
||||||
{
|
|
||||||
Action<BinaryReader> fixedSizeParser(byte command, int size)
|
|
||||||
{
|
|
||||||
return (BinaryReader current) =>
|
|
||||||
{
|
|
||||||
var lastByte = current.ReadBytes(size - 2).Last();
|
|
||||||
if (lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException($"Failed to parse command {command:X}, last byte is {lastByte:X}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Action<BinaryReader> variableSizeParser (byte command, int offset)
|
|
||||||
{
|
|
||||||
return (BinaryReader current) =>
|
|
||||||
{
|
|
||||||
var totalBytes = 2;
|
|
||||||
totalBytes += current.ReadBytes(offset - 2).Length;
|
|
||||||
for(var x = current.ReadByte(); x != 0xFF; x = current.ReadByte())
|
|
||||||
{
|
|
||||||
totalBytes += 1;
|
|
||||||
|
|
||||||
|
|
||||||
var size = ((x >> 4) + 1) * 4;
|
|
||||||
totalBytes += current.ReadBytes(size).Length;
|
|
||||||
}
|
|
||||||
totalBytes += 1;
|
|
||||||
var chk = totalBytes;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
var list = new List<(byte, Func<byte, int, Action<BinaryReader>>, int, string)>
|
|
||||||
{
|
|
||||||
(0x00, fixedSizeParser, 45, "展开建筑/建造碉堡(?)"),
|
|
||||||
(0x03, fixedSizeParser, 17, "开始升级"),
|
|
||||||
(0x04, fixedSizeParser, 17, "暂停/中止升级"),
|
|
||||||
(0x05, fixedSizeParser, 20, "开始生产单位或纳米核心"),
|
|
||||||
(0x06, fixedSizeParser, 20, "暂停/取消生产单位或纳米核心"),
|
|
||||||
(0x07, fixedSizeParser, 17, "开始建造建筑"),
|
|
||||||
(0x08, fixedSizeParser, 17, "暂停/取消建造建筑"),
|
|
||||||
(0x09, fixedSizeParser, 35, "摆放建筑"),
|
|
||||||
(0x0F, fixedSizeParser, 16, "(未知指令)"),
|
|
||||||
(0x14, fixedSizeParser, 16, "移动"),
|
|
||||||
(0x15, fixedSizeParser, 16, "移动攻击(A)"),
|
|
||||||
(0x16, fixedSizeParser, 16, "强制移动/碾压(G)"),
|
|
||||||
(0x21, fixedSizeParser, 20, "[游戏每3秒自动产生的指令]"),
|
|
||||||
(0x2C, fixedSizeParser, 29, "队形移动(左右键)"),
|
|
||||||
(0x32, fixedSizeParser, 53, "释放技能或协议(多个目标,如侦察扫描协议)"),
|
|
||||||
(0x34, fixedSizeParser, 45, "[游戏自动产生的UUID]"),
|
|
||||||
(0x35, fixedSizeParser, 1049, "[玩家信息(?)]"),
|
|
||||||
(0x36, fixedSizeParser, 16, "倒车移动(D)"),
|
|
||||||
(0x5F, fixedSizeParser, 11, "(未知指令)"),
|
|
||||||
|
|
||||||
(0x0A, variableSizeParser, 2, "出售建筑"),
|
|
||||||
(0x0D, variableSizeParser, 2, "右键攻击"),
|
|
||||||
(0x0E, variableSizeParser, 2, "强制攻击(Ctrl)"),
|
|
||||||
(0x12, variableSizeParser, 2, "(未知指令)"),
|
|
||||||
(0x1A, variableSizeParser, 2, "停止(S)"),
|
|
||||||
(0x1B, variableSizeParser, 2, "(未知指令)"),
|
|
||||||
(0x28, variableSizeParser, 2, "开始维修建筑"),
|
|
||||||
(0x29, variableSizeParser, 2, "停止维修建筑"),
|
|
||||||
(0x2A, variableSizeParser, 2, "选择所有单位(Q)"),
|
|
||||||
(0x2E, variableSizeParser, 2, "切换警戒/侵略/固守/停火模式"),
|
|
||||||
(0x2F, variableSizeParser, 2, "路径点模式(Alt)(?)"),
|
|
||||||
(0x37, variableSizeParser, 2, "[游戏不定期自动产生的指令]"),
|
|
||||||
(0x47, variableSizeParser, 2, "[游戏在第五帧自动产生的指令]"),
|
|
||||||
(0x48, variableSizeParser, 2, "(未知指令)"),
|
|
||||||
(0x4C, variableSizeParser, 2, "删除信标(或F9?)"),
|
|
||||||
(0x4E, variableSizeParser, 2, "选择协议"),
|
|
||||||
(0x52, variableSizeParser, 2, "(未知指令)"),
|
|
||||||
(0xF5, variableSizeParser, 5, "选择单位"),
|
|
||||||
(0xF6, variableSizeParser, 5, "[未知指令,貌似会在展开兵营核心时自动产生?]"),
|
|
||||||
(0xF8, variableSizeParser, 4, "鼠标左键单击/取消选择"),
|
|
||||||
(0xF9, variableSizeParser, 2, "[可能是步兵自行从进驻的建筑撤出]"),
|
|
||||||
(0xFA, variableSizeParser, 7, "创建编队"),
|
|
||||||
(0xFB, variableSizeParser, 2, "选择编队"),
|
|
||||||
(0xFC, variableSizeParser, 2, "(未知指令)"),
|
|
||||||
(0xFD, variableSizeParser, 7, "(未知指令)"),
|
|
||||||
(0xFE, variableSizeParser, 15, "释放技能或协议(无目标)"),
|
|
||||||
(0xFF, variableSizeParser, 34, "释放技能或协议(单个目标)"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var specialList = new List<(byte, Action<BinaryReader>, string)>
|
|
||||||
{
|
|
||||||
(0x01, ParseSpecialChunk0x01, "[游戏自动生成的指令]"),
|
|
||||||
(0x02, ParseSetRallyPoint0x02, "设计集结点"),
|
|
||||||
(0x0C, ParseUngarrison0x0C, "从进驻的建筑撤出(?)"),
|
|
||||||
(0x10, ParseGarrison0x10, "进驻建筑"),
|
|
||||||
(0x33, ParseUUID0x33, "[游戏自动生成的UUID指令]"),
|
|
||||||
(0x4B, ParsePlaceBeacon0x4B, "信标")
|
|
||||||
};
|
|
||||||
|
|
||||||
_commandParser = new Dictionary<byte, Action<BinaryReader>>();
|
|
||||||
_commandNames = new Dictionary<byte, string>();
|
|
||||||
|
|
||||||
foreach (var (id, maker, size, description) in list)
|
|
||||||
{
|
|
||||||
_commandParser.Add(id, maker(id, size));
|
|
||||||
_commandNames.Add(id, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (id, parser, description) in specialList)
|
|
||||||
{
|
|
||||||
_commandParser.Add(id, parser);
|
|
||||||
_commandNames.Add(id, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
_commandNames.Add(0x4D, "在信标里输入文字");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void UnknownCommandParser(BinaryReader current, byte commandID)
|
|
||||||
{
|
|
||||||
while(true)
|
|
||||||
{
|
|
||||||
var value = current.ReadByte();
|
|
||||||
if (value == 0xFF)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//return $"(未知指令 0x{commandID:2X})";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetCommandName(byte commandID)
|
|
||||||
{
|
|
||||||
return CommandNames.TryGetValue(commandID, out var storedName) ? storedName : $"(未知指令 0x{commandID:2X})";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseSpecialChunk0x01(BinaryReader current)
|
|
||||||
{
|
|
||||||
var firstByte = current.ReadByte();
|
|
||||||
if (firstByte == 0xFF)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sixthByte = current.ReadBytes(5).Last();
|
|
||||||
if(sixthByte == 0xFF)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sixteenthByte = current.ReadBytes(10).Last();
|
|
||||||
var size = (int)(sixteenthByte + 1) * 4 + 14;
|
|
||||||
var lastByte = current.ReadBytes(size).Last();
|
|
||||||
if(lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseSetRallyPoint0x02(BinaryReader current)
|
|
||||||
{
|
|
||||||
var size = (current.ReadBytes(23).Last() + 1) * 2 + 1;
|
|
||||||
var lastByte = current.ReadBytes(size).Last();
|
|
||||||
if (lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseUngarrison0x0C(BinaryReader current)
|
|
||||||
{
|
|
||||||
current.ReadByte();
|
|
||||||
var size = (current.ReadByte() + 1) * 4 + 1;
|
|
||||||
var lastByte = current.ReadBytes(size).Last();
|
|
||||||
if (lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseGarrison0x10(BinaryReader current)
|
|
||||||
{
|
|
||||||
var type = current.ReadByte();
|
|
||||||
var size = -1;
|
|
||||||
if(type == 0x14)
|
|
||||||
{
|
|
||||||
size = 9;
|
|
||||||
}
|
|
||||||
else if(type == 0x04)
|
|
||||||
{
|
|
||||||
size = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastByte = current.ReadBytes(size).Last();
|
|
||||||
if(lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseUUID0x33(BinaryReader current)
|
|
||||||
{
|
|
||||||
current.ReadByte();
|
|
||||||
var firstStringLength = (int)current.ReadByte();
|
|
||||||
current.ReadBytes(firstStringLength + 1);
|
|
||||||
var secondStringLength = current.ReadByte() * 2;
|
|
||||||
var lastByte = current.ReadBytes(secondStringLength + 6).Last();
|
|
||||||
if (lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParsePlaceBeacon0x4B(BinaryReader current)
|
|
||||||
{
|
|
||||||
var type = current.ReadByte();
|
|
||||||
var size = -1;
|
|
||||||
if(type == 0x04)
|
|
||||||
{
|
|
||||||
size = 5;
|
|
||||||
}
|
|
||||||
else if(type == 0x07)
|
|
||||||
{
|
|
||||||
size = 13;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastByte = current.ReadBytes(size).Last();
|
|
||||||
if (lastByte != 0xFF)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class CommandChunk
|
|
||||||
{
|
|
||||||
public byte CommandID { get; private set; }
|
|
||||||
public int PlayerIndex { get; private set; }
|
|
||||||
|
|
||||||
public static List<CommandChunk> Parse(in ReplayChunk chunk)
|
|
||||||
{
|
|
||||||
if (chunk.Type != 1)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
int ManglePlayerID(byte ID)
|
|
||||||
{
|
|
||||||
return ID / 8 - 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var stream = new MemoryStream(chunk.Data))
|
|
||||||
using (var reader = new BinaryReader(stream))
|
|
||||||
{
|
|
||||||
if(reader.ReadByte() != 1)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Payload first byte not 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
var list = new List<CommandChunk>();
|
|
||||||
var numberOfCommands = reader.ReadInt32();
|
|
||||||
for(var i = 0; i < numberOfCommands; ++i)
|
|
||||||
{
|
|
||||||
var commandID = reader.ReadByte();
|
|
||||||
var playerID = reader.ReadByte();
|
|
||||||
if (RA3Commands.CommandParser.TryGetValue(commandID, out var parser))
|
|
||||||
{
|
|
||||||
RA3Commands.CommandParser[commandID](reader);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RA3Commands.UnknownCommandParser(reader, commandID);
|
|
||||||
}
|
|
||||||
|
|
||||||
list.Add(new CommandChunk { CommandID = commandID, PlayerIndex = ManglePlayerID(playerID) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if(reader.BaseStream.Position != reader.BaseStream.Length)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Payload not fully parsed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+47871
File diff suppressed because it is too large
Load Diff
+16
-4
@@ -6,8 +6,20 @@
|
|||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="Debug" Height="450" Width="800">
|
Title="Debug" Height="450" Width="800">
|
||||||
<Grid>
|
<DockPanel Margin="10,10,10,10">
|
||||||
<TextBox x:Name="_textBox" Margin="10,34,10,10" TextWrapping="Wrap" Text="{Binding Path=DebugMessage, Mode=TwoWay}" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
<StackPanel DockPanel.Dock="Top"
|
||||||
<Button x:Name="_export" Content="导出日志" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75" Click="OnExport_Click"/>
|
Orientation="Horizontal"
|
||||||
</Grid>
|
Margin="0,0,0,10">
|
||||||
|
<Button Padding="8,2"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Content="导出日志"
|
||||||
|
Click="OnExportButtonClick" />
|
||||||
|
<Button Padding="8,2"
|
||||||
|
Content="清空日志"
|
||||||
|
Click="OnClearButtonClick" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox x:Name="_textBox"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||||
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
+89
-38
@@ -1,68 +1,119 @@
|
|||||||
using System;
|
using AnotherReplayReader.Utils;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Data;
|
|
||||||
using System.Windows.Documents;
|
|
||||||
using System.Windows.Input;
|
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Shapes;
|
|
||||||
using Microsoft.Win32;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
public sealed class DebugMessageWrapper : INotifyPropertyChanged
|
public sealed class DebugMessageWrapper
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler PropertyChanged;
|
public readonly struct Proxy
|
||||||
public string DebugMessage
|
|
||||||
{
|
{
|
||||||
get => _debugMessage;
|
public readonly string Payload;
|
||||||
set
|
public Proxy(string text)
|
||||||
{
|
{
|
||||||
_debugMessage = value;
|
Payload = text;
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("DebugMessage"));
|
}
|
||||||
|
public static Proxy operator +(Proxy p, string text)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(p.Payload) ? new Proxy(text) : new Proxy(p.Payload + text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _debugMessage;
|
private readonly object _lock = new();
|
||||||
|
private readonly List<string> _list = new();
|
||||||
|
|
||||||
|
public event Action<string>? NewText;
|
||||||
|
public Proxy DebugMessage
|
||||||
|
{
|
||||||
|
get => new();
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var text = value.Payload;
|
||||||
|
using var locker = new Lock(_lock);
|
||||||
|
if (NewText is null || _list.Count > 0)
|
||||||
|
{
|
||||||
|
_list.Add(text);
|
||||||
|
if (NewText is not null)
|
||||||
|
{
|
||||||
|
text = string.Join(string.Empty, _list);
|
||||||
|
_list.Clear();
|
||||||
|
_list.Capacity = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NewText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? RequestedSave;
|
||||||
|
public void RequestSave() => RequestedSave?.Invoke();
|
||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Debug.xaml 的交互逻辑
|
/// Debug.xaml 的交互逻辑
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class Debug : Window
|
public sealed partial class Debug : Window
|
||||||
{
|
{
|
||||||
public static readonly DebugMessageWrapper Instance = new DebugMessageWrapper();
|
public static readonly DebugMessageWrapper Instance = new();
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
private static Debug? _window = null;
|
||||||
|
|
||||||
public Debug()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
DataContext = Instance;
|
using var locker = new Lock(_lock);
|
||||||
InitializeComponent();
|
if (_window is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var window = new Debug();
|
||||||
|
window.InitializeComponent();
|
||||||
|
_window = window;
|
||||||
|
Instance.NewText += _window.AppendText;
|
||||||
|
Instance.RequestedSave += _window.ExportInMainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnExport_Click(object sender, RoutedEventArgs e)
|
public static new void ShowDialog() => Lock.Run(_lock, () => (_window as Window)?.ShowDialog());
|
||||||
{
|
|
||||||
var saveFileDialog = new SaveFileDialog
|
|
||||||
{
|
|
||||||
Filter = "文本文档 (*.txt)|*.txt|所有文件 (*.*)|*.*",
|
|
||||||
OverwritePrompt = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = saveFileDialog.ShowDialog(this);
|
protected override void OnClosing(CancelEventArgs e)
|
||||||
if (result == true)
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Debug() { }
|
||||||
|
|
||||||
|
private void OnExportButtonClick(object sender, RoutedEventArgs e) => Export(this);
|
||||||
|
|
||||||
|
private void OnClearButtonClick(object sender, RoutedEventArgs e) => _textBox.Clear();
|
||||||
|
|
||||||
|
private void AppendText(string s) => Dispatcher.InvokeAsync(() => _textBox.AppendText(s));
|
||||||
|
|
||||||
|
private void ExportInMainWindow() => Export(Application.Current.MainWindow);
|
||||||
|
|
||||||
|
private void Export(Window owner)
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
using (var file = saveFileDialog.OpenFile())
|
var saveFileDialog = new SaveFileDialog
|
||||||
using (var writer = new StreamWriter(file))
|
|
||||||
{
|
{
|
||||||
writer.Write(Instance.DebugMessage);
|
Filter = "文本文档 (*.txt)|*.txt|所有文件 (*.*)|*.*",
|
||||||
|
OverwritePrompt = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = saveFileDialog.ShowDialog(owner);
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
using var file = saveFileDialog.OpenFile();
|
||||||
|
using var writer = new StreamWriter(file);
|
||||||
|
writer.Write(_textBox.Text);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using TechnologyAssembler;
|
||||||
|
using TechnologyAssembler.Core.Diagnostics;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
public static class DronePlatform
|
||||||
|
{
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
private static bool _built = false;
|
||||||
|
|
||||||
|
public static void BuildTechnologyAssembler()
|
||||||
|
{
|
||||||
|
using var locker = new Lock(_lock);
|
||||||
|
if (_built)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new TechnologyAssemblerCoreModule().Initialize();
|
||||||
|
Tracer.SetTraceLevel(7);
|
||||||
|
Tracer.TraceWrite += (source, type, message) => Debug.Instance.DebugMessage += $"[{source}][{type}] {message}\r\n";
|
||||||
|
_built = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<Window x:Class="AnotherReplayReader.EventDump"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="流水账" Height="450" Width="800">
|
||||||
|
<DockPanel Margin="10,10,10,10">
|
||||||
|
<StackPanel DockPanel.Dock="Top"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Margin="0,0,0,10">
|
||||||
|
<Button Padding="8,2"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Content="导出内容"
|
||||||
|
Click="OnExportButtonClick" />
|
||||||
|
<Label Content="压缩程度"
|
||||||
|
Target="_compactLevelComboBox"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,4,0" />
|
||||||
|
<ComboBox x:Name="_compactLevelComboBox"
|
||||||
|
Width="120"
|
||||||
|
SelectionChanged="OnCompactLevelComboBoxSelectionChanged" />
|
||||||
|
<Label x:Name="_tokenUsageLabel"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="4,0,0,0" />
|
||||||
|
<Button Padding="8,2"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Content="AI分析"
|
||||||
|
Click="OnAIAnalyzeClick" />
|
||||||
|
</StackPanel>
|
||||||
|
<TabControl>
|
||||||
|
<TabItem x:Name="_eventTab"
|
||||||
|
Header="流水账">
|
||||||
|
<TextBox x:Name="_textBox"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsReadOnly="True"
|
||||||
|
IsUndoEnabled="False"
|
||||||
|
UndoLimit="0"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="AI 分析结果">
|
||||||
|
<local:AIChatPanel x:Name="_aiPanel"/>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="AI 设置">
|
||||||
|
<local:AIProviderSettingsControl x:Name="_aiSettings"/>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
using AnotherReplayReader.Apm;
|
||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using static AnotherReplayReader.AIProviderSettingsControl;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EventDump.xaml 的交互逻辑
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class EventDump : Window
|
||||||
|
{
|
||||||
|
public enum CompactLevel
|
||||||
|
{
|
||||||
|
NoCompact,
|
||||||
|
ForAI,
|
||||||
|
VeryCompactedForAI,
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Model(
|
||||||
|
Replay? Replay,
|
||||||
|
ImmutableSortedDictionary<int, Player> Players,
|
||||||
|
ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands,
|
||||||
|
CompactLevel Level
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public Mod Mod => Replay?.Mod ?? new("RA3");
|
||||||
|
public ImmutableSortedDictionary<int, string>? PlayersNamesForAI { get; } = Replay is null || Level <= CompactLevel.NoCompact
|
||||||
|
? null
|
||||||
|
: AIAnalyze.PlayerNamesForAI(Replay.Mod, Players);
|
||||||
|
public bool IsDefault => Players.IsEmpty && Commands.IsEmpty;
|
||||||
|
|
||||||
|
public Model() : this(
|
||||||
|
null,
|
||||||
|
ImmutableSortedDictionary<int, Player>.Empty,
|
||||||
|
ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)>.Empty,
|
||||||
|
CompactLevel.NoCompact
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PlayerNameByPlayerListIndex(int playerId)
|
||||||
|
{
|
||||||
|
var playerRawName = $"玩家#{playerId}";
|
||||||
|
var playerName = Players.TryGetValue(playerId, out var player)
|
||||||
|
? player.PlayerName
|
||||||
|
: playerRawName;
|
||||||
|
if (PlayersNamesForAI?.TryGetValue(playerId, out var playerNameForAI) is true)
|
||||||
|
{
|
||||||
|
playerName = playerNameForAI;
|
||||||
|
}
|
||||||
|
return playerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PlayerNameByGameSlotIndex(int index) => PlayerNameByPlayerListIndex(Players.ElementAt(index).Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate string? ToStringHook(int argumentIndex, CommandArgumentType type, int elementIndex, object value, string currentTextValue);
|
||||||
|
|
||||||
|
private static readonly Regex _matchHotkey = new("^(.*)((左右键|[A-Za-z]+))$");
|
||||||
|
private static ImmutableDictionary<uint, string> _stringHashes = ImmutableDictionary<uint, string>.Empty;
|
||||||
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
|
private Model _model = new();
|
||||||
|
private string? _cached;
|
||||||
|
private TimeIndexedPrefixSums? _cachedPrefixSums;
|
||||||
|
private ImmutableArray<EventSpan> _cachedSpans = ImmutableArray<EventSpan>.Empty;
|
||||||
|
private ReplayFactIndex? _cachedFactIndex;
|
||||||
|
|
||||||
|
public EventDump()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Closing += EventDump_Closing;
|
||||||
|
Closed += EventDump_Closed;
|
||||||
|
// add enum values of CompactLevel to _playerComboBox
|
||||||
|
var values = Enum.GetValues(typeof(CompactLevel)).Cast<CompactLevel>();
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
_compactLevelComboBox.Items.Add(value.ToString());
|
||||||
|
}
|
||||||
|
_compactLevelComboBox.SelectedIndex = (int)CompactLevel.VeryCompactedForAI;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EventDump_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_aiSettings.SaveCurrentSelection();
|
||||||
|
_cancellation.Cancel();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"EventDump_Closing: {ex}\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EventDump_Closed(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_aiSettings.SaveCurrentSelection();
|
||||||
|
_cancellation.Cancel();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"EventDump_Closed: {ex}\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadStringHashes()
|
||||||
|
{
|
||||||
|
// 临时方案:优先使用随程序分发的快照,找不到再回退到本地 SDK。
|
||||||
|
// 该快照后续应改为可配置路径,或只打包当前命令实际用到的 hash 子集。
|
||||||
|
var bundledPath = Path.Combine(AppContext.BaseDirectory, "Data", "StringHashes.xml");
|
||||||
|
var externalPath = @"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml";
|
||||||
|
var sourcePath = File.Exists(bundledPath) ? bundledPath : externalPath;
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
"未找到 StringHashes.xml;请检查程序目录下的 Data\\StringHashes.xml,或安装 RA3-MODSDK-X。",
|
||||||
|
sourcePath);
|
||||||
|
}
|
||||||
|
Debug.Instance.DebugMessage += $"StringHashes 来源: {sourcePath}\r\n";
|
||||||
|
|
||||||
|
var stringHashes = File.ReadAllText(sourcePath);
|
||||||
|
XDocument doc = XDocument.Parse(stringHashes);
|
||||||
|
XNamespace ns = "uri:ea.com:eala:asset";
|
||||||
|
var table = doc
|
||||||
|
.Descendants(ns + "StringHashTable")
|
||||||
|
.FirstOrDefault(x => (string?)x.Attribute("id") == "StringHashBin_INSTANCEID");
|
||||||
|
|
||||||
|
if (table == null)
|
||||||
|
throw new InvalidOperationException("StringHashBin_INSTANCEID not found.");
|
||||||
|
|
||||||
|
_stringHashes = table
|
||||||
|
.Elements(ns + "StringAndHash")
|
||||||
|
.ToImmutableDictionary(
|
||||||
|
x => uint.Parse(x.Attribute("Hash")!.Value),
|
||||||
|
x => x.Attribute("Text")!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetDumpData(ApmPlotter plotter)
|
||||||
|
{
|
||||||
|
var level = (CompactLevel)_compactLevelComboBox.SelectedIndex;
|
||||||
|
_model = new Model(plotter.Replay, plotter.PlayersMap, plotter.Commands, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowPlainText()
|
||||||
|
{
|
||||||
|
_cached = null;
|
||||||
|
if (_model.IsDefault)
|
||||||
|
{
|
||||||
|
_textBox.Text = "";
|
||||||
|
_tokenUsageLabel.Content = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var playerData = _model.Players.Values.Select(x =>
|
||||||
|
{
|
||||||
|
var factionName = ModData.GetFaction(_model.Mod, x.FactionId).Name;
|
||||||
|
return $"{x.PlayerName},队伍{x.Team},{factionName}";
|
||||||
|
});
|
||||||
|
//await analyzer.AnalyzeAsync("deepseek-v4-flash",
|
||||||
|
// AIAnalyze.GetSystemPrompt(_model.Mod, _model.Players),
|
||||||
|
// AIAnalyze.BuildUserPrompt(_model.Mod, _model.Players, text),
|
||||||
|
// deepSeekExtraParams);
|
||||||
|
|
||||||
|
_textBox.Text = "正在加载,请稍候";
|
||||||
|
_tokenUsageLabel.Content = "";
|
||||||
|
Show();
|
||||||
|
var (text, prefixSums, spans) = await Task.Run(() => GeneratePlainText(_model));
|
||||||
|
var (bytesCount, estimatedTokenCount) = AIAnalyze.EstimateTokenCount(text);
|
||||||
|
_textBox.Text = text;
|
||||||
|
_cached = text;
|
||||||
|
_cachedPrefixSums = prefixSums;
|
||||||
|
_cachedSpans = spans;
|
||||||
|
_cachedFactIndex = await Task.Run(() => ReplayFactIndex.Build(_model.Commands, _stringHashes));
|
||||||
|
// display KB and K tokens in _tokenUsageLabel
|
||||||
|
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Text, TimeIndexedPrefixSums PrefixSums, ImmutableArray<EventSpan> EventSpans) GeneratePlainText(Model model)
|
||||||
|
{
|
||||||
|
var prefixSums = new TimeIndexedPrefixSums([], []);
|
||||||
|
var spans = ImmutableArray.CreateBuilder<EventSpan>();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int chunkIndex = 0; chunkIndex < model.Commands.Length; ++chunkIndex)
|
||||||
|
{
|
||||||
|
var (time, commands) = model.Commands[chunkIndex];
|
||||||
|
var filtered = commands
|
||||||
|
.Where((c, i) => ShouldDisplay(model.Level, commands, i))
|
||||||
|
.ToList();
|
||||||
|
if (filtered.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var blockStart = sb.Length;
|
||||||
|
prefixSums.Add(time, filtered.Count);
|
||||||
|
sb.AppendLine($"[{TimeStampToString(time, model.Level)}]");
|
||||||
|
foreach (var command in filtered)
|
||||||
|
{
|
||||||
|
var commandName = RA3Commands.GetCommandName(command.CommandId);
|
||||||
|
if (model.Level > CompactLevel.NoCompact)
|
||||||
|
{
|
||||||
|
commandName = command.CommandId switch
|
||||||
|
{
|
||||||
|
0x1F5 => ((bool[])command.Data[0].Value)[0] switch
|
||||||
|
{
|
||||||
|
true when command.Data.Length <= 1 || command.Data[1].Count == 0 => "取消选择",
|
||||||
|
true => "重新选择单位",
|
||||||
|
false => "追加选择单位"
|
||||||
|
},
|
||||||
|
0x22E => "切换姿态",
|
||||||
|
_ => CommandNameRemoveDescription(commandName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var playerName = model.PlayerNameByPlayerListIndex(command.PlayerIndex);
|
||||||
|
|
||||||
|
sb.AppendLine($"{playerName}: {commandName}");
|
||||||
|
AppendCommandArguments(sb, command, (i, type, j, value, text) => command.CommandId switch
|
||||||
|
{
|
||||||
|
0x1F5 when i == 0 => j switch
|
||||||
|
{
|
||||||
|
0 when model.Level > CompactLevel.NoCompact => null,
|
||||||
|
0 => (bool)value ? "替换现有选择" : "加入到当前选择",
|
||||||
|
1 => null,
|
||||||
|
_ => throw new NotImplementedException(),
|
||||||
|
},
|
||||||
|
0x1F8 when i == 0 && model.Level > CompactLevel.NoCompact => null,
|
||||||
|
0x205 or 0x206 when i == 2 => (bool)value ? "连续5个" : null,
|
||||||
|
0x205 or 0x206 when i == 3 => $"序列:{ProductionQueueTypeToString((int)value)}",
|
||||||
|
0x207 when i == 1 && j == 1 => $"序列:{ProductionQueueTypeToString((int)value)}",
|
||||||
|
0x207 or 0x208 or 0x209 when i == 0 => $"{text}(建造者)",
|
||||||
|
0x205 or 0x206 when i == 0 => $"{text}(出兵建筑)",
|
||||||
|
0x252 => $"{model.PlayerNameByGameSlotIndex(FirstInt32(command.Data[0]) ?? 0)}已主动退出游戏",
|
||||||
|
0x22E when i == 0 => j == 0 ? StanceToString((int)value) : null,
|
||||||
|
_ => text,
|
||||||
|
});
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
var blockLength = sb.Length - blockStart;
|
||||||
|
var tokens = (int)Math.Ceiling(
|
||||||
|
Encoding.UTF8.GetByteCount(sb.ToString(blockStart, blockLength)) / 2.2);
|
||||||
|
spans.Add(new EventSpan(time, blockStart, blockLength, tokens));
|
||||||
|
}
|
||||||
|
return (sb.ToString(), prefixSums, spans.ToImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldDisplay(CompactLevel level, ImmutableArray<CommandChunk> commands, int i)
|
||||||
|
{
|
||||||
|
var chunk = commands[i];
|
||||||
|
var commandId = chunk.CommandId;
|
||||||
|
if (commandId is 0x1 or 0x252)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (level == CompactLevel.NoCompact)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ApmPlotter.IsUnknown(commandId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ApmPlotter.IsAuto(commandId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (commandId is 0x1F5) // 选择单位
|
||||||
|
{
|
||||||
|
var isNewSelection = chunk.Data[0].Value switch
|
||||||
|
{
|
||||||
|
bool[] bools => bools.Length > 0 && bools[0],
|
||||||
|
bool b => b,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if (chunk.Data.Length <= 1 || chunk.Data[1].Count == 0)
|
||||||
|
{
|
||||||
|
// 空选择:
|
||||||
|
// 假如是追加到当前选择,那么等于无操作,没什么意义,可以过滤掉
|
||||||
|
// 假如是新建选择,那么等于取消当前选择,在最高 compact level 下也可以过滤掉
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
<= CompactLevel.NoCompact => true,
|
||||||
|
CompactLevel.ForAI => isNewSelection,
|
||||||
|
>= CompactLevel.VeryCompactedForAI => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (level >= CompactLevel.ForAI && commandId is 0x1F8) // 取消选择
|
||||||
|
{
|
||||||
|
if (level >= CompactLevel.VeryCompactedForAI)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var hasSelectGroupAfterThis = commands
|
||||||
|
.Skip(i + 1)
|
||||||
|
.Any(c => c.PlayerIndex == chunk.PlayerIndex && c.CommandId == 0x1FB);
|
||||||
|
if (hasSelectGroupAfterThis)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TimeStampToString(TimeSpan t, CompactLevel level) => level switch
|
||||||
|
{
|
||||||
|
<= CompactLevel.NoCompact => $"{t:hh\\:mm\\:ss\\.ff}",
|
||||||
|
_ => $"{(int)t.TotalMinutes}:{t:ss\\.ff}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string CommandNameRemoveDescription(string commandName)
|
||||||
|
{
|
||||||
|
var match = _matchHotkey.Match(commandName);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
commandName = match.Groups[1].Value;
|
||||||
|
}
|
||||||
|
return commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProductionQueueTypeToString(int value) => value switch
|
||||||
|
{
|
||||||
|
0 => "主要建筑",
|
||||||
|
1 => "其他建筑",
|
||||||
|
2 => "步兵",
|
||||||
|
3 => "载具",
|
||||||
|
4 => "飞行器",
|
||||||
|
5 => "升级",
|
||||||
|
6 => "舰船",
|
||||||
|
_ => $"无效({value})"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int? FirstInt32(CommandArgumentEntry entry) =>
|
||||||
|
entry.Count == 1 && entry.Value is int singleInt
|
||||||
|
? singleInt
|
||||||
|
: entry.Value is int[] ints && ints.Length > 0
|
||||||
|
? ints[0]
|
||||||
|
: (int?)null;
|
||||||
|
|
||||||
|
private static string StanceToString(int value) => value switch
|
||||||
|
{
|
||||||
|
0 => "Guard",
|
||||||
|
1 => "Aggressive",
|
||||||
|
2 => "HoldPosition",
|
||||||
|
3 => "HoldFire",
|
||||||
|
_ => $"Unknown({value})"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void AppendCommandArguments(StringBuilder sb, CommandChunk command, ToStringHook hook)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var (argType, argValue, argCount) in command.Data)
|
||||||
|
{
|
||||||
|
++count;
|
||||||
|
var prefix = argType is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
|
||||||
|
? "[UnitId]"
|
||||||
|
: string.Empty;
|
||||||
|
if (argCount == 1)
|
||||||
|
{
|
||||||
|
var textValue = CommandArgumentToString(argType, argValue);
|
||||||
|
textValue = hook(count - 1, argType, 0, argValue, textValue);
|
||||||
|
if (textValue is not null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" {prefix}{textValue}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var textValues = ((Array)argValue).Cast<object>().Select((x, i) =>
|
||||||
|
{
|
||||||
|
var textValue = CommandArgumentToString(argType, x);
|
||||||
|
textValue = hook(count - 1, argType, i, x, textValue);
|
||||||
|
return textValue;
|
||||||
|
});
|
||||||
|
if (textValues.All(x => x is null))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.AppendLine($" {prefix}{string.Join(",", textValues.Where(x => x is not null))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CommandArgumentToString(CommandArgumentType type, object value)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
CommandArgumentType.Int32 => Int32BitToString((int)value),
|
||||||
|
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2 => Int32BitToString((uint)value),
|
||||||
|
|
||||||
|
CommandArgumentType.Float32 => ((float)value).ToString("0.##"),
|
||||||
|
|
||||||
|
CommandArgumentType.Bool
|
||||||
|
or CommandArgumentType.UInt16
|
||||||
|
or CommandArgumentType.ObjectId
|
||||||
|
or CommandArgumentType.ObjectId_2
|
||||||
|
or CommandArgumentType.AsciiString
|
||||||
|
or CommandArgumentType.UnicodeString
|
||||||
|
or CommandArgumentType.AssetId
|
||||||
|
or CommandArgumentType.Vector3 => value.ToString(),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown argument type {value}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Int32BitToString<T>(T value) where T : struct
|
||||||
|
{
|
||||||
|
// try to convert int32 or uint32 to hashes and retrieve text
|
||||||
|
uint hashValue = value switch
|
||||||
|
{
|
||||||
|
int intValue => unchecked((uint)intValue),
|
||||||
|
uint uintValue => uintValue,
|
||||||
|
_ => throw new InvalidOperationException($"Unexpected type {typeof(T)}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return _stringHashes.TryGetValue(hashValue, out var text) ? text : hashValue.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExportButtonClick(object sender, RoutedEventArgs e) => Export(this);
|
||||||
|
|
||||||
|
private void Export(Window owner)
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
var saveFileDialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Filter = "文本文档 (*.txt)|*.txt|所有文件 (*.*)|*.*",
|
||||||
|
OverwritePrompt = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = saveFileDialog.ShowDialog(owner);
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
using var file = saveFileDialog.OpenFile();
|
||||||
|
using var writer = new StreamWriter(file);
|
||||||
|
writer.Write(_textBox.Text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnCompactLevelComboBoxSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var selectedLevel = (CompactLevel)_compactLevelComboBox.SelectedIndex;
|
||||||
|
_model = new Model(_model.Replay, _model.Players, _model.Commands, selectedLevel);
|
||||||
|
await ShowPlainText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_model.Replay is not { } replay)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "请先选择录像");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_model.Level == CompactLevel.NoCompact)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "AI 分析需要使用 AI 压缩级别(ForAI 或 VeryCompactedForAI),请先在右上角切换。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums || _cachedFactIndex is not { } factIndex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "请先生成文本");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = _aiSettings.GetCurrentContext();
|
||||||
|
if (context == null)
|
||||||
|
{
|
||||||
|
MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
|
||||||
|
_aiPanel.GetPromptSettings = _aiSettings.GetPromptSettings;
|
||||||
|
|
||||||
|
await _aiPanel.StartAnalysisAsync(
|
||||||
|
replay,
|
||||||
|
_model.Players,
|
||||||
|
cached,
|
||||||
|
cachedPrefixSums,
|
||||||
|
factIndex,
|
||||||
|
_cachedSpans,
|
||||||
|
_cancellation.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
Veldrid\.SDL2\.dll
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{
|
|
||||||
"General": {
|
|
||||||
"InputAssemblies": [
|
|
||||||
"$(TargetDir)Microsoft.DotNet.PlatformAbstractions.dll",
|
|
||||||
"$(TargetDir)Microsoft.Extensions.DependencyModel.dll",
|
|
||||||
"$(TargetDir)Microsoft.Win32.Primitives.dll",
|
|
||||||
"$(TargetDir)NativeLibraryLoader.dll",
|
|
||||||
"$(TargetDir)netstandard.dll",
|
|
||||||
"$(TargetDir)Newtonsoft.Json.dll",
|
|
||||||
"$(TargetDir)NLog.dll",
|
|
||||||
"$(TargetDir)OpenSage.Core.dll",
|
|
||||||
"$(TargetDir)OpenSage.FileFormats.Big.dll",
|
|
||||||
"$(TargetDir)OpenSage.FileFormats.dll",
|
|
||||||
"$(TargetDir)OpenSage.FileFormats.RefPack.dll",
|
|
||||||
"$(TargetDir)OpenSage.Mathematics.dll",
|
|
||||||
"$(TargetDir)Pfim.dll",
|
|
||||||
"$(TargetDir)System.AppContext.dll",
|
|
||||||
"$(TargetDir)System.Collections.Concurrent.dll",
|
|
||||||
"$(TargetDir)System.Collections.dll",
|
|
||||||
"$(TargetDir)System.Collections.NonGeneric.dll",
|
|
||||||
"$(TargetDir)System.Collections.Specialized.dll",
|
|
||||||
"$(TargetDir)System.ComponentModel.dll",
|
|
||||||
"$(TargetDir)System.ComponentModel.EventBasedAsync.dll",
|
|
||||||
"$(TargetDir)System.ComponentModel.Primitives.dll",
|
|
||||||
"$(TargetDir)System.ComponentModel.TypeConverter.dll",
|
|
||||||
"$(TargetDir)System.Console.dll",
|
|
||||||
"$(TargetDir)System.Data.Common.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.Contracts.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.Debug.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.DiagnosticSource.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.FileVersionInfo.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.Process.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.StackTrace.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.TextWriterTraceListener.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.Tools.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.TraceSource.dll",
|
|
||||||
"$(TargetDir)System.Diagnostics.Tracing.dll",
|
|
||||||
"$(TargetDir)System.Drawing.Primitives.dll",
|
|
||||||
"$(TargetDir)System.Dynamic.Runtime.dll",
|
|
||||||
"$(TargetDir)System.Globalization.Calendars.dll",
|
|
||||||
"$(TargetDir)System.Globalization.dll",
|
|
||||||
"$(TargetDir)System.Globalization.Extensions.dll",
|
|
||||||
"$(TargetDir)System.IO.Compression.dll",
|
|
||||||
"$(TargetDir)System.IO.Compression.ZipFile.dll",
|
|
||||||
"$(TargetDir)System.IO.dll",
|
|
||||||
"$(TargetDir)System.IO.FileSystem.dll",
|
|
||||||
"$(TargetDir)System.IO.FileSystem.DriveInfo.dll",
|
|
||||||
"$(TargetDir)System.IO.FileSystem.Primitives.dll",
|
|
||||||
"$(TargetDir)System.IO.FileSystem.Watcher.dll",
|
|
||||||
"$(TargetDir)System.IO.IsolatedStorage.dll",
|
|
||||||
"$(TargetDir)System.IO.MemoryMappedFiles.dll",
|
|
||||||
"$(TargetDir)System.IO.Pipes.dll",
|
|
||||||
"$(TargetDir)System.IO.UnmanagedMemoryStream.dll",
|
|
||||||
"$(TargetDir)System.Linq.dll",
|
|
||||||
"$(TargetDir)System.Linq.Expressions.dll",
|
|
||||||
"$(TargetDir)System.Linq.Parallel.dll",
|
|
||||||
"$(TargetDir)System.Linq.Queryable.dll",
|
|
||||||
"$(TargetDir)System.Net.Http.dll",
|
|
||||||
"$(TargetDir)System.Net.NameResolution.dll",
|
|
||||||
"$(TargetDir)System.Net.NetworkInformation.dll",
|
|
||||||
"$(TargetDir)System.Net.Ping.dll",
|
|
||||||
"$(TargetDir)System.Net.Primitives.dll",
|
|
||||||
"$(TargetDir)System.Net.Requests.dll",
|
|
||||||
"$(TargetDir)System.Net.Security.dll",
|
|
||||||
"$(TargetDir)System.Net.Sockets.dll",
|
|
||||||
"$(TargetDir)System.Net.WebHeaderCollection.dll",
|
|
||||||
"$(TargetDir)System.Net.WebSockets.Client.dll",
|
|
||||||
"$(TargetDir)System.Net.WebSockets.dll",
|
|
||||||
"$(TargetDir)System.Numerics.Vectors.dll",
|
|
||||||
"$(TargetDir)System.ObjectModel.dll",
|
|
||||||
"$(TargetDir)System.Reflection.dll",
|
|
||||||
"$(TargetDir)System.Reflection.Extensions.dll",
|
|
||||||
"$(TargetDir)System.Reflection.Primitives.dll",
|
|
||||||
"$(TargetDir)System.Resources.Reader.dll",
|
|
||||||
"$(TargetDir)System.Resources.ResourceManager.dll",
|
|
||||||
"$(TargetDir)System.Resources.Writer.dll",
|
|
||||||
"$(TargetDir)System.Runtime.CompilerServices.Unsafe.dll",
|
|
||||||
"$(TargetDir)System.Runtime.CompilerServices.VisualC.dll",
|
|
||||||
"$(TargetDir)System.Runtime.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Extensions.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Handles.dll",
|
|
||||||
"$(TargetDir)System.Runtime.InteropServices.dll",
|
|
||||||
"$(TargetDir)System.Runtime.InteropServices.RuntimeInformation.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Numerics.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Serialization.Formatters.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Serialization.Json.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Serialization.Primitives.dll",
|
|
||||||
"$(TargetDir)System.Runtime.Serialization.Xml.dll",
|
|
||||||
"$(TargetDir)System.Security.Claims.dll",
|
|
||||||
"$(TargetDir)System.Security.Cryptography.Algorithms.dll",
|
|
||||||
"$(TargetDir)System.Security.Cryptography.Csp.dll",
|
|
||||||
"$(TargetDir)System.Security.Cryptography.Encoding.dll",
|
|
||||||
"$(TargetDir)System.Security.Cryptography.Primitives.dll",
|
|
||||||
"$(TargetDir)System.Security.Cryptography.X509Certificates.dll",
|
|
||||||
"$(TargetDir)System.Security.Principal.dll",
|
|
||||||
"$(TargetDir)System.Security.SecureString.dll",
|
|
||||||
"$(TargetDir)System.Text.Encoding.CodePages.dll",
|
|
||||||
"$(TargetDir)System.Text.Encoding.dll",
|
|
||||||
"$(TargetDir)System.Text.Encoding.Extensions.dll",
|
|
||||||
"$(TargetDir)System.Text.RegularExpressions.dll",
|
|
||||||
"$(TargetDir)System.Threading.dll",
|
|
||||||
"$(TargetDir)System.Threading.Overlapped.dll",
|
|
||||||
"$(TargetDir)System.Threading.Tasks.dll",
|
|
||||||
"$(TargetDir)System.Threading.Tasks.Parallel.dll",
|
|
||||||
"$(TargetDir)System.Threading.Thread.dll",
|
|
||||||
"$(TargetDir)System.Threading.ThreadPool.dll",
|
|
||||||
"$(TargetDir)System.Threading.Timer.dll",
|
|
||||||
"$(TargetDir)System.ValueTuple.dll",
|
|
||||||
"$(TargetDir)System.Xml.ReaderWriter.dll",
|
|
||||||
"$(TargetDir)System.Xml.XDocument.dll",
|
|
||||||
"$(TargetDir)System.Xml.XmlDocument.dll",
|
|
||||||
"$(TargetDir)System.Xml.XmlSerializer.dll",
|
|
||||||
"$(TargetDir)System.Xml.XPath.dll",
|
|
||||||
"$(TargetDir)System.Xml.XPath.XDocument.dll"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Advanced": {
|
|
||||||
"AllowWildCards": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
public sealed class IpAndPlayer
|
||||||
|
{
|
||||||
|
public static string SimpleIPToString(uint ip)
|
||||||
|
{
|
||||||
|
return $"{ip / 256 / 256 / 256}.{ip / 256 / 256 % 256}.{ip / 256 % 256}.{ip % 256}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("IP")]
|
||||||
|
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
|
||||||
|
public uint Ip
|
||||||
|
{
|
||||||
|
get => _ip;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_ip = value;
|
||||||
|
IpString = SimpleIPToString(_ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[JsonIgnore]
|
||||||
|
public string IpString { get; private set; } = "0.0.0.0";
|
||||||
|
|
||||||
|
[JsonPropertyName("ID")]
|
||||||
|
public string Id
|
||||||
|
{
|
||||||
|
get => _id;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_id = value;
|
||||||
|
_pinyin = _id.ToPinyin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? PinyinId => _pinyin;
|
||||||
|
|
||||||
|
private uint _ip;
|
||||||
|
private string _id = string.Empty;
|
||||||
|
private string? _pinyin;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-10
@@ -5,7 +5,9 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="MainWindow" Width="800" Height="600">
|
Title="MainWindow" Width="800" Height="600"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Loaded="OnMainWindowLoaded">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="20"/>
|
<RowDefinition Height="20"/>
|
||||||
@@ -22,8 +24,8 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Label x:Name="label" Content="录像文件夹" Grid.Column="0" Margin="0,-3,5,8" HorizontalContentAlignment="Right" HorizontalAlignment="Right" Width="75"/>
|
<Label x:Name="label" Content="录像文件夹" Grid.Column="0" Margin="0,-3,5,8" HorizontalContentAlignment="Right" HorizontalAlignment="Right" Width="75"/>
|
||||||
<TextBox x:Name="_replayFolderPathBox" Grid.Column="1" TextWrapping="Wrap" Text="{Binding Path=ReplayFolderPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="OnReplayFolderPathBoxTextChanged" Margin="0,0,0,10" Grid.ColumnSpan="2" />
|
<TextBox x:Name="_replayFolderPathBox" Grid.Column="1" TextWrapping="Wrap" Text="{Binding Path=ReplayFolderPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="OnReplayFolderPathBoxTextChanged" Margin="0,0,0,10" Grid.ColumnSpan="2" />
|
||||||
<Button x:Name="_browseButton" Content="浏览..." Grid.Column="3" Margin="10,0,5,11" Click="OnBrowseButton_Click"/>
|
<Button x:Name="_browseButton" Content="浏览..." Grid.Column="3" Margin="10,0,5,11" Click="OnBrowseButtonClick"/>
|
||||||
<Button x:Name="_aboutButton" Content="关于..." Click="OnAboutButton_Click" Grid.Column="4" Margin="5,0,12,11"/>
|
<Button x:Name="_aboutButton" Content="关于..." Click="OnAboutButtonClick" Grid.Column="4" Margin="5,0,12,11"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Grid.Row="2">
|
<Grid Grid.Row="2">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -35,26 +37,57 @@
|
|||||||
<ColumnDefinition Width="145"/>
|
<ColumnDefinition Width="145"/>
|
||||||
<ColumnDefinition Width="472*"/>
|
<ColumnDefinition Width="472*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBox x:Name="_replayFilterBox" Grid.Row="0" Grid.Column="2" Margin="120,10,10,0" TextWrapping="Wrap" Height="20" VerticalAlignment="Top" />
|
<Grid Grid.Row="0"
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="120,10,10,0"
|
||||||
|
Height="20"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<TextBox x:Name="_replayFilterBox"
|
||||||
|
TextChanged="OnReplayFilterBoxTextChanged" />
|
||||||
|
<TextBlock IsHitTestVisible="False"
|
||||||
|
Text="输入录像名称、玩家名称或地图名称等 可以筛选录像"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="10,0,0,0"
|
||||||
|
Foreground="DarkGray">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="{x:Type TextBlock}">
|
||||||
|
<Setter Property="Visibility"
|
||||||
|
Value="Collapsed" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Text, ElementName=_replayFilterBox}"
|
||||||
|
Value="">
|
||||||
|
<Setter Property="Visibility"
|
||||||
|
Value="Visible" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Button x:Name="_refreshButton" Content="刷新" Grid.Column="2" HorizontalAlignment="Left" Margin="0,11,0,0" VerticalAlignment="Top" Width="80" Click="OnReplayFolderPathBoxTextChanged"/>
|
<Button x:Name="_refreshButton" Content="刷新" Grid.Column="2" HorizontalAlignment="Left" Margin="0,11,0,0" VerticalAlignment="Top" Width="80" Click="OnReplayFolderPathBoxTextChanged"/>
|
||||||
<DataGrid x:Name="_dataGrid" Grid.Row="0" Grid.Column="2" Grid.RowSpan="2" Margin="0,30,10,16" SelectionMode="Single" SelectionChanged="OnReplaySelectionChanged">
|
<DataGrid x:Name="_dataGrid" Grid.Row="0" Grid.Column="2" Grid.RowSpan="2" Margin="0,30,10,16"
|
||||||
|
SelectionMode="Single" SelectionChanged="OnReplaySelectionChanged"
|
||||||
|
AutoGenerateColumns="False">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="文件名" Binding="{Binding Path=FileName}" Width="4*" IsReadOnly="True"/>
|
<DataGridTextColumn Header="文件名" Binding="{Binding Path=FileName}" Width="4*" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="玩家人数" Binding="{Binding Path=NumberOfPlayingPlayers}" Width="1.5*" IsReadOnly="True"/>
|
<DataGridTextColumn Header="玩家人数" Binding="{Binding Path=NumberOfPlayingPlayers}" Width="1.5*" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="录像时长" Binding="{Binding Path=Length, TargetNullValue='?'}" Width="1.65*" IsReadOnly="True"/>
|
<DataGridTextColumn Header="录像时长"
|
||||||
|
Binding="{Binding Path=Length, TargetNullValue='?'}" Width="1.65*" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="Mod" Binding="{Binding Path=Mod}" Width="1.5*" IsReadOnly="True"/>
|
<DataGridTextColumn Header="Mod" Binding="{Binding Path=Mod}" Width="1.5*" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="录像日期" Binding="{Binding Path=Date, StringFormat='{}{0:yyyy/MM/dd HH:mm:SS}'}" Width="3*" IsReadOnly="True" />
|
<DataGridTextColumn Header="录像日期" Binding="{Binding Path=Date, StringFormat='{}{0:yyyy/MM/dd HH:mm:SS}'}" Width="3*" IsReadOnly="True" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
<Button x:Name="_debugButton" Content="调试信息" Grid.Row="0" Grid.Column="0" VerticalAlignment="Top" Click="OnDebugButton_Click" Margin="20,6,0,0" HorizontalAlignment="Left" Width="80"/>
|
<Button x:Name="_debugButton" Content="调试信息" Grid.Row="0" Grid.Column="0" VerticalAlignment="Top" Click="OnDebugButtonClick" Margin="20,6,0,0" HorizontalAlignment="Left" Width="80"/>
|
||||||
<TextBox x:Name="_replayDetailsBox" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="20,30,20,0" Text="{Binding Path=ReplayDetails}" TextWrapping="Wrap"/>
|
<TextBox x:Name="_replayDetailsBox" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="20,30,20,0" Text="{Binding Path=ReplayDetails}" TextWrapping="Wrap"/>
|
||||||
<Image x:Name="_image" Grid.Row="1" Grid.Column="0" Margin="18,20,0,0" Stretch="Uniform" HorizontalAlignment="Left" Width="120" Height="120" VerticalAlignment="Top" />
|
<Image x:Name="_image" Grid.Row="1" Grid.Column="0" Margin="18,20,0,0" Stretch="Uniform" HorizontalAlignment="Left" Width="120" Height="120" VerticalAlignment="Top" />
|
||||||
<Button x:Name="_playButton" Grid.Row="1" Grid.Column="1" Content="播放录像"
|
<Button x:Name="_playButton" Grid.Row="1" Grid.Column="1" Content="播放录像"
|
||||||
Margin="10,0,20,96"
|
Margin="10,0,20,96"
|
||||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayPlayable}" Click="OnPlayReplayButton_Click"/>
|
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayPlayable}" Click="OnPlayReplayButtonClick"/>
|
||||||
<Button x:Name="_saveAsButton" Grid.Row="1" Grid.Column="1" Content="修复录像"
|
<Button x:Name="_saveAsButton" Grid.Row="1" Grid.Column="1" Content="修复录像"
|
||||||
Margin="10,0,20,56"
|
Margin="10,0,20,56"
|
||||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayDamaged}" Click="OnFixReplayButton_Click"/>
|
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayDamaged}" Click="OnFixReplayButtonClick"/>
|
||||||
<Button x:Name="_detailsButton"
|
<Button x:Name="_detailsButton"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -63,7 +96,7 @@
|
|||||||
Height="35"
|
Height="35"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
IsEnabled="{Binding Path=ReplaySelected}"
|
IsEnabled="{Binding Path=ReplaySelected}"
|
||||||
Click="OnDetailsButton_Click" />
|
Click="OnDetailsButtonClick" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
+292
-362
@@ -1,6 +1,9 @@
|
|||||||
using Microsoft.Win32;
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using Microsoft.Win32;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -9,6 +12,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
@@ -16,21 +20,14 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
public MainWindowProperties()
|
public MainWindowProperties()
|
||||||
{
|
{
|
||||||
string userDataLeafName = null;
|
const RegistryHive hklm = RegistryHive.LocalMachine;
|
||||||
string replayFolderName = null;
|
RA3Directory = RegistryUtils.RetrieveInRa3(hklm, "Install Dir");
|
||||||
using (var view32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32))
|
string? userDataLeafName = RegistryUtils.RetrieveInRa3(hklm, "UserDataLeafName");
|
||||||
using (var ra3Key = view32.OpenSubKey(@"Software\Electronic Arts\Electronic Arts\Red Alert 3", false))
|
string? replayFolderName = RegistryUtils.RetrieveInRa3(hklm, "ReplayFolderName");
|
||||||
{
|
|
||||||
RA3Directory = ra3Key?.GetValue("Install Dir") as string;
|
|
||||||
userDataLeafName = ra3Key?.GetValue("UserDataLeafName") as string;
|
|
||||||
replayFolderName = ra3Key?.GetValue("ReplayFolderName") as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(userDataLeafName))
|
if (string.IsNullOrWhiteSpace(userDataLeafName))
|
||||||
{
|
{
|
||||||
userDataLeafName = "Red Alert 3";
|
userDataLeafName = "Red Alert 3";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(replayFolderName))
|
if (string.IsNullOrWhiteSpace(replayFolderName))
|
||||||
{
|
{
|
||||||
replayFolderName = "Replays";
|
replayFolderName = "Replays";
|
||||||
@@ -42,17 +39,21 @@ namespace AnotherReplayReader
|
|||||||
ModsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), userDataLeafName, "Mods");
|
ModsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), userDataLeafName, "Mods");
|
||||||
}
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
// This method is called by the Set accessor of each property.
|
// This method is called by the Set accessor of each property.
|
||||||
// The CallerMemberName attribute that is applied to the optional propertyName
|
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||||
// parameter causes the property name of the caller to be substituted as an argument.
|
// parameter causes the property name of the caller to be substituted as an argument.
|
||||||
private void NotifyPropertyChanged<T>(T value, [CallerMemberName] string propertyName = "")
|
private void SetAndNotifyPropertyChanged<T>(ref T target, T newValue, [CallerMemberName] string propertyName = "")
|
||||||
{
|
{
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
if (!Equals(target, newValue))
|
||||||
|
{
|
||||||
|
target = newValue;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string RA3Directory { get; }
|
public string? RA3Directory { get; }
|
||||||
public string RA3ReplayFolderPath { get; }
|
public string RA3ReplayFolderPath { get; }
|
||||||
public string RA3Exe => Path.Combine(RA3Directory, "RA3.exe");
|
public string RA3Exe => Path.Combine(RA3Directory, "RA3.exe");
|
||||||
public string CustomMapsDirectory { get; }
|
public string CustomMapsDirectory { get; }
|
||||||
@@ -60,46 +61,38 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
public string ReplayFolderPath
|
public string ReplayFolderPath
|
||||||
{
|
{
|
||||||
get { return _replayFolderPath; }
|
get => _replayFolderPath;
|
||||||
set { _replayFolderPath = value; NotifyPropertyChanged(_replayFolderPath); }
|
set => SetAndNotifyPropertyChanged(ref _replayFolderPath, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ReplayFilterString
|
public string? ReplayDetails
|
||||||
{
|
{
|
||||||
get { return _replayFilterString; }
|
get => _replayDetails;
|
||||||
set { _replayFilterString = value; NotifyPropertyChanged(_replayFilterString); }
|
set => SetAndNotifyPropertyChanged(ref _replayDetails, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ReplayDetails
|
public bool ReplaySelected => CurrentReplay != null;
|
||||||
|
|
||||||
|
public bool ReplayPlayable => CurrentReplay?.HasFooter == true && CurrentReplay?.HasCommentator == true && (RA3Directory != null) && File.Exists(RA3Exe);
|
||||||
|
|
||||||
|
public bool ReplayDamaged => CurrentReplay?.HasFooter == false;
|
||||||
|
|
||||||
|
public Replay? CurrentReplay
|
||||||
{
|
{
|
||||||
get { return _replayDetails; }
|
get => _currentReplay;
|
||||||
set { _replayDetails = value; NotifyPropertyChanged(_replayDetails); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool ReplaySelected => _currentReplay != null;
|
|
||||||
|
|
||||||
public bool ReplayPlayable => _currentReplay?.HasFooter == true && _currentReplay?.HasCommentator == true && (RA3Directory != null) && File.Exists(RA3Exe);
|
|
||||||
|
|
||||||
public bool ReplayDamaged => _currentReplay?.HasFooter == false;
|
|
||||||
|
|
||||||
public Replay CurrentReplay
|
|
||||||
{
|
|
||||||
get { return _currentReplay; }
|
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_currentReplay = value;
|
SetAndNotifyPropertyChanged(ref _currentReplay, value);
|
||||||
NotifyPropertyChanged(_currentReplay);
|
|
||||||
ReplayDetails = "";
|
ReplayDetails = "";
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplayPlayable"));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplayPlayable)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplaySelected"));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplaySelected)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplayDamaged"));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplayDamaged)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private volatile string _replayFolderPath;
|
private string _replayFolderPath = null!;
|
||||||
private volatile string _replayDetails;
|
private string? _replayDetails;
|
||||||
private volatile string _replayFilterString;
|
private Replay? _currentReplay;
|
||||||
private volatile Replay _currentReplay;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -107,360 +100,287 @@ namespace AnotherReplayReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private MainWindowProperties _properties = new MainWindowProperties();
|
private readonly TaskQueue _taskQueue;
|
||||||
private volatile List<Replay> _replayList;
|
private readonly MainWindowProperties _properties = new();
|
||||||
private Cache _cache = new Cache();
|
private readonly Cache _cache = new();
|
||||||
private PlayerIdentity _playerIdentity;
|
private readonly BigMinimapCache _minimapCache;
|
||||||
private BigMinimapCache _minimapCache;
|
private readonly MinimapReader _minimapReader;
|
||||||
private MinimapReader _minimapReader;
|
private readonly CancelManager _cancelLoadReplays = new();
|
||||||
private CancellationTokenSource _loadReplaysToken;
|
private readonly CancelManager _cancelFilterReplays = new();
|
||||||
|
private readonly CancelManager _cancelDisplayReplays = new();
|
||||||
|
private ReplayPinyinList _replayList;
|
||||||
|
private ImmutableArray<string> _filterStrings = ImmutableArray<string>.Empty;
|
||||||
|
private ImmutableArray<Replay> _filteredReplays = ImmutableArray<Replay>.Empty;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
|
_taskQueue = new(Dispatcher);
|
||||||
|
_minimapCache = new BigMinimapCache(_properties.RA3Directory);
|
||||||
|
_minimapReader = new MinimapReader(_minimapCache, _properties.CustomMapsDirectory, _properties.ModsDirectory);
|
||||||
|
_replayList = new();
|
||||||
|
|
||||||
DataContext = _properties;
|
DataContext = _properties;
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
Closing += (sender, eventArgs) =>
|
||||||
var handling = new bool[1] { false };
|
|
||||||
Application.Current.Dispatcher.UnhandledException += (sender, eventArgs) =>
|
|
||||||
{
|
{
|
||||||
if (handling == null || handling[0] == true)
|
_cache.Save().Wait();
|
||||||
{
|
Application.Current.Shutdown();
|
||||||
return;
|
|
||||||
}
|
|
||||||
handling[0] = true;
|
|
||||||
Dispatcher.Invoke(() => MessageBox.Show($"错误:\r\n{eventArgs.Exception}"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Closing += ((sender, eventArgs) => _cache.Save());
|
|
||||||
|
|
||||||
_playerIdentity = new PlayerIdentity(_cache);
|
|
||||||
_minimapCache = new BigMinimapCache(_cache, _properties.RA3Directory);
|
|
||||||
_minimapReader = new MinimapReader(_minimapCache, _properties.RA3Directory, _properties.CustomMapsDirectory, _properties.ModsDirectory);
|
|
||||||
|
|
||||||
LoadReplays();
|
|
||||||
_ = AutoSaveReplays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AutoSaveReplays()
|
private async void OnMainWindowLoaded(object sender, EventArgs eventArgs)
|
||||||
{
|
{
|
||||||
const string ourPrefix = "自动保存";
|
Debug.Initialize();
|
||||||
var errorMessageCount = 0;
|
await _cache.Initialization;
|
||||||
// filename and last write time
|
ReplayAutoSaver.SpawnAutoSaveReplaysTask(_properties.RA3ReplayFolderPath);
|
||||||
var previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||||
// filename and file size
|
_ = _taskQueue.Enqueue(() => LoadReplays(null, token), token);
|
||||||
var lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
while (true)
|
const string permissionKey = "questionAsked";
|
||||||
|
if (_cache.GetOrDefault(permissionKey, false) is not true)
|
||||||
{
|
{
|
||||||
try
|
_cache.Set(permissionKey, true);
|
||||||
|
var sb = new StringWriter();
|
||||||
|
sb.WriteLine("要不要自动检查更新呢?");
|
||||||
|
sb.WriteLine("之后也可以在“关于”窗口里,设置自动更新的选项");
|
||||||
|
var choice = MessageBox.Show(this, sb.ToString(), App.Name, MessageBoxButton.YesNo);
|
||||||
|
_cache.Set(UpdateChecker.CheckForUpdatesKey, choice is MessageBoxResult.Yes);
|
||||||
|
await _cache.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = UpdateChecker.CheckForUpdates(_cache).ContinueWith(t => Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var updateData = t.Result;
|
||||||
|
if (updateData.IsNewVersion())
|
||||||
{
|
{
|
||||||
var changed = (from fileName in Directory.GetFiles(_properties.RA3ReplayFolderPath, "*.RA3Replay")
|
var about = new About(_cache, updateData)
|
||||||
let info = new FileInfo(fileName)
|
|
||||||
where !info.Name.StartsWith(ourPrefix)
|
|
||||||
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
|
||||||
select info).ToList();
|
|
||||||
|
|
||||||
foreach (var info in changed)
|
|
||||||
{
|
{
|
||||||
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
Owner = this
|
||||||
|
};
|
||||||
|
about.ShowDialog();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadReplays(string? nextSelected, CancellationToken cancelToken)
|
||||||
|
{
|
||||||
|
if (!IsLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
var filterToken = _cancelFilterReplays.ResetAndGetToken(cancelToken);
|
||||||
|
_cancelDisplayReplays.Reset(_cancelFilterReplays.Token);
|
||||||
|
|
||||||
|
_properties.CurrentReplay = null;
|
||||||
|
_image.Source = null;
|
||||||
|
if (_dataGrid.Items.Count > 0)
|
||||||
|
{
|
||||||
|
_dataGrid.ItemsSource = Array.Empty<Replay>();
|
||||||
|
_dataGrid.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
_replayList = new();
|
||||||
|
|
||||||
|
var path = _properties.ReplayFolderPath;
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
await FilterReplays("这个文件夹并不存在。", nextSelected, filterToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var list = new List<Replay>();
|
||||||
|
var clock = new Stopwatch();
|
||||||
|
clock.Start();
|
||||||
|
foreach (var replayPath in Directory.EnumerateFiles(path, "*.RA3Replay"))
|
||||||
|
{
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var replay = new Replay(replayPath);
|
||||||
|
list.Add(replay);
|
||||||
}
|
}
|
||||||
|
catch (Exception exception)
|
||||||
var replays = changed.Select(info =>
|
|
||||||
{
|
{
|
||||||
Debug.Instance.DebugMessage += $"正在尝试检测已更改的文件:{info.FullName}\r\n";
|
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay list: \r\n{exception}\r\n";
|
||||||
try
|
continue;
|
||||||
{
|
|
||||||
using (var stream = info.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
||||||
{
|
|
||||||
return new Replay(info.FullName, stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).Where(replay => replay != null);
|
|
||||||
|
|
||||||
var newLastReplays = from replay in replays
|
|
||||||
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
|
||||||
let endDate = replay.Date.Add(replay.Length ?? TimeSpan.Zero)
|
|
||||||
let endThreshold = Math.Abs((DateTime.UtcNow - endDate).TotalSeconds)
|
|
||||||
where threshold < 40 || endThreshold < 40
|
|
||||||
select replay;
|
|
||||||
|
|
||||||
var toBeChecked = newLastReplays.ToDictionary(replay => replay.Path, StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var savedLastReplay in lastReplays.Keys)
|
|
||||||
{
|
|
||||||
if (!toBeChecked.ContainsKey(savedLastReplay))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var stream = File.Open(savedLastReplay, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
||||||
{
|
|
||||||
toBeChecked.Add(savedLastReplay, new Replay(savedLastReplay, stream));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (clock.ElapsedMilliseconds > 300)
|
||||||
foreach (var kv in toBeChecked)
|
|
||||||
{
|
{
|
||||||
Debug.Instance.DebugMessage += $"正在检测录像更改:{kv.Key}\r\n";
|
var text = $"正在加载录像列表,请稍候… 已加载 {list.Count} 个录像";
|
||||||
var replay = kv.Value;
|
Dispatcher.Invoke(() => _properties.ReplayDetails = text);
|
||||||
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
clock.Restart();
|
||||||
{
|
|
||||||
if (fileSize == replay.Size)
|
|
||||||
{
|
|
||||||
// skip if size is not changed
|
|
||||||
Debug.Instance.DebugMessage += $"已跳过未更改的录像:{kv.Key}\r\n";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Debug.Instance.DebugMessage += $"将会自动保存已更改的录像:{kv.Key}\r\n";
|
|
||||||
lastReplays[kv.Key] = replay.Size;
|
|
||||||
|
|
||||||
var date = replay.Date;
|
|
||||||
|
|
||||||
var playerString = $"{replay.NumberOfPlayingPlayers}名玩家";
|
|
||||||
if (replay.NumberOfPlayingPlayers <= 2)
|
|
||||||
{
|
|
||||||
var playingPlayers = from player in replay.Players
|
|
||||||
let faction = ModData.GetFaction(replay.Mod, player.FactionID)
|
|
||||||
where faction.Kind != FactionKind.Observer
|
|
||||||
select $"{player.PlayerName}({faction.Name})";
|
|
||||||
playerString = playingPlayers.Aggregate(string.Empty, (x, y) => x + y);
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateString = $"{date.Year}{date.Month:D2}{date.Day:D2}_{date.Hour:D2}{date.Minute:D2}{date.Second:D2}";
|
|
||||||
var destinationPath = Path.Combine(_properties.RA3ReplayFolderPath, $"{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Copy(replay.Path, destinationPath, true);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
throw new Exception($"复制文件({replay.Path} -> {destinationPath})失败:{e.Message}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
return new ReplayPinyinList(list.ToImmutableArray());
|
||||||
|
}, cancelToken);
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
_replayList = result;
|
||||||
|
await FilterReplays(string.Empty, nextSelected, filterToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FilterReplays(string message, string? nextSelected, CancellationToken cancelToken)
|
||||||
|
{
|
||||||
|
if (!IsLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
_cancelDisplayReplays.Reset(cancelToken);
|
||||||
|
_filteredReplays = _replayList.Replays;
|
||||||
|
|
||||||
|
_properties.CurrentReplay = null;
|
||||||
|
_dataGrid.SelectedItem = null;
|
||||||
|
_image.Source = null;
|
||||||
|
|
||||||
|
if (_filterStrings.Any())
|
||||||
|
{
|
||||||
|
_properties.ReplayDetails = "正在筛选符合条件的录像…";
|
||||||
|
if (_dataGrid.Items.Count > 0)
|
||||||
{
|
{
|
||||||
var errorString = $"自动保存录像时出现错误:\r\n{e}\r\n";
|
_dataGrid.ItemsSource = Array.Empty<Replay>();
|
||||||
Debug.Instance.DebugMessage += errorString;
|
_dataGrid.Items.Refresh();
|
||||||
_ = Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Interlocked.Increment(ref errorMessageCount) == 1)
|
|
||||||
{
|
|
||||||
MessageBox.Show(errorString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Interlocked.Decrement(ref errorMessageCount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(10 * 1000);
|
var (pinyins, list) = (_filterStrings, _replayList.Pinyins);
|
||||||
|
var result = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var query = from replay in list.AsParallel().WithCancellation(cancelToken)
|
||||||
|
where pinyins.Any(pinyin => replay.MatchPinyin(pinyin))
|
||||||
|
select replay.Replay;
|
||||||
|
return query.ToImmutableArray();
|
||||||
|
}, cancelToken);
|
||||||
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
_filteredReplays = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_properties.CurrentReplay = null;
|
||||||
|
_dataGrid.SelectedItem = null;
|
||||||
|
_dataGrid.ItemsSource = _filteredReplays;
|
||||||
|
_dataGrid.Items.Refresh();
|
||||||
|
_properties.ReplayDetails = message;
|
||||||
|
|
||||||
|
if (nextSelected is not null)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _dataGrid.Items.Count; ++i)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (_dataGrid.Items[i] is Replay replay && replay.PathEquals(nextSelected))
|
||||||
|
{
|
||||||
|
_dataGrid.SelectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void LoadReplays(string nextSelected = null)
|
private async Task DisplayReplayDetail(Replay replay, string replayDetails, CancellationToken cancelToken)
|
||||||
{
|
{
|
||||||
const string loadingString = "正在加载录像列表,请稍候";
|
if (!IsLoaded)
|
||||||
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
{
|
||||||
if (_image != null)
|
return;
|
||||||
{
|
}
|
||||||
_image.Source = null;
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
}
|
_properties.CurrentReplay = null;
|
||||||
|
_image.Source = null;
|
||||||
|
_properties.ReplayDetails = replayDetails;
|
||||||
|
|
||||||
if (_dataGrid != null)
|
// 开始获取小地图
|
||||||
{
|
var mapPath = replay.MapPath;
|
||||||
_dataGrid.Items.Clear();
|
var minimapTask = _minimapReader.TryReadTargaAsync(replay);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_loadReplaysToken?.Cancel();
|
// 解析录像内容
|
||||||
_loadReplaysToken = new CancellationTokenSource();
|
try
|
||||||
|
|
||||||
var cancelToken = _loadReplaysToken.Token;
|
|
||||||
var path = _properties.ReplayFolderPath;
|
|
||||||
var task = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
var messages = "";
|
replay = await Task.Run(() => new Replay(replay.Path, true));
|
||||||
var replayList = new List<Replay>();
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay body: \r\n{e}\r\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Directory.Exists(path))
|
// 假如小地图变了(这……),那么重新加载小地图
|
||||||
|
if (replay.MapPath != mapPath)
|
||||||
|
{
|
||||||
|
minimapTask.Forget();
|
||||||
|
minimapTask = _minimapReader.TryReadTargaAsync(replay);
|
||||||
|
}
|
||||||
|
var newDetails = replay.GetDetails();
|
||||||
|
if (_properties.ReplayDetails != newDetails)
|
||||||
|
{
|
||||||
|
if (_replayList.Replays.FindIndex(r => r.PathEquals(replay)) is int index)
|
||||||
{
|
{
|
||||||
messages = "这个文件夹并不存在。";
|
_replayList = _replayList.SetItem(index, replay.CloneHeader());
|
||||||
|
var token = _cancelFilterReplays.ResetAndGetToken(_cancelLoadReplays.Token);
|
||||||
|
await FilterReplays(newDetails, replay.Path, token);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
_properties.CurrentReplay = replay;
|
||||||
var replays = Directory.EnumerateFiles(path, "*.RA3Replay");
|
_properties.ReplayDetails = newDetails;
|
||||||
foreach (var replayPath in replays)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var replay = await Task.Run(() => new Replay(replayPath));
|
|
||||||
replayList.Add(replay);
|
|
||||||
_properties.ReplayDetails = loadingString + $"\n已加载 {replayList.Count} 个录像";
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay list: \r\n{exception}\r\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelToken.ThrowIfCancellationRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new { Replays = replayList, Messages = messages };
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await task;
|
var newSource = await minimapTask;
|
||||||
_replayList = result.Replays;
|
|
||||||
cancelToken.ThrowIfCancellationRequested();
|
cancelToken.ThrowIfCancellationRequested();
|
||||||
DisplayReplays(result.Messages, nextSelected);
|
_image.Source = newSource;
|
||||||
|
/* _image.Width = source.Width;
|
||||||
|
_image.Height = source.Height; */
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (Exception e) when (e is not OperationCanceledException)
|
||||||
}
|
|
||||||
|
|
||||||
private void DisplayReplays(string message = null, string nextSelected = null)
|
|
||||||
{
|
|
||||||
var filtered = _replayList;
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
{
|
||||||
_properties.CurrentReplay = null;
|
Debug.Instance.DebugMessage += $"Uncaught exception when loading minimap: \r\n{e}\r\n";
|
||||||
_dataGrid.Items.Clear();
|
}
|
||||||
filtered.ForEach(x => _dataGrid.Items.Add(x));
|
|
||||||
_properties.ReplayDetails = message;
|
|
||||||
|
|
||||||
if (nextSelected != null)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < _dataGrid.Items.Count; ++i)
|
|
||||||
{
|
|
||||||
var replay = _dataGrid.Items[i] as Replay;
|
|
||||||
if (replay == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replay.Path.Equals(nextSelected, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_dataGrid.SelectedIndex = i;
|
|
||||||
OnReplaySelectionChanged(null, null);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnReplayFolderPathBoxTextChanged(object sender, EventArgs e)
|
private async void OnReplayFolderPathBoxTextChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
LoadReplays();
|
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||||
|
await _taskQueue.Enqueue(() => LoadReplays(null, token), token);
|
||||||
|
var text = _replayFolderPathBox.Text;
|
||||||
|
const string assemblyMagic = "!DreamSign";
|
||||||
|
const string jsonMagic = "!FantasySeal";
|
||||||
|
switch (_replayFolderPathBox.Text)
|
||||||
|
{
|
||||||
|
case "!SpellCard":
|
||||||
|
_replayDetailsBox.Text = $"{assemblyMagic}\r\n";
|
||||||
|
_replayDetailsBox.Text += $"{jsonMagic}\r\n";
|
||||||
|
break;
|
||||||
|
case assemblyMagic:
|
||||||
|
break;
|
||||||
|
case jsonMagic:
|
||||||
|
UpdateChecker.Sign();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnReplaySelectionChanged(object sender, EventArgs e)
|
private async void OnReplaySelectionChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_properties.CurrentReplay = _dataGrid.SelectedItem as Replay;
|
if (_dataGrid.SelectedItem is not Replay replay)
|
||||||
if (_properties.CurrentReplay == null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Dispatcher.Invoke(() => { _image.Source = null; });
|
var token = _cancelDisplayReplays.ResetAndGetToken(_cancelFilterReplays.Token);
|
||||||
|
_properties.ReplayDetails = replay.GetDetails();
|
||||||
string GetSizeString(double size)
|
await _taskQueue.Enqueue(() => DisplayReplayDetail(replay, _properties.ReplayDetails, token), token);
|
||||||
{
|
|
||||||
if (size > 1024 * 1024)
|
|
||||||
{
|
|
||||||
return $"{Math.Round(size / (1024 * 1024), 2)}MB";
|
|
||||||
}
|
|
||||||
return $"{Math.Round(size / 1024)}KB";
|
|
||||||
}
|
|
||||||
|
|
||||||
const string formatA = "文件名:{0}\n大小:{1}\n";
|
|
||||||
const string formatB = "地图:{0}\n日期:{1}\n长度:{2}\n";
|
|
||||||
const string formatC = "录像类别:{0}\n这个文件是{1}保存的\n";
|
|
||||||
const string playerListTitle = "玩家列表:\n";
|
|
||||||
var replay = _properties.CurrentReplay;
|
|
||||||
var sizeString = GetSizeString(replay.Size);
|
|
||||||
var lengthString = "录像已损坏,请先修复录像";
|
|
||||||
if (replay.HasFooter)
|
|
||||||
{
|
|
||||||
lengthString = $"{replay.Length}";
|
|
||||||
}
|
|
||||||
|
|
||||||
var replaySaver = "[无法获取保存录像的玩家]";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
replaySaver = replay.ReplaySaver.PlayerName;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
_properties.ReplayDetails = string.Format(formatA, replay.FileName, sizeString);
|
|
||||||
_properties.ReplayDetails += string.Format(formatB, replay.MapName, replay.Date, lengthString);
|
|
||||||
_properties.ReplayDetails += string.Format(formatC, replay.TypeString, replaySaver);
|
|
||||||
_properties.ReplayDetails += playerListTitle;
|
|
||||||
foreach (var player in replay.Players)
|
|
||||||
{
|
|
||||||
if (player == replay.Players.Last() && player.PlayerName.Equals("post Commentator"))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
var factionName = ModData.GetFaction(replay.Mod, player.FactionID).Name;
|
|
||||||
var realName = replay.Type == ReplayType.Lan ? _playerIdentity.QueryRealName(player.PlayerIP) : string.Empty;
|
|
||||||
_properties.ReplayDetails += $"{player.PlayerName + realName},{factionName}\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var mapPath = replay.MapPath.TrimEnd('/');
|
|
||||||
var mapName = mapPath.Substring(mapPath.LastIndexOf('/') + 1);
|
|
||||||
var minimapPath = $"{mapPath}/{mapName}_art.tga";
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
var source = _minimapReader.TryReadTarga(minimapPath, replay.Mod);
|
|
||||||
_image.Source = source;
|
|
||||||
/*_image.Width = source.Width;
|
|
||||||
_image.Height = source.Height;*/
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading minimap: \r\n{exception}\r\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
replay.ParseBody();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay body: \r\n{exception}\r\n";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAboutButton_Click(object sender, RoutedEventArgs e)
|
private void OnAboutButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var aboutWindow = new About();
|
var aboutWindow = new About(_cache)
|
||||||
|
{
|
||||||
|
Owner = this
|
||||||
|
};
|
||||||
aboutWindow.ShowDialog();
|
aboutWindow.ShowDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnBrowseButton_Click(object sender, RoutedEventArgs e)
|
private void OnBrowseButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -470,13 +390,12 @@ namespace AnotherReplayReader
|
|||||||
InitialDirectory = _properties.ReplayFolderPath,
|
InitialDirectory = _properties.ReplayFolderPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = openFileDialog.ShowDialog();
|
var result = openFileDialog.ShowDialog(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
{
|
{
|
||||||
var fileName = openFileDialog.FileName;
|
var fileName = openFileDialog.FileName;
|
||||||
var directoryName = Path.GetDirectoryName(fileName);
|
var directoryName = Path.GetDirectoryName(fileName);
|
||||||
_properties.ReplayFolderPath = directoryName;
|
_properties.ReplayFolderPath = directoryName;
|
||||||
LoadReplays(fileName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -485,29 +404,28 @@ namespace AnotherReplayReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetailsButton_Click(object sender, RoutedEventArgs e)
|
private void OnDetailsButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var detailsWindow = new APM(_properties.CurrentReplay, _playerIdentity);
|
var detailsWindow = new ApmWindow(_properties.CurrentReplay!)
|
||||||
|
{
|
||||||
|
Owner = this
|
||||||
|
};
|
||||||
detailsWindow.ShowDialog();
|
detailsWindow.ShowDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDebugButton_Click(object sender, RoutedEventArgs e)
|
private void OnDebugButtonClick(object sender, RoutedEventArgs e) => Debug.ShowDialog();
|
||||||
{
|
|
||||||
var debug = new Debug();
|
|
||||||
debug.ShowDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPlayReplayButton_Click(object sender, RoutedEventArgs e)
|
private void OnPlayReplayButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
Process.Start(_properties.RA3Exe, $" -replayGame \"{_properties.CurrentReplay}\" ");
|
Process.Start(_properties.RA3Exe, $" -replayGame \"{_properties.CurrentReplay}\" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFixReplayButton_Click(object sender, RoutedEventArgs e)
|
private async void OnFixReplayButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
var replay = _properties.CurrentReplay ?? throw new InvalidOperationException("Trying to fix a null replay");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var replay = _properties.CurrentReplay;
|
|
||||||
|
|
||||||
var saveFileDialog = new SaveFileDialog
|
var saveFileDialog = new SaveFileDialog
|
||||||
{
|
{
|
||||||
Filter = "红警3录像文件 (*.RA3Replay)|*.RA3Replay|所有文件 (*.*)|*.*",
|
Filter = "红警3录像文件 (*.RA3Replay)|*.RA3Replay|所有文件 (*.*)|*.*",
|
||||||
@@ -519,19 +437,31 @@ namespace AnotherReplayReader
|
|||||||
var result = saveFileDialog.ShowDialog(this);
|
var result = saveFileDialog.ShowDialog(this);
|
||||||
if (result == true)
|
if (result == true)
|
||||||
{
|
{
|
||||||
using (var file = saveFileDialog.OpenFile())
|
using var file = saveFileDialog.OpenFile();
|
||||||
using (var writer = new BinaryWriter(file))
|
using var writer = new BinaryWriter(file);
|
||||||
{
|
writer.Write(replay);
|
||||||
writer.WriteReplay(replay);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
MessageBox.Show($"无法修复录像:\r\n{exception}");
|
MessageBox.Show(this, $"无法修复录像:\r\n{exception}");
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadReplays();
|
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||||
|
await _taskQueue.Enqueue(() => LoadReplays(replay.Path, token), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnReplayFilterBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_filterStrings = _replayFilterBox.Text
|
||||||
|
.Split(',', ' ', ',')
|
||||||
|
.Select(x => x.ToPinyin())
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x))
|
||||||
|
.ToImmutableArray()!;
|
||||||
|
var currentReplayPath = _properties?.CurrentReplay?.Path;
|
||||||
|
|
||||||
|
var token = _cancelFilterReplays.ResetAndGetToken(_cancelLoadReplays.Token);
|
||||||
|
await _taskQueue.Enqueue(() => FilterReplays(string.Empty, currentReplayPath, token), token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-71
@@ -1,13 +1,14 @@
|
|||||||
using System;
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using Pfim;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Runtime.InteropServices.ComTypes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using OpenSage.FileFormats.Big;
|
using TechnologyAssembler.Core.IO;
|
||||||
using Pfim;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
@@ -24,105 +25,140 @@ namespace AnotherReplayReader
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly BigMinimapCache _cache;
|
private readonly BigMinimapCache _cache;
|
||||||
private readonly string _ra3InstallPath;
|
|
||||||
private readonly string _mapFolderPath;
|
private readonly string _mapFolderPath;
|
||||||
private readonly string _modFolderPath;
|
private readonly string _modFolderPath;
|
||||||
|
|
||||||
public MinimapReader(BigMinimapCache cache, string ra3InstallPath, string mapFolderPath, string modFolderPath)
|
public MinimapReader(BigMinimapCache cache, string mapFolderPath, string modFolderPath)
|
||||||
{
|
{
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_ra3InstallPath = ra3InstallPath;
|
|
||||||
_mapFolderPath = mapFolderPath;
|
_mapFolderPath = mapFolderPath;
|
||||||
_modFolderPath = modFolderPath;
|
_modFolderPath = modFolderPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BitmapSource TryReadTarga(string path, Mod mod, double dpiX = 96.0, double dpiY = 96.0)
|
public Task<BitmapSource?> TryReadTargaAsync(Replay replay, double dpiX = 96.0, double dpiY = 96.0)
|
||||||
{
|
{
|
||||||
using (var targa = TryGetTarga(path, mod))
|
var mapPath = replay.MapPath.TrimEnd('/');
|
||||||
|
var mapName = mapPath.Substring(mapPath.LastIndexOf('/') + 1);
|
||||||
|
var minimapPath = $"{mapPath}/{mapName}_art.tga";
|
||||||
|
return Task.Run(() => TryReadTarga(minimapPath, replay.Mod, dpiX, dpiY));
|
||||||
|
}
|
||||||
|
|
||||||
|
public BitmapSource? TryReadTarga(string path, Mod mod, double dpiX = 96.0, double dpiY = 96.0)
|
||||||
|
{
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
{
|
{
|
||||||
if(targa == null)
|
using var stream = TryGetStream(path, mod);
|
||||||
|
if (stream is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
stream.CopyTo(memoryStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isJpgPng = IsJpgPng(memoryStream);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
if (isJpgPng)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return BitmapSource.Create(targa.Width, targa.Height, dpiX, dpiY, FormatMapper[targa.Format], null, targa.Data, targa.Stride);
|
var decoder = BitmapDecoder.Create(memoryStream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||||
}
|
return decoder.Frames[0];
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception creating BitmapSource from minimap:\r\n {exception}\r\n";
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
using var targa = Targa.Create(memoryStream, new PfimConfig());
|
||||||
|
if (targa == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bitmap = BitmapSource.Create(targa.Width, targa.Height, dpiX, dpiY, FormatMapper[targa.Format], null, targa.Data, targa.Stride);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Exception creating BitmapSource from minimap:\r\n {exception}\r\n";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Targa TryGetTarga(string path, Mod mod)
|
private Stream? TryGetStream(string path, Mod mod)
|
||||||
{
|
{
|
||||||
var tga = null as Targa;
|
|
||||||
const string customMapPrefix = "data/maps/internal/";
|
const string customMapPrefix = "data/maps/internal/";
|
||||||
if (Directory.Exists(_mapFolderPath) && path.StartsWith(customMapPrefix))
|
if (Directory.Exists(_mapFolderPath) && path.StartsWith(customMapPrefix))
|
||||||
{
|
{
|
||||||
var minimapPath = Path.Combine(_mapFolderPath, path.Substring(customMapPrefix.Length));
|
var minimapPath = Path.Combine(_mapFolderPath, path.Substring(customMapPrefix.Length));
|
||||||
if(File.Exists(minimapPath))
|
|
||||||
{
|
|
||||||
return Targa.Create(File.ReadAllBytes(minimapPath), new PfimConfig());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// now, normalize paths
|
|
||||||
path = path.Replace('/', '\\');
|
|
||||||
|
|
||||||
if(!mod.IsRA3)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var modSkudefPaths = Enumerable.Empty<string>();
|
|
||||||
foreach (var subFolder in Directory.EnumerateDirectories(_modFolderPath))
|
|
||||||
{
|
|
||||||
modSkudefPaths = modSkudefPaths.Concat(Directory.EnumerateFiles(subFolder, $"{mod.ModName}_{mod.ModVersion}.SkuDef"));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var modSkudefPath in modSkudefPaths)
|
|
||||||
{
|
|
||||||
var modBigPaths = BigMinimapCache.ParseSkudefs(new[] { modSkudefPath });
|
|
||||||
foreach (var modBigPath in modBigPaths)
|
|
||||||
{
|
|
||||||
using (var modBig = new BigArchive(modBigPath))
|
|
||||||
{
|
|
||||||
var entry = modBig.GetEntry(path);
|
|
||||||
if (entry != null)
|
|
||||||
{
|
|
||||||
return Targa.Create(entry.Open(), new PfimConfig());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Debug.Instance.DebugMessage += $"Exception when reading minimap from Skudef big:\r\n {exception}\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_cache != null && _cache.TryGetBigByEntryPath(path, out var big))
|
|
||||||
{
|
|
||||||
using (big)
|
|
||||||
{
|
|
||||||
return Targa.Create(big.GetEntry(path).Open(), new PfimConfig());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Directory.Exists(_ra3InstallPath))
|
|
||||||
{
|
|
||||||
var minimapPath = Path.Combine(_mapFolderPath, path);
|
|
||||||
if (File.Exists(minimapPath))
|
if (File.Exists(minimapPath))
|
||||||
{
|
{
|
||||||
return Targa.Create(File.ReadAllBytes(minimapPath), new PfimConfig());
|
return File.OpenRead(minimapPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mod.IsRA3)
|
||||||
|
{
|
||||||
|
var modSkudefPaths = Enumerable.Empty<string>();
|
||||||
|
foreach (var subFolder in Directory.EnumerateDirectories(_modFolderPath))
|
||||||
|
{
|
||||||
|
modSkudefPaths = modSkudefPaths.Concat(Directory.EnumerateFiles(subFolder, $"{mod.ModName}_{mod.ModVersion}.SkuDef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
DronePlatform.BuildTechnologyAssembler();
|
||||||
|
foreach (var modSkudefPath in modSkudefPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = new SkuDefFileSystemProvider("modConfig", modSkudefPath);
|
||||||
|
if (!fs.FileExists(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return fs.OpenStream(path, VirtualFileModeType.Open);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Exception when reading minimap from mod bigs:\r\n {exception}\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cache != null && _cache.TryGetEntry(path, out var big))
|
||||||
|
{
|
||||||
|
return big;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsJpgPng(Stream stream)
|
||||||
|
{
|
||||||
|
var header = new byte[4];
|
||||||
|
var pos = stream.Position;
|
||||||
|
|
||||||
|
stream.Read(header, 0, header.Length);
|
||||||
|
stream.Position = pos;
|
||||||
|
|
||||||
|
// PNG
|
||||||
|
if (header[0] == 0x89 &&
|
||||||
|
header[1] == 0x50 &&
|
||||||
|
header[2] == 0x4E &&
|
||||||
|
header[3] == 0x47)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JPEG
|
||||||
|
if (header[0] == 0xFF &&
|
||||||
|
header[1] == 0xD8)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TGA(很粗略判断:通常无统一magic,只能 fallback)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-73
@@ -1,8 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
@@ -42,13 +39,13 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
public int CompareTo(object other)
|
public int CompareTo(object other)
|
||||||
{
|
{
|
||||||
if(!(other is Mod))
|
if (!(other is Mod))
|
||||||
{
|
{
|
||||||
return GetType().FullName.CompareTo(other.GetType().FullName);
|
return GetType().FullName.CompareTo(other.GetType().FullName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var otherMod = (Mod)other;
|
var otherMod = (Mod)other;
|
||||||
if(IsRA3 != otherMod.IsRA3)
|
if (IsRA3 != otherMod.IsRA3)
|
||||||
{
|
{
|
||||||
if (IsRA3)
|
if (IsRA3)
|
||||||
{
|
{
|
||||||
@@ -84,20 +81,13 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
internal static class ModData
|
internal static class ModData
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3Factions;
|
private static readonly Faction _unknown = new(FactionKind.Unknown, "未知阵营");
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3ARFactions;
|
private static readonly IReadOnlyDictionary<string, IReadOnlyDictionary<int, Faction>> _factions;
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3CoronaFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3DawnFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3INSFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3FSFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3EisenreichFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3TNWFactions;
|
|
||||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3WOPFactions;
|
|
||||||
private static readonly Faction _unknown;
|
|
||||||
|
|
||||||
static ModData()
|
static ModData()
|
||||||
{
|
{
|
||||||
_ra3Factions = new Dictionary<int, Faction>
|
var ra3Factions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -107,7 +97,7 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3ARFactions = new Dictionary<int, Faction>
|
var arFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 1, new Faction(FactionKind.Player, "阳炎") },
|
{ 1, new Faction(FactionKind.Player, "阳炎") },
|
||||||
{ 2, new Faction(FactionKind.Player, "天琼") },
|
{ 2, new Faction(FactionKind.Player, "天琼") },
|
||||||
@@ -123,7 +113,7 @@ namespace AnotherReplayReader
|
|||||||
{ 14, new Faction(FactionKind.Player, "涅墨西斯") },
|
{ 14, new Faction(FactionKind.Player, "涅墨西斯") },
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
};
|
};
|
||||||
_ra3CoronaFactions = new Dictionary<int, Faction>
|
var coronaFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -134,7 +124,7 @@ namespace AnotherReplayReader
|
|||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
{ 9, new Faction(FactionKind.Player, "神州") },
|
{ 9, new Faction(FactionKind.Player, "神州") },
|
||||||
};
|
};
|
||||||
_ra3DawnFactions = new Dictionary<int, Faction>
|
var dawnFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -147,7 +137,7 @@ namespace AnotherReplayReader
|
|||||||
{ 11, new Faction(FactionKind.Player, "随机") },
|
{ 11, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 12, new Faction(FactionKind.Player, "苏联") },
|
{ 12, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3INSFactions = new Dictionary<int, Faction>
|
var insFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -157,7 +147,7 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3FSFactions = new Dictionary<int, Faction>
|
var fsFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -167,7 +157,7 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3EisenreichFactions = new Dictionary<int, Faction>
|
var eisenreichFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -177,7 +167,7 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3TNWFactions = new Dictionary<int, Faction>
|
var tnwFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -187,7 +177,7 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_ra3WOPFactions = new Dictionary<int, Faction>
|
var wopFactions = new Dictionary<int, Faction>
|
||||||
{
|
{
|
||||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||||
@@ -197,61 +187,31 @@ namespace AnotherReplayReader
|
|||||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||||
};
|
};
|
||||||
_unknown = new Faction(FactionKind.Unknown, "未知阵营");
|
_factions = new Dictionary<string, IReadOnlyDictionary<int, Faction>>(StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
{
|
||||||
|
["RA3"] = ra3Factions,
|
||||||
|
["Armor Rush"] = arFactions,
|
||||||
|
["ART"] = arFactions,
|
||||||
|
["corona"] = coronaFactions,
|
||||||
|
["Dawn"] = dawnFactions,
|
||||||
|
["Insurrection"] = insFactions,
|
||||||
|
["1.12+FS"] = fsFactions,
|
||||||
|
["Eisenreich"] = eisenreichFactions,
|
||||||
|
["The New World"] = tnwFactions,
|
||||||
|
["War Of Powers"] = wopFactions
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Faction GetFaction(Mod mod, int factionID)
|
public static Faction GetFaction(Mod mod, int factionId)
|
||||||
{
|
{
|
||||||
new Faction(FactionKind.Player, mod.ModName + "-" + factionID);
|
if (_factions.TryGetValue(mod.ModName, out var table))
|
||||||
|
|
||||||
if
|
|
||||||
(mod.ModName.Equals("RA3"))
|
|
||||||
{
|
{
|
||||||
return _ra3Factions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
if (table.TryGetValue(factionId, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mod.ModName.Equals("Armor Rush"))
|
|
||||||
{
|
|
||||||
return _ra3ARFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("ART"))
|
|
||||||
{
|
|
||||||
return _ra3ARFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("corona"))
|
|
||||||
{
|
|
||||||
return _ra3CoronaFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("Dawn"))
|
|
||||||
{
|
|
||||||
return _ra3DawnFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("Insurrection"))
|
|
||||||
{
|
|
||||||
return _ra3INSFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("1.12+FS"))
|
|
||||||
{
|
|
||||||
return _ra3FSFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("Eisenreich"))
|
|
||||||
{
|
|
||||||
return _ra3EisenreichFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("The New World"))
|
|
||||||
{
|
|
||||||
return _ra3TNWFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
if (mod.ModName.Equals("War Of Powers"))
|
|
||||||
{
|
|
||||||
return _ra3WOPFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _unknown;
|
return _unknown;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# AI 分析 v2 计划:上下文策略与管线重构
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 日期:2026-08-23
|
||||||
|
- 版本:v2.2。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
||||||
|
- 关联文档:[AI_REASONING_CONTINUATION_RESEARCH.md](AI_REASONING_CONTINUATION_RESEARCH.md)
|
||||||
|
|
||||||
|
## 实施状态修订(推理保护已移除)
|
||||||
|
|
||||||
|
- 实测表明:截断/修改 `reasoning_content` 并伪造 tool call 续写会影响输出质量,已取消该方案。
|
||||||
|
- 当前代码不再包含 `AiReasoningGuard`、`ReasoningGuardEnabled`/`ReasoningGuardTokenLimit` 设置、推理保护 UI、`ReasoningGuard` 测试或相关 E2E。
|
||||||
|
- 仍保留对模型返回的 `reasoning_content` 的流式展示与 usage 统计;但不会截断、改写、回传或让它触发额外请求。
|
||||||
|
- 解决长思考的策略改为:提高输出 token 上限 + 让模型专注更短的时间范围(段内焦点窗口),同时仍提供尽量长的切片上下文并鼓励跨时间关联。
|
||||||
|
|
||||||
|
## 实施状态修订(2026-08-24 段内焦点窗口)
|
||||||
|
|
||||||
|
- 新增「段内焦点窗口」设计:**数据层尽量宽**(整段机械切片按上下文上限提供),**注意力层聚焦窄时间窗**(每个切片再切分为若干分析窗口,每轮一个窗口作为重点)。
|
||||||
|
- 实现:`FocusPlanner`(按 token 把切片切成 1~5 个窗口,上限 5、目标 12K/窗、过小切片不细分)+ `AIAnalyze.BuildFocusWindowUserPromptV2`(强调“数据=整段、重点=窗口、主动关联窗口外/跨时间事件”)。
|
||||||
|
- 管线影响:原「每段一次分析」改为「每段逐窗口分析」;每个窗口独立会话(system+摘要+总览+整段切片+已发现事实+窗口指令),保留逐窗口的验证/修订/回查;窗口小结与机器可读声明进入已发现事实(段内窗口间共享 + 跨段累积)。
|
||||||
|
- 提示词与知识文件(`AIAnalyze` 回退路径、`knowledge_default.md`、`knowledge_corona.md`)已同步说明窗口机制。
|
||||||
|
- 本项未涉及上下文预算公式变化:窗口不改变可见数据,只改变每轮“重点”的粒度。
|
||||||
|
|
||||||
|
### 提示词表述修订(2026-08-25)
|
||||||
|
|
||||||
|
- 主指令进一步简化为**直接指出重点时间段**:如 `请重点分析 0:30.00 至 2:00.00 时间段的操作数据`。
|
||||||
|
- 窗口编号(`第 k/n 窗口`)从主指令中移除,仅保留一句次要说明("本段已按时间划分为 N 个重点时间段,当前是第 k 个"):编号是程序内部概念,模型无法从原始数据核实,而时间范围可直接映射到数据;保留编号信息有助于用户/日志关联,但不再作为指令重心。
|
||||||
|
- 知识文件与单元测试已同步(PromptBuilders 断言时间段优先、编号降级)。
|
||||||
|
|
||||||
|
## 实施状态(2026-08-20)
|
||||||
|
|
||||||
|
里程碑全部完成,代码已落地并通过 149 项单元测试(`AiV2.Tests`,见 §12 M7;启用真实回放诊断时为 151 项)。
|
||||||
|
|
||||||
|
| 里程碑 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 预算与护栏 | ✅ | `AiModel.ContextBudget`(0/空 = 档位默认 160K/100K)、`AiContextBudget` 安全系数与硬护栏、设置 UI 编辑项 |
|
||||||
|
| M2 管线重构 | ✅ | 机械分段 + 对局摘要 + 总览轮 + 分段独立会话 + 已发现事实 + 总结轮;移除 `[分段列表]` 解析与旧流式状态机;提示词与知识文件已重新生成 |
|
||||||
|
| M3 回查机制 | ✅ | `[回查]` 标记、容错时间解析、切片提取、每段 3 次上限、失败降级 Info |
|
||||||
|
| M4 验证修正 | ✅ | 所有权强/弱分层与 4 条规则、施法者归属(仅 `0x1FE/0x200`)、协议记录与校验、多 JSON 块合并、move 降级、首次出兵时间线、player 映射接线 |
|
||||||
|
| M5 知识修正 | ✅ | `knowledge_units_default.json` 按 mod 加载、旧提示词副作用修复、标签体系补全与加载校验、渲染按参战阵营过滤、用户知识 JSON 覆盖 |
|
||||||
|
| M6 修订 pass | ✅ | 段内 1 次修订、修订草稿实时流式显示、完成后草稿折叠、最终正文默认展开、UI 日志提示 |
|
||||||
|
| M7 测试与评估 | ✅/部分 | 单元测试完成;A/B 对比与估算校准需真实 API 运行(见 §13) |
|
||||||
|
|
||||||
|
**实施中的取舍与遗留**
|
||||||
|
|
||||||
|
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
|
||||||
|
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
|
||||||
|
- 修订 pass 的展示采用"修订草稿实时流式 + 完成后草稿折叠、最终正文默认展开";原隐藏修订决策中的"完全缓冲"已改为"默认折叠中间草稿"。
|
||||||
|
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
|
||||||
|
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估。
|
||||||
|
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
|
||||||
|
|
||||||
|
## 实施状态修订(2026-08-22)
|
||||||
|
|
||||||
|
- “重新分析”策略:除 `Info` 外,`Warning/WeakEvidence/Contradiction/Fatal` 都触发一次隐藏修订;修订后遗留问题仅记录,不循环请求。
|
||||||
|
- 预算检查改为“估算 prompt + 输出/推理余量”,并在回查/修订前重新检查;`stream` 标志尊重模型配置。
|
||||||
|
- 事实索引:编队所有权按玩家隔离;接入 `0x1F6/0x22A`;unpack 歧义规则只作用于 MCV/基地车类实体。
|
||||||
|
- 知识:`aliases/alsoProducedBy` 已解析;摘要补充协议选择与所有权证据;总览的段落描述和回查提示会传入分段指令。
|
||||||
|
- 测试:当前 `AiV2.Tests` 默认 149 项断言全部通过;启用 `ARR_E2E_REPLAY=1` 时为 151 项。
|
||||||
|
|
||||||
|
### 2026-08-22 第二轮修订
|
||||||
|
|
||||||
|
- `eventClaims/timelineClaims` 现在也会校验:不存在 UnitId、技能与事实索引冲突、协议未在任何玩家选择中观察到都会产生验证问题。
|
||||||
|
- 总结轮支持一次回查:模型可请求远处原始区间,程序在同一总结会话中追加提供。
|
||||||
|
- 机械分段超过上限时先过滤纯选择/编队事件块,压缩无效才放宽预算。
|
||||||
|
- Corona flat 文本现在也按参战阵营过滤。
|
||||||
|
- `AiV2.Tests` 的真实回放诊断改为默认跳过(设置 `ARR_E2E_REPLAY=1` 启用);UI 状态栏显示推理 token。
|
||||||
|
- 思维链回传实验:主项目不再包含 `reasoning_content` 回传、截断、UI 实验开关或 tool call 历史构造代码,未进入生产管线。
|
||||||
|
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
|
||||||
|
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
||||||
|
|
||||||
|
### 2026-08-23 推理保护实现(历史记录,后续已移除)
|
||||||
|
|
||||||
|
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
||||||
|
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
||||||
|
- `ChatMessage` 支持 `reasoning_content`/`tool_calls`/`tool_call_id`,`Result` 保留完整推理、中断/续写状态与预算/错误信息。
|
||||||
|
- UI 触发推理保护后会显示首次请求与续写请求的完整消息日志;日志默认折叠,用户可展开检查 `messages`/`tools`/`tool_choice`。
|
||||||
|
- UI 改为按阶段分组:总览/各段/总结各自独立容器;修订时保留旧版本并折叠,最新版本默认展开;机器可读声明 JSON 与验证结果作为独立折叠块,不再混在正文中。
|
||||||
|
- 推理保护诊断改为在触发时通过流式事件输出,位于续写思考块之前;总览重复日志已移除。
|
||||||
|
- 成功提取机器可读声明后,UI 正文会移除 `[机器可读声明]` JSON,只在折叠块中显示一次;推理保护诊断改为“消息数变化 + 新增 assistant/tool 消息摘要”,完整续写 JSON 仍作为折叠附件。
|
||||||
|
- 总览、分段修订、分段回查和总结回查都会显示实际发送的 user prompt(默认折叠)。
|
||||||
|
- `AiV2.Tests` 新增 `ReasoningGuard` 测试套件;默认测试总计 173 项通过。
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经旧文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地。
|
||||||
|
|
||||||
|
本计划的目标:
|
||||||
|
|
||||||
|
1. 用"部分操作记录 + 结构化上下文"替代"每轮全量日志",在不明显牺牲远距离关联能力的前提下提升长录像的分析质量与成本效率。
|
||||||
|
2. 只维护一条分析管线:"短录像 = 只有一个 slice",不保留两个独立模式。
|
||||||
|
3. 上下文预算成为每模型可配置的软上限,并把长期未使用的 `ContextLength` 接进护栏。
|
||||||
|
4. 修正验证层与知识层在本会话中发现的所有问题(见第 2 节追踪表)。
|
||||||
|
5. 落地隐藏修订 pass。
|
||||||
|
|
||||||
|
## 2. 问题追踪表(第一轮审视 + 后续讨论确认)
|
||||||
|
|
||||||
|
下表汇总 2026-08-20 会话对旧文档与代码的审视结论。后续讨论(所有权分层、缓存、预算、统一管线)调整了部分原始结论,表中"处理章节"指向本文的落地位置。
|
||||||
|
|
||||||
|
| # | 问题 | 处理章节 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | 结构化单位知识无 mod 维度:`knowledge_units.json` 是全局单例,Corona 盟军数据被基础版替换(如 `AlliedBomberAircraft` vs `AlliedAntiStructureBomberAircraft`),违背"每 mod 自包含"原则 | §8.1 |
|
||||||
|
| P2 | `GetSystemPrompt` 无条件执行旧 `BuildDefaultSystemPrompt`,即使走知识文件也会弹"缺乏苏联/未知地图"MessageBox(副作用) | §8.2 |
|
||||||
|
| P3 | 事实索引把特殊能力的施法者与目标混淆(`0x201/0x232` 的 ObjectId 不一定是施法者),Contradiction 校验可能误报/漏报 | §7.2 |
|
||||||
|
| P4 | 协议(`0x24E 选择协议`)不在验证体系,evidence schema 无法表达无单位能力 | §7.3 |
|
||||||
|
| P5 | 全量日志每轮重发、对话历史只增不减,长录像易超上下文;`ContextLength` 配置了但从未使用 | §4、§5、§6 |
|
||||||
|
| P6 | 解析健壮性:`[分段列表]` 缺失导致整体失败、`ParseAITimeSpan` 抛异常、机器可读声明只解析最后一个 JSON 块(中间块静默丢失) | §7.4 |
|
||||||
|
| P7 | `move` 证据只带坐标不带 UnitId,永远无法验证,却允许撑高置信结论 | §7.5 |
|
||||||
|
| P8 | 所有权归属:原建议"只能选中自己单位"过强;修正为强/弱分层(编队≈确定,选择≈弱信号) | §7.1 |
|
||||||
|
| P9 | 标签体系漂移:JSON 出现 `land/sea/miner/scout/support/siege/bomber` 等未定义 tag,`KnowledgeTag` 常量未被执行 | §8.3 |
|
||||||
|
| P10 | `PlayerFirstProductionTime` 已建未消费,"首次出兵时间线"规则(如轰炸机)未做 | §7.6 |
|
||||||
|
| P11 | 修订 pass 未接线:`RequiresRevision` 存在但未用,`Fatal` 严重度从不产生,严重度语义未统一 | §10、§7.7 |
|
||||||
|
| P12 | 验证器/解析器/事实索引是纯逻辑但无测试 | §12 M7 |
|
||||||
|
| P13 | token 估算 `bytes/2.2` 对中文偏乐观,且无真实用量校准 | §4.3 |
|
||||||
|
| P14 | flat 文本作为单一 global 条目渲染,未参战阵营的知识也全部进入提示词(token 浪费) | §8.4 |
|
||||||
|
| P15 | 提示词/知识文件写死三阶段流程(`[分段列表]` 输出要求),需与 v2 管线同步修改 | §9 |
|
||||||
|
|
||||||
|
## 3. 决策摘要
|
||||||
|
|
||||||
|
| 决策点 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 上下文预算 | 每模型 `ContextBudget` 软上限,默认档位:≥1M → 160K;200K~256K → 100K;<200K → 只支持短录像(单 slice) |
|
||||||
|
| 模式 | 单一管线;"全量模式"取消,短录像 = 1 个 slice |
|
||||||
|
| 分段 | 机械式(按 token 预算 + 事件数,带重叠);不再由 LLM 决定边界 |
|
||||||
|
| 段内焦点窗口 | 数据层 = 整段切片(尽量长);注意力层 = 每轮一个窗口(每段最多 5 个,目标 12K/窗);窗口可跨时间关联段内其他事件 |
|
||||||
|
| 总览轮 | 保留;输入为摘要 + 分段元数据(不读全量日志);输出允许跨段描述、跨段线索、回查建议 |
|
||||||
|
| 回查机制 | 进 v1;允许模型按需请求远处原始区间 |
|
||||||
|
| 缓存 | 稳定内容前置;跨段前缀 = system+摘要+总览;段内复用 = 前缀+slice(修订/回查共用) |
|
||||||
|
| 128K 及以下 | 允许短录像(切片后为 1 个 slice 时自然工作),不承诺长录像质量 |
|
||||||
|
| 修订 pass | 按隐藏修订方案在窗口内落地,每窗口最多 1 次 |
|
||||||
|
|
||||||
|
## 4. 上下文预算策略
|
||||||
|
|
||||||
|
### 4.1 语义
|
||||||
|
|
||||||
|
- `ContextBudget` = 一次请求的总 token 软上限(prompt + 输出/推理余量)。
|
||||||
|
- 用户给出的 150K / 90K 已经是保守值;为显式容纳估算误差与输出余量,默认档位取 **160K(≥1M)/ 100K(200K~256K)**。
|
||||||
|
- 每个模型可单独覆盖(新增 `AiModel.ContextBudget`,0 表示用档位默认)。
|
||||||
|
|
||||||
|
### 4.2 输出余量与切片上限
|
||||||
|
|
||||||
|
- 输出余量 = `max(2 × max_tokens, 32K)`,推理模型的 thinking token 计入输出余量。
|
||||||
|
- 切片上限由预算反推:`slice_max = ContextBudget − 固定开销 − 已发现事实 − 输出余量`。
|
||||||
|
- 参考值:1M 档 slice ≈ 90K;256K 档 slice ≈ 40K(固定开销约 system 20K + 摘要 3K + 总览 3K + 指令 1K)。
|
||||||
|
|
||||||
|
### 4.3 估算与护栏
|
||||||
|
|
||||||
|
- 现用 `bytes / 2.2` 对中文偏乐观;切片计算统一加安全系数 ×1.2。
|
||||||
|
- 用 API 返回的真实 `usage`(已收集 `PromptTokens`)校准估算系数,可记录在设置中或仅用于诊断。
|
||||||
|
- 硬护栏:估算总用量超过 `ContextLength` 的 90% 时拒绝发起请求并提示;超过 `ContextBudget` 时警告并自动收缩 slice。
|
||||||
|
|
||||||
|
## 5. 统一管线(单一模式)
|
||||||
|
|
||||||
|
流程:预处理 → 机械分段 → 对局摘要 → 总览轮 → 分段分析轮(每段独立会话)→ 总结轮。
|
||||||
|
|
||||||
|
### 5.1 预处理
|
||||||
|
|
||||||
|
- 保留现有 `CompactLevel` 压缩逻辑。
|
||||||
|
- 长录像的切片若仍超过 slice_max(密集事件区),对切片再做一次噪声过滤(如丢弃纯选择、空选择),仍超限则按时间二次细分。
|
||||||
|
|
||||||
|
### 5.2 机械分段
|
||||||
|
|
||||||
|
- 按事件累积估计 token,达到 slice_max 即切段;相邻段重叠前一段尾部 5%~10%(或 min(10%, 2K 事件))。
|
||||||
|
- 每段最小约 2K token;边界对齐 `TimeIndexedPrefixSums` 的事件分块。
|
||||||
|
- 分段数上限(如 20);超限时提高切片压缩力度而不是无限增加段数。
|
||||||
|
- N=1 时即旧"全量模式"的特例:整份日志作为一个 slice。
|
||||||
|
|
||||||
|
### 5.3 对局摘要(确定性)
|
||||||
|
|
||||||
|
- 来源:`ReplayFactIndex` + 规则采样器,不依赖 LLM 输出,保证同一次运行内稳定。
|
||||||
|
- 内容:玩家/阵营、首次出兵时间表、打包/展开链、建造者/生产者/所有权证据、协议选择、每段时间范围与事件数、每段采样关键事件(建造/摆放/出售/技能/协议)。
|
||||||
|
- 目标 2~5K token;是跨段缓存前缀的一部分。
|
||||||
|
|
||||||
|
### 5.4 总览轮
|
||||||
|
|
||||||
|
- 输入:system + 对局摘要 + 机械分段元数据(每段时间范围、事件数、采样事件)。
|
||||||
|
- 输出(自由格式,允许跨段):
|
||||||
|
- 每段标题 + 一两句概述(以机械段为锚点,不要求逐段对齐);
|
||||||
|
- 整局走势的跨段描述;
|
||||||
|
- 值得注意的跨段线索(如"第 1 段打包基地,第 3 段才重新展开");
|
||||||
|
- 回查建议(如"第 4 段分析时可回查 1:20~1:45")。
|
||||||
|
- 不读全量原始日志;输出在同一次运行内作为稳定前缀的一部分。
|
||||||
|
- 若总览轮输出明确建议合并/调整边界,v1 忽略,仅记录为后续可选优化。
|
||||||
|
- 总览轮失败(空输出/解析异常)→ 重试 1 次,仍失败则降级为"无总览输出"直接进入分段分析轮。
|
||||||
|
|
||||||
|
### 5.5 分段分析轮
|
||||||
|
|
||||||
|
- 每段一个独立阶段,段内再按“焦点窗口”逐轮分析。窗口划分与数据范围分离:
|
||||||
|
- **数据层(不变)**:每轮都提供当前段的完整切片 `slice_i`(尽量长、不超过上下文上限),用于跨时间关联。
|
||||||
|
- **注意力层(新增)**:`FocusPlanner` 把切片按 token 切成 1~5 个窗口(默认目标 12K/窗;≤24K 的切片不细分);每轮只“重点分析”一个窗口,且鼓励关联窗口外/跨时间事件。
|
||||||
|
- 消息顺序(缓存关键,稳定在前):
|
||||||
|
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1 + 段内前窗口) → 窗口指令(含窗口时间范围/事件数)`
|
||||||
|
- 窗口指令:段标题/概述 + "请重点分析第 N 段第 k/n 窗口(起止时间),数据为整段切片,可回查远处区间"。
|
||||||
|
- 输出:自然语言分析 + `[机器可读声明]`(沿用现有 schema,见 §7.4 的解析修正)。
|
||||||
|
- 同一窗口的后续请求(修订、回查)复用同一消息列表;窗口之间独立会话,但共享已发现事实。
|
||||||
|
|
||||||
|
### 5.6 已发现事实
|
||||||
|
|
||||||
|
- 每个窗口分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每项 ≤ ~1K token。
|
||||||
|
- 窗口小结追加在消息尾部,不影响前缀缓存;既是跨段关联的主要载体,也是同一段内窗口间的关联载体。
|
||||||
|
|
||||||
|
### 5.7 回查协议(v1)
|
||||||
|
|
||||||
|
- 格式:段回复末尾输出 `[回查] mm:ss~mm:ss`(可多个)。
|
||||||
|
- 程序解析后从缓存日志切出该区间,作为同一会话的追加 user 消息发回。
|
||||||
|
- 限制:每段最多 3 次;单次区间 ≤ 10K token;总回查量受预算约束。
|
||||||
|
- 解析失败/越界/超限 → 忽略并记录 Info 级 issue,不中断流程。
|
||||||
|
- 时间解析复用容错解析器(见 §7.4),不允许抛异常导致整段失败。
|
||||||
|
- 回查区间内容不进入"已发现事实"(它是临时上下文,不跨会话累积)。
|
||||||
|
|
||||||
|
### 5.8 总结轮
|
||||||
|
|
||||||
|
- 输入:system + 摘要 + 总览 + 各段分析 + 已发现事实(不含原始日志)。
|
||||||
|
- 沿用现有指令:允许跨段修正之前的分析;修正理由记录回验证层。
|
||||||
|
- 后段分析不直接改写前段结论,跨段修正统一由总结轮承担。
|
||||||
|
|
||||||
|
## 6. 缓存设计
|
||||||
|
|
||||||
|
- 前缀顺序是核心实现细节:稳定内容在前,变量内容在后,不要在稳定段中间插入变化内容。
|
||||||
|
- 两层复用:
|
||||||
|
- 跨段:`system + 摘要 + 总览输出` 对所有分段请求一致(主要命中点);
|
||||||
|
- 段内:`以上 + slice_i` 被分析、修订、回查多次复用(P5 缓存诉求的落点)。
|
||||||
|
- 系统提示本身约 20K token,通常已超过各家缓存最小前缀要求;若未来换更短的 system,需复核。
|
||||||
|
- 成本说明:缓存折扣可达 90~95%,但本方案的主要动机是质量与延迟,成本是次要收益。
|
||||||
|
|
||||||
|
## 7. 验证与事实索引修正
|
||||||
|
|
||||||
|
### 7.1 所有权证据(强/弱分层)与校验规则(P8)
|
||||||
|
|
||||||
|
- 强证据(近乎确定是己方单位):`0x1FA 创建编队` 的成员;以建造者/出兵建筑身份出现(`0x207/0x209/0x205`);维修(`0x228`)、出售(`0x20A`)、矿车指令(`0x212/0x248`);特殊能力施法者(仅 `0x1FE/0x200`,见 §7.2)。
|
||||||
|
- 弱证据(可能是点了敌方单位):`0x1F5 选择单位`;`0x1FB/0x1FC` 通过编队状态解析出的成员可升级为强证据。
|
||||||
|
- 注意:`0x205` 不带新单位 UnitId,不能作为所有权证据(只能做时间线检查,见 §7.6)。
|
||||||
|
- 校验规则:
|
||||||
|
1. 声称 X 属于 PlayerA 且为 `confirmed`/`highly likely`,但 X 对 A 无任何强/弱证据 → `WeakEvidence`。
|
||||||
|
2. X 存在 PlayerB 的强证据而声称属于 A → `Contradiction`。
|
||||||
|
3. X 仅有 PlayerB 的弱证据且对 A 无证据 → `Warning`,建议降置信度。
|
||||||
|
4. 双玩家强证据冲突(异常/作弊操作)→ `Contradiction`,提示无法判定归属。
|
||||||
|
- `unitClaims.player` 接入上述规则。
|
||||||
|
|
||||||
|
### 7.2 特殊能力归属修正(P3)
|
||||||
|
|
||||||
|
- 只对布局确凿的 `0x1FE/0x200` 记录施法者;`0x201/0x232`(及待核实的 `0x1FF`)不用于施法者校验。
|
||||||
|
- 用真实回放抽样验证各命令类型的 ObjectId 含义后再扩展。
|
||||||
|
|
||||||
|
### 7.3 协议与选择类指令(P4)
|
||||||
|
|
||||||
|
- 事实索引记录 `0x24E 选择协议`;evidence schema 新增 `protocol|时间|科技名`。
|
||||||
|
- 事实索引/编队状态补全:`0x1F6`、`0x1FA`、`0x1FB`、`0x1FC`、`0x22A`;维护编队号 → 成员 UnitId 的状态表。
|
||||||
|
|
||||||
|
### 7.4 机器可读声明解析健壮性(P6)
|
||||||
|
|
||||||
|
- 机器可读声明不再"只取最后一个 JSON 块":改为解析标记之后的所有 JSON 块并合并声明(重复 unitId 取后块),或至少对被忽略的块记录 Info issue。
|
||||||
|
- v2 移除 `[分段列表]` 解析(分段改机械式),消除"分段标记缺失导致整体失败"的路径。
|
||||||
|
- 时间解析统一改为容错实现(返回 null + issue,而不是抛异常),回查与段边界共用。
|
||||||
|
|
||||||
|
### 7.5 move 证据处理(P7)
|
||||||
|
|
||||||
|
- 提示词注明:`move` 证据只带坐标不带 UnitId,不能单独支撑 `confirmed`/`highly likely` 结论。
|
||||||
|
- 验证器对 move 证据不做交叉校验;若高置信声明仅有 move 类证据,补发 `WeakEvidence` 提示。
|
||||||
|
|
||||||
|
### 7.6 首次出兵时间线规则(P10)
|
||||||
|
|
||||||
|
- 消费 `PlayerFirstProductionTime`:如"某 UnitId 被操作的时间早于该玩家首次生产对应单位"→ `Contradiction`;"轰炸机在首次生产轰炸机之前就被操作"→ 要求降级或解释。
|
||||||
|
- 需要结构化知识的类型 tag 映射(如 `bomber`/`aircraft`/`producedBy`)支持"哪个单位名属于哪类",随 §8.1 的 mod 拆分落地。
|
||||||
|
|
||||||
|
### 7.7 严重度语义统一(P11)
|
||||||
|
|
||||||
|
- 明确 `Fatal` 的产生条件:段输出为空、机器可读声明完全不可解析,且修订后仍失败。
|
||||||
|
- `MissingMachineReadableClaims` 当前为 Warning(仅记录);是否升级为一次"修复请求"由 M6 与修订 pass 一并决定。
|
||||||
|
|
||||||
|
## 8. 知识架构修正
|
||||||
|
|
||||||
|
### 8.1 结构化数据按 mod 拆分(P1)
|
||||||
|
|
||||||
|
- `knowledge_units.json` 按 mod 拆分(如 `knowledge_corona_units.json`)或加 mod 键,与 flat 文本同作用域。
|
||||||
|
- `StripUnitSections` 只剥离"该 mod 确有结构化数据"的阵营;硬编码章节标题改为可配置/可校验。
|
||||||
|
|
||||||
|
### 8.2 旧提示词构建副作用修复(P2)
|
||||||
|
|
||||||
|
- `GetSystemPrompt` 先查知识文件,命中即走 `RenderAsPrompt`;旧 `BuildDefaultSystemPrompt` 的副作用(苏联/未知地图 MessageBox)只在 fallback 路径执行。
|
||||||
|
|
||||||
|
### 8.3 标签体系执行(P9)
|
||||||
|
|
||||||
|
- 加载 `knowledge_units.json` 时校验未知 tag 并告警;补全/收敛 taxonomy(`land/sea/miner/scout/support/siege/bomber` 等要么进定义、要么移除)。
|
||||||
|
- 加载告警在 Debug/设置页可见,避免静默漂移。
|
||||||
|
|
||||||
|
### 8.4 渲染过滤(P14)
|
||||||
|
|
||||||
|
- flat 文本按参战阵营过滤非参战阵营章节;结构化渲染只渲染参战阵营(`RenderAsPrompt` 已有 factionNames 参数,flat 文本需要配套切分)。
|
||||||
|
|
||||||
|
### 8.5 用户知识 JSON 加载(实施遗留)
|
||||||
|
|
||||||
|
- 落地 `AnotherReplayReader.user_knowledge.json`:按 id 覆盖内置条目,加载顺序:内置 → 用户覆盖。
|
||||||
|
|
||||||
|
## 9. 提示词与知识文件更新
|
||||||
|
|
||||||
|
### 9.1 v2 流程(P15)
|
||||||
|
|
||||||
|
- 三阶段流程改为:总览轮(无 `[分段列表]` 输出)→ 分段分析轮 → 总结轮。
|
||||||
|
- 同步修改 `knowledge_default.md`、`knowledge_corona.md`、`BuildDefaultSystemPrompt` 回退路径和 `tools/expand_knowledge.py` 生成的模板。
|
||||||
|
|
||||||
|
### 9.2 选择/所有权表述修正(P8)
|
||||||
|
|
||||||
|
- 修正"选择单位……这些操作的对象是玩家自己的单位":选择可能包含敌方单位(不能下达命令);加入编队几乎可确定是己方单位。
|
||||||
|
|
||||||
|
### 9.3 evidence 格式更新(P4、P7)
|
||||||
|
|
||||||
|
- 补充 `protocol|时间|科技名` 类型。
|
||||||
|
- 注明 `move` 证据的验证限制(见 §7.5)。
|
||||||
|
|
||||||
|
## 10. 修订 pass(P11)
|
||||||
|
|
||||||
|
- 窗口内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每窗口最多 1 次。
|
||||||
|
- 修订后仍 `Fatal` → 回退显示原文 + 警告(`Fatal` 条件见 §7.7)。
|
||||||
|
- 修订请求复用窗口会话(同一前缀,缓存友好)。
|
||||||
|
- UI:增加"验证器发现并修正 N 个问题"提示;修订草稿实时流式显示,完成后自动折叠,最终正文默认展开。
|
||||||
|
|
||||||
|
## 11. 设置与 UI
|
||||||
|
|
||||||
|
- `AiModel.ContextBudget`(0 = 档位默认);`AIProviderSettingsControl` 增加编辑项。
|
||||||
|
- 128K 及以下模型:允许短录像(单 slice),设置页提示"长录像不保证质量"。
|
||||||
|
- 请求 Token 构成展示:system / 摘要 / 总览 / slice / 已发现事实 / 输出余量。
|
||||||
|
- 可选:回查统计(次数、命中率)与估算系数校准结果显示。
|
||||||
|
|
||||||
|
## 12. 实施步骤(里程碑)
|
||||||
|
|
||||||
|
1. **M1 预算与护栏**:`ContextBudget` + 档位默认 + 估算安全系数 + 超限警告/拒绝。
|
||||||
|
2. **M2 管线重构**:机械分段 + 对局摘要 + 总览轮 + 分段独立会话;移除 `[分段列表]` 解析(P5、P6)。
|
||||||
|
3. **M3 回查机制**:标记解析(容错时间解析)、切片提取、限流、失败降级。
|
||||||
|
4. **M4 验证修正**:所有权证据与规则(§7.1)、施法者归属(§7.2)、协议与选择指令(§7.3)、JSON 解析健壮性(§7.4)、move 降级(§7.5)、首次出兵时间线(§7.6)、严重度语义(§7.7)。
|
||||||
|
5. **M5 知识修正**:mod 拆分(§8.1)、副作用修复(§8.2)、标签校验(§8.3)、渲染过滤(§8.4)、用户知识 JSON(§8.5)。
|
||||||
|
6. **M6 修订 pass**:段内修订 + 草稿流式显示 + 完成后折叠 + 最终正文默认展开(§10)。
|
||||||
|
7. **M7 测试与评估**:单元测试(解析器/验证器/事实索引/分段)+ A/B 对比(§13)。
|
||||||
|
|
||||||
|
依赖关系:M2 先于 M3;M4/M5 可与 M2 并行;M6 依赖 M2 + M4;M7 覆盖全部。
|
||||||
|
|
||||||
|
完成标准(每步):`dotnet build` 通过;对应功能用手工回放验证一次;解析/验证改动附单元测试(M7 前至少保证新增逻辑可测)。
|
||||||
|
|
||||||
|
## 13. 评估方法
|
||||||
|
|
||||||
|
- 指标:验证 issue 数量与严重度分布;关键事件覆盖率(人工清单抽查);总 token/费用;耗时;回查次数与命中率;总览轮失败率。
|
||||||
|
- A/B:同一录像对比现状(全量模式)与 v2 管线,优先选短/中/长各一局。
|
||||||
|
- 校准:用真实 `usage` 修正估算系数,检查预算是否被实际超用。
|
||||||
|
|
||||||
|
## 14. 风险与开放问题
|
||||||
|
|
||||||
|
- 密集事件段可能仍超 slice 预算 → 切片压缩与二次细分是兜底。
|
||||||
|
- 总览轮质量影响后续所有段 → 摘要/采样质量需要迭代;失败降级路径见 §5.4。
|
||||||
|
- 回查滥用或格式不稳定 → 限流 + 失败降级(§5.7)。
|
||||||
|
- 修订后机器可读声明可能与正文不一致 → 修订轮要求同时重出声明并重新验证。
|
||||||
|
- 段内焦点窗口依赖 `EventSpan` 时间索引;若索引缺失(防御性回退)则单窗口分析。
|
||||||
|
- 用户在运行中修改设置导致前缀变化 → 缓存失效,仅影响本次运行。
|
||||||
|
- 开放:是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
|
||||||
|
|
||||||
|
## 15. 本计划不涉及
|
||||||
|
|
||||||
|
- 非 AI 分析功能、其他 UI 改动、第三方库升级。
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Web;
|
|
||||||
using System.Web.Script.Serialization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
|
||||||
{
|
|
||||||
internal sealed class IPAndPlayer
|
|
||||||
{
|
|
||||||
public static string SimpleIPToString(uint ip)
|
|
||||||
{
|
|
||||||
return $"{ip / 256 / 256 / 256}.{(ip / 256 / 256) % 256}.{(ip / 256) % 256}.{ip % 256}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public uint IP
|
|
||||||
{
|
|
||||||
get { return _ip; }
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_ip = value;
|
|
||||||
IPString = SimpleIPToString(_ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public string IPString { get; private set; }
|
|
||||||
public string ID { get; set; }
|
|
||||||
|
|
||||||
private uint _ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class PlayerIdentity
|
|
||||||
{
|
|
||||||
public bool IsUsable => IsListUsable(_list);
|
|
||||||
|
|
||||||
private Cache _cache;
|
|
||||||
private volatile IReadOnlyDictionary<uint, string> _list;
|
|
||||||
|
|
||||||
public PlayerIdentity(Cache cache)
|
|
||||||
{
|
|
||||||
_cache = cache;
|
|
||||||
Fetch();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var stored = _cache.GetOrDefault("pt", string.Empty);
|
|
||||||
if (string.IsNullOrWhiteSpace(stored))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bytes = Convert.FromBase64String(stored);
|
|
||||||
var id = Encoding.UTF8.GetBytes(Auth.ID);
|
|
||||||
var data = Encoding.UTF8.GetString(bytes.Select((x, i) => (byte)(x ^ id[i % id.Length])).ToArray());
|
|
||||||
var serializer = new JavaScriptSerializer();
|
|
||||||
var cachedTable = serializer.Deserialize<List<IPAndPlayer>>(data);
|
|
||||||
if(cachedTable == null)
|
|
||||||
{
|
|
||||||
_list = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var converted = cachedTable.ToDictionary(x => x.IP, x => x.ID);
|
|
||||||
converted[0] = "【没有网络连接,正在使用上次保存的数据】";
|
|
||||||
|
|
||||||
_list = converted;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsListUsable(IReadOnlyDictionary<uint, string> list)
|
|
||||||
{
|
|
||||||
return list != null && list.Count != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task Fetch()
|
|
||||||
{
|
|
||||||
return Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var key = HttpUtility.UrlEncode(Auth.GetKey());
|
|
||||||
var request = WebRequest.Create($"https://lanyi.altervista.org/playertable/playertable.php?do=getTable&key={key}");
|
|
||||||
|
|
||||||
using (var stream = request.GetResponse().GetResponseStream())
|
|
||||||
using (var reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
var response = reader.ReadToEnd();
|
|
||||||
var serializer = new JavaScriptSerializer();
|
|
||||||
var temp = serializer.Deserialize<List<IPAndPlayer>>(response);
|
|
||||||
if(temp == null)
|
|
||||||
{
|
|
||||||
_list = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var converted = temp.ToDictionary(x => x.IP, x => x.ID);
|
|
||||||
_list = converted;
|
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(serializer.Serialize(temp));
|
|
||||||
var id = Encoding.UTF8.GetBytes(Auth.ID);
|
|
||||||
var base64 = Convert.ToBase64String(bytes.Select((x, i) => (byte)(x ^ id[i % id.Length])).ToArray());
|
|
||||||
_cache.Set("pt", base64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<IPAndPlayer> AsSortedList()
|
|
||||||
{
|
|
||||||
var list = _list;
|
|
||||||
|
|
||||||
if(!IsListUsable(list))
|
|
||||||
{
|
|
||||||
return new List<IPAndPlayer>();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _list.Select((kv) => new IPAndPlayer { IP = kv.Key, ID = kv.Value }).OrderBy(x => x.IP).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string QueryRealName(uint ip)
|
|
||||||
{
|
|
||||||
var list = _list;
|
|
||||||
|
|
||||||
if (!IsListUsable(list))
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(ip == 0)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = list.TryGetValue(ip, out var realName) ? realName : IPAndPlayer.SimpleIPToString(ip);
|
|
||||||
return $"({name})";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string QueryRealNameAndIP(uint ip)
|
|
||||||
{
|
|
||||||
var list = _list;
|
|
||||||
|
|
||||||
if (!IsListUsable(list))
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip == 0)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = list.TryGetValue(ip, out var realName) ? realName + "," : string.Empty;
|
|
||||||
return name + IPAndPlayer.SimpleIPToString(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,17 +4,8 @@ using System.Runtime.CompilerServices;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
// 有关程序集的一般信息由以下
|
// 允许 AiV2.Tests 测试工程访问 internal 类型(验证器/事实索引/分段等纯逻辑)
|
||||||
// 控制。更改这些特性值可修改
|
[assembly: InternalsVisibleTo("AiV2.Tests")]
|
||||||
// 与程序集关联的信息。
|
|
||||||
[assembly: AssemblyTitle("AnotherReplayReader")]
|
|
||||||
[assembly: AssemblyDescription("")]
|
|
||||||
[assembly: AssemblyConfiguration("")]
|
|
||||||
[assembly: AssemblyCompany("")]
|
|
||||||
[assembly: AssemblyProduct("AnotherReplayReader")]
|
|
||||||
[assembly: AssemblyCopyright("Copyright © 2019")]
|
|
||||||
[assembly: AssemblyTrademark("")]
|
|
||||||
[assembly: AssemblyCulture("")]
|
|
||||||
|
|
||||||
// 将 ComVisible 设置为 false 会使此程序集中的类型
|
// 将 ComVisible 设置为 false 会使此程序集中的类型
|
||||||
//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型
|
//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型
|
||||||
@@ -39,17 +30,3 @@ using System.Windows;
|
|||||||
//(未在页面中找到资源时使用,
|
//(未在页面中找到资源时使用,
|
||||||
//、应用程序或任何主题专用资源字典中找到时使用)
|
//、应用程序或任何主题专用资源字典中找到时使用)
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
|
||||||
// 程序集的版本信息由下列四个值组成:
|
|
||||||
//
|
|
||||||
// 主版本
|
|
||||||
// 次版本
|
|
||||||
// 生成号
|
|
||||||
// 修订号
|
|
||||||
//
|
|
||||||
// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号
|
|
||||||
// 方法是按如下所示使用“*”: :
|
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
|
||||||
[assembly: AssemblyVersion("0.0.3.0")]
|
|
||||||
[assembly: AssemblyFileVersion("0.0.3.0")]
|
|
||||||
|
|||||||
@@ -1,431 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
|
||||||
{
|
|
||||||
internal sealed class Player
|
|
||||||
{
|
|
||||||
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ "E", "简单" },
|
|
||||||
{ "M", "中等" },
|
|
||||||
{ "H", "困难" },
|
|
||||||
{ "B", "凶残" },
|
|
||||||
};
|
|
||||||
|
|
||||||
public string PlayerName { get; private set; }
|
|
||||||
public uint PlayerIP { get; private set; }
|
|
||||||
public string PlayerRealName { get; private set; }
|
|
||||||
public int FactionID { get; private set; }
|
|
||||||
public int Team { get; private set; }
|
|
||||||
|
|
||||||
public Player(string[] playerEntry)
|
|
||||||
{
|
|
||||||
var isComputer = playerEntry[0][0] == 'C';
|
|
||||||
|
|
||||||
PlayerName = playerEntry[0].Substring(1);
|
|
||||||
|
|
||||||
if (isComputer)
|
|
||||||
{
|
|
||||||
PlayerName = ComputerNames[PlayerName];
|
|
||||||
PlayerIP = 0;
|
|
||||||
FactionID = int.Parse(playerEntry[2]);
|
|
||||||
Team = int.Parse(playerEntry[4]);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PlayerIP = uint.Parse(playerEntry[1], System.Globalization.NumberStyles.HexNumber);
|
|
||||||
FactionID = int.Parse(playerEntry[5]);
|
|
||||||
Team = int.Parse(playerEntry[7]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal enum ReplayType
|
|
||||||
{
|
|
||||||
Skirmish,
|
|
||||||
Lan,
|
|
||||||
Online
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class ReplayChunk
|
|
||||||
{
|
|
||||||
public uint TimeCode { get; private set; }
|
|
||||||
public byte Type { get; private set; }
|
|
||||||
public byte[] Data { get; private set; }
|
|
||||||
|
|
||||||
public ReplayChunk(uint timeCode, BinaryReader reader)
|
|
||||||
{
|
|
||||||
TimeCode = timeCode; // reader.ReadUInt32();
|
|
||||||
Type = reader.ReadByte();
|
|
||||||
var chunkSize = reader.ReadInt32();
|
|
||||||
Data = reader.ReadBytes(chunkSize);
|
|
||||||
if(reader.ReadInt32() != 0)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Replay Chunk not ended with zero");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal enum ReplayFooterOption
|
|
||||||
{
|
|
||||||
SeekToFooter,
|
|
||||||
CurrentlyAtFooter,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class ReplayFooter
|
|
||||||
{
|
|
||||||
public const uint Terminator = 0x7FFFFFFF;
|
|
||||||
public static readonly byte[] FooterString = Encoding.ASCII.GetBytes("RA3 REPLAY FOOTER");
|
|
||||||
public uint FinalTimeCode { get; private set; }
|
|
||||||
public byte[] Data { get; private set; }
|
|
||||||
|
|
||||||
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
|
||||||
{
|
|
||||||
var currentPosition = reader.BaseStream.Position;
|
|
||||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
|
||||||
var footerLength = reader.ReadInt32();
|
|
||||||
|
|
||||||
if (option == ReplayFooterOption.SeekToFooter)
|
|
||||||
{
|
|
||||||
currentPosition = reader.BaseStream.Length - footerLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.BaseStream.Seek(currentPosition, SeekOrigin.Begin);
|
|
||||||
var footer = reader.ReadBytes(footerLength);
|
|
||||||
|
|
||||||
if(footer.Length != footerLength || reader.BaseStream.Position != reader.BaseStream.Length)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Invalid footer");
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var footerStream = new MemoryStream(footer))
|
|
||||||
using (var footerReader = new BinaryReader(footerStream))
|
|
||||||
{
|
|
||||||
var footerString = footerReader.ReadBytes(17);
|
|
||||||
if(!footerString.SequenceEqual(FooterString))
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Invalid footer, no footer string");
|
|
||||||
}
|
|
||||||
FinalTimeCode = footerReader.ReadUInt32();
|
|
||||||
Data = footerReader.ReadBytes(footer.Length - 25);
|
|
||||||
if(footerReader.ReadInt32() != footerLength)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ReplayFooter(uint finalTimeCode)
|
|
||||||
{
|
|
||||||
FinalTimeCode = finalTimeCode;
|
|
||||||
Data = new byte[] { 0x02, 0x1A, 0x00, 0x00, 0x00 };
|
|
||||||
}
|
|
||||||
|
|
||||||
List<float> TryGetKillDeathRatio()
|
|
||||||
{
|
|
||||||
if(Data.Length < 24)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ratios = new List<float>();
|
|
||||||
using (var stream = new MemoryStream(Data, Data.Length - 24, 24))
|
|
||||||
using (var reader = new BinaryReader(stream))
|
|
||||||
{
|
|
||||||
ratios.Add(reader.ReadSingle());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ratios;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class ReplayExtensions
|
|
||||||
{
|
|
||||||
public static string ReadUTF16String(this BinaryReader reader)
|
|
||||||
{
|
|
||||||
var currentBytes = new List<byte>();
|
|
||||||
byte[] lastTwoBytes = null;
|
|
||||||
while(true)
|
|
||||||
{
|
|
||||||
lastTwoBytes = reader.ReadBytes(2);
|
|
||||||
if (lastTwoBytes.Length != 2)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
if(lastTwoBytes.All(x => x == 0))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currentBytes.AddRange(lastTwoBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Encoding.Unicode.GetString(currentBytes.ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteChunk(this BinaryWriter writer, ReplayChunk chunk)
|
|
||||||
{
|
|
||||||
writer.Write(chunk.TimeCode);
|
|
||||||
writer.Write(chunk.Type);
|
|
||||||
writer.Write(chunk.Data.Length);
|
|
||||||
writer.Write(chunk.Data);
|
|
||||||
writer.Write(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteFooter(this BinaryWriter writer, ReplayFooter footer)
|
|
||||||
{
|
|
||||||
writer.Write(ReplayFooter.Terminator);
|
|
||||||
writer.Write(ReplayFooter.FooterString);
|
|
||||||
writer.Write(footer.FinalTimeCode);
|
|
||||||
writer.Write(footer.Data);
|
|
||||||
writer.Write(ReplayFooter.FooterString.Length + footer.Data.Length + 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteReplay(this BinaryWriter writer, Replay replay)
|
|
||||||
{
|
|
||||||
writer.Write(replay.RawHeader);
|
|
||||||
|
|
||||||
var lastTimeCode = (uint)0;
|
|
||||||
foreach(var chunk in replay.Body)
|
|
||||||
{
|
|
||||||
lastTimeCode = chunk.TimeCode;
|
|
||||||
writer.WriteChunk(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replay.HasFooter)
|
|
||||||
{
|
|
||||||
writer.WriteFooter(replay.Footer);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
writer.WriteFooter(new ReplayFooter(lastTimeCode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class Replay
|
|
||||||
{
|
|
||||||
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
|
||||||
public static readonly Dictionary<ReplayType, string> TypeStrings = new Dictionary<ReplayType, string>()
|
|
||||||
{
|
|
||||||
{ ReplayType.Skirmish, "遭遇战录像" },
|
|
||||||
{ ReplayType.Lan, "局域网录像" },
|
|
||||||
{ ReplayType.Online, "官网录像" },
|
|
||||||
};
|
|
||||||
|
|
||||||
public string Path { get; private set; }
|
|
||||||
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
|
||||||
public DateTime Date { get; private set; }
|
|
||||||
public bool HasFooter => Footer != null;
|
|
||||||
public TimeSpan? Length { get; private set; }
|
|
||||||
public string MapName { get; private set; }
|
|
||||||
public string MapPath { get; private set; }
|
|
||||||
public IReadOnlyList<Player> Players => _players;
|
|
||||||
public int NumberOfPlayingPlayers { get; private set; }
|
|
||||||
public long Size { get; private set; }
|
|
||||||
public Mod Mod { get; private set; }
|
|
||||||
public ReplayType Type { get; private set; }
|
|
||||||
public string TypeString => TypeStrings[Type];
|
|
||||||
public bool HasCommentator { get; private set; }
|
|
||||||
public Player ReplaySaver => Players[_replaySaverIndex];
|
|
||||||
|
|
||||||
public byte[] RawHeader { get; private set; }
|
|
||||||
public IReadOnlyList<ReplayChunk> Body => _body;
|
|
||||||
public ReplayFooter Footer { get; private set; }
|
|
||||||
|
|
||||||
private List<Player> _players;
|
|
||||||
private byte _replaySaverIndex;
|
|
||||||
private long _headerSize;
|
|
||||||
private List<ReplayChunk> _body;
|
|
||||||
|
|
||||||
public Replay(string path)
|
|
||||||
{
|
|
||||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
||||||
{
|
|
||||||
Parse(path, stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Replay(string path, Stream stream)
|
|
||||||
{
|
|
||||||
Parse(path, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Parse(string path, Stream stream)
|
|
||||||
{
|
|
||||||
Path = path;
|
|
||||||
|
|
||||||
using (var reader = new BinaryReader(stream))
|
|
||||||
{
|
|
||||||
Size = reader.BaseStream.Length;
|
|
||||||
var headerMagic = reader.ReadBytes(HeaderMagic.Length);
|
|
||||||
if(!headerMagic.SequenceEqual(HeaderMagic))
|
|
||||||
{
|
|
||||||
throw new InvalidDataException($"{Path} is not a replay, header is {BitConverter.ToString(headerMagic)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var isSkirmish = reader.ReadByte() == 0x04;
|
|
||||||
reader.ReadBytes(4 * 4); // version and builds
|
|
||||||
reader.ReadBytes(2); // commentary flag, and padding zero byte
|
|
||||||
|
|
||||||
reader.ReadUTF16String(); // title
|
|
||||||
reader.ReadUTF16String(); // description
|
|
||||||
MapName = reader.ReadUTF16String(); // map name
|
|
||||||
reader.ReadUTF16String(); // map id
|
|
||||||
|
|
||||||
NumberOfPlayingPlayers = reader.ReadByte();
|
|
||||||
|
|
||||||
for(var i = 0; i <= NumberOfPlayingPlayers; ++i)
|
|
||||||
{
|
|
||||||
reader.ReadUInt32();
|
|
||||||
reader.ReadUTF16String(); // utf16 player name
|
|
||||||
if(!isSkirmish)
|
|
||||||
{
|
|
||||||
reader.ReadByte(); // team
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = reader.ReadInt32();
|
|
||||||
var cnc3MagicLength = reader.ReadInt32();
|
|
||||||
_headerSize = reader.BaseStream.Position + offset;
|
|
||||||
reader.ReadBytes(cnc3MagicLength);
|
|
||||||
|
|
||||||
var modInfo = reader.ReadBytes(22);
|
|
||||||
Mod = new Mod(Encoding.UTF8.GetString(modInfo));
|
|
||||||
|
|
||||||
var timeStamp = reader.ReadUInt32();
|
|
||||||
Date = DateTimeOffset.FromUnixTimeSeconds(timeStamp).DateTime;
|
|
||||||
|
|
||||||
reader.ReadBytes(31);
|
|
||||||
var descriptionsLength = reader.ReadInt32();
|
|
||||||
var description = Encoding.UTF8.GetString(reader.ReadBytes(descriptionsLength));
|
|
||||||
|
|
||||||
var entries = null as Dictionary<string, string>;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
entries = description.Split(';').Where(x => !string.IsNullOrWhiteSpace(x)).ToDictionary(x => x.Split('=')[0], x => x.Split('=')[1]);
|
|
||||||
}
|
|
||||||
catch(Exception exception)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{exception}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_players = entries["S"].Split(':')
|
|
||||||
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
|
||||||
.Select(x => new Player(x.Split(',')))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException($"Failed to parse playerdata from string header of replay {Path}: \r\n{exception}");
|
|
||||||
}
|
|
||||||
|
|
||||||
MapPath = entries["M"].Substring(3);
|
|
||||||
|
|
||||||
HasCommentator = !entries["PC"].Equals("-1");
|
|
||||||
|
|
||||||
var lanFlag = int.Parse(entries["GT"]) == 0;
|
|
||||||
if (lanFlag)
|
|
||||||
{
|
|
||||||
if(_players.First().PlayerIP == 0)
|
|
||||||
{
|
|
||||||
Type = ReplayType.Skirmish;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Type = ReplayType.Lan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Type = ReplayType.Online;
|
|
||||||
}
|
|
||||||
|
|
||||||
_replaySaverIndex = reader.ReadByte();
|
|
||||||
|
|
||||||
reader.ReadBytes(8); // 8 bit paddings
|
|
||||||
var fileNameLength = reader.ReadInt32();
|
|
||||||
reader.ReadBytes(fileNameLength * 2);
|
|
||||||
reader.ReadBytes(16);
|
|
||||||
var verMagicLength = reader.ReadInt32();
|
|
||||||
reader.ReadBytes(verMagicLength);
|
|
||||||
reader.ReadBytes(85);
|
|
||||||
|
|
||||||
if(reader.BaseStream.Position != _headerSize)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
|
||||||
Length = TimeSpan.FromSeconds(Math.Round(Footer.FinalTimeCode / 15.0));
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
Length = null;
|
|
||||||
Footer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ParseBody()
|
|
||||||
{
|
|
||||||
_body = new List<ReplayChunk>();
|
|
||||||
|
|
||||||
using (var stream = new FileStream(Path, FileMode.Open))
|
|
||||||
using (var reader = new BinaryReader(stream))
|
|
||||||
{
|
|
||||||
RawHeader = reader.ReadBytes((int)_headerSize);
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var timeCode = reader.ReadUInt32();
|
|
||||||
if (timeCode == ReplayFooter.Terminator)
|
|
||||||
{
|
|
||||||
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
_body.Add(new ReplayChunk(timeCode, reader));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Dictionary<byte, int[]> GetCommandCounts()
|
|
||||||
{
|
|
||||||
var playerCommands = new Dictionary<byte, int[]>();
|
|
||||||
|
|
||||||
foreach(var chunk in _body)
|
|
||||||
{
|
|
||||||
if(chunk.Type != 1)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach(var command in CommandChunk.Parse(chunk))
|
|
||||||
{
|
|
||||||
var commandCount = playerCommands.TryGetValue(command.CommandID, out var current) ? current : new int[Players.Count];
|
|
||||||
if(command.PlayerIndex >= commandCount.Length) // unknown or unparsable command?
|
|
||||||
{
|
|
||||||
commandCount = commandCount
|
|
||||||
.Concat(new int[command.PlayerIndex - commandCount.Length + 1])
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
|
|
||||||
playerCommands[command.CommandID] = commandCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return playerCommands;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
public static class ReplayAutoSaver
|
||||||
|
{
|
||||||
|
private static int _errorMessageCount = 0;
|
||||||
|
|
||||||
|
public static void SpawnAutoSaveReplaysTask(string replayFolderPath)
|
||||||
|
{
|
||||||
|
Task.Run(() => AutoSaveReplays(replayFolderPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AutoSaveReplays(string replayFolderPath)
|
||||||
|
{
|
||||||
|
const string ourPrefix = "自动保存";
|
||||||
|
|
||||||
|
// filename and last write time
|
||||||
|
var previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// filename and file size
|
||||||
|
var lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var changed = (from fileName in Directory.GetFiles(replayFolderPath, "*.RA3Replay")
|
||||||
|
let info = new FileInfo(fileName)
|
||||||
|
where !info.Name.StartsWith(ourPrefix)
|
||||||
|
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
||||||
|
select info).ToList();
|
||||||
|
|
||||||
|
foreach (var info in changed)
|
||||||
|
{
|
||||||
|
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
var replays = changed.Select(info =>
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"正在尝试检测已更改的文件:{info.FullName}\r\n";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = info.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
return new Replay(info.FullName, stream);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).Where(replay => replay != null);
|
||||||
|
|
||||||
|
var newLastReplays = from replay in replays
|
||||||
|
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
||||||
|
let endDate = replay.Date.Add(replay.Length ?? TimeSpan.Zero)
|
||||||
|
let endThreshold = Math.Abs((DateTime.UtcNow - endDate).TotalSeconds)
|
||||||
|
where threshold < 40 || endThreshold < 40
|
||||||
|
select replay;
|
||||||
|
|
||||||
|
var toBeChecked = newLastReplays.ToDictionary(replay => replay.Path, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var savedLastReplay in lastReplays.Keys)
|
||||||
|
{
|
||||||
|
if (!toBeChecked.ContainsKey(savedLastReplay))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.Open(savedLastReplay, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
toBeChecked.Add(savedLastReplay, new Replay(savedLastReplay, stream));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kv in toBeChecked)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"正在检测录像更改:{kv.Key}\r\n";
|
||||||
|
var replay = kv.Value;
|
||||||
|
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
||||||
|
{
|
||||||
|
if (fileSize == replay.Size)
|
||||||
|
{
|
||||||
|
// skip if size is not changed
|
||||||
|
Debug.Instance.DebugMessage += $"已跳过未更改的录像:{kv.Key}\r\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Debug.Instance.DebugMessage += $"将会自动保存已更改的录像:{kv.Key}\r\n";
|
||||||
|
lastReplays[kv.Key] = replay.Size;
|
||||||
|
|
||||||
|
var date = replay.Date;
|
||||||
|
|
||||||
|
var playerString = $"{replay.NumberOfPlayingPlayers}名玩家";
|
||||||
|
if (replay.NumberOfPlayingPlayers <= 2)
|
||||||
|
{
|
||||||
|
var playingPlayers = from player in replay.Players
|
||||||
|
let faction = ModData.GetFaction(replay.Mod, player.FactionId)
|
||||||
|
where faction.Kind != FactionKind.Observer
|
||||||
|
select $"{player.PlayerName}({faction.Name})";
|
||||||
|
playerString = playingPlayers.Aggregate(string.Empty, (x, y) => x + y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateString = $"{date.Year}{date.Month:D2}{date.Day:D2}_{date.Hour:D2}{date.Minute:D2}{date.Second:D2}";
|
||||||
|
var destinationPath = Path.Combine(replayFolderPath, $"{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(replay.Path, destinationPath, true);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new Exception($"复制文件({replay.Path} -> {destinationPath})失败:{e.Message}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
var errorString = $"自动保存录像时出现错误:\r\n{e}\r\n";
|
||||||
|
Debug.Instance.DebugMessage += errorString;
|
||||||
|
if (Interlocked.Increment(ref _errorMessageCount) == 1)
|
||||||
|
{
|
||||||
|
_ = Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MessageBox.Show(errorString);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _errorMessageCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(10 * 1000).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
public enum CommandArgumentType
|
||||||
|
{
|
||||||
|
Int32 = 0,
|
||||||
|
Float32 = 1,
|
||||||
|
Bool = 2,
|
||||||
|
AssetId = 3,
|
||||||
|
ObjectId = 4,
|
||||||
|
ObjectId_2 = 5,
|
||||||
|
UInt32 = 6,
|
||||||
|
Vector3 = 7,
|
||||||
|
UInt32_2 = 8,
|
||||||
|
UInt16 = 9,
|
||||||
|
AsciiString = 10,
|
||||||
|
UnicodeString = 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
public record struct CommandArgumentEntry(CommandArgumentType Type, object Value, int Count);
|
||||||
|
public record struct Vector3(float X, float Y, float Z)
|
||||||
|
{
|
||||||
|
public override readonly string ToString() => $"(X={Math.Round(X)},Y={Math.Round(Y)},Z={Math.Round(Z)})";
|
||||||
|
}
|
||||||
|
public record struct AssetId(uint TypeId, uint InstanceId)
|
||||||
|
{
|
||||||
|
public override readonly string ToString() => $"(TypeId={TypeId:X},InstanceId={InstanceId:X})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CommandChunk
|
||||||
|
{
|
||||||
|
public int CommandId { get; private set; }
|
||||||
|
public int PlayerIndex { get; private set; }
|
||||||
|
public ImmutableArray<CommandArgumentEntry> Data { get; private set; }
|
||||||
|
|
||||||
|
public static List<CommandChunk> Parse(ReplayChunk chunk)
|
||||||
|
{
|
||||||
|
if (chunk.Type != 1)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = chunk.GetReader();
|
||||||
|
if (reader.ReadByte() != 1)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Payload first byte not 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<CommandChunk>();
|
||||||
|
var numberOfCommands = reader.ReadInt32();
|
||||||
|
for (var i = 0; i < numberOfCommands; ++i)
|
||||||
|
{
|
||||||
|
var commandIdAndPlayerId = reader.ReadUInt16();
|
||||||
|
var commandId = commandIdAndPlayerId & 0x7FF;
|
||||||
|
var playerId = commandIdAndPlayerId >> 11;
|
||||||
|
var data = reader.ReadCommandData();
|
||||||
|
list.Add(new CommandChunk
|
||||||
|
{
|
||||||
|
CommandId = commandId,
|
||||||
|
PlayerIndex = playerId,
|
||||||
|
Data = data.ToImmutableArray(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.BaseStream.Position != reader.BaseStream.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Payload not fully parsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"[玩家 {PlayerIndex},{RA3Commands.GetCommandName(CommandId)}]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
public sealed class Player
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "E", "简单" },
|
||||||
|
{ "M", "中等" },
|
||||||
|
{ "H", "困难" },
|
||||||
|
{ "B", "凶残" },
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsComputer { get; }
|
||||||
|
public string PlayerName { get; }
|
||||||
|
public uint PlayerIp { get; }
|
||||||
|
public int FactionId { get; }
|
||||||
|
public int Team { get; }
|
||||||
|
|
||||||
|
public Player(string[] playerEntry)
|
||||||
|
{
|
||||||
|
IsComputer = playerEntry[0][0] == 'C';
|
||||||
|
|
||||||
|
PlayerName = playerEntry[0].Substring(1);
|
||||||
|
|
||||||
|
if (IsComputer)
|
||||||
|
{
|
||||||
|
PlayerName = ComputerNames[PlayerName];
|
||||||
|
PlayerIp = 0;
|
||||||
|
FactionId = int.Parse(playerEntry[2]);
|
||||||
|
Team = int.Parse(playerEntry[4]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PlayerIp = uint.Parse(playerEntry[1], System.Globalization.NumberStyles.HexNumber);
|
||||||
|
FactionId = int.Parse(playerEntry[5]);
|
||||||
|
Team = int.Parse(playerEntry[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PlayerName.Length == 20)
|
||||||
|
{
|
||||||
|
PlayerName = Ra3.BattleNet.Database.Utils.ChineseEncoding.DecodeChineseFromBase64(PlayerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
internal static class RA3Commands
|
||||||
|
{
|
||||||
|
private interface IArgumentReader
|
||||||
|
{
|
||||||
|
object Read(BinaryReader reader, int count);
|
||||||
|
}
|
||||||
|
private sealed class ArgumentReader<T> : IArgumentReader
|
||||||
|
{
|
||||||
|
private readonly Func<BinaryReader, T> _reader;
|
||||||
|
|
||||||
|
public ArgumentReader(Func<BinaryReader, T> reader) => _reader = reader;
|
||||||
|
|
||||||
|
public object Read(BinaryReader reader, int count)
|
||||||
|
{
|
||||||
|
if (count == 1)
|
||||||
|
{
|
||||||
|
return _reader(reader)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new T[count];
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
result[i] = _reader(reader);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static readonly Dictionary<CommandArgumentType, IArgumentReader> _readers = new()
|
||||||
|
{
|
||||||
|
[CommandArgumentType.Int32] = new ArgumentReader<int>(r => r.ReadInt32()),
|
||||||
|
[CommandArgumentType.Float32] = new ArgumentReader<float>(r => r.ReadSingle()),
|
||||||
|
[CommandArgumentType.Bool] = new ArgumentReader<bool>(r => r.ReadByte() != 0),
|
||||||
|
[CommandArgumentType.UInt16] = new ArgumentReader<ushort>(r => r.ReadUInt16()),
|
||||||
|
[CommandArgumentType.UInt32] = new ArgumentReader<uint>(r => r.ReadUInt32()),
|
||||||
|
[CommandArgumentType.UInt32_2] = new ArgumentReader<uint>(r => r.ReadUInt32()),
|
||||||
|
[CommandArgumentType.ObjectId] = new ArgumentReader<uint>(r => r.ReadUInt32()),
|
||||||
|
[CommandArgumentType.ObjectId_2] = new ArgumentReader<uint>(r => r.ReadUInt32()),
|
||||||
|
[CommandArgumentType.Vector3] = new ArgumentReader<Vector3>(ReadVector3),
|
||||||
|
[CommandArgumentType.AssetId] = new ArgumentReader<AssetId>(ReadAssetId),
|
||||||
|
[CommandArgumentType.AsciiString] = new ArgumentReader<string>(r => ReadString(r, CommandArgumentType.AsciiString)),
|
||||||
|
[CommandArgumentType.UnicodeString] = new ArgumentReader<string>(r => ReadString(r, CommandArgumentType.UnicodeString)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<int, string> _commandNames;
|
||||||
|
|
||||||
|
|
||||||
|
public static ImmutableArray<int> UnknownCommands { get; } = new[]
|
||||||
|
{
|
||||||
|
0x1FD, // unk
|
||||||
|
0x25F, // unk
|
||||||
|
}.ToImmutableArray();
|
||||||
|
|
||||||
|
public static ImmutableArray<int> AutoCommands { get; } = new[]
|
||||||
|
{
|
||||||
|
0x1,
|
||||||
|
0x221, // 3 seconds heartbeat
|
||||||
|
0x233, // uuid
|
||||||
|
0x234, // uuid
|
||||||
|
0x235, // player info
|
||||||
|
0x237, // indeterminate autogen
|
||||||
|
0x247, // 5th frame auto gen
|
||||||
|
0x252, // another player quits
|
||||||
|
}.ToImmutableArray();
|
||||||
|
|
||||||
|
|
||||||
|
static RA3Commands()
|
||||||
|
{
|
||||||
|
_commandNames = new()
|
||||||
|
{
|
||||||
|
[0x1] = "[游戏结束]", // 1
|
||||||
|
|
||||||
|
[0x1F5] = "选择单位", // 501
|
||||||
|
[0x1F6] = "选择相同单位(W)", // 502
|
||||||
|
[0x1F8] = "取消选择", // 504
|
||||||
|
[0x1F9] = "从选择中移除单位", // 505
|
||||||
|
[0x1FA] = "创建编队", // 506
|
||||||
|
[0x1FB] = "选择编队", // 507
|
||||||
|
[0x1FC] = "将编队加入选择", // 508
|
||||||
|
[0x1FD] = "(未知指令 0x1FD)", // 509
|
||||||
|
// 0x1FE: int special power id; int 0 unknown; int unit id count;
|
||||||
|
// unit ids; unit id 0;
|
||||||
|
[0x1FE] = "释放特殊能力(无目标)", // 510
|
||||||
|
[0x1FF] = "释放特殊能力(指定位置)", // 511
|
||||||
|
[0x200] = "释放特殊能力(指定位置和角度)", // 512
|
||||||
|
[0x201] = "释放特殊能力(指定目标)", // 513
|
||||||
|
[0x202] = "设置集结点", // 514
|
||||||
|
[0x203] = "开始升级", // 515
|
||||||
|
[0x204] = "暂停/中止升级", // 516
|
||||||
|
[0x205] = "开始出兵", // 517
|
||||||
|
[0x206] = "暂停/取消出兵", // 518
|
||||||
|
[0x207] = "开始建造", // 519
|
||||||
|
[0x208] = "暂停/取消建造", // 520
|
||||||
|
[0x209] = "摆放建筑", // 521
|
||||||
|
|
||||||
|
[0x20A] = "出售建筑", // 522
|
||||||
|
// 523
|
||||||
|
[0x20C] = "从进驻的建筑或载具撤出(?)", // 524
|
||||||
|
[0x20D] = "集火攻击", // 525
|
||||||
|
[0x20E] = "强制攻击单位(Ctrl)", // 526
|
||||||
|
[0x20F] = "强制攻击地板(Ctrl)", // 527
|
||||||
|
[0x210] = "进驻建筑或载具", // 528
|
||||||
|
// 529
|
||||||
|
[0x212] = "命令矿车交矿", // 530
|
||||||
|
// 531
|
||||||
|
[0x214] = "移动", // 532
|
||||||
|
[0x215] = "行进攻击(A)", // 533
|
||||||
|
[0x216] = "强制移动/碾压(G)", // 534
|
||||||
|
// 535
|
||||||
|
// 536
|
||||||
|
// 537
|
||||||
|
[0x21A] = "停止(S)", // 538
|
||||||
|
[0x21B] = "散开(X)", // 539
|
||||||
|
|
||||||
|
// 0x21E 542 有可能是 AI 信标相关的?
|
||||||
|
|
||||||
|
[0x221] = "[游戏每3秒自动产生的检测不同步指令]", // 545
|
||||||
|
|
||||||
|
[0x228] = "开始维修建筑", // 552
|
||||||
|
[0x229] = "停止维修建筑", // 553
|
||||||
|
[0x22A] = "选择所有单位(Q)", // 554
|
||||||
|
// 555
|
||||||
|
[0x22C] = "队形操作(左右键)", // 556
|
||||||
|
// 557 0x22D 似乎是付款
|
||||||
|
[0x22E] = "切换姿态(警戒/侵略/固守/停火模式)", // 558
|
||||||
|
[0x22F] = "路径点模式/计划模式(Alt)", // 559
|
||||||
|
// 560
|
||||||
|
// 561
|
||||||
|
[0x232] = "释放特殊能力(一个或多个目标)", // 562
|
||||||
|
[0x233] = "[游戏自动生成的UUID指令]", // 563
|
||||||
|
[0x234] = "[游戏自动产生的UUID]", // 564
|
||||||
|
[0x235] = "[玩家信息(?)]", // 565
|
||||||
|
[0x236] = "倒车(D)", // 566
|
||||||
|
[0x237] = "[游戏不定期自动产生的指令]", // 567
|
||||||
|
|
||||||
|
[0x247] = "[游戏在第五帧自动产生的指令]", // 583
|
||||||
|
[0x248] = "让矿车去采矿", // 584
|
||||||
|
|
||||||
|
[0x24B] = "信标", // 587
|
||||||
|
[0x24C] = "删除信标", // 588
|
||||||
|
[0x24D] = "在信标里输入文字", // 589
|
||||||
|
[0x24E] = "选择协议", // 590
|
||||||
|
|
||||||
|
[0x252] = "[其他玩家主动退出游戏]", // 594
|
||||||
|
|
||||||
|
[0x25F] = "(未知指令 0x25F)", // 607
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CommandArgumentEntry> ReadCommandData(this BinaryReader reader)
|
||||||
|
{
|
||||||
|
var list = new List<CommandArgumentEntry>();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
byte head = reader.ReadByte();
|
||||||
|
|
||||||
|
if (head == 0xFF)
|
||||||
|
break;
|
||||||
|
|
||||||
|
int count = (head >> 4) + 1;
|
||||||
|
var type = (CommandArgumentType)(head & 0xF);
|
||||||
|
|
||||||
|
var value = _readers[type].Read(reader, count);
|
||||||
|
|
||||||
|
list.Add(new CommandArgumentEntry(type, value, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsUnknownCommand(int commandId)
|
||||||
|
{
|
||||||
|
return !_commandNames.ContainsKey(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetCommandName(int commandId)
|
||||||
|
{
|
||||||
|
return _commandNames.TryGetValue(commandId, out var storedName)
|
||||||
|
? storedName
|
||||||
|
: $"(未知指令 0x{commandId:X2})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssetId ReadAssetId(BinaryReader reader)
|
||||||
|
{
|
||||||
|
var version = reader.ReadByte();
|
||||||
|
var typeId = reader.ReadUInt32();
|
||||||
|
var instanceId = reader.ReadUInt32();
|
||||||
|
return new(TypeId: typeId, InstanceId: instanceId);
|
||||||
|
}
|
||||||
|
private static Vector3 ReadVector3(BinaryReader reader)
|
||||||
|
{
|
||||||
|
var x = reader.ReadSingle();
|
||||||
|
var y = reader.ReadSingle();
|
||||||
|
var z = reader.ReadSingle();
|
||||||
|
return new(X: x, Y: y, Z: z);
|
||||||
|
}
|
||||||
|
private static string ReadString(BinaryReader current, CommandArgumentType type)
|
||||||
|
{
|
||||||
|
var length = (int)current.ReadByte();
|
||||||
|
if (length == 0xFF)
|
||||||
|
{
|
||||||
|
length = current.ReadInt32();
|
||||||
|
}
|
||||||
|
// read byte string or wchar_t string based on T type
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case CommandArgumentType.AsciiString:
|
||||||
|
return Encoding.UTF8.GetString(current.ReadBytes(length));
|
||||||
|
case CommandArgumentType.UnicodeString:
|
||||||
|
return Encoding.Unicode.GetString(current.ReadBytes(length * 2));
|
||||||
|
}
|
||||||
|
throw new InvalidDataException($"Invalid string type {type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
internal enum ReplayType
|
||||||
|
{
|
||||||
|
Skirmish,
|
||||||
|
Lan,
|
||||||
|
Online
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Replay
|
||||||
|
{
|
||||||
|
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
||||||
|
public static readonly Dictionary<ReplayType, string> TypeStrings = new()
|
||||||
|
{
|
||||||
|
{ ReplayType.Skirmish, "遭遇战录像" },
|
||||||
|
{ ReplayType.Lan, "局域网录像" },
|
||||||
|
{ ReplayType.Online, "官网录像" },
|
||||||
|
};
|
||||||
|
public const double FrameRate = 15.0;
|
||||||
|
public const string PostCommentator = "post Commentator";
|
||||||
|
|
||||||
|
private readonly byte _replaySaverIndex;
|
||||||
|
private readonly byte[]? _rawHeader;
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
public DateTime Date { get; }
|
||||||
|
public string MapName { get; }
|
||||||
|
public string MapPath { get; }
|
||||||
|
public ImmutableArray<Player> Players { get; }
|
||||||
|
public int NumberOfPlayingPlayers { get; }
|
||||||
|
public Mod Mod { get; }
|
||||||
|
public ReplayType Type { get; }
|
||||||
|
public bool HasCommentator { get; }
|
||||||
|
|
||||||
|
public long Size { get; }
|
||||||
|
public ReplayFooter? Footer { get; }
|
||||||
|
public ImmutableArray<ReplayChunk>? Body { get; }
|
||||||
|
|
||||||
|
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||||
|
public Player ReplaySaver => Players[_replaySaverIndex];
|
||||||
|
public string TypeString => TypeStrings[Type];
|
||||||
|
public bool HasFooter => Footer != null;
|
||||||
|
public ShortTimeSpan? Length => Footer?.ReplayLength;
|
||||||
|
|
||||||
|
public Replay(string path, bool parseBody = false) :
|
||||||
|
this(path, new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), parseBody)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Replay(string path, Stream stream, bool parseBody = false)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
|
||||||
|
using var reader = new BinaryReader(stream);
|
||||||
|
Size = reader.BaseStream.Length;
|
||||||
|
|
||||||
|
var headerMagic = reader.ReadBytes(HeaderMagic.Length);
|
||||||
|
if (!headerMagic.SequenceEqual(HeaderMagic))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"{Path} is not a replay, header is {BitConverter.ToString(headerMagic)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSkirmish = reader.ReadByte() == 0x04;
|
||||||
|
reader.ReadBytes(4 * 4); // version and builds
|
||||||
|
reader.ReadBytes(2); // commentary flag, and padding zero byte
|
||||||
|
|
||||||
|
reader.ReadUTF16String(); // title
|
||||||
|
reader.ReadUTF16String(); // description
|
||||||
|
MapName = reader.ReadUTF16String(); // map name
|
||||||
|
reader.ReadUTF16String(); // map id
|
||||||
|
|
||||||
|
NumberOfPlayingPlayers = reader.ReadByte();
|
||||||
|
|
||||||
|
for (var i = 0; i <= NumberOfPlayingPlayers; ++i)
|
||||||
|
{
|
||||||
|
reader.ReadUInt32();
|
||||||
|
reader.ReadUTF16String(); // utf16 player name
|
||||||
|
if (!isSkirmish)
|
||||||
|
{
|
||||||
|
reader.ReadByte(); // team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = reader.ReadInt32();
|
||||||
|
var cnc3MagicLength = reader.ReadInt32();
|
||||||
|
var headerSize = checked((int)(reader.BaseStream.Position + offset));
|
||||||
|
reader.ReadBytes(cnc3MagicLength);
|
||||||
|
|
||||||
|
var modInfo = reader.ReadBytes(22);
|
||||||
|
Mod = new Mod(Encoding.UTF8.GetString(modInfo));
|
||||||
|
|
||||||
|
var timeStamp = reader.ReadUInt32();
|
||||||
|
Date = DateTimeOffset.FromUnixTimeSeconds(timeStamp).DateTime;
|
||||||
|
|
||||||
|
reader.ReadBytes(31);
|
||||||
|
var descriptionsLength = reader.ReadInt32();
|
||||||
|
var description = Encoding.UTF8.GetString(reader.ReadBytes(descriptionsLength));
|
||||||
|
|
||||||
|
var entries = null as Dictionary<string, string>;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = from splitted in description.Split(';')
|
||||||
|
where !string.IsNullOrWhiteSpace(splitted)
|
||||||
|
select splitted.Split(new[] { '=' }, 2);
|
||||||
|
entries = query.ToDictionary(x => x[0], x => x[1]);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Players = entries["S"].Split(':')
|
||||||
|
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
||||||
|
.Select(x => new Player(x.Split(',')))
|
||||||
|
.ToImmutableArray();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Failed to parse playerdata from string header of replay {Path}: \r\n{exception}");
|
||||||
|
}
|
||||||
|
|
||||||
|
MapPath = entries["M"].Substring(3);
|
||||||
|
|
||||||
|
HasCommentator = !entries["PC"].Equals("-1");
|
||||||
|
|
||||||
|
var lanFlag = int.Parse(entries["GT"]) == 0;
|
||||||
|
if (lanFlag)
|
||||||
|
{
|
||||||
|
if (Players.First().PlayerIp == 0)
|
||||||
|
{
|
||||||
|
Type = ReplayType.Skirmish;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Type = ReplayType.Lan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Type = ReplayType.Online;
|
||||||
|
}
|
||||||
|
|
||||||
|
_replaySaverIndex = reader.ReadByte();
|
||||||
|
|
||||||
|
reader.ReadBytes(8); // 8 bit paddings
|
||||||
|
var fileNameLength = reader.ReadInt32();
|
||||||
|
reader.ReadBytes(fileNameLength * 2);
|
||||||
|
reader.ReadBytes(16);
|
||||||
|
var verMagicLength = reader.ReadInt32();
|
||||||
|
reader.ReadBytes(verMagicLength);
|
||||||
|
reader.ReadBytes(85);
|
||||||
|
|
||||||
|
if (reader.BaseStream.Position != headerSize)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (acutally {reader.BaseStream.Position})\r\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parseBody)
|
||||||
|
{
|
||||||
|
// jump to footer directly
|
||||||
|
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Failed to parse replay footer, replay might be corrupt: {e}\r\n";
|
||||||
|
Footer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = new List<ReplayChunk>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var timeCode = reader.ReadUInt32();
|
||||||
|
if (timeCode == ReplayFooter.Terminator)
|
||||||
|
{
|
||||||
|
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Add(new ReplayChunk(timeCode, reader));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Failed to parse replay body, replay might be corrupt: {e}\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[]? rawHeader = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 重新读取原来的整个录像头
|
||||||
|
reader.BaseStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
rawHeader = reader.ReadBytes(headerSize);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Warning: failed to read raw header: {e}\r\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rawHeader.Length != headerSize)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (raw header length = {rawHeader.Length})\r\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rawHeader = rawHeader;
|
||||||
|
Body = body.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Replay CloneHeader()
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
|
||||||
|
{
|
||||||
|
WriteTo(writer);
|
||||||
|
}
|
||||||
|
stream.Position = 0;
|
||||||
|
return new Replay(Path, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool PathEquals(Replay replay) => PathEquals(replay.Path);
|
||||||
|
public bool PathEquals(string path) => Path.Equals(path, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string GetDetails()
|
||||||
|
{
|
||||||
|
static string GetSizeString(double size)
|
||||||
|
{
|
||||||
|
if (size > 1024 * 1024)
|
||||||
|
{
|
||||||
|
return $"{Math.Round(size / (1024 * 1024), 2)}MB";
|
||||||
|
}
|
||||||
|
return $"{Math.Round(size / 1024)}KB";
|
||||||
|
}
|
||||||
|
|
||||||
|
string size = GetSizeString(Size);
|
||||||
|
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
|
||||||
|
|
||||||
|
var replaySaver = _replaySaverIndex < Players.Length
|
||||||
|
? ReplaySaver.PlayerName
|
||||||
|
: "[无法获取保存录像的玩家]";
|
||||||
|
|
||||||
|
using var writer = new StringWriter();
|
||||||
|
writer.WriteLine("文件名:{0}", FileName);
|
||||||
|
writer.WriteLine("大小:{0}", size);
|
||||||
|
writer.WriteLine("地图:{0}", MapName);
|
||||||
|
writer.WriteLine("日期:{0}", Date);
|
||||||
|
writer.WriteLine("长度:{0}", length);
|
||||||
|
writer.WriteLine("录像类别:{0}", TypeString);
|
||||||
|
writer.WriteLine("这个文件是{0}保存的", replaySaver);
|
||||||
|
writer.WriteLine("玩家列表:");
|
||||||
|
foreach (var player in Players)
|
||||||
|
{
|
||||||
|
if (player == Players.Last() && player.PlayerName.Equals(PostCommentator))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var factionName = ModData.GetFaction(Mod, player.FactionId).Name;
|
||||||
|
writer.WriteLine($"{player.PlayerName},{factionName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return writer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteTo(BinaryWriter writer) => WriteTo(writer, false);
|
||||||
|
|
||||||
|
private void WriteTo(BinaryWriter writer, bool skipBody)
|
||||||
|
{
|
||||||
|
if ((_rawHeader is null || Body is null) && !skipBody)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Replay body must be parsed before writing replay");
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(_rawHeader);
|
||||||
|
|
||||||
|
var lastTimeCode = Footer?.FinalTimeCode;
|
||||||
|
if (Body is not null)
|
||||||
|
{
|
||||||
|
foreach (var chunk in Body)
|
||||||
|
{
|
||||||
|
lastTimeCode = chunk.TimeCode;
|
||||||
|
writer.Write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Footer is not null)
|
||||||
|
{
|
||||||
|
writer.Write(Footer);
|
||||||
|
}
|
||||||
|
else if (lastTimeCode is uint lastTimeCodeValue)
|
||||||
|
{
|
||||||
|
writer.Write(new ReplayFooter(lastTimeCodeValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
public sealed class ReplayChunk
|
||||||
|
{
|
||||||
|
private readonly byte[] _data;
|
||||||
|
|
||||||
|
public uint TimeCode { get; }
|
||||||
|
public byte Type { get; }
|
||||||
|
|
||||||
|
public TimeSpan Time => TimeSpan.FromSeconds(TimeCode / Replay.FrameRate);
|
||||||
|
|
||||||
|
public ReplayChunk(uint timeCode, BinaryReader reader)
|
||||||
|
{
|
||||||
|
TimeCode = timeCode; // reader.ReadUInt32();
|
||||||
|
Type = reader.ReadByte();
|
||||||
|
var chunkSize = reader.ReadInt32();
|
||||||
|
_data = reader.ReadBytes(chunkSize);
|
||||||
|
if (reader.ReadInt32() != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Replay Chunk not ended with zero");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BinaryReader GetReader() => new(new MemoryStream(_data, false));
|
||||||
|
|
||||||
|
public void WriteTo(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(TimeCode);
|
||||||
|
writer.Write(Type);
|
||||||
|
writer.Write(_data.Length);
|
||||||
|
writer.Write(_data);
|
||||||
|
writer.Write(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
internal static class ReplayExtensions
|
||||||
|
{
|
||||||
|
public static string ReadUTF16String(this BinaryReader reader)
|
||||||
|
{
|
||||||
|
var currentBytes = new List<byte>();
|
||||||
|
var lastTwoBytes = Array.Empty<byte>();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
lastTwoBytes = reader.ReadBytes(2);
|
||||||
|
if (lastTwoBytes.Length != 2)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException();
|
||||||
|
}
|
||||||
|
if (lastTwoBytes.All(x => x == 0))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentBytes.AddRange(lastTwoBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.Unicode.GetString(currentBytes.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(this BinaryWriter writer, ReplayChunk chunk) => chunk.WriteTo(writer);
|
||||||
|
|
||||||
|
public static void Write(this BinaryWriter writer, ReplayFooter footer) => footer.WriteTo(writer);
|
||||||
|
|
||||||
|
public static void Write(this BinaryWriter writer, Replay replay) => replay.WriteTo(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.ReplayFile
|
||||||
|
{
|
||||||
|
internal enum ReplayFooterOption
|
||||||
|
{
|
||||||
|
SeekToFooter,
|
||||||
|
CurrentlyAtFooter,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ReplayFooter
|
||||||
|
{
|
||||||
|
public const uint Terminator = 0x7FFFFFFF;
|
||||||
|
public static readonly byte[] FooterString = Encoding.ASCII.GetBytes("RA3 REPLAY FOOTER");
|
||||||
|
|
||||||
|
private readonly byte[] _data;
|
||||||
|
|
||||||
|
public uint FinalTimeCode { get; }
|
||||||
|
public TimeSpan ReplayLength => TimeSpan.FromSeconds(FinalTimeCode / Replay.FrameRate);
|
||||||
|
|
||||||
|
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
||||||
|
{
|
||||||
|
var currentPosition = reader.BaseStream.Position;
|
||||||
|
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||||
|
var footerLength = reader.ReadInt32();
|
||||||
|
|
||||||
|
if (option == ReplayFooterOption.SeekToFooter)
|
||||||
|
{
|
||||||
|
currentPosition = reader.BaseStream.Length - footerLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.BaseStream.Seek(currentPosition, SeekOrigin.Begin);
|
||||||
|
var footer = reader.ReadBytes(footerLength);
|
||||||
|
|
||||||
|
if (footer.Length != footerLength || reader.BaseStream.Position != reader.BaseStream.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid footer");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var footerStream = new MemoryStream(footer);
|
||||||
|
using var footerReader = new BinaryReader(footerStream);
|
||||||
|
var footerString = footerReader.ReadBytes(17);
|
||||||
|
if (!footerString.SequenceEqual(FooterString))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid footer, no footer string");
|
||||||
|
}
|
||||||
|
FinalTimeCode = footerReader.ReadUInt32();
|
||||||
|
_data = footerReader.ReadBytes(footer.Length - 25);
|
||||||
|
if (footerReader.ReadInt32() != footerLength)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReplayFooter(uint finalTimeCode)
|
||||||
|
{
|
||||||
|
FinalTimeCode = finalTimeCode;
|
||||||
|
_data = new byte[] { 0x02, 0x1A, 0x00, 0x00, 0x00 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[]? TryGetKillDeathRatios()
|
||||||
|
{
|
||||||
|
if (_data.Length < 24)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ratios = new float[6];
|
||||||
|
using var stream = new MemoryStream(_data, _data.Length - 24, 24);
|
||||||
|
using var reader = new BinaryReader(stream);
|
||||||
|
for (var i = 0; i < ratios.Length; ++i)
|
||||||
|
{
|
||||||
|
ratios[i] = reader.ReadSingle();
|
||||||
|
}
|
||||||
|
return ratios;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteTo(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Terminator);
|
||||||
|
writer.Write(FooterString);
|
||||||
|
writer.Write(FinalTimeCode);
|
||||||
|
writer.Write(_data);
|
||||||
|
writer.Write(FooterString.Length + _data.Length + 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
|||||||
|
using AnotherReplayReader.Utils;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
internal class UpdateCheckerVersionData
|
||||||
|
{
|
||||||
|
public string NewVersion { get; }
|
||||||
|
public string Description { get; }
|
||||||
|
public ImmutableArray<string> Urls { get; }
|
||||||
|
|
||||||
|
public UpdateCheckerVersionData(string newVersion,
|
||||||
|
string description,
|
||||||
|
ImmutableArray<string> urls)
|
||||||
|
{
|
||||||
|
NewVersion = newVersion;
|
||||||
|
Description = description;
|
||||||
|
Urls = urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsNewVersion() => NewVersion != App.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UpdateChecker
|
||||||
|
{
|
||||||
|
public const string CheckForUpdatesKey = "checkForUpdates";
|
||||||
|
public const string CachedDataKey = "cachedUpdateData";
|
||||||
|
private static readonly ImmutableArray<string> _updateSources = new[]
|
||||||
|
{
|
||||||
|
"https://lanyi.altervista.org/playertable/file.json"
|
||||||
|
}.ToImmutableArray();
|
||||||
|
|
||||||
|
public static Task<UpdateCheckerVersionData> CheckForUpdates(Cache cache)
|
||||||
|
{
|
||||||
|
var taskSource = new TaskCompletionSource<UpdateCheckerVersionData>();
|
||||||
|
if (cache.GetOrDefault(CheckForUpdatesKey, false) is not true)
|
||||||
|
{
|
||||||
|
return taskSource.Task;
|
||||||
|
}
|
||||||
|
foreach (var source in _updateSources)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = await DownloadAndVerify(source).ConfigureAwait(false);
|
||||||
|
if (data.IsNewVersion())
|
||||||
|
{
|
||||||
|
cache.Set(CachedDataKey, data);
|
||||||
|
taskSource.TrySetResult(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Update check failed: {e}";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return taskSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UpdateCheckerVersionData> DownloadAndVerify(string url)
|
||||||
|
{
|
||||||
|
var payload = await Network.HttpGetJson<VerifierPayload>(url)
|
||||||
|
?? throw new InvalidDataException(nameof(VerifierPayload) + " is null");
|
||||||
|
if (!Verifier.Verify(payload))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(nameof(VerifierPayload) + " cannot be verified");
|
||||||
|
}
|
||||||
|
return JsonSerializer.Deserialize<UpdateCheckerVersionData>(payload.ByteData.Value, Network.CommonJsonOptions)
|
||||||
|
?? throw new InvalidDataException(nameof(UpdateCheckerVersionData) + " is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Sign()
|
||||||
|
{
|
||||||
|
var sample = new UpdateCheckerVersionData(App.Version, "Alice Margatroid", _updateSources);
|
||||||
|
var sampleText = JsonSerializer.Serialize(sample, Network.CommonJsonOptions);
|
||||||
|
Verifier.Sign(sampleText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1528
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal static class AIAnalyzeUI
|
||||||
|
{
|
||||||
|
public static List<StringBuilder> Debug { get; } = [];
|
||||||
|
|
||||||
|
public record AIAnalyzeProgressData(AIAnalyze.AIChunk Delta, DateTimeOffset? TimeStamp, bool IsExtra);
|
||||||
|
|
||||||
|
public class EmaSpeed
|
||||||
|
{
|
||||||
|
const double Tau = 2;
|
||||||
|
|
||||||
|
private DateTimeOffset lastEventTime = DateTimeOffset.UtcNow;
|
||||||
|
private DateTimeOffset timeSinceLastSpeedMeasure = DateTimeOffset.UtcNow;
|
||||||
|
private int bufferedCharactersSinceLastSpeedMeasure = 0;
|
||||||
|
private double emaSpeed = double.NaN;
|
||||||
|
|
||||||
|
public void ProcessEvent(int textLength, DateTimeOffset? eventTime)
|
||||||
|
{
|
||||||
|
if (eventTime is { } value)
|
||||||
|
{
|
||||||
|
lastEventTime = value;
|
||||||
|
}
|
||||||
|
bufferedCharactersSinceLastSpeedMeasure += textLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double GetDisplaySpeed(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
// =========================
|
||||||
|
// 2. 计算 instant speed(真实时间)
|
||||||
|
// =========================
|
||||||
|
double instant = 0;
|
||||||
|
double dt = (now - timeSinceLastSpeedMeasure).TotalSeconds;
|
||||||
|
|
||||||
|
if (/*bufferedCharactersSinceLastSpeedMeasure > 0 && */dt > 0.05)
|
||||||
|
{
|
||||||
|
instant = bufferedCharactersSinceLastSpeedMeasure / dt;
|
||||||
|
|
||||||
|
// reset window
|
||||||
|
bufferedCharactersSinceLastSpeedMeasure = 0;
|
||||||
|
timeSinceLastSpeedMeasure = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
double alpha = 1 - Math.Exp(-dt / Tau);
|
||||||
|
|
||||||
|
if (double.IsNaN(emaSpeed))
|
||||||
|
{
|
||||||
|
emaSpeed = instant;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
emaSpeed += alpha * (instant - emaSpeed);
|
||||||
|
}
|
||||||
|
double idle = (now - lastEventTime).TotalSeconds;
|
||||||
|
double display = emaSpeed;
|
||||||
|
|
||||||
|
//if (idle > 0.5)
|
||||||
|
//{
|
||||||
|
// double decay = Math.Exp(-(idle - 0.5) / Tau);
|
||||||
|
// display *= decay;
|
||||||
|
//}
|
||||||
|
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Action<AIAnalyze.AIChunk> BuildAIChunkReader(Action<AIAnalyzeProgressData> newContent)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
Debug.Add(sb);
|
||||||
|
void OnAIChunk(AIAnalyze.AIChunk c)
|
||||||
|
{
|
||||||
|
if (c.Type == AIAnalyze.AIChunkType.Json)
|
||||||
|
{
|
||||||
|
sb.AppendLine(c.Text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newContent(new(Delta: c, TimeStamp: DateTimeOffset.UtcNow, IsExtra: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return OnAIChunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 上下文预算策略:每模型软上限、输出余量、估算安全系数与请求护栏。
|
||||||
|
/// </summary>
|
||||||
|
internal static class AiContextBudget
|
||||||
|
{
|
||||||
|
public const int Tier1MBudget = 160_000;
|
||||||
|
public const int Tier256KBudget = 100_000;
|
||||||
|
public const double EstimatorSafetyFactor = 1.2;
|
||||||
|
public const double HardUsageRatio = 0.9;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取模型的一次请求总 token 软上限。0 表示该模型不支持长录像(只能短录像单 slice)。
|
||||||
|
/// </summary>
|
||||||
|
public static int GetContextBudget(AiModel model)
|
||||||
|
{
|
||||||
|
if (model.ContextBudget is { } explicitBudget && explicitBudget > 0)
|
||||||
|
{
|
||||||
|
return explicitBudget;
|
||||||
|
}
|
||||||
|
if (model.ContextLength >= 1_000_000)
|
||||||
|
{
|
||||||
|
return Tier1MBudget;
|
||||||
|
}
|
||||||
|
if (model.ContextLength >= 200_000)
|
||||||
|
{
|
||||||
|
return Tier256KBudget;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为输出/推理 tokens 预留的余量。</summary>
|
||||||
|
public static int GetOutputHeadroom(AiProvider provider, AiModel model)
|
||||||
|
{
|
||||||
|
var maxTokens = provider.DefaultMaxTokens;
|
||||||
|
return Math.Max(2 * maxTokens, 32_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>带安全系数的 token 估算(对中文偏乐观的 bytes/2.2 估算 × 1.2)。</summary>
|
||||||
|
public static int EstimateTokens(string text) =>
|
||||||
|
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
|
||||||
|
|
||||||
|
/// <summary>请求护栏:只按 prompt 估算检查(保留给旧调用/测试使用)。</summary>
|
||||||
|
public static ContextCheckResult CheckPromptUsage(
|
||||||
|
int estimatedPromptTokens,
|
||||||
|
AiProvider provider,
|
||||||
|
AiModel model)
|
||||||
|
{
|
||||||
|
return CheckUsage(estimatedPromptTokens, provider, model, includeOutputHeadroom: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>请求护栏:把输出/推理余量也算入总用量。</summary>
|
||||||
|
public static ContextCheckResult CheckRequestUsage(
|
||||||
|
int estimatedPromptTokens,
|
||||||
|
AiProvider provider,
|
||||||
|
AiModel model)
|
||||||
|
{
|
||||||
|
return CheckUsage(estimatedPromptTokens, provider, model, includeOutputHeadroom: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContextCheckResult CheckUsage(
|
||||||
|
int estimatedPromptTokens,
|
||||||
|
AiProvider provider,
|
||||||
|
AiModel model,
|
||||||
|
bool includeOutputHeadroom)
|
||||||
|
{
|
||||||
|
var headroom = includeOutputHeadroom ? GetOutputHeadroom(provider, model) : 0;
|
||||||
|
var estimatedTotal = estimatedPromptTokens + headroom;
|
||||||
|
if (model.ContextLength > 0)
|
||||||
|
{
|
||||||
|
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
|
||||||
|
if (estimatedTotal > hardLimit)
|
||||||
|
{
|
||||||
|
return new ContextCheckResult(
|
||||||
|
true,
|
||||||
|
includeOutputHeadroom
|
||||||
|
? $"估算用量(输入 {estimatedPromptTokens:N0} + 输出余量 {headroom:N0} = {estimatedTotal:N0})超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。"
|
||||||
|
: $"估算输入 {estimatedPromptTokens:N0} token 超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var budget = GetContextBudget(model);
|
||||||
|
if (budget > 0 && estimatedTotal > budget)
|
||||||
|
{
|
||||||
|
return new ContextCheckResult(
|
||||||
|
false,
|
||||||
|
includeOutputHeadroom
|
||||||
|
? $"估算用量(输入 {estimatedPromptTokens:N0} + 输出余量 {headroom:N0} = {estimatedTotal:N0})超过上下文预算 {budget:N0}(可在模型设置中调整)。"
|
||||||
|
: $"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
|
||||||
|
}
|
||||||
|
return ContextCheckResult.Ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ContextCheckResult(bool Block, string Message)
|
||||||
|
{
|
||||||
|
public static ContextCheckResult Ok { get; } = new(false, string.Empty);
|
||||||
|
public bool IsOk => !Block && string.IsNullOrEmpty(Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Predefined tag taxonomy for KnowledgeEntry.
|
||||||
|
/// Tags bridge prompt rendering and validation logic.
|
||||||
|
/// </summary>
|
||||||
|
internal static class KnowledgeTag
|
||||||
|
{
|
||||||
|
// ── Capability tags (what a unit can do) ──────────────────────
|
||||||
|
public const string Builder = "builder";
|
||||||
|
public const string Pack = "pack";
|
||||||
|
public const string Unpack = "unpack";
|
||||||
|
public const string Amphibious = "amphibious";
|
||||||
|
public const string Transport = "transport";
|
||||||
|
public const string ReturnToProducer = "returnToProducer";
|
||||||
|
public const string Cloak = "cloak";
|
||||||
|
public const string ToggleWeapon = "toggleWeapon";
|
||||||
|
|
||||||
|
// ── Type tags (what a unit is) ────────────────────────────────
|
||||||
|
public const string Infantry = "infantry";
|
||||||
|
public const string Vehicle = "vehicle";
|
||||||
|
public const string Aircraft = "aircraft";
|
||||||
|
public const string Naval = "naval";
|
||||||
|
public const string Structure = "structure";
|
||||||
|
public const string Hero = "hero";
|
||||||
|
public const string Production = "production";
|
||||||
|
public const string Defense = "defense";
|
||||||
|
public const string Superweapon = "superweapon";
|
||||||
|
public const string Land = "land";
|
||||||
|
public const string Sea = "sea";
|
||||||
|
public const string Air = "air";
|
||||||
|
|
||||||
|
// ── Combat role tags (what a unit fights) ─────────────────────
|
||||||
|
public const string AntiInfantry = "antiInfantry";
|
||||||
|
public const string AntiVehicle = "antiVehicle";
|
||||||
|
public const string AntiStructure = "antiStructure";
|
||||||
|
public const string AntiAir = "antiAir";
|
||||||
|
public const string AntiNaval = "antiNaval";
|
||||||
|
public const string AntiGround = "antiGround";
|
||||||
|
|
||||||
|
// ── 其余 JSON 实际使用的角色/定位标签 ──────────────────────────
|
||||||
|
public const string Miner = "miner";
|
||||||
|
public const string Scout = "scout";
|
||||||
|
public const string Support = "support";
|
||||||
|
public const string Siege = "siege";
|
||||||
|
public const string Bomber = "bomber";
|
||||||
|
public const string Engineer = "engineer";
|
||||||
|
public const string Fighter = "fighter";
|
||||||
|
|
||||||
|
/// <summary>完整标签集合:加载 JSON 时校验未知 tag 用。</summary>
|
||||||
|
public static readonly ImmutableArray<string> All = ImmutableArray.Create(
|
||||||
|
Builder, Pack, Unpack, Amphibious, Transport, ReturnToProducer, Cloak, ToggleWeapon,
|
||||||
|
Miner, Scout, Support, Siege, Bomber,
|
||||||
|
Infantry, Vehicle, Aircraft, Naval, Structure, Hero, Production, Defense, Superweapon,
|
||||||
|
Land, Sea, Air,
|
||||||
|
AntiInfantry, AntiVehicle, AntiStructure, AntiAir, AntiNaval, AntiGround,
|
||||||
|
Engineer, Fighter);
|
||||||
|
|
||||||
|
/// <summary>Create a special power reference tag.</summary>
|
||||||
|
public static string SpecialPower(string powerName) => $"specialPower:{powerName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scope kind of a knowledge entry within a KnowledgeSet.
|
||||||
|
/// Scope is determined by the entry's position in the hierarchy,
|
||||||
|
/// not stored in the entry itself.
|
||||||
|
/// </summary>
|
||||||
|
internal enum KnowledgeScopeKind
|
||||||
|
{
|
||||||
|
Global,
|
||||||
|
Faction,
|
||||||
|
Map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which scope a knowledge entry belongs to.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record KnowledgeScope(KnowledgeScopeKind Kind, string? Name = null)
|
||||||
|
{
|
||||||
|
public static KnowledgeScope Global { get; } = new(KnowledgeScopeKind.Global);
|
||||||
|
public static KnowledgeScope Faction(string name) => new(KnowledgeScopeKind.Faction, name);
|
||||||
|
public static KnowledgeScope Map(string id) => new(KnowledgeScopeKind.Map, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The smallest reusable unit of game knowledge.
|
||||||
|
/// Id is unique within a knowledge set; tags enable validation queries;
|
||||||
|
/// text is the markdown description used for prompt rendering.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record KnowledgeEntry(
|
||||||
|
string Id,
|
||||||
|
ImmutableArray<string> Tags,
|
||||||
|
string Text)
|
||||||
|
{
|
||||||
|
public bool HasTag(string tag) => Tags.Contains(tag, StringComparer.OrdinalIgnoreCase);
|
||||||
|
public bool HasAnyTag(params string[] tags) => tags.Any(HasTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Structured game entity knowledge ───────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>A special power with its observable name and description.</summary>
|
||||||
|
internal sealed record SpecialPowerInfo(string Name, string Description);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structured knowledge about a game entity (unit or building).
|
||||||
|
/// Buildings omit <see cref="Tier"/> and <see cref="ProducedBy"/>;
|
||||||
|
/// the <c>structure</c> tag distinguishes them from units.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record EntityKnowledge(
|
||||||
|
string AssetName,
|
||||||
|
string DisplayName,
|
||||||
|
string Faction,
|
||||||
|
string? Tier,
|
||||||
|
ImmutableArray<string> Tags,
|
||||||
|
ImmutableArray<SpecialPowerInfo> SpecialPowers,
|
||||||
|
ImmutableArray<string> ProducedBy,
|
||||||
|
ImmutableArray<string> Aliases,
|
||||||
|
string Text)
|
||||||
|
{
|
||||||
|
public bool IsBuilding => HasTag(KnowledgeTag.Structure);
|
||||||
|
public bool IsUnit => !IsBuilding;
|
||||||
|
public bool HasTag(string tag) => Tags.Contains(tag, StringComparer.OrdinalIgnoreCase);
|
||||||
|
public bool HasSpecialPower(string name) =>
|
||||||
|
SpecialPowers.Any(sp => string.Equals(sp.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory index of structured game knowledge loaded from knowledge_units.json.
|
||||||
|
/// Used by validation to query unit capabilities deterministically.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StructuredKnowledge
|
||||||
|
{
|
||||||
|
public ImmutableDictionary<string, EntityKnowledge> EntitiesByAssetName { get; }
|
||||||
|
public ImmutableArray<EntityKnowledge> AllEntities { get; }
|
||||||
|
public ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> EntitiesByFaction { get; }
|
||||||
|
public ImmutableArray<string> UnknownTags { get; }
|
||||||
|
|
||||||
|
private StructuredKnowledge(
|
||||||
|
ImmutableDictionary<string, EntityKnowledge> byAsset,
|
||||||
|
ImmutableArray<EntityKnowledge> allEntities,
|
||||||
|
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction,
|
||||||
|
ImmutableArray<string> unknownTags)
|
||||||
|
{
|
||||||
|
EntitiesByAssetName = byAsset;
|
||||||
|
AllEntities = allEntities;
|
||||||
|
EntitiesByFaction = byFaction;
|
||||||
|
UnknownTags = unknownTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public ImmutableArray<EntityKnowledge> EntitiesWithTag(string tag) =>
|
||||||
|
AllEntities.Where(e => e.HasTag(tag)).ToImmutableArray();
|
||||||
|
|
||||||
|
public ImmutableArray<EntityKnowledge> EntitiesWithSpecialPower(string power) =>
|
||||||
|
AllEntities.Where(e => e.HasSpecialPower(power)).ToImmutableArray();
|
||||||
|
|
||||||
|
public EntityKnowledge? GetEntity(string? assetName) =>
|
||||||
|
assetName is not null && EntitiesByAssetName.TryGetValue(assetName, out var e) ? e : null;
|
||||||
|
|
||||||
|
public bool IsKnownBuilder(string? assetName) =>
|
||||||
|
GetEntity(assetName)?.HasTag(KnowledgeTag.Builder) == true;
|
||||||
|
|
||||||
|
public bool EntityHasSpecialPower(string? assetName, string? powerName) =>
|
||||||
|
assetName is not null && powerName is not null &&
|
||||||
|
GetEntity(assetName)?.HasSpecialPower(powerName) == true;
|
||||||
|
|
||||||
|
// ── Factory ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly Lazy<StructuredKnowledge?> _lazyDefault = new(() => GetForMod("default"));
|
||||||
|
|
||||||
|
public static StructuredKnowledge? Instance => _lazyDefault.Value;
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<string, StructuredKnowledge?> _cache =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>按 mod 加载结构化知识;文件不存在返回 null(该 mod 无结构化数据)。</summary>
|
||||||
|
public static StructuredKnowledge? GetForMod(string? modName)
|
||||||
|
{
|
||||||
|
var key = modName ?? "default";
|
||||||
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
var loaded = LoadFromFile(key);
|
||||||
|
if (loaded is not null)
|
||||||
|
{
|
||||||
|
_cache.TryAdd(key, loaded);
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Look up the display name for an asset name.</summary>
|
||||||
|
public string? GetDisplayName(string? assetName) =>
|
||||||
|
GetEntity(assetName)?.DisplayName;
|
||||||
|
|
||||||
|
private static StructuredKnowledge? LoadFromFile(string modName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, $"knowledge_units_{modName}.json");
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builtin = ParseFactions(path, out var unknownTags);
|
||||||
|
if (builtin is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户知识覆盖:AnotherReplayReader.user_knowledge.json,按 (阵营, assetName) 覆盖或新增
|
||||||
|
var userPath = Path.Combine(AppContext.BaseDirectory, "AnotherReplayReader.user_knowledge.json");
|
||||||
|
if (File.Exists(userPath))
|
||||||
|
{
|
||||||
|
var user = ParseFactions(userPath, out var userUnknownTags);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
foreach (var kv in user)
|
||||||
|
{
|
||||||
|
if (!builtin.TryGetValue(kv.Key, out var factionEntries))
|
||||||
|
{
|
||||||
|
factionEntries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
builtin[kv.Key] = factionEntries;
|
||||||
|
}
|
||||||
|
foreach (var entity in kv.Value)
|
||||||
|
{
|
||||||
|
factionEntries[entity.Key] = entity.Value;
|
||||||
|
}
|
||||||
|
unknownTags.UnionWith(userUnknownTags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allEntities = builtin.Values
|
||||||
|
.SelectMany(d => d.Values)
|
||||||
|
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
var byAsset = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var entity in allEntities)
|
||||||
|
{
|
||||||
|
byAsset[entity.AssetName] = entity;
|
||||||
|
foreach (var alias in entity.Aliases)
|
||||||
|
{
|
||||||
|
byAsset[alias] = entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var byFaction = builtin.ToImmutableDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var unknownTagsArray = unknownTags.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToImmutableArray();
|
||||||
|
if (!unknownTagsArray.IsEmpty)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine(
|
||||||
|
$"[AiKnowledge] 未知标签({modName}):{string.Join(", ", unknownTagsArray)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StructuredKnowledge(
|
||||||
|
byAsset.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
allEntities,
|
||||||
|
byFaction,
|
||||||
|
unknownTagsArray);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units_{modName}.json: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析知识 JSON 的 factions 结构,返回 faction → (assetName → EntityKnowledge);
|
||||||
|
/// 同时收集未知标签。
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<string, Dictionary<string, EntityKnowledge>>? ParseFactions(
|
||||||
|
string path,
|
||||||
|
out HashSet<string> unknownTags)
|
||||||
|
{
|
||||||
|
var unknownTagsLocal = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var json = File.ReadAllText(path, Encoding.UTF8);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("factions", out var factions))
|
||||||
|
{
|
||||||
|
unknownTags = unknownTagsLocal;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, Dictionary<string, EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var faction in factions.EnumerateObject())
|
||||||
|
{
|
||||||
|
var factionName = faction.Name;
|
||||||
|
if (!result.TryGetValue(factionName, out var entries))
|
||||||
|
{
|
||||||
|
entries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
result[factionName] = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddEntity(JsonElement el, bool isBuilding)
|
||||||
|
{
|
||||||
|
var assetName = GetString(el, "assetName") ?? "unknown";
|
||||||
|
var tags = GetStringArray(el, "tags");
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
if (!KnowledgeTag.All.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
unknownTagsLocal.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var producedBy = GetStringArray(el, "producedBy");
|
||||||
|
var alsoProducedBy = GetStringArray(el, "alsoProducedBy");
|
||||||
|
var combinedProducedBy = producedBy
|
||||||
|
.Concat(alsoProducedBy)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
var ek = new EntityKnowledge(
|
||||||
|
assetName,
|
||||||
|
GetString(el, "displayName") ?? "",
|
||||||
|
factionName,
|
||||||
|
isBuilding ? null : GetString(el, "tier"),
|
||||||
|
tags,
|
||||||
|
ParseSpecialPowers(el),
|
||||||
|
isBuilding ? ImmutableArray<string>.Empty : combinedProducedBy,
|
||||||
|
GetStringArray(el, "aliases"),
|
||||||
|
GetString(el, "text") ?? "");
|
||||||
|
entries[assetName] = ek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faction.Value.TryGetProperty("buildings", out var bldgs))
|
||||||
|
{
|
||||||
|
foreach (var b in bldgs.EnumerateArray())
|
||||||
|
{
|
||||||
|
AddEntity(b, isBuilding: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faction.Value.TryGetProperty("units", out var units))
|
||||||
|
{
|
||||||
|
foreach (var u in units.EnumerateArray())
|
||||||
|
{
|
||||||
|
AddEntity(u, isBuilding: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unknownTags = unknownTagsLocal;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static ImmutableArray<string> GetStringArray(JsonElement el, string prop)
|
||||||
|
{
|
||||||
|
if (!el.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||||
|
return ImmutableArray<string>.Empty;
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var item in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
|
||||||
|
result.Add(s);
|
||||||
|
}
|
||||||
|
return result.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<SpecialPowerInfo> ParseSpecialPowers(JsonElement el)
|
||||||
|
{
|
||||||
|
if (!el.TryGetProperty("specialPowers", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||||
|
return ImmutableArray<SpecialPowerInfo>.Empty;
|
||||||
|
var result = new List<SpecialPowerInfo>();
|
||||||
|
foreach (var item in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
var name = GetString(item, "name") ?? "";
|
||||||
|
var desc = GetString(item, "description") ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
result.Add(new SpecialPowerInfo(name, desc));
|
||||||
|
}
|
||||||
|
return result.ToImmutableArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named collection of game knowledge entries for a specific game version (mod).
|
||||||
|
/// Entries are organized by scope; the set is self-contained and complete for its mod.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class KnowledgeSet
|
||||||
|
{
|
||||||
|
private readonly ImmutableArray<(KnowledgeScope Scope, KnowledgeEntry Entry)> _entries;
|
||||||
|
|
||||||
|
public KnowledgeSet(IEnumerable<(KnowledgeScope Scope, KnowledgeEntry Entry)> entries)
|
||||||
|
{
|
||||||
|
_entries = entries.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public ImmutableArray<KnowledgeEntry> ByScope(KnowledgeScopeKind kind, string? name = null) =>
|
||||||
|
_entries
|
||||||
|
.Where(e => e.Scope.Kind == kind
|
||||||
|
&& (name is null || string.Equals(e.Scope.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.Select(e => e.Entry)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
public ImmutableArray<KnowledgeEntry> ByTag(string tag) =>
|
||||||
|
_entries
|
||||||
|
.Where(e => e.Entry.HasTag(tag))
|
||||||
|
.Select(e => e.Entry)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
public ImmutableArray<KnowledgeEntry> ByAnyTag(params string[] tags) =>
|
||||||
|
_entries
|
||||||
|
.Where(e => e.Entry.HasAnyTag(tags))
|
||||||
|
.Select(e => e.Entry)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
// ── Prompt rendering ─────────────────────────────────────────
|
||||||
|
|
||||||
|
public string RenderAsPrompt(IReadOnlyList<string> factionNames, string? mapId)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
|
||||||
|
{
|
||||||
|
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
|
||||||
|
|| entry.Id.StartsWith("knowledge-file-", StringComparison.Ordinal)
|
||||||
|
? FilterFlatTextByFactions(entry.Text, factionNames)
|
||||||
|
: entry.Text;
|
||||||
|
sb.AppendLine(text.Trim());
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var faction in factionNames)
|
||||||
|
{
|
||||||
|
foreach (var entry in ByScope(KnowledgeScopeKind.Faction, faction))
|
||||||
|
{
|
||||||
|
sb.AppendLine(entry.Text.Trim());
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapId is not null)
|
||||||
|
{
|
||||||
|
foreach (var entry in ByScope(KnowledgeScopeKind.Map, mapId))
|
||||||
|
{
|
||||||
|
sb.AppendLine(entry.Text.Trim());
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().Replace("\r", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly (string Faction, string StartMarker)[] FactionSectionMarkers =
|
||||||
|
{
|
||||||
|
("盟军", "盟军常用建筑与升级"),
|
||||||
|
("神州", "神州常用建筑"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>flat 文本按参战阵营过滤:只保留全局部分与参战阵营的章节,减少 token 浪费。</summary>
|
||||||
|
private static string FilterFlatTextByFactions(string text, IReadOnlyList<string> factionNames)
|
||||||
|
{
|
||||||
|
if (factionNames.Count == 0)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
var participating = new HashSet<string>(factionNames, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var lines = text.Replace("\r", "").Split('\n');
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
string? currentFaction = null;
|
||||||
|
foreach (var raw in lines)
|
||||||
|
{
|
||||||
|
var line = raw.TrimStart();
|
||||||
|
var matched = FactionSectionMarkers.FirstOrDefault(
|
||||||
|
m => line.StartsWith(m.StartMarker, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matched.Faction is not null)
|
||||||
|
{
|
||||||
|
currentFaction = matched.Faction;
|
||||||
|
if (participating.Contains(currentFaction))
|
||||||
|
{
|
||||||
|
sb.AppendLine(raw);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("# 地图参数", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
currentFaction = null;
|
||||||
|
}
|
||||||
|
if (currentFaction is null || participating.Contains(currentFaction))
|
||||||
|
{
|
||||||
|
sb.AppendLine(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in factory ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a built-in KnowledgeSet for a given mod name.
|
||||||
|
/// Loads text from <c>knowledge_{modName}.md</c> and structured entries
|
||||||
|
/// from <c>knowledge_units.json</c>. The flat text's unit/building
|
||||||
|
/// sections are stripped and replaced by structured entries for rendering.
|
||||||
|
/// </summary>
|
||||||
|
public static KnowledgeSet ForMod(string modName, string? baseDirectory = null)
|
||||||
|
{
|
||||||
|
var searchDir = baseDirectory ?? AppContext.BaseDirectory;
|
||||||
|
var entries = new List<(KnowledgeScope Scope, KnowledgeEntry Entry)>();
|
||||||
|
var structured = StructuredKnowledge.GetForMod(modName);
|
||||||
|
|
||||||
|
// 1. Load flat text from knowledge_{modName}.md
|
||||||
|
var flatPath = Path.Combine(searchDir, $"knowledge_{modName}.md");
|
||||||
|
string? flatText = null;
|
||||||
|
if (File.Exists(flatPath))
|
||||||
|
{
|
||||||
|
flatText = File.ReadAllText(flatPath, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If structured data is available, strip unit sections from flat text
|
||||||
|
// and add structured entries as faction-scoped KnowledgeEntry.
|
||||||
|
if (structured is not null && flatText is not null)
|
||||||
|
{
|
||||||
|
var cleaned = StripUnitSections(flatText, structured);
|
||||||
|
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
|
||||||
|
$"knowledge-text-{modName}",
|
||||||
|
ImmutableArray.Create("rule"),
|
||||||
|
cleaned)));
|
||||||
|
|
||||||
|
foreach (var kv in structured.EntitiesByFaction)
|
||||||
|
{
|
||||||
|
var factionName = kv.Key;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# {factionName}");
|
||||||
|
|
||||||
|
// Buildings (entities with structure tag)
|
||||||
|
var bldgs = kv.Value
|
||||||
|
.Where(e => e.IsBuilding)
|
||||||
|
.ToImmutableArray();
|
||||||
|
if (!bldgs.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine("## 建筑与升级");
|
||||||
|
foreach (var b in bldgs)
|
||||||
|
{
|
||||||
|
sb.Append("- ");
|
||||||
|
sb.Append(b.DisplayName);
|
||||||
|
sb.Append('(');
|
||||||
|
sb.Append(b.AssetName);
|
||||||
|
sb.Append("):");
|
||||||
|
sb.AppendLine(b.Text.Trim());
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Units (entities without structure tag) by tier
|
||||||
|
var units = kv.Value
|
||||||
|
.Where(e => e.IsUnit)
|
||||||
|
.GroupBy(u => u.Tier ?? "")
|
||||||
|
.OrderBy(g => TierOrder(g.Key));
|
||||||
|
foreach (var tier in units)
|
||||||
|
{
|
||||||
|
var label = tier.Key switch
|
||||||
|
{
|
||||||
|
"基础" => "基础单位",
|
||||||
|
"T2" => "T2 单位(需要T2升级)",
|
||||||
|
"T3" => "T3 单位(需要T3升级)",
|
||||||
|
"T4" => "T4 单位(需要T4升级)",
|
||||||
|
_ => tier.Key,
|
||||||
|
};
|
||||||
|
sb.AppendLine($"## {label}");
|
||||||
|
foreach (var unit in tier)
|
||||||
|
{
|
||||||
|
sb.Append("- ");
|
||||||
|
sb.Append(unit.DisplayName);
|
||||||
|
sb.Append('(');
|
||||||
|
sb.Append(unit.AssetName);
|
||||||
|
sb.AppendLine(")");
|
||||||
|
|
||||||
|
// Type info from tags
|
||||||
|
var typeTags = unit.Tags
|
||||||
|
.Where(t => t is "vehicle" or "infantry" or "aircraft" or "naval" or "structure" or "hero" or "amphibious")
|
||||||
|
.Select(TagDisplayName);
|
||||||
|
if (typeTags.Any())
|
||||||
|
{
|
||||||
|
sb.Append(" - 类型: ");
|
||||||
|
sb.AppendLine(string.Join("、", typeTags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special powers
|
||||||
|
if (!unit.SpecialPowers.IsEmpty)
|
||||||
|
{
|
||||||
|
foreach (var sp in unit.SpecialPowers)
|
||||||
|
{
|
||||||
|
sb.Append(" - 技能: ");
|
||||||
|
sb.Append(sp.Name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(sp.Description))
|
||||||
|
{
|
||||||
|
sb.Append(" — ");
|
||||||
|
sb.Append(sp.Description);
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produced by
|
||||||
|
if (!unit.ProducedBy.IsEmpty)
|
||||||
|
{
|
||||||
|
var producerNames = unit.ProducedBy
|
||||||
|
.Select(name => structured.GetDisplayName(name) ?? name)
|
||||||
|
.ToImmutableArray();
|
||||||
|
sb.Append(" - 生产: ");
|
||||||
|
sb.AppendLine(string.Join("、", producerNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining description
|
||||||
|
if (!string.IsNullOrWhiteSpace(unit.Text))
|
||||||
|
{
|
||||||
|
sb.Append(" - 描述: ");
|
||||||
|
sb.AppendLine(unit.Text.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add((KnowledgeScope.Faction(factionName), new KnowledgeEntry(
|
||||||
|
$"structured-{factionName}",
|
||||||
|
ImmutableArray.Create("faction", "structured"),
|
||||||
|
sb.ToString().TrimEnd())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (flatText is not null)
|
||||||
|
{
|
||||||
|
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
|
||||||
|
$"knowledge-file-{modName}",
|
||||||
|
ImmutableArray.Create("rule"),
|
||||||
|
flatText)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
|
||||||
|
"knowledge-unavailable",
|
||||||
|
ImmutableArray.Create("rule"),
|
||||||
|
$"# 注意\n\n游戏知识文件 knowledge_{modName}.md 未找到。\n\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new KnowledgeSet(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TierOrder(string tier) => tier switch
|
||||||
|
{
|
||||||
|
"基础" => 0,
|
||||||
|
"T2" => 1,
|
||||||
|
"T3" => 2,
|
||||||
|
"T4" => 3,
|
||||||
|
_ => 99,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TagDisplayName(string tag) => tag switch
|
||||||
|
{
|
||||||
|
"vehicle" => "载具",
|
||||||
|
"infantry" => "步兵",
|
||||||
|
"aircraft" => "飞行器",
|
||||||
|
"naval" => "海军",
|
||||||
|
"structure" => "建筑",
|
||||||
|
"hero" => "英雄",
|
||||||
|
"amphibious" => "两栖",
|
||||||
|
_ => tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strip unit and building sections from flat text for factions that
|
||||||
|
/// have structured data, to avoid duplication when rendering.
|
||||||
|
/// </summary>
|
||||||
|
private static string StripUnitSections(string flatText, StructuredKnowledge structured)
|
||||||
|
{
|
||||||
|
var stripMarkers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var factionName in structured.EntitiesByFaction.Keys)
|
||||||
|
{
|
||||||
|
var (buildingHeader, nextHeader) = factionName switch
|
||||||
|
{
|
||||||
|
"盟军" => ("盟军常用建筑与升级", "盟军常用开局"),
|
||||||
|
"神州" => ("神州常用建筑", "神州常用开局"),
|
||||||
|
_ => ((string?)null, (string?)null),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (buildingHeader is not null && nextHeader is not null)
|
||||||
|
{
|
||||||
|
stripMarkers[buildingHeader] = nextHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripMarkers.Count == 0) return flatText;
|
||||||
|
|
||||||
|
var lines = flatText.Replace("\r", "").Split('\n');
|
||||||
|
var result = new List<string>();
|
||||||
|
var skipping = false;
|
||||||
|
var currentStopMarker = (string?)null;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmed = line.TrimStart();
|
||||||
|
|
||||||
|
if (skipping)
|
||||||
|
{
|
||||||
|
if (currentStopMarker is not null &&
|
||||||
|
trimmed.StartsWith(currentStopMarker, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
skipping = false;
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = stripMarkers.Keys.FirstOrDefault(
|
||||||
|
m => trimmed.StartsWith(m, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matched is not null)
|
||||||
|
{
|
||||||
|
skipping = true;
|
||||||
|
currentStopMarker = stripMarkers[matched];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KnowledgeSet ForReplay(ReplayFile.Replay replay, string? baseDirectory = null)
|
||||||
|
{
|
||||||
|
var modName = replay.Mod.ModName?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"corona" => "corona",
|
||||||
|
_ => "default",
|
||||||
|
};
|
||||||
|
return ForMod(modName, baseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端点配置(如 DeepSeek 官方、NVIDIA NIM)
|
||||||
|
/// </summary>
|
||||||
|
public class AiProvider
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
|
public List<AiModel> Models { get; set; } = [];
|
||||||
|
|
||||||
|
public double DefaultTemperature { get; set; } = 0.35;
|
||||||
|
public double DefaultTopP { get; set; } = 0.95;
|
||||||
|
public int DefaultMaxTokens { get; set; } = 16384;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 分析提示词配置。
|
||||||
|
/// </summary>
|
||||||
|
public class AiPromptSettings
|
||||||
|
{
|
||||||
|
public bool UseCustomSystemPrompt { get; set; }
|
||||||
|
public string CustomSystemPrompt { get; set; } = string.Empty;
|
||||||
|
public string AdditionalRules { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型条目
|
||||||
|
/// </summary>
|
||||||
|
public class AiModel
|
||||||
|
{
|
||||||
|
public string ModelId { get; set; } = string.Empty;
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public bool IsStream { get; set; }
|
||||||
|
public int ContextLength { get; set; } // 0 表示未知
|
||||||
|
/// <summary>
|
||||||
|
/// 一次请求的总 token 软上限(prompt + 输出/推理余量)。
|
||||||
|
/// null 或 0 表示使用档位默认值:≥1M 上下文 → 160K;200K~256K → 100K;更小 → 0(不支持长录像)。
|
||||||
|
/// </summary>
|
||||||
|
public int? ContextBudget { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构建最终请求参数(合并 Provider 默认值、模型特有参数和运行时覆盖)
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, object> BuildRequestParams(
|
||||||
|
AiProvider provider,
|
||||||
|
double? temperatureOverride = null,
|
||||||
|
double? topPOverride = null,
|
||||||
|
int? maxTokensOverride = null)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["model"] = ModelId,
|
||||||
|
["temperature"] = temperatureOverride ?? provider.DefaultTemperature,
|
||||||
|
["top_p"] = topPOverride ?? provider.DefaultTopP,
|
||||||
|
["max_tokens"] = maxTokensOverride ?? provider.DefaultMaxTokens,
|
||||||
|
["stream"] = IsStream
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var kv in this.ExtraParameters)
|
||||||
|
{
|
||||||
|
parameters[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每次请求前的动态配置上下文(当前选中的 Provider 和 Model)
|
||||||
|
/// </summary>
|
||||||
|
public record AiRequestContext(AiProvider Provider, AiModel Model)
|
||||||
|
{
|
||||||
|
public Dictionary<string, object> BuildRequestParams(
|
||||||
|
double? temperatureOverride = null,
|
||||||
|
double? topPOverride = null,
|
||||||
|
int? maxTokensOverride = null)
|
||||||
|
{
|
||||||
|
return Model.BuildRequestParams(Provider,
|
||||||
|
temperatureOverride, topPOverride, maxTokensOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内置已知模型信息(提供商无关,纯模型参数模板)
|
||||||
|
/// </summary>
|
||||||
|
public static class KnownModels
|
||||||
|
{
|
||||||
|
public const int SimilarityThreshold = 80;
|
||||||
|
|
||||||
|
public static int GetSimilarity(string sourceModelId, string targetModelId)
|
||||||
|
{
|
||||||
|
// prefer exact match
|
||||||
|
if (sourceModelId.Equals(targetModelId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
// match the part after slash, e.g. "deepseek-ai/deepseek-v4-flash" vs "deepseek-v4-flash"
|
||||||
|
// if last part matches, return 90
|
||||||
|
var sourceModelIdLastPart = sourceModelId.Split('/').LastOrDefault() ?? sourceModelId;
|
||||||
|
var targetModelIdLastPart = targetModelId.Split('/').LastOrDefault() ?? targetModelId;
|
||||||
|
if (sourceModelIdLastPart.Equals(targetModelIdLastPart, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 返回一组已知模型,包含正确的 ExtraParameters。
|
||||||
|
/// 调用方可按需复制到 Provider 的 Models 列表中。
|
||||||
|
/// </summary>
|
||||||
|
public static List<AiModel> GetAll()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
// DeepSeek 官方
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "deepseek-v4-flash",
|
||||||
|
DisplayName = "DeepSeek V4 Flash",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
["thinking"] = new { type = "enabled" },
|
||||||
|
["reasoning_effort"] = "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "deepseek-v4-pro",
|
||||||
|
DisplayName = "DeepSeek V4 Pro",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
["thinking"] = new { type = "enabled" },
|
||||||
|
["reasoning_effort"] = "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// NVIDIA NIM 上的 DeepSeek 模型
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "deepseek-ai/deepseek-v4-flash",
|
||||||
|
DisplayName = "DeepSeek V4 Flash (NIM)",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
// ["chat_template_kwargs"] = new { thinking = true },
|
||||||
|
["thinking"] = new { type = "enabled" },
|
||||||
|
["reasoning_effort"] = "high",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "deepseek-ai/deepseek-v4-pro",
|
||||||
|
DisplayName = "DeepSeek V4 Pro (NIM)",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
// ["chat_template_kwargs"] = new { thinking = true },
|
||||||
|
["thinking"] = new { type = "enabled" },
|
||||||
|
["reasoning_effort"] = "high",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// NVIDIA Nemotron
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "nvidia/nemotron-3-super-120b-a12b",
|
||||||
|
DisplayName = "Nemotron Super 120B (NIM)",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
["reasoning_budget"] = 16384
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Minimax
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "minimaxai/minimax-m3",
|
||||||
|
DisplayName = "MiniMax-M3 (NIM)",
|
||||||
|
IsStream = false,
|
||||||
|
ContextLength = 1_000_000,
|
||||||
|
ExtraParameters = []
|
||||||
|
},
|
||||||
|
// Kimi
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "moonshotai/kimi-k2.6",
|
||||||
|
DisplayName = "Kimi-K2.6 (NIM)",
|
||||||
|
IsStream = false,
|
||||||
|
ContextLength = 256_000,
|
||||||
|
ExtraParameters = []
|
||||||
|
},
|
||||||
|
// Google DiffusionGemma
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "google/diffusiongemma-26b-a4b-it",
|
||||||
|
DisplayName = "DiffusionGemma 26B A4B IT (NIM)",
|
||||||
|
IsStream = false,
|
||||||
|
ContextLength = 250_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
["chat_template_kwargs"] = new { enable_thinking = true },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// OpenAI 兼容
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ModelId = "openai/gpt-oss-120b",
|
||||||
|
DisplayName = "GPT OSS 120B (NIM)",
|
||||||
|
IsStream = true,
|
||||||
|
ContextLength = 128_000,
|
||||||
|
ExtraParameters = new()
|
||||||
|
{
|
||||||
|
["reasoning_effort"] = "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从 OpenAI 兼容的 /v1/models 端点获取可用模型 ID 列表
|
||||||
|
/// </summary>
|
||||||
|
public static class AiModelFetcher
|
||||||
|
{
|
||||||
|
public static async Task<List<string>> FetchModelsAsync(
|
||||||
|
string baseUrl, string apiKey)
|
||||||
|
{
|
||||||
|
baseUrl = baseUrl.TrimEnd('/') + "/";
|
||||||
|
|
||||||
|
var models = new List<string>();
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get, new Uri(new(baseUrl), "models"));
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {apiKey}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new Exception(
|
||||||
|
$"获取模型列表失败: HTTP {(int)response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("data", out var dataArray))
|
||||||
|
{
|
||||||
|
foreach (var item in dataArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("id", out var idProp))
|
||||||
|
{
|
||||||
|
var id = idProp.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
models.Add(id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全局 AI 配置(多个 Provider)的持久化管理
|
||||||
|
/// </summary>
|
||||||
|
public class AiSettings
|
||||||
|
{
|
||||||
|
public List<AiProvider> Providers { get; set; } = [];
|
||||||
|
public AiPromptSettings Prompt { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上次选中的 Provider 名称(持久化;用于下次打开设置页时恢复)。
|
||||||
|
/// 按名称识别,Provider 被重命名/删除后自动回退到第一个 Provider。
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentProviderName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上次选中的模型 ID(持久化;与 <see cref="CurrentProviderName"/> 配合使用)。
|
||||||
|
/// 模型被删除后自动回退到该 Provider 的第一个模型。
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentModelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新并持久化当前选中的 Provider 与模型。
|
||||||
|
/// </summary>
|
||||||
|
public void SetCurrentSelection(AiProvider provider, AiModel model)
|
||||||
|
{
|
||||||
|
CurrentProviderName = provider.Name;
|
||||||
|
CurrentModelId = model.ModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析持久化的上次选择。任一标识缺失或对应 Provider/Model 已不存在时返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public (AiProvider Provider, AiModel Model)? ResolveLastSelection()
|
||||||
|
{
|
||||||
|
var providerName = CurrentProviderName?.Trim();
|
||||||
|
var modelId = CurrentModelId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(providerName) || string.IsNullOrEmpty(modelId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = Providers.FirstOrDefault(p =>
|
||||||
|
string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = provider.Models.FirstOrDefault(m =>
|
||||||
|
string.Equals(m.ModelId, modelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (provider, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string ConfigPath = Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"AnotherReplayReader.ai_settings.json");
|
||||||
|
|
||||||
|
public static AiSettings Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(ConfigPath);
|
||||||
|
var settings = JsonSerializer.Deserialize<AiSettings>(json);
|
||||||
|
if (settings is { } value && value.Providers.Count > 0)
|
||||||
|
{
|
||||||
|
value.Prompt ??= new AiPromptSettings();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 返回默认配置
|
||||||
|
Debug.Instance.DebugMessage += $"加载 AI 配置失败: {ex}\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回默认配置:包含两个常用 Provider,各附一个内置模型
|
||||||
|
var defaults = new AiSettings();
|
||||||
|
var nimProvider = new AiProvider
|
||||||
|
{
|
||||||
|
Name = "NVIDIA NIM",
|
||||||
|
BaseUrl = "https://integrate.api.nvidia.com/v1",
|
||||||
|
ApiKey = "",
|
||||||
|
Models =
|
||||||
|
[
|
||||||
|
KnownModels.GetAll().First(m => m.ModelId == "deepseek-ai/deepseek-v4-flash")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var deepseekProvider = new AiProvider
|
||||||
|
{
|
||||||
|
Name = "DeepSeek 官方",
|
||||||
|
BaseUrl = "https://api.deepseek.com",
|
||||||
|
ApiKey = "",
|
||||||
|
Models =
|
||||||
|
[
|
||||||
|
KnownModels.GetAll().First(m => m.ModelId == "deepseek-v4-flash")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
defaults.Providers.Add(nimProvider);
|
||||||
|
defaults.Providers.Add(deepseekProvider);
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(ConfigPath);
|
||||||
|
if (dir is not null)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
});
|
||||||
|
File.WriteAllText(ConfigPath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,933 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
/// <summary>一段操作记录(一个时间分块)在全文中的位置与规模。</summary>
|
||||||
|
internal sealed record EventSpan(TimeSpan Time, int StartIndex, int Length, int EstimatedTokens);
|
||||||
|
|
||||||
|
/// <summary>机械分段得到的操作记录切片。</summary>
|
||||||
|
internal sealed record ReplaySlice(
|
||||||
|
int Index,
|
||||||
|
TimeSpan Start,
|
||||||
|
TimeSpan End,
|
||||||
|
int StartIndex,
|
||||||
|
int Length,
|
||||||
|
int EventCount,
|
||||||
|
int EstimatedTokens)
|
||||||
|
{
|
||||||
|
public string GetText(string fullText) => fullText.Substring(StartIndex, Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 机械分段:按 token 预算切分操作记录,相邻段重叠前一段尾部。
|
||||||
|
/// </summary>
|
||||||
|
internal static class MechanicalSegmenter
|
||||||
|
{
|
||||||
|
public const int MinSliceTokens = 2_000;
|
||||||
|
public const int MaxSlices = 20;
|
||||||
|
|
||||||
|
public static (ImmutableArray<ReplaySlice> Slices, ImmutableArray<string> Warnings) Slice(
|
||||||
|
string fullText,
|
||||||
|
ImmutableArray<EventSpan> spans,
|
||||||
|
int sliceTokenBudget,
|
||||||
|
int overlapTokenBudget)
|
||||||
|
{
|
||||||
|
var warnings = ImmutableArray.CreateBuilder<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(fullText) || spans.IsEmpty)
|
||||||
|
{
|
||||||
|
return (ImmutableArray<ReplaySlice>.Empty, warnings.ToImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
var budget = Math.Max(sliceTokenBudget, MinSliceTokens);
|
||||||
|
var overlap = Math.Max(overlapTokenBudget, 200);
|
||||||
|
var slices = SliceCore(fullText, spans, budget, overlap);
|
||||||
|
|
||||||
|
if (slices.Count > MaxSlices)
|
||||||
|
{
|
||||||
|
// 先尝试压缩噪声事件块(纯选择/编队类),避免一超限就放宽预算。
|
||||||
|
var compressedSpans = CompressNoiseSpans(fullText, spans);
|
||||||
|
if (compressedSpans.Length < spans.Length && !compressedSpans.IsEmpty)
|
||||||
|
{
|
||||||
|
var compressedSlices = SliceCore(fullText, compressedSpans, budget, overlap);
|
||||||
|
if (compressedSlices.Count <= MaxSlices)
|
||||||
|
{
|
||||||
|
warnings.Add($"分段数超过上限 {MaxSlices},已过滤纯选择/编队事件块,压缩到 {compressedSlices.Count} 段。");
|
||||||
|
return (compressedSlices.ToImmutableArray(), warnings.ToImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressedTokens = compressedSpans.Sum(s => s.EstimatedTokens);
|
||||||
|
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(compressedTokens / (double)MaxSlices));
|
||||||
|
slices = SliceCore(fullText, compressedSpans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
|
||||||
|
warnings.Add($"分段数仍超过上限 {MaxSlices},已过滤噪声并放宽单段预算到 {raisedBudget:N0} token。");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var totalTokens = spans.Sum(s => s.EstimatedTokens);
|
||||||
|
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices));
|
||||||
|
slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
|
||||||
|
warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (slices.ToImmutableArray(), warnings.ToImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<EventSpan> CompressNoiseSpans(
|
||||||
|
string fullText,
|
||||||
|
ImmutableArray<EventSpan> spans)
|
||||||
|
{
|
||||||
|
var result = ImmutableArray.CreateBuilder<EventSpan>();
|
||||||
|
foreach (var span in spans)
|
||||||
|
{
|
||||||
|
if (!IsNoiseEventBlock(fullText.Substring(span.StartIndex, span.Length)))
|
||||||
|
{
|
||||||
|
result.Add(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.ToImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNoiseEventBlock(string text)
|
||||||
|
{
|
||||||
|
var hasCommand = false;
|
||||||
|
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("[", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令行通常形如 "PlayerA: 选择单位";测试/简写文本把非空行也视作命令。
|
||||||
|
hasCommand = true;
|
||||||
|
if (line.IndexOf("选择", StringComparison.Ordinal) < 0
|
||||||
|
&& line.IndexOf("编队", StringComparison.Ordinal) < 0
|
||||||
|
&& line.IndexOf("取消选择", StringComparison.Ordinal) < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ReplaySlice> SliceCore(
|
||||||
|
string fullText,
|
||||||
|
ImmutableArray<EventSpan> spans,
|
||||||
|
int budget,
|
||||||
|
int overlap)
|
||||||
|
{
|
||||||
|
var slices = new List<ReplaySlice>();
|
||||||
|
var segStart = 0;
|
||||||
|
var i = 0;
|
||||||
|
var acc = 0;
|
||||||
|
while (i < spans.Length)
|
||||||
|
{
|
||||||
|
var span = spans[i];
|
||||||
|
if (acc > 0 && acc + span.EstimatedTokens > budget && acc >= MinSliceTokens)
|
||||||
|
{
|
||||||
|
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, i));
|
||||||
|
|
||||||
|
// 下一段起点:重叠前一段尾部(重叠总 token ≤ overlap)
|
||||||
|
var overlapStart = i;
|
||||||
|
var overlapTokens = 0;
|
||||||
|
for (var j = i - 1; j >= segStart; --j)
|
||||||
|
{
|
||||||
|
if (overlapTokens + spans[j].EstimatedTokens > overlap)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
overlapTokens += spans[j].EstimatedTokens;
|
||||||
|
overlapStart = j;
|
||||||
|
}
|
||||||
|
segStart = overlapStart;
|
||||||
|
acc = 0;
|
||||||
|
for (var j = segStart; j < i; ++j)
|
||||||
|
{
|
||||||
|
acc += spans[j].EstimatedTokens;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
acc += span.EstimatedTokens;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segStart < spans.Length)
|
||||||
|
{
|
||||||
|
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, spans.Length));
|
||||||
|
}
|
||||||
|
return slices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReplaySlice CreateSlice(
|
||||||
|
int index,
|
||||||
|
string fullText,
|
||||||
|
ImmutableArray<EventSpan> spans,
|
||||||
|
int start,
|
||||||
|
int endExclusive)
|
||||||
|
{
|
||||||
|
var first = spans[start];
|
||||||
|
var last = spans[endExclusive - 1];
|
||||||
|
var tokens = 0;
|
||||||
|
for (var j = start; j < endExclusive; ++j)
|
||||||
|
{
|
||||||
|
tokens += spans[j].EstimatedTokens;
|
||||||
|
}
|
||||||
|
return new ReplaySlice(
|
||||||
|
index,
|
||||||
|
first.Time,
|
||||||
|
last.Time,
|
||||||
|
first.StartIndex,
|
||||||
|
last.StartIndex + last.Length - first.StartIndex,
|
||||||
|
endExclusive - start,
|
||||||
|
tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。
|
||||||
|
/// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同:
|
||||||
|
/// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FocusPlanner
|
||||||
|
{
|
||||||
|
/// <summary>焦点窗口的目标 token 大小(近似)。</summary>
|
||||||
|
public const int DefaultWindowTokens = 12_000;
|
||||||
|
/// <summary>单个机械分段最多切分的焦点窗口数。</summary>
|
||||||
|
public const int MaxWindowsPerSlice = 5;
|
||||||
|
/// <summary>小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。</summary>
|
||||||
|
public const int MinSliceForSplitTokens = DefaultWindowTokens * 2;
|
||||||
|
|
||||||
|
public static ImmutableArray<FocusWindow> Plan(
|
||||||
|
ReplaySlice slice,
|
||||||
|
ImmutableArray<EventSpan> fullSpans)
|
||||||
|
{
|
||||||
|
if (slice.EventCount <= 0 || fullSpans.IsEmpty)
|
||||||
|
{
|
||||||
|
return ImmutableArray<FocusWindow>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接用切片自身的字符区间(StartIndex/Length)在 span 索引中定位,
|
||||||
|
// 避免按时间范围匹配与机械分段(含重叠)的实际内容不一致。
|
||||||
|
var startIndex = 0;
|
||||||
|
while (startIndex < fullSpans.Length
|
||||||
|
&& fullSpans[startIndex].StartIndex < slice.StartIndex)
|
||||||
|
{
|
||||||
|
startIndex++;
|
||||||
|
}
|
||||||
|
var endIndexExclusive = startIndex;
|
||||||
|
while (endIndexExclusive < fullSpans.Length
|
||||||
|
&& fullSpans[endIndexExclusive].StartIndex < slice.StartIndex + slice.Length)
|
||||||
|
{
|
||||||
|
endIndexExclusive++;
|
||||||
|
}
|
||||||
|
if (startIndex >= fullSpans.Length || endIndexExclusive <= startIndex)
|
||||||
|
{
|
||||||
|
// 回退:用切片自身的长度作为单一窗口(不应发生,防御性处理)。
|
||||||
|
return ImmutableArray.Create(
|
||||||
|
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若切片本身不大,或者时间太短,则单一窗口。
|
||||||
|
var totalTokens = 0;
|
||||||
|
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||||
|
{
|
||||||
|
totalTokens += fullSpans[i].EstimatedTokens;
|
||||||
|
}
|
||||||
|
if (totalTokens <= MinSliceForSplitTokens
|
||||||
|
|| endIndexExclusive - startIndex <= 1)
|
||||||
|
{
|
||||||
|
return ImmutableArray.Create(
|
||||||
|
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, totalTokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
var windows = new List<FocusWindow>();
|
||||||
|
var acc = 0;
|
||||||
|
var winStart = startIndex;
|
||||||
|
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||||
|
{
|
||||||
|
var span = fullSpans[i];
|
||||||
|
if (acc > 0 && acc + span.EstimatedTokens > DefaultWindowTokens
|
||||||
|
&& i - winStart >= 1)
|
||||||
|
{
|
||||||
|
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, i));
|
||||||
|
winStart = i;
|
||||||
|
acc = 0;
|
||||||
|
}
|
||||||
|
acc += span.EstimatedTokens;
|
||||||
|
}
|
||||||
|
if (winStart < endIndexExclusive)
|
||||||
|
{
|
||||||
|
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, endIndexExclusive));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过上限时合并尾部窗口(优先合并 token 较小的相邻窗口,保持时间顺序)。
|
||||||
|
while (windows.Count > MaxWindowsPerSlice)
|
||||||
|
{
|
||||||
|
var best = -1;
|
||||||
|
var bestTokens = int.MaxValue;
|
||||||
|
for (var i = 0; i < windows.Count - 1 && windows.Count > MaxWindowsPerSlice; ++i)
|
||||||
|
{
|
||||||
|
var merged = windows[i].EstimatedTokens + windows[i + 1].EstimatedTokens;
|
||||||
|
if (merged < bestTokens)
|
||||||
|
{
|
||||||
|
best = i;
|
||||||
|
bestTokens = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
windows[best] = MergeWindows(windows[best], windows[best + 1]);
|
||||||
|
windows.RemoveAt(best + 1);
|
||||||
|
RenumberWindows(windows);
|
||||||
|
}
|
||||||
|
return windows.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocusWindow CreateWindow(
|
||||||
|
int index,
|
||||||
|
ReplaySlice slice,
|
||||||
|
ImmutableArray<EventSpan> fullSpans,
|
||||||
|
int start,
|
||||||
|
int endExclusive)
|
||||||
|
{
|
||||||
|
var tokens = 0;
|
||||||
|
var events = 0;
|
||||||
|
for (var j = start; j < endExclusive; ++j)
|
||||||
|
{
|
||||||
|
tokens += fullSpans[j].EstimatedTokens;
|
||||||
|
events++;
|
||||||
|
}
|
||||||
|
return new FocusWindow(
|
||||||
|
index,
|
||||||
|
fullSpans[start].Time,
|
||||||
|
fullSpans[endExclusive - 1].Time,
|
||||||
|
events,
|
||||||
|
tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocusWindow MergeWindows(FocusWindow a, FocusWindow b)
|
||||||
|
{
|
||||||
|
return new FocusWindow(
|
||||||
|
a.Index,
|
||||||
|
a.Start,
|
||||||
|
b.End,
|
||||||
|
a.EventCount + b.EventCount,
|
||||||
|
a.EstimatedTokens + b.EstimatedTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenumberWindows(List<FocusWindow> windows)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < windows.Count; ++i)
|
||||||
|
{
|
||||||
|
windows[i] = windows[i] with { Index = i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
|
||||||
|
/// </summary>
|
||||||
|
internal static class MatchDigestBuilder
|
||||||
|
{
|
||||||
|
public static string Build(
|
||||||
|
ReplayFactIndex factIndex,
|
||||||
|
ImmutableSortedDictionary<int, Player> players,
|
||||||
|
Mod mod,
|
||||||
|
ImmutableArray<ReplaySlice> slices,
|
||||||
|
string fullText)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var names = AIAnalyze.PlayerNamesForAI(mod, players);
|
||||||
|
|
||||||
|
sb.AppendLine("# 玩家");
|
||||||
|
foreach (var kv in players)
|
||||||
|
{
|
||||||
|
var faction = ModData.GetFaction(mod, kv.Value.FactionId);
|
||||||
|
var factionName = faction.Name;
|
||||||
|
if (faction.Kind == FactionKind.Observer)
|
||||||
|
{
|
||||||
|
sb.AppendLine(
|
||||||
|
$"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),"
|
||||||
|
+ $"{factionName},解说员(观战),不参与对局");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
|
||||||
|
var teamText = kv.Value.Team < 0 ? "无队伍" : $"队伍{kv.Value.Team}";
|
||||||
|
sb.AppendLine(
|
||||||
|
$"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),"
|
||||||
|
+ $"{factionName},{teamText},{kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 首次出兵时间表(命令开始时间)");
|
||||||
|
if (factIndex.PlayerFirstProductionTime.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine("- (无)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
|
||||||
|
{
|
||||||
|
var productions = kv.Value
|
||||||
|
.OrderBy(x => x.Value)
|
||||||
|
.Take(15)
|
||||||
|
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
|
||||||
|
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", productions)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 协议选择");
|
||||||
|
if (factIndex.PlayerTechChoices.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine("- (无)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var kv in factIndex.PlayerTechChoices.OrderBy(k => k.Key))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", kv.Value.OrderBy(x => x))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
var unitRoles = BuildUnitRoleMap(factIndex);
|
||||||
|
sb.AppendLine("# 所有权证据(节选)");
|
||||||
|
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
|
||||||
|
{
|
||||||
|
if (IsObserver(mod, players, kv.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var ids = kv.Value.OrderBy(x => x).Take(20);
|
||||||
|
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
||||||
|
sb.AppendLine(
|
||||||
|
$"- 玩家#{kv.Key}({names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId"
|
||||||
|
+ $"({string.Join("、", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix})");
|
||||||
|
}
|
||||||
|
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds.OrderBy(k => k.Key))
|
||||||
|
{
|
||||||
|
if (IsObserver(mod, players, kv.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var ids = kv.Value.OrderBy(x => x).Take(20);
|
||||||
|
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
||||||
|
sb.AppendLine(
|
||||||
|
$"- 玩家#{kv.Key}({names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId"
|
||||||
|
+ $"({string.Join("、", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix})");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 打包/展开(按实际事件时间)");
|
||||||
|
var packEvents = factIndex.SpecialPowerEvents
|
||||||
|
.Where(e => ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||||
|
|| ContainsIgnoreCase(e.PowerName, "UnpackReplaceSelf"))
|
||||||
|
.OrderBy(e => e.Time)
|
||||||
|
.ThenBy(e => e.PlayerIndex);
|
||||||
|
if (!packEvents.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine("- (无)");
|
||||||
|
}
|
||||||
|
foreach (var e in packEvents)
|
||||||
|
{
|
||||||
|
var action = ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||||
|
? "Pack"
|
||||||
|
: "Unpack";
|
||||||
|
var playerName = names.TryGetValue(e.PlayerIndex, out var name)
|
||||||
|
? name
|
||||||
|
: $"玩家#{e.PlayerIndex}";
|
||||||
|
var conflict = IsPackFactionConflict(mod, players, e)
|
||||||
|
? " [阵营冲突,需回查]"
|
||||||
|
: string.Empty;
|
||||||
|
sb.AppendLine(
|
||||||
|
$"- 玩家#{e.PlayerIndex}({playerName})UnitId {e.UnitId}:"
|
||||||
|
+ $"{action}@{FormatTime(e.Time)}{conflict}");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 建造者/出兵建筑");
|
||||||
|
sb.AppendLine($"- 建造者:{string.Join("、", factIndex.BuilderUnitIds.OrderBy(x => x).Take(20))}");
|
||||||
|
sb.AppendLine($"- 出兵建筑:{string.Join("、", factIndex.ProducerUnitIds.OrderBy(x => x).Take(20))}");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 分段");
|
||||||
|
foreach (var slice in slices)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- 第{slice.Index + 1}段:{FormatTime(slice.Start)}~{FormatTime(slice.End)},事件数 {slice.EventCount},约 {slice.EstimatedTokens:N0} token");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("# 各段关键事件采样");
|
||||||
|
foreach (var slice in slices)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"## 第{slice.Index + 1}段");
|
||||||
|
foreach (var line in SampleKeyEvents(slice.GetText(fullText), 6))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().Replace("\r", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var counts = new Dictionary<(string Player, string Category), int>();
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
var currentTime = string.Empty;
|
||||||
|
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
if (result.Count >= maxEvents)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = rawLine.TrimEnd();
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (TimeStampPattern.IsMatch(trimmed))
|
||||||
|
{
|
||||||
|
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数/续行统一跳过,避免把 [UnitId]... 当作事件
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed)
|
||||||
|
|| line.StartsWith(" ", StringComparison.Ordinal)
|
||||||
|
|| line.StartsWith("\t", StringComparison.Ordinal)
|
||||||
|
|| trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandMatch = CommandPattern.Match(trimmed);
|
||||||
|
if (!commandMatch.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var player = commandMatch.Groups[1].Value.Trim();
|
||||||
|
var command = commandMatch.Groups[2].Value.Trim();
|
||||||
|
var category = GetEventCategory(command);
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.Add(player + "|" + command))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = (player, category);
|
||||||
|
counts.TryGetValue(key, out var count);
|
||||||
|
if (count >= 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts[key] = count + 1;
|
||||||
|
result.Add($"[{currentTime}] {player}: {command}");
|
||||||
|
}
|
||||||
|
return result.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetEventCategory(string command)
|
||||||
|
{
|
||||||
|
if (command.Contains("开始建造")) return "建造";
|
||||||
|
if (command.Contains("摆放建筑")) return "摆放";
|
||||||
|
if (command.Contains("出售建筑")) return "出售";
|
||||||
|
if (command.Contains("开始出兵")) return "生产";
|
||||||
|
if (command.Contains("开始升级")) return "升级";
|
||||||
|
if (command.Contains("选择协议")) return "协议";
|
||||||
|
if (command.Contains("释放特殊能力")) return "技能";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex TimeStampPattern = new(
|
||||||
|
@"^\[(\d+:\d+(?:\.\d+)?)\]$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex CommandPattern = new(
|
||||||
|
@"^([^::,,]+)\s*[::,,]\s*(.+)$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static Dictionary<uint, HashSet<string>> BuildUnitRoleMap(ReplayFactIndex factIndex)
|
||||||
|
{
|
||||||
|
var roles = new Dictionary<uint, HashSet<string>>();
|
||||||
|
void Add(uint unitId, string role)
|
||||||
|
{
|
||||||
|
if (!roles.TryGetValue(unitId, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<string>();
|
||||||
|
roles[unitId] = set;
|
||||||
|
}
|
||||||
|
set.Add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in factIndex.BuilderUnitIds)
|
||||||
|
{
|
||||||
|
Add(id, "建造者");
|
||||||
|
}
|
||||||
|
foreach (var id in factIndex.ProducerUnitIds)
|
||||||
|
{
|
||||||
|
Add(id, "出兵建筑");
|
||||||
|
}
|
||||||
|
foreach (var kv in factIndex.UnitIdSpecialPowers)
|
||||||
|
{
|
||||||
|
if (kv.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
||||||
|
{
|
||||||
|
Add(kv.Key, "打包/展开");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatUnitIdWithRole(
|
||||||
|
uint unitId,
|
||||||
|
Dictionary<uint, HashSet<string>> unitRoles)
|
||||||
|
{
|
||||||
|
if (!unitRoles.TryGetValue(unitId, out var roles) || roles.Count == 0)
|
||||||
|
{
|
||||||
|
return unitId.ToString();
|
||||||
|
}
|
||||||
|
return $"{unitId}({string.Join("/", roles.OrderBy(x => x))})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsObserver(
|
||||||
|
Mod mod,
|
||||||
|
ImmutableSortedDictionary<int, Player> players,
|
||||||
|
int playerIndex)
|
||||||
|
{
|
||||||
|
return players.TryGetValue(playerIndex, out var player)
|
||||||
|
&& ModData.GetFaction(mod, player.FactionId).Kind == FactionKind.Observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPackFactionConflict(
|
||||||
|
Mod mod,
|
||||||
|
ImmutableSortedDictionary<int, Player> players,
|
||||||
|
SpecialPowerEvent e)
|
||||||
|
{
|
||||||
|
if (!ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||||
|
|| !players.TryGetValue(e.PlayerIndex, out var player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ModData.GetFaction(mod, player.FactionId).Name != "盟军";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsIgnoreCase(string text, string value) =>
|
||||||
|
text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SegmentOverview(
|
||||||
|
int Index,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
ImmutableArray<string> BackqueryHints);
|
||||||
|
|
||||||
|
internal sealed record OverviewResult(
|
||||||
|
string Narrative,
|
||||||
|
ImmutableArray<SegmentOverview> Segments);
|
||||||
|
|
||||||
|
/// <summary>解析总览轮输出:[分段概述] 块之前的自由文本是整局叙述,块内是每段标题/概述。</summary>
|
||||||
|
internal static class OverviewParser
|
||||||
|
{
|
||||||
|
private const string Marker = "[分段概述]";
|
||||||
|
|
||||||
|
public static OverviewResult Parse(string response)
|
||||||
|
{
|
||||||
|
var markerIndex = response.LastIndexOf(Marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (markerIndex < 0)
|
||||||
|
{
|
||||||
|
return new OverviewResult(response.Trim(), ImmutableArray<SegmentOverview>.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrative = response.Substring(0, markerIndex).Trim();
|
||||||
|
var builder = ImmutableArray.CreateBuilder<SegmentOverview>();
|
||||||
|
var hints = new List<string>();
|
||||||
|
SegmentOverview? current = null;
|
||||||
|
|
||||||
|
foreach (var rawLine in response.Substring(markerIndex + Marker.Length).Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(line, @"^#(\d+)\s*(.*)$");
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
if (current is not null)
|
||||||
|
{
|
||||||
|
builder.Add(current);
|
||||||
|
}
|
||||||
|
var index = int.Parse(match.Groups[1].Value);
|
||||||
|
var rest = match.Groups[2].Value.Trim();
|
||||||
|
var sep = rest.IndexOfAny(new[] { ':', ':' });
|
||||||
|
var title = sep > 0 ? rest.Substring(0, sep).Trim() : rest;
|
||||||
|
var description = sep > 0 ? rest.Substring(sep + 1).Trim() : "";
|
||||||
|
hints = new List<string>();
|
||||||
|
current = new SegmentOverview(index, title, description, ImmutableArray<string>.Empty);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is not null && line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var sepIndex = line.IndexOfAny(new[] { ':', ':' });
|
||||||
|
if (sepIndex >= 0)
|
||||||
|
{
|
||||||
|
hints.Add(line.Substring(sepIndex + 1).Trim());
|
||||||
|
}
|
||||||
|
current = current with { BackqueryHints = hints.ToImmutableArray() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current is not null)
|
||||||
|
{
|
||||||
|
builder.Add(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OverviewResult(narrative, builder.ToImmutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解析段回复中的 [回查] 标记(M3 使用)。</summary>
|
||||||
|
internal static class BackqueryParser
|
||||||
|
{
|
||||||
|
public static ImmutableArray<(TimeSpan Start, TimeSpan End)> Parse(string response)
|
||||||
|
{
|
||||||
|
var result = ImmutableArray.CreateBuilder<(TimeSpan, TimeSpan)>();
|
||||||
|
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.IndexOf("[回查]", StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
&& !line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = line.Replace("[回查]", "").Trim();
|
||||||
|
var sep = content.IndexOfAny(new[] { ':', ':' });
|
||||||
|
if (sep >= 0 && content.Substring(0, sep).Trim().Equals("回查", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
content = content.Substring(sep + 1).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match m in Regex.Matches(content, @"(\d+:\d+(?:\.\d+)?)\s*~\s*(\d+:\d+(?:\.\d+)?)"))
|
||||||
|
{
|
||||||
|
if (AiTimeParser.TryParse(m.Groups[1].Value, out var start)
|
||||||
|
&& AiTimeParser.TryParse(m.Groups[2].Value, out var end))
|
||||||
|
{
|
||||||
|
result.Add((start, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.ToImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按时间区间从全文切出回查所需的原始记录片段。</summary>
|
||||||
|
internal static class BackquerySliceExtractor
|
||||||
|
{
|
||||||
|
public const int MaxBackqueryTokens = 10_000;
|
||||||
|
|
||||||
|
public static (string? Text, string? Reason) Extract(
|
||||||
|
string fullText,
|
||||||
|
ImmutableArray<EventSpan> spans,
|
||||||
|
TimeSpan start,
|
||||||
|
TimeSpan end)
|
||||||
|
{
|
||||||
|
if (spans.IsEmpty)
|
||||||
|
{
|
||||||
|
return (null, "回放没有事件索引,无法回查。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = -1;
|
||||||
|
var last = -1;
|
||||||
|
for (var i = 0; i < spans.Length; ++i)
|
||||||
|
{
|
||||||
|
if (first < 0 && spans[i].Time >= start)
|
||||||
|
{
|
||||||
|
first = i;
|
||||||
|
}
|
||||||
|
if (spans[i].Time <= end)
|
||||||
|
{
|
||||||
|
last = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first < 0 || last < 0 || last < first)
|
||||||
|
{
|
||||||
|
return (null, $"区间 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)} 内没有事件。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = fullText.Substring(
|
||||||
|
spans[first].StartIndex,
|
||||||
|
spans[last].StartIndex + spans[last].Length - spans[first].StartIndex);
|
||||||
|
var tokens = AiContextBudget.EstimateTokens(text);
|
||||||
|
if (tokens > MaxBackqueryTokens)
|
||||||
|
{
|
||||||
|
return (null, $"回查区间过大(约 {tokens:N0} token,上限 {MaxBackqueryTokens:N0})。");
|
||||||
|
}
|
||||||
|
return (text, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>容错的时间解析:失败返回 false,不抛异常。</summary>
|
||||||
|
internal static class AiTimeParser
|
||||||
|
{
|
||||||
|
public static bool TryParse(string input, out TimeSpan result)
|
||||||
|
{
|
||||||
|
result = TimeSpan.Zero;
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
input = input.Trim();
|
||||||
|
var parts = input.Split(':');
|
||||||
|
if (parts.Length == 0
|
||||||
|
|| !float.TryParse(parts[parts.Length - 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
|
||||||
|
|| seconds < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSeconds = (long)(int)seconds;
|
||||||
|
var millis = (int)Math.Round((seconds - (int)seconds) * 1000);
|
||||||
|
long multiplier = 60;
|
||||||
|
for (var i = parts.Length - 2; i >= 0; --i)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(parts[i], out var value) || value < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
totalSeconds += value * multiplier;
|
||||||
|
multiplier *= 60;
|
||||||
|
}
|
||||||
|
result = TimeSpan.FromSeconds(totalSeconds) + TimeSpan.FromMilliseconds(millis);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把验证过的机器可读声明格式化为"已发现事实",供后续分段引用。</summary>
|
||||||
|
internal static class ClaimFindingsFormatter
|
||||||
|
{
|
||||||
|
public static string Format(AIMachineReadableClaims claims)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var claim in claims.UnitClaims)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- UnitId {claim.UnitId}({claim.Player}):推测 {claim.Claim},证据等级 {LevelName(claim.EvidenceLevel)}");
|
||||||
|
if (!claim.Evidence.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" 证据:{string.Join(";", claim.Evidence)}");
|
||||||
|
}
|
||||||
|
if (!claim.Alternatives.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" 备选:{string.Join(";", claim.Alternatives)}");
|
||||||
|
}
|
||||||
|
if (!claim.NeedsConfirmation.IsEmpty)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" 待确认:{string.Join(";", claim.NeedsConfirmation)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var claim in claims.EventClaims)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- 事件:{claim.Claim}({LevelName(claim.EvidenceLevel)})");
|
||||||
|
}
|
||||||
|
foreach (var claim in claims.TimelineClaims)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- 时间线:{claim.Claim}({LevelName(claim.EvidenceLevel)})");
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ExtractSummary(string response)
|
||||||
|
{
|
||||||
|
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.StartsWith("[小结]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var text = line.Substring("[小结]".Length).Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LevelName(AIEvidenceLevel level) => level switch
|
||||||
|
{
|
||||||
|
AIEvidenceLevel.Confirmed => "确定",
|
||||||
|
AIEvidenceLevel.HighlyLikely => "高度可能",
|
||||||
|
AIEvidenceLevel.Possible => "可能",
|
||||||
|
AIEvidenceLevel.Uncertain => "不确定",
|
||||||
|
AIEvidenceLevel.RuledOut => "已排除",
|
||||||
|
_ => "未知",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为修订 pass 生成与受影响声明相关的事实摘要。</summary>
|
||||||
|
internal static class RelevantFactsFormatter
|
||||||
|
{
|
||||||
|
public static string Format(AIMachineReadableClaims claims, ReplayFactIndex factIndex)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var claim in claims.UnitClaims)
|
||||||
|
{
|
||||||
|
if (!uint.TryParse(claim.UnitId, out var unitId)
|
||||||
|
|| !factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var first))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.AppendLine($"- UnitId {claim.UnitId}:首次出现 {MatchDigestBuilder.FormatTime(first)};"
|
||||||
|
+ (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var powers) && powers.Count > 0
|
||||||
|
? $"观察到的能力:{string.Join("、", powers.OrderBy(x => x))}"
|
||||||
|
: "未观察到特殊能力"));
|
||||||
|
if (factIndex.BuilderUnitIds.Contains(unitId))
|
||||||
|
{
|
||||||
|
sb.AppendLine(" 该 UnitId 曾作为建造者出现");
|
||||||
|
}
|
||||||
|
if (factIndex.ProducerUnitIds.Contains(unitId))
|
||||||
|
{
|
||||||
|
sb.AppendLine(" 该 UnitId 曾作为出兵建筑出现");
|
||||||
|
}
|
||||||
|
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
|
||||||
|
{
|
||||||
|
if (kv.Value.Contains(unitId))
|
||||||
|
{
|
||||||
|
sb.AppendLine($" 玩家 {kv.Key} 对它有强所有权证据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
|
||||||
|
{
|
||||||
|
if (kv.Value.Contains(unitId))
|
||||||
|
{
|
||||||
|
sb.AppendLine($" 玩家 {kv.Key} 对它有弱所有权证据(选中过)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal class CancelManager : IDisposable
|
||||||
|
{
|
||||||
|
private CancellationToken _linkedToken;
|
||||||
|
private CancellationTokenSource? _source;
|
||||||
|
|
||||||
|
public CancellationToken Token => Materialize().Token;
|
||||||
|
|
||||||
|
public void Reset(CancellationToken linked)
|
||||||
|
{
|
||||||
|
_linkedToken = linked;
|
||||||
|
if (_source is { } source)
|
||||||
|
{
|
||||||
|
_source = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.Cancel();
|
||||||
|
}
|
||||||
|
catch (AggregateException e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Cancellation failed: {e}";
|
||||||
|
}
|
||||||
|
source.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CancellationToken ResetAndGetToken(CancellationToken linked)
|
||||||
|
{
|
||||||
|
Reset(linked);
|
||||||
|
return Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Reset(default);
|
||||||
|
|
||||||
|
private CancellationTokenSource Materialize()
|
||||||
|
{
|
||||||
|
if (_source is null)
|
||||||
|
{
|
||||||
|
_source = CancellationTokenSource.CreateLinkedTokenSource(_linkedToken);
|
||||||
|
}
|
||||||
|
return _source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal static class CancellableTaskExtensions
|
||||||
|
{
|
||||||
|
public static async Task IgnoreCancel(this Task task)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Forget(this Task task)
|
||||||
|
{
|
||||||
|
const TaskContinuationOptions flags = TaskContinuationOptions.NotOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously;
|
||||||
|
task.ContinueWith(t => t.Exception?.Handle(_ => true), flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace System.Runtime.CompilerServices
|
||||||
|
{
|
||||||
|
internal static class IsExternalInit
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
static class ImmutableArrayExtensions
|
||||||
|
{
|
||||||
|
public static int? FindIndex<T>(this in ImmutableArray<T> a, Predicate<T> p)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < a.Length; ++i)
|
||||||
|
{
|
||||||
|
if (p(a[i]))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal class Lock : IDisposable
|
||||||
|
{
|
||||||
|
public readonly object LockObject;
|
||||||
|
private bool _disposed = false;
|
||||||
|
|
||||||
|
public Lock(object lockObject)
|
||||||
|
{
|
||||||
|
LockObject = lockObject;
|
||||||
|
Monitor.Enter(LockObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
Monitor.Exit(LockObject);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T Run<T>(object @lock, Func<T> action)
|
||||||
|
{
|
||||||
|
using var locker = new Lock(@lock);
|
||||||
|
return action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
public static class Network
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions CommonJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string UrlEncode(string text) => HttpUtility.UrlEncode(text);
|
||||||
|
|
||||||
|
public static async Task<T?> HttpGetJson<T>(string url,
|
||||||
|
CancellationToken cancelToken = default)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient();
|
||||||
|
using var response = await client.GetAsync(url, cancelToken).ConfigureAwait(false);
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
return await JsonSerializer.DeserializeAsync<T>(stream, CommonJsonOptions, cancelToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using NPinyin;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
static class PinyinExtensions
|
||||||
|
{
|
||||||
|
public static bool ContainsIgnoreCase(this string self, string? s)
|
||||||
|
{
|
||||||
|
return s != null && self.IndexOf(s, StringComparison.CurrentCultureIgnoreCase) != -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ToPinyin(this string self)
|
||||||
|
{
|
||||||
|
string pinyin;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pinyin = Pinyin.GetPinyin(self);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pinyin.Replace(" ", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
public static class RegistryUtils
|
||||||
|
{
|
||||||
|
public static string? Retrieve32(RegistryHive hive, string path, string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var view32 = RegistryKey.OpenBaseKey(hive, RegistryView.Registry32);
|
||||||
|
using var key = view32.OpenSubKey(path, false);
|
||||||
|
return key?.GetValue(value) as string;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Failed to retrieve registy {hive}:{path}:{value}: {e}";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? RetrieveInHklm64(string path, string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var view64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||||
|
using var key = view64?.OpenSubKey(path, false);
|
||||||
|
return key?.GetValue(value) as string;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.Instance.DebugMessage += $"Failed to retrieve registy HKLM64:{path}:{value}: {e}";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? RetrieveInRa3(RegistryHive hive, string value)
|
||||||
|
{
|
||||||
|
return Retrieve32(hive, @"Software\Electronic Arts\Electronic Arts\Red Alert 3", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
/// <summary>一次特殊能力事件(含真实发生时间与玩家),用于生成可读的打包/展开时间线。</summary>
|
||||||
|
internal sealed record SpecialPowerEvent(
|
||||||
|
TimeSpan Time,
|
||||||
|
int PlayerIndex,
|
||||||
|
uint UnitId,
|
||||||
|
string PowerName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of replay facts extracted from CommandChunk data.
|
||||||
|
/// Used by AIAnalysisValidation to cross-reference LLM claims against
|
||||||
|
/// actual replay operations.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ReplayFactIndex
|
||||||
|
{
|
||||||
|
/// <summary>First time a UnitId was observed in any command.</summary>
|
||||||
|
public ImmutableDictionary<uint, TimeSpan> UnitIdFirstObservedTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Special powers used by each UnitId.</summary>
|
||||||
|
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
|
||||||
|
|
||||||
|
/// <summary>按时间排序的特殊能力事件列表。</summary>
|
||||||
|
public ImmutableArray<SpecialPowerEvent> SpecialPowerEvents { get; }
|
||||||
|
|
||||||
|
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
|
||||||
|
public ImmutableHashSet<uint> BuilderUnitIds { get; }
|
||||||
|
|
||||||
|
/// <summary>UnitIds that appeared as production structures ("出兵建筑").</summary>
|
||||||
|
public ImmutableHashSet<uint> ProducerUnitIds { get; }
|
||||||
|
|
||||||
|
/// <summary>Per player, per unit asset name, first production start time.</summary>
|
||||||
|
public ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> PlayerFirstProductionTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Per player, which UnitIds they have selected.</summary>
|
||||||
|
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerSelectedUnitIds { get; }
|
||||||
|
|
||||||
|
/// <summary>Per player, UnitIds with strong ownership evidence (control group, builder/producer, repair, sell, power caster).</summary>
|
||||||
|
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerStrongOwnershipUnitIds { get; }
|
||||||
|
|
||||||
|
/// <summary>Per player, UnitIds with weak ownership evidence (plain selection only).</summary>
|
||||||
|
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerWeakOwnershipUnitIds { get; }
|
||||||
|
|
||||||
|
/// <summary>Per player, tech/protocol choices (0x24E).</summary>
|
||||||
|
public ImmutableDictionary<int, ImmutableHashSet<string>> PlayerTechChoices { get; }
|
||||||
|
|
||||||
|
public ReplayFactIndex(
|
||||||
|
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
|
||||||
|
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
|
||||||
|
ImmutableArray<SpecialPowerEvent> specialPowerEvents,
|
||||||
|
ImmutableHashSet<uint> builderUnitIds,
|
||||||
|
ImmutableHashSet<uint> producerUnitIds,
|
||||||
|
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
|
||||||
|
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds,
|
||||||
|
ImmutableDictionary<int, ImmutableHashSet<uint>> playerStrongOwnershipUnitIds,
|
||||||
|
ImmutableDictionary<int, ImmutableHashSet<uint>> playerWeakOwnershipUnitIds,
|
||||||
|
ImmutableDictionary<int, ImmutableHashSet<string>> playerTechChoices)
|
||||||
|
{
|
||||||
|
UnitIdFirstObservedTime = unitIdFirstObservedTime;
|
||||||
|
UnitIdSpecialPowers = unitIdSpecialPowers;
|
||||||
|
SpecialPowerEvents = specialPowerEvents;
|
||||||
|
BuilderUnitIds = builderUnitIds;
|
||||||
|
ProducerUnitIds = producerUnitIds;
|
||||||
|
PlayerFirstProductionTime = playerFirstProductionTime;
|
||||||
|
PlayerSelectedUnitIds = playerSelectedUnitIds;
|
||||||
|
PlayerStrongOwnershipUnitIds = playerStrongOwnershipUnitIds;
|
||||||
|
PlayerWeakOwnershipUnitIds = playerWeakOwnershipUnitIds;
|
||||||
|
PlayerTechChoices = playerTechChoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReplayFactIndex Build(
|
||||||
|
ImmutableArray<(TimeSpan Time, ImmutableArray<CommandChunk> Commands)> timeline,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable)
|
||||||
|
{
|
||||||
|
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
|
||||||
|
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
|
||||||
|
var specialPowerEvents = new List<SpecialPowerEvent>();
|
||||||
|
var builderUnits = new HashSet<uint>();
|
||||||
|
var producerUnits = new HashSet<uint>();
|
||||||
|
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
|
||||||
|
var playerSelected = new Dictionary<int, HashSet<uint>>();
|
||||||
|
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
|
||||||
|
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
|
||||||
|
var playerTechChoices = new Dictionary<int, HashSet<string>>();
|
||||||
|
// 编队号在同一局内可能被不同玩家复用,因此键必须包含玩家。
|
||||||
|
var controlGroups = new Dictionary<long, HashSet<uint>>();
|
||||||
|
|
||||||
|
foreach (var (time, commands) in timeline)
|
||||||
|
{
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
ProcessCommand(time, command, stringHashTable,
|
||||||
|
unitFirstObserved, unitSpecialPowers, specialPowerEvents,
|
||||||
|
builderUnits, producerUnits,
|
||||||
|
playerFirstProduction, playerSelected,
|
||||||
|
playerStrongOwnership, playerWeakOwnership,
|
||||||
|
playerTechChoices, controlGroups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReplayFactIndex(
|
||||||
|
unitFirstObserved.ToImmutableDictionary(),
|
||||||
|
unitSpecialPowers.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||||
|
specialPowerEvents
|
||||||
|
.OrderBy(e => e.Time)
|
||||||
|
.ThenBy(e => e.PlayerIndex)
|
||||||
|
.ThenBy(e => e.UnitId)
|
||||||
|
.ToImmutableArray(),
|
||||||
|
builderUnits.ToImmutableHashSet(),
|
||||||
|
producerUnits.ToImmutableHashSet(),
|
||||||
|
playerFirstProduction.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableDictionary()),
|
||||||
|
playerSelected.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||||
|
playerStrongOwnership.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||||
|
playerWeakOwnership.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||||
|
playerTechChoices.ToImmutableDictionary(
|
||||||
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ProcessCommand(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||||
|
List<SpecialPowerEvent> specialPowerEvents,
|
||||||
|
HashSet<uint> builderUnits,
|
||||||
|
HashSet<uint> producerUnits,
|
||||||
|
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||||
|
Dictionary<int, HashSet<uint>> playerSelected,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
|
Dictionary<int, HashSet<uint>> playerWeakOwnership,
|
||||||
|
Dictionary<int, HashSet<string>> playerTechChoices,
|
||||||
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
|
{
|
||||||
|
var player = command.PlayerIndex;
|
||||||
|
var cmdId = command.CommandId;
|
||||||
|
|
||||||
|
switch (cmdId)
|
||||||
|
{
|
||||||
|
// select unit(s): 0x1F5
|
||||||
|
case 0x1F5:
|
||||||
|
// 选择相同单位(W):ObjectId 语义与选择相同,作为弱所有权
|
||||||
|
case 0x1F6:
|
||||||
|
// 选择所有单位(Q):若能解析出 UnitId,同样作为弱所有权
|
||||||
|
case 0x22A:
|
||||||
|
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 从选择中移除单位:与选择类似,仅弱所有权
|
||||||
|
case 0x1F9:
|
||||||
|
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerWeakOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// special power (no target): 0x1FE —— 布局确凿,ObjectId 是施法者
|
||||||
|
case 0x1FE:
|
||||||
|
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
|
||||||
|
case 0x200:
|
||||||
|
RecordSpecialPower(time, command, player, stringHashTable,
|
||||||
|
unitFirstObserved, unitSpecialPowers, playerStrongOwnership,
|
||||||
|
specialPowerEvents);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
|
||||||
|
case 0x1FF:
|
||||||
|
// special power (target unit): 0x201 —— ObjectId 可能是目标
|
||||||
|
case 0x201:
|
||||||
|
// special power (one or more targets): 0x232 —— ObjectId 可能是目标
|
||||||
|
case 0x232:
|
||||||
|
RecordObjectReference(time, command, unitFirstObserved);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// start production: 0x205
|
||||||
|
case 0x205:
|
||||||
|
RecordProduction(time, command, player, unitFirstObserved,
|
||||||
|
stringHashTable, producerUnits, playerFirstProduction, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// start construction: 0x207
|
||||||
|
case 0x207:
|
||||||
|
RecordConstruction(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// place building: 0x209
|
||||||
|
case 0x209:
|
||||||
|
RecordPlaceBuilding(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// sell building: 0x20A
|
||||||
|
case 0x20A:
|
||||||
|
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 开始/停止维修建筑:只能维修己方建筑 → 强所有权
|
||||||
|
case 0x228:
|
||||||
|
case 0x229:
|
||||||
|
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 命令矿车交矿 / 让矿车去采矿:只能命令己方矿车 → 强所有权
|
||||||
|
case 0x212:
|
||||||
|
case 0x248:
|
||||||
|
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 创建编队:编队成员几乎确定是己方单位 → 强所有权
|
||||||
|
case 0x1FA:
|
||||||
|
RecordControlGroupCreate(time, command, player, unitFirstObserved,
|
||||||
|
playerStrongOwnership, controlGroups);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 选择编队 / 将编队加入选择:通过编队状态解析成员 → 强所有权
|
||||||
|
case 0x1FB:
|
||||||
|
case 0x1FC:
|
||||||
|
RecordControlGroupSelect(time, command, player, unitFirstObserved,
|
||||||
|
playerStrongOwnership, controlGroups);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 选择协议:全局生效,无 UnitId
|
||||||
|
case 0x24E:
|
||||||
|
RecordTechChoice(time, command, player, stringHashTable, playerTechChoices);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// move: 0x214
|
||||||
|
case 0x214:
|
||||||
|
// attack move: 0x215
|
||||||
|
case 0x215:
|
||||||
|
// These commands operate on currently selected units.
|
||||||
|
// The target is a position, not a UnitId.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordSelectUnit(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<int, HashSet<uint>> playerSelected,
|
||||||
|
Dictionary<int, HashSet<uint>> playerWeakOwnership)
|
||||||
|
{
|
||||||
|
// Data layout for 0x1F5:
|
||||||
|
// Data[0]: Bool (isReplace), if count > 0 the rest are ObjectIds
|
||||||
|
// Data[1..]: ObjectIds of selected units
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
|
||||||
|
{
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var unitId = (uint)entry.Value;
|
||||||
|
TryRecordFirstObserved(unitId, time, unitFirstObserved);
|
||||||
|
RecordPlayerSelection(player, unitId, playerSelected);
|
||||||
|
RecordPlayerOwnership(player, unitId, playerWeakOwnership);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var id in (uint[])entry.Value)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
RecordPlayerSelection(player, id, playerSelected);
|
||||||
|
RecordPlayerOwnership(player, id, playerWeakOwnership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordSpecialPower(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
|
List<SpecialPowerEvent> specialPowerEvents)
|
||||||
|
{
|
||||||
|
string? powerName = null;
|
||||||
|
var unitIds = new List<uint>();
|
||||||
|
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
switch (entry.Type)
|
||||||
|
{
|
||||||
|
case CommandArgumentType.Int32 when powerName is null:
|
||||||
|
{
|
||||||
|
// First Int32 is the special power hash ID;
|
||||||
|
// 同类型参数可能被打包成数组(首个元素是 hash)
|
||||||
|
var hash = entry.Count == 1 && entry.Value is int singleInt
|
||||||
|
? unchecked((uint)singleInt)
|
||||||
|
: entry.Value is int[] ints && ints.Length > 0
|
||||||
|
? unchecked((uint)ints[0])
|
||||||
|
: (uint?)null;
|
||||||
|
if (hash is { } hashValue)
|
||||||
|
{
|
||||||
|
powerName = stringHashTable.TryGetValue(hashValue, out var name)
|
||||||
|
? name
|
||||||
|
: $"Hash_{hashValue:X8}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2:
|
||||||
|
{
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var id = (uint)entry.Value;
|
||||||
|
if (id != 0) unitIds.Add(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var id in (uint[])entry.Value)
|
||||||
|
{
|
||||||
|
if (id != 0) unitIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (powerName is null || unitIds.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var unitId in unitIds)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(unitId, time, unitFirstObserved);
|
||||||
|
if (!unitSpecialPowers.TryGetValue(unitId, out var powers))
|
||||||
|
{
|
||||||
|
powers = new HashSet<string>();
|
||||||
|
unitSpecialPowers[unitId] = powers;
|
||||||
|
}
|
||||||
|
powers.Add(powerName);
|
||||||
|
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
|
||||||
|
specialPowerEvents.Add(new SpecialPowerEvent(
|
||||||
|
time, player, unitId, powerName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordProduction(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
HashSet<uint> producerUnits,
|
||||||
|
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||||
|
{
|
||||||
|
uint? producerId = null;
|
||||||
|
string? unitName = null;
|
||||||
|
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
switch (entry.Type)
|
||||||
|
{
|
||||||
|
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
|
||||||
|
when producerId is null:
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var id = (uint)entry.Value;
|
||||||
|
if (id != 0) producerId = id;
|
||||||
|
}
|
||||||
|
else if (entry.Value is uint[] ids)
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
if (id != 0)
|
||||||
|
{
|
||||||
|
producerId = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
|
||||||
|
or CommandArgumentType.Int32
|
||||||
|
or CommandArgumentType.UInt32
|
||||||
|
or CommandArgumentType.UInt32_2
|
||||||
|
when unitName is null:
|
||||||
|
if (TryReadCommandName(entry, stringHashTable, out var resolvedName))
|
||||||
|
{
|
||||||
|
unitName = resolvedName;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (producerId.HasValue)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(producerId.Value, time, unitFirstObserved);
|
||||||
|
producerUnits.Add(producerId.Value);
|
||||||
|
RecordPlayerOwnership(player, producerId.Value, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(unitName))
|
||||||
|
{
|
||||||
|
if (!playerFirstProduction.TryGetValue(player, out var perPlayer))
|
||||||
|
{
|
||||||
|
perPlayer = new Dictionary<string, TimeSpan>();
|
||||||
|
playerFirstProduction[player] = perPlayer;
|
||||||
|
}
|
||||||
|
if (unitName is not null && !perPlayer.ContainsKey(unitName))
|
||||||
|
{
|
||||||
|
perPlayer[unitName] = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordConstruction(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
HashSet<uint> builderUnits,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||||
|
{
|
||||||
|
// Data[0]: ObjectId (builder)
|
||||||
|
// Data[1]: AsciiString (building name)
|
||||||
|
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordPlaceBuilding(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
HashSet<uint> builderUnits,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||||
|
{
|
||||||
|
// Data[0]: ObjectId (builder)
|
||||||
|
// Data[1]: AsciiString (building name)
|
||||||
|
// Data[2]: Int32 (count)
|
||||||
|
// Data[3]: Vector3 (position)
|
||||||
|
// Data[4]: Float32 (angle)
|
||||||
|
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordBuilder(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
HashSet<uint> builderUnits,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||||
|
{
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var id = (uint)entry.Value;
|
||||||
|
if (id != 0)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
builderUnits.Add(id);
|
||||||
|
RecordPlayerOwnership(player, id, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
return; // only first ObjectId is the builder
|
||||||
|
}
|
||||||
|
if (entry.Value is uint[] ids)
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
if (id != 0)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
builderUnits.Add(id);
|
||||||
|
RecordPlayerOwnership(player, id, playerStrongOwnership);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordControlGroupCreate(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
|
{
|
||||||
|
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
|
||||||
|
int? groupNumber = null;
|
||||||
|
var members = new List<uint>();
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (groupNumber is null && entry.Type == CommandArgumentType.Int32)
|
||||||
|
{
|
||||||
|
groupNumber = entry.Count == 1 && entry.Value is int singleInt
|
||||||
|
? singleInt
|
||||||
|
: entry.Value is int[] ints && ints.Length > 0
|
||||||
|
? ints[0]
|
||||||
|
: (int?)null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
|
||||||
|
{
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var id = (uint)entry.Value;
|
||||||
|
if (id != 0) members.Add(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var id in (uint[])entry.Value)
|
||||||
|
{
|
||||||
|
if (id != 0) members.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupNumber is null || members.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var group = new HashSet<uint>(members);
|
||||||
|
controlGroups[ControlGroupKey(player, groupNumber.Value)] = group;
|
||||||
|
foreach (var id in members)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
RecordPlayerOwnership(player, id, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordControlGroupSelect(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
|
{
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type != CommandArgumentType.Int32)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var groupNumber = entry.Count == 1 && entry.Value is int singleInt
|
||||||
|
? singleInt
|
||||||
|
: entry.Value is int[] ints && ints.Length > 0
|
||||||
|
? ints[0]
|
||||||
|
: (int?)null;
|
||||||
|
if (groupNumber is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (controlGroups.TryGetValue(ControlGroupKey(player, groupNumber.Value), out var members))
|
||||||
|
{
|
||||||
|
foreach (var id in members)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
RecordPlayerOwnership(player, id, playerStrongOwnership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ControlGroupKey(int player, int group) =>
|
||||||
|
((long)player << 32) | (uint)group;
|
||||||
|
|
||||||
|
private static void RecordTechChoice(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
Dictionary<int, HashSet<string>> playerTechChoices)
|
||||||
|
{
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type is CommandArgumentType.AsciiString
|
||||||
|
or CommandArgumentType.UnicodeString
|
||||||
|
or CommandArgumentType.Int32
|
||||||
|
or CommandArgumentType.UInt32
|
||||||
|
or CommandArgumentType.UInt32_2)
|
||||||
|
{
|
||||||
|
if (!TryReadCommandName(entry, stringHashTable, out var tech))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!playerTechChoices.TryGetValue(player, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<string>();
|
||||||
|
playerTechChoices[player] = set;
|
||||||
|
}
|
||||||
|
set.Add(tech);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCommandName(
|
||||||
|
CommandArgumentEntry entry,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
out string name)
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
|
||||||
|
{
|
||||||
|
if (entry.Count == 1 && entry.Value is string single)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(single))
|
||||||
|
{
|
||||||
|
name = single;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (entry.Value is string[] values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
name = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryReadCommandNameAsHash(entry, stringHashTable, out name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCommandNameAsHash(
|
||||||
|
CommandArgumentEntry entry,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
out string name)
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
IEnumerable<uint> hashes = entry.Type switch
|
||||||
|
{
|
||||||
|
CommandArgumentType.Int32 when entry.Count == 1 && entry.Value is int singleInt =>
|
||||||
|
new[] { unchecked((uint)singleInt) },
|
||||||
|
CommandArgumentType.Int32 when entry.Value is int[] ints =>
|
||||||
|
ints.Select(x => unchecked((uint)x)),
|
||||||
|
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||||
|
when entry.Count == 1 && entry.Value is uint singleUint =>
|
||||||
|
new[] { singleUint },
|
||||||
|
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||||
|
when entry.Value is uint[] uints =>
|
||||||
|
uints,
|
||||||
|
_ => Array.Empty<uint>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var hash in hashes)
|
||||||
|
{
|
||||||
|
if (stringHashTable.TryGetValue(hash, out var resolved)
|
||||||
|
&& !string.IsNullOrWhiteSpace(resolved))
|
||||||
|
{
|
||||||
|
name = resolved;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordObjectReferenceWithOwnership(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
int player,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
Dictionary<int, HashSet<uint>> playerOwnership)
|
||||||
|
{
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
var id = (uint)entry.Value;
|
||||||
|
if (id == 0) continue;
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
RecordPlayerOwnership(player, id, playerOwnership);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var id in (uint[])entry.Value)
|
||||||
|
{
|
||||||
|
if (id == 0) continue;
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
RecordPlayerOwnership(player, id, playerOwnership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordObjectReference(
|
||||||
|
TimeSpan time,
|
||||||
|
CommandChunk command,
|
||||||
|
Dictionary<uint, TimeSpan> unitFirstObserved)
|
||||||
|
{
|
||||||
|
foreach (var entry in command.Data)
|
||||||
|
{
|
||||||
|
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
|
||||||
|
{
|
||||||
|
if (entry.Count == 1)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved((uint)entry.Value, time, unitFirstObserved);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var id in (uint[])entry.Value)
|
||||||
|
{
|
||||||
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordPlayerSelection(int player, uint unitId, Dictionary<int, HashSet<uint>> playerSelected)
|
||||||
|
{
|
||||||
|
if (!playerSelected.TryGetValue(player, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<uint>();
|
||||||
|
playerSelected[player] = set;
|
||||||
|
}
|
||||||
|
set.Add(unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordPlayerOwnership(
|
||||||
|
int player,
|
||||||
|
uint unitId,
|
||||||
|
Dictionary<int, HashSet<uint>> playerOwnership)
|
||||||
|
{
|
||||||
|
if (!playerOwnership.TryGetValue(player, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<uint>();
|
||||||
|
playerOwnership[player] = set;
|
||||||
|
}
|
||||||
|
set.Add(unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary<uint, TimeSpan> unitFirstObserved)
|
||||||
|
{
|
||||||
|
if (unitId == 0) return;
|
||||||
|
if (unitFirstObserved.ContainsKey(unitId)) return;
|
||||||
|
unitFirstObserved[unitId] = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using AnotherReplayReader.ReplayFile;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal class ReplayPinyinList
|
||||||
|
{
|
||||||
|
public ImmutableArray<Replay> Replays { get; } = ImmutableArray<Replay>.Empty;
|
||||||
|
public ImmutableArray<ReplayPinyinData> Pinyins { get; } = ImmutableArray<ReplayPinyinData>.Empty;
|
||||||
|
|
||||||
|
public ReplayPinyinList() :
|
||||||
|
this(ImmutableArray<Replay>.Empty)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReplayPinyinList(ImmutableArray<Replay> replay) :
|
||||||
|
this(replay,
|
||||||
|
replay.Select(replay => new ReplayPinyinData(replay)).ToImmutableArray())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReplayPinyinList(ImmutableArray<Replay> replay,
|
||||||
|
ImmutableArray<ReplayPinyinData> pinyins)
|
||||||
|
{
|
||||||
|
Replays = replay;
|
||||||
|
Pinyins = pinyins;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReplayPinyinList SetItem(int index, Replay replay)
|
||||||
|
{
|
||||||
|
return new(Replays.SetItem(index, replay),
|
||||||
|
Pinyins.SetItem(index, new(replay)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReplayPinyinData
|
||||||
|
{
|
||||||
|
public Replay Replay { get; }
|
||||||
|
public string? PinyinDetails { get; }
|
||||||
|
public string? PinyinMod { get; }
|
||||||
|
|
||||||
|
public ReplayPinyinData(Replay replay)
|
||||||
|
{
|
||||||
|
Replay = replay;
|
||||||
|
PinyinDetails = replay.GetDetails().ToPinyin();
|
||||||
|
PinyinMod = replay.Mod.ModName.ToPinyin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MatchPinyin(string? pinyin)
|
||||||
|
{
|
||||||
|
return PinyinDetails?.ContainsIgnoreCase(pinyin) is true
|
||||||
|
|| PinyinMod?.ContainsIgnoreCase(pinyin) is true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
public readonly struct ShortTimeSpan : IEquatable<ShortTimeSpan>, IComparable<ShortTimeSpan>, IComparable
|
||||||
|
{
|
||||||
|
public readonly TimeSpan Value;
|
||||||
|
|
||||||
|
public ShortTimeSpan(TimeSpan value) => Value = value;
|
||||||
|
public static implicit operator TimeSpan(ShortTimeSpan span) => span.Value;
|
||||||
|
public static implicit operator ShortTimeSpan(TimeSpan value) => new(value);
|
||||||
|
public override string ToString() => $"{(int)Value.TotalMinutes:00}:{Value.Seconds:00}";
|
||||||
|
|
||||||
|
public int CompareTo(ShortTimeSpan other) => Value.CompareTo(other.Value);
|
||||||
|
public int CompareTo(object obj) => obj is ShortTimeSpan span ? CompareTo(span) : 1;
|
||||||
|
public override bool Equals(object? obj) => obj is ShortTimeSpan span && Equals(span);
|
||||||
|
public bool Equals(ShortTimeSpan other) => Value.Equals(other.Value);
|
||||||
|
public override int GetHashCode() => Value.GetHashCode();
|
||||||
|
public static bool operator ==(ShortTimeSpan left, ShortTimeSpan right) => left.Equals(right);
|
||||||
|
public static bool operator !=(ShortTimeSpan left, ShortTimeSpan right) => !(left == right);
|
||||||
|
public static bool operator <(ShortTimeSpan left, ShortTimeSpan right) => left.CompareTo(right) < 0;
|
||||||
|
public static bool operator >(ShortTimeSpan left, ShortTimeSpan right) => left.CompareTo(right) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal class TaskQueue
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly Dispatcher _dispatcher;
|
||||||
|
private Task _current = Task.CompletedTask;
|
||||||
|
|
||||||
|
public TaskQueue(Dispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Enqueue(Func<Task> getTask, CancellationToken cancelToken)
|
||||||
|
{
|
||||||
|
using var locker = new Lock(_lock);
|
||||||
|
_current = _current.ContinueWith(async t =>
|
||||||
|
{
|
||||||
|
var task = await _dispatcher.InvokeAsync(getTask, DispatcherPriority.Background, cancelToken);
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}, cancelToken).Unwrap();
|
||||||
|
return _current.IgnoreCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
public record TimeIndexedPrefixSums(List<TimeSpan> Times, List<int> PrefixSums)
|
||||||
|
{
|
||||||
|
public void Add(TimeSpan time, int value)
|
||||||
|
{
|
||||||
|
if (Times.Count > 0 && time < Times.Last())
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Time must be added in non-decreasing order.");
|
||||||
|
}
|
||||||
|
Times.Add(time);
|
||||||
|
PrefixSums.Add((PrefixSums.LastOrDefault()) + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Query(TimeSpan start, TimeSpan end)
|
||||||
|
{
|
||||||
|
var times = Times;
|
||||||
|
var prefix = PrefixSums;
|
||||||
|
|
||||||
|
int startIndex = LowerBound(times, start);
|
||||||
|
int endIndex = UpperBound(times, end);
|
||||||
|
|
||||||
|
if (startIndex >= times.Count || endIndex < 0 || startIndex > endIndex)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int result = prefix[endIndex];
|
||||||
|
|
||||||
|
if (startIndex > 0)
|
||||||
|
{
|
||||||
|
result -= prefix[startIndex - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetTotal()
|
||||||
|
{
|
||||||
|
return PrefixSums.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int LowerBound(List<TimeSpan> arr, TimeSpan target)
|
||||||
|
{
|
||||||
|
int left = 0, right = arr.Count;
|
||||||
|
|
||||||
|
while (left < right)
|
||||||
|
{
|
||||||
|
int mid = left + (right - left) / 2;
|
||||||
|
|
||||||
|
if (arr[mid] < target)
|
||||||
|
{
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int UpperBound(List<TimeSpan> arr, TimeSpan target)
|
||||||
|
{
|
||||||
|
int left = 0, right = arr.Count;
|
||||||
|
|
||||||
|
while (left < right)
|
||||||
|
{
|
||||||
|
int mid = left + (right - left) / 2;
|
||||||
|
|
||||||
|
if (arr[mid] <= target)
|
||||||
|
{
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return left - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal class VerifierPayload
|
||||||
|
{
|
||||||
|
public string Data { get; }
|
||||||
|
public string Signature { get; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public Lazy<byte[]> ByteData { get; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public Lazy<byte[]> ByteSignature { get; }
|
||||||
|
|
||||||
|
public VerifierPayload(string data, string signature)
|
||||||
|
{
|
||||||
|
Data = data;
|
||||||
|
Signature = signature;
|
||||||
|
ByteData = new(() => Convert.FromBase64String(Data),
|
||||||
|
LazyThreadSafetyMode.PublicationOnly);
|
||||||
|
ByteSignature = new(() => Convert.FromBase64String(Signature),
|
||||||
|
LazyThreadSafetyMode.PublicationOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VerifierPayload FromBytes(byte[] data, byte[] signature)
|
||||||
|
{
|
||||||
|
return new(Convert.ToBase64String(data), Convert.ToBase64String(signature));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Verifier
|
||||||
|
{
|
||||||
|
private const string _publicKey = @"<RSAKeyValue><Modulus>3vw5CoFRDFt2ri4jLDTu75cw1U/tCRjya7q8X/IdULaOJOYG8C+uqrF2Atb4ou+4SrmF+bvJM9cFsf3yO7XpeIDpkxD3KGbIEw+0JixTIIm+y5xlLKDDwbZHnYjJOBTt6JBn0yqwx7vY2UEZIcRU6wlOmUapnkpiaC2anNhSPqk=</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>";
|
||||||
|
|
||||||
|
public static bool Verify(VerifierPayload payload)
|
||||||
|
{
|
||||||
|
using var rsa = new RSACng();
|
||||||
|
rsa.FromXmlString(_publicKey);
|
||||||
|
return rsa.VerifyData(payload.ByteData.Value, payload.ByteSignature.Value, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Sign(object sample)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetTempFileName();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ea = Enumerable.Repeat<byte>(0xEA, 1024).ToArray();
|
||||||
|
const string splitter = "|DuplexBarrier|";
|
||||||
|
var isByteArray = false;
|
||||||
|
if (sample is string sampleText)
|
||||||
|
{
|
||||||
|
File.WriteAllText(fileName, $"输入私钥信息以及需要签名的数据,用 `{splitter}` 分开\r\n\r\n{sampleText}");
|
||||||
|
}
|
||||||
|
else if (sample is byte[] readyArray)
|
||||||
|
{
|
||||||
|
isByteArray = true;
|
||||||
|
File.WriteAllText(fileName, $"输入私钥信息以及需要签名的数据{splitter}{Convert.ToBase64String(readyArray)}{splitter}");
|
||||||
|
}
|
||||||
|
using (var process = Process.Start("notepad.exe", fileName))
|
||||||
|
{
|
||||||
|
process.WaitForExit();
|
||||||
|
}
|
||||||
|
var splitted = File.ReadAllText(fileName).Split(new[] { splitter }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
File.WriteAllBytes(fileName, ea);
|
||||||
|
byte[] bytes;
|
||||||
|
byte[] signature;
|
||||||
|
|
||||||
|
using (var rsa = new RSACng())
|
||||||
|
{
|
||||||
|
rsa.FromXmlString(splitted[0]);
|
||||||
|
var text = splitted[1];
|
||||||
|
splitted = null;
|
||||||
|
GC.Collect();
|
||||||
|
MessageBox.Show(text, $"快来确认一下~");
|
||||||
|
bytes = isByteArray
|
||||||
|
? Convert.FromBase64String(text)
|
||||||
|
: Encoding.UTF8.GetBytes(text);
|
||||||
|
signature = rsa.SignData(bytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
GC.Collect();
|
||||||
|
var payload = VerifierPayload.FromBytes(bytes, signature);
|
||||||
|
var serialized = JsonSerializer.Serialize(payload, Network.CommonJsonOptions);
|
||||||
|
var choice = MessageBox.Show(serialized, "嗯哼", MessageBoxButton.YesNo);
|
||||||
|
while (choice == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
File.WriteAllText(fileName, serialized);
|
||||||
|
using (var process = Process.Start("notepad.exe", fileName))
|
||||||
|
{
|
||||||
|
process.WaitForExit();
|
||||||
|
}
|
||||||
|
using var rsa = new RSACng();
|
||||||
|
rsa.FromXmlString(_publicKey);
|
||||||
|
var checkContent = File.ReadAllText(fileName);
|
||||||
|
var check = JsonSerializer.Deserialize<VerifierPayload>(checkContent, Network.CommonJsonOptions) ?? new("", "");
|
||||||
|
var checkData = Convert.FromBase64String(check.Data);
|
||||||
|
var checkSignature = Convert.FromBase64String(check.Signature);
|
||||||
|
var verified = rsa.VerifyData(checkData, checkSignature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||||
|
choice = MessageBox.Show($"结果:{verified}", "嗯哼", MessageBoxButton.YesNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Utils
|
||||||
|
{
|
||||||
|
internal static class WpfExtensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<T> FindVisualChildren<T>(this DependencyObject depObj) where T : DependencyObject
|
||||||
|
{
|
||||||
|
foreach (var x in LogicalTreeHelper.GetChildren(depObj))
|
||||||
|
{
|
||||||
|
if (x is not DependencyObject child)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child is T tchild)
|
||||||
|
{
|
||||||
|
yield return tchild;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var childOfChild in FindVisualChildren<T>(child))
|
||||||
|
{
|
||||||
|
yield return childOfChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# AI Analysis WIP
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史设计/进度记录,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
|
||||||
|
|
||||||
|
## User Need
|
||||||
|
|
||||||
|
The application is adding AI analysis for Red Alert 3 replay operation logs. The current flow sends player information and a compacted operation log to a chat-completion-compatible LLM, then displays the analysis in `AIChatPanel`.
|
||||||
|
|
||||||
|
The main goals are:
|
||||||
|
|
||||||
|
- Improve the current system prompt.
|
||||||
|
- Make the system prompt configurable by the user.
|
||||||
|
- Reduce AI analysis errors, especially errors caused by general-world assumptions or overconfident UnitId guesses.
|
||||||
|
- Add a validation path for LLM output so wrong claims can be detected and corrected without wasting all prior reasoning.
|
||||||
|
|
||||||
|
## Current Code Areas
|
||||||
|
|
||||||
|
- `Utils/AIAnalyze.cs`: builds the system prompt, user prompts, segment prompts, final summary prompts, and performs OpenAI-compatible chat completion calls.
|
||||||
|
- `AIChatPanel.xaml.cs`: runs the analysis workflow, displays streaming chunks, retries failed segments, and now logs machine-readable claim validation results.
|
||||||
|
- `Utils/AiSettings.cs`: stores AI provider/model settings and now prompt settings.
|
||||||
|
- `AIProviderSettingsControl.xaml(.cs)`: edits provider/model settings and now prompt settings.
|
||||||
|
- `EventDump.xaml.cs`: generates the replay operation text and starts AI analysis.
|
||||||
|
- `Utils/AIAnalysisValidation.cs`: new validation model and parser for machine-readable AI claims.
|
||||||
|
- `CONTEXT.md`: glossary for the AI analysis domain.
|
||||||
|
|
||||||
|
## Discussion Notes
|
||||||
|
|
||||||
|
### Prompt Problems
|
||||||
|
|
||||||
|
The current prompt has a lot of useful game knowledge, but the LLM can still:
|
||||||
|
|
||||||
|
- Use common sense that is wrong for the game or mod.
|
||||||
|
- Assume infantry, helicopters, transports, amphibious movement, and water placement work like they do in other RTS games.
|
||||||
|
- Treat one observed skill as conclusive evidence when multiple units share that skill.
|
||||||
|
- Overstate UnitId guesses.
|
||||||
|
|
||||||
|
Examples discussed:
|
||||||
|
|
||||||
|
- Only units explicitly marked amphibious can move on both land and water.
|
||||||
|
- Only units explicitly marked as passenger transports can transport infantry.
|
||||||
|
- Building water placement depends on game rules, not common assumptions.
|
||||||
|
- `SpecialPower_UnpackReplaceSelf` does not uniquely identify an Allied MCV because Allied miners can also unpack into a command hub.
|
||||||
|
- A UnitId claimed as an aircraft should be challenged if the same UnitId is observed using an unpack/deploy skill.
|
||||||
|
- A UnitId claimed as a bomber should be challenged if it is operated before the player starts producing their first bomber.
|
||||||
|
|
||||||
|
### Documented Example: MCV vs Miner Ambiguity (Allied)
|
||||||
|
|
||||||
|
Observed replay sequence:
|
||||||
|
1. UnitId 246 (confirmed main base) → `PackReplaceSelf`
|
||||||
|
2. Player selects UnitId 587
|
||||||
|
3. UnitId 587 → `UnpackReplaceSelf`
|
||||||
|
4. AI claims: `587 = AlliedMCV`, evidenceLevel: `confirmed`
|
||||||
|
|
||||||
|
**Why this cannot be definitively resolved:**
|
||||||
|
- After base 246 packs, the engine creates a new MCV (UnitId A). The miner (UnitId B) also exists on the map.
|
||||||
|
- When the player selects 587, we cannot prove 587 = A vs 587 = B.
|
||||||
|
- After `UnpackReplaceSelf`, 587 is replaced by yet another UnitId (C if MCV→base, D if miner→command hub).
|
||||||
|
- Even if we later see C building things (`开始建造建筑 [UnitId]C(建造者)`), there is no replay-observable link connecting C back to 587.
|
||||||
|
- Allied MCV in mobile form has no unique observable ability that would distinguish it from a miner.
|
||||||
|
|
||||||
|
**Conclusion:** There is **no deterministic validation rule** that can confirm an Allied MCV claim from replay operations alone. The upper bound for any such claim is `possible`, and an alternative (miner command hub) must always be listed.
|
||||||
|
|
||||||
|
**Contrast with other factions:**
|
||||||
|
- Soviet/Japan/神州 MCVs may have different observable behaviors (e.g., unique deploy animations, different upgrade paths) — each faction needs independent analysis.
|
||||||
|
|
||||||
|
**Validation rule (negative check only):**
|
||||||
|
- If a claim says `confirmed` or `highly likely` for AlliedMCV based only on `PackReplaceSelf → UnpackReplaceSelf` sequence, flag as **overconfident** (WeakEvidence). Downgrade recommendation: `possible` with miner command hub as alternative.
|
||||||
|
|
||||||
|
### Prompt Decisions
|
||||||
|
|
||||||
|
The default prompt should explicitly require:
|
||||||
|
|
||||||
|
- Evidence-first analysis.
|
||||||
|
- No use of external common sense over replay facts and supplied game rules.
|
||||||
|
- UnitId guesses with evidence levels.
|
||||||
|
- Multiple candidates when a behavior has several possible sources.
|
||||||
|
- Support evidence and possible counter-evidence for important claims.
|
||||||
|
- Correction or abandonment of claims contradicted by replay facts.
|
||||||
|
|
||||||
|
Evidence levels currently used:
|
||||||
|
|
||||||
|
- confirmed
|
||||||
|
- highly likely
|
||||||
|
- possible
|
||||||
|
- uncertain
|
||||||
|
- ruled out
|
||||||
|
|
||||||
|
The default provider temperature was lowered from `0.75` to `0.35` because this task is closer to audit/reconstruction than creative writing.
|
||||||
|
|
||||||
|
### Prompt Configuration Decisions
|
||||||
|
|
||||||
|
The prompt is now configurable through AI settings.
|
||||||
|
|
||||||
|
The design has two prompt layers:
|
||||||
|
|
||||||
|
- A built-in dynamic system prompt, still assembled from replay/mod/faction/map context.
|
||||||
|
- User prompt settings:
|
||||||
|
- optional full custom system prompt
|
||||||
|
- additional rules appended to the final system prompt
|
||||||
|
|
||||||
|
This keeps the normal path safe while allowing advanced users to override the whole prompt.
|
||||||
|
|
||||||
|
### Validation Philosophy
|
||||||
|
|
||||||
|
LLM natural-language analysis should not be treated as directly valid. The plan is to validate structured claims emitted by the LLM.
|
||||||
|
|
||||||
|
Important decision:
|
||||||
|
|
||||||
|
- Do not immediately throw away a whole analysis when a problem is found.
|
||||||
|
- Do not show the user two competing analyses or apology text such as "sorry, my previous answer was wrong."
|
||||||
|
- Prefer a hidden revision pass: send the draft, validation issues, and relevant replay facts back to the AI, asking it to output a clean corrected version without mentioning the revision.
|
||||||
|
- Limit retries/revisions. If the model still cannot resolve a claim, downgrade confidence or mark it uncertain instead of looping forever.
|
||||||
|
|
||||||
|
Severity model:
|
||||||
|
|
||||||
|
- `Info`: useful diagnostic only.
|
||||||
|
- `WeakEvidence`: claim may be plausible but lacks enough support.
|
||||||
|
- `Warning`: malformed or questionable claim that should be logged or possibly revised.
|
||||||
|
- `Contradiction`: claim conflicts with replay facts or game rules and should trigger revision.
|
||||||
|
- `Fatal`: output cannot be used for the current phase, such as empty or unparseable required output.
|
||||||
|
|
||||||
|
### JSON Format Decision
|
||||||
|
|
||||||
|
We discussed whether to require JSON or use a simpler line-based format.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Use JSON for machine-readable claims.
|
||||||
|
- Keep the schema small.
|
||||||
|
- Make the parser tolerant.
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
|
||||||
|
- JSON can naturally represent evidence arrays, alternatives, and needed confirmations.
|
||||||
|
- A custom line format would be easier for a trivial parser but would become fragile once nested data is needed.
|
||||||
|
- The app can tolerate partial or missing JSON by logging validation issues instead of failing the whole analysis.
|
||||||
|
|
||||||
|
### Knowledge Architecture Decisions (2026-07-07)
|
||||||
|
|
||||||
|
We conducted a `/grilling` session (via `/domain-modeling` skill) to address the growing split between prompt knowledge and validation knowledge.
|
||||||
|
|
||||||
|
**Recognised problems:**
|
||||||
|
|
||||||
|
- Prompt knowledge lives in `BuildDefaultSystemPrompt()` as large hardcoded strings.
|
||||||
|
- Validation knowledge lives in `AIAnalysisValidation.cs` as hardcoded string matching (`claimLooksLikeBuilder` checks `"MCV"`, `"基地车"`, `"Nanocore"` etc.).
|
||||||
|
- The two are not synchronised — adding a unit type requires editing both places.
|
||||||
|
- Users can only override the entire system prompt or append text.
|
||||||
|
|
||||||
|
**Decisions reached (recorded in ADR 0002):**
|
||||||
|
|
||||||
|
1. **KnowledgeSet as the single source of truth.** Game knowledge is organised into named KnowledgeSets keyed by mod (e.g., `"default"`, `"corona"`). Each set is self-contained and complete — no cross-set inheritance or conditional sharing. The mod name from the replay directly selects which set to load, replacing the current `[MOD:]` inline tag system.
|
||||||
|
|
||||||
|
2. **KnowledgeEntry is the unified format.** Every entry has an `id`, `tags[]`, and `text` (markdown). Same format for built-in and user-supplied entries — no separate internal/external format.
|
||||||
|
|
||||||
|
3. **Predefined finite tag taxonomy.** Tags are the bridge between prompt knowledge and validation. Three categories: capability (`builder`, `pack`, `unpack`, `amphibious`, `returnToProducer`, ...), type (`infantry`, `vehicle`, `aircraft`, `naval`, `structure`, ...), combat role (`antiInfantry`, `antiVehicle`, `antiAir`, ...), plus `specialPower:*` references. No ad-hoc tags.
|
||||||
|
|
||||||
|
4. **Prompt rendering order:** global entries → faction entries (per player) → map entries. User `AdditionalRules` appended at the end.
|
||||||
|
|
||||||
|
5. **Validation consumes tags instead of hardcoded strings.** Validators query `entries.WithTag("builder")` instead of `claim.IndexOf("MCV") >= 0`.
|
||||||
|
|
||||||
|
6. **User extensibility via JSON.** User knowledge file (`AnotherReplayReader.user_knowledge.json`) overlays built-in entries by matching `id`. No code changes needed to add map/faction/mod knowledge.
|
||||||
|
|
||||||
|
7. **Storage format:** JSON container with markdown text in `text` fields. The existing `AiPromptSettings` text fields remain as a simpler escape hatch.
|
||||||
|
|
||||||
|
**Refinement — mods are independent complete sets:** Initially the ADR described mod knowledge sets as "overlaying or extending" the base set. After further discussion, this was corrected: each mod is a self-contained game version with its own complete knowledge set. There is no `[MOD:]`-style conditional sharing because:
|
||||||
|
- Users editing a mod's JSON should see only that mod's entries, not conditional inclusion logic.
|
||||||
|
- The replays already identify the mod; loading the right set is a simple name lookup.
|
||||||
|
- Duplication between mod sets is acceptable for clarity — the deduplication cost of `[MOD:]` tags is not worth it in a structured data format.
|
||||||
|
|
||||||
|
**Reversal from earlier statement:** The user noted that mods are game versions and should not be a separate scope dimension. This was accepted: the mod selects which KnowledgeSet to load, and within a set only `global`, `faction`, and `map` scopes exist.
|
||||||
|
|
||||||
|
**Reversal from earlier assumption:** I (the agent) initially claimed that Z-coordinate rules were duplicated across faction sections. After re-reading the full prompt, the user was correct — Z rules are in the `generalDescriptions` (global) section only. No duplication.
|
||||||
|
|
||||||
|
### Evidence Format Decision (2026-07-06)
|
||||||
|
|
||||||
|
We decided to move from free-form evidence text to a **structured pipe-delimited format**:
|
||||||
|
|
||||||
|
```
|
||||||
|
type|time|param1|param2|...
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported types: `build`, `place`, `produce`, `sell`, `select`, `move`, `power`.
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
- Free-form text could not be programmatically validated without NLP.
|
||||||
|
- Structured evidence can be parsed deterministically with a simple regex.
|
||||||
|
- Enables deterministic validation rules like unpack-ambiguity checking.
|
||||||
|
- The format is simple enough for AI models to follow reliably.
|
||||||
|
|
||||||
|
Current expected shape (all three claim types now have schema definitions in the prompt):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unitClaims": [
|
||||||
|
{
|
||||||
|
"unitId": 123,
|
||||||
|
"player": "PlayerA",
|
||||||
|
"claim": "AlliedMCV",
|
||||||
|
"evidenceLevel": "possible",
|
||||||
|
"evidence": ["8:30 使用 SpecialPower_UnpackReplaceSelf"],
|
||||||
|
"alternatives": ["AlliedMiner 展开后的指挥中心"],
|
||||||
|
"needsConfirmation": ["是否曾使用 SpecialPower_PackReplaceSelf", "后续是否作为建造者出现"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"eventClaims": [
|
||||||
|
{
|
||||||
|
"claim": "PlayerA 主基地打包并开始迁移",
|
||||||
|
"evidenceLevel": "confirmed",
|
||||||
|
"evidence": ["1:24.00 SpecialPower_PackReplaceSelf", "后续移动和展开操作"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timelineClaims": [
|
||||||
|
{
|
||||||
|
"claim": "PlayerA 在开局 2 分钟内完成了基地迁移",
|
||||||
|
"evidenceLevel": "confirmed",
|
||||||
|
"evidence": ["1:24.00 打包", "1:41.00 展开"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt asks the AI to output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[机器可读声明]
|
||||||
|
```json
|
||||||
|
{ ... }
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
The parser first looks for the last fenced JSON block near `[机器可读声明]`, then falls back to the last `{...}`.
|
||||||
|
|
||||||
|
**Known format issue (fixed):** The original prompt only defined `unitClaims` entries; `eventClaims` and `timelineClaims` were shown as empty arrays. The AI therefore invented its own fields (e.g., `"event"` / `"time"` instead of `"claim"`), which the parser silently ignored. Fixed by:
|
||||||
|
1. Adding full schema definitions for all three claim types in the prompt.
|
||||||
|
2. Making the parser accept `"event"` as a fallback for `"claim"` in `eventClaims`.
|
||||||
|
|
||||||
|
**Claim count limits added:** Prompt instructs the AI to limit output (unitClaims ≤ 10, eventClaims ≤ 5, timelineClaims ≤ 3). The parser enforces these caps and emits Info-level issues if the AI exceeds them.
|
||||||
|
|
||||||
|
## Validation We Can Do
|
||||||
|
|
||||||
|
### Implemented Now
|
||||||
|
|
||||||
|
Format validation:
|
||||||
|
|
||||||
|
- Missing machine-readable claims.
|
||||||
|
- JSON parse failure.
|
||||||
|
- Root value is not an object.
|
||||||
|
- Claim count limits with truncation warnings.
|
||||||
|
- `eventClaims` accepts both `"claim"` and `"event"` as field names.
|
||||||
|
- Unknown `evidenceLevel` values logged as Info issue, fallback to `Uncertain`.
|
||||||
|
|
||||||
|
Self-consistency validation:
|
||||||
|
|
||||||
|
- Unit claim missing `unitId`.
|
||||||
|
- Unit claim missing `claim`.
|
||||||
|
- High-confidence UnitId guess without evidence.
|
||||||
|
- Low-confidence UnitId guess without alternatives or needed confirmation.
|
||||||
|
|
||||||
|
### Evidence Format (Structured)
|
||||||
|
|
||||||
|
The `evidence` field now uses a structured pipe-delimited format instead of free-form text:
|
||||||
|
|
||||||
|
```
|
||||||
|
build|time|assetName|builderUnitId
|
||||||
|
place|time|assetName|builderUnitId|x,y,z
|
||||||
|
produce|time|unitName|producerUnitId
|
||||||
|
sell|time|unitId
|
||||||
|
select|time|unitId
|
||||||
|
move|time|x,y,z
|
||||||
|
power|time|powerName|unitId
|
||||||
|
```
|
||||||
|
|
||||||
|
This format is parsed by `ParseStructuredEvidence()` into a `StructuredEvidence` record with typed `AIEvidenceType` enum. Parsing uses a single regex and is fully deterministic.
|
||||||
|
|
||||||
|
**Backward compatibility:** The parser silently returns `Unknown` type for strings that don't match the structured format. No validation rules currently fire on unknown-typed evidence, so it degrades gracefully but invisibly.
|
||||||
|
|
||||||
|
### Near-Term Validations
|
||||||
|
|
||||||
|
These need replay facts extracted from `CommandChunk` or an intermediate fact index:
|
||||||
|
|
||||||
|
- UnitId production timeline contradictions (e.g., "bomber" claimed before first bomber production — needs game knowledge of which unit names are bombers).
|
||||||
|
- Claims that use game knowledge not present in rules, such as transport/amphibious/building-placement abilities.
|
||||||
|
- **Overconfidence detection:** Claims with `confirmed`/`highly likely` that lack sufficient evidence given what is knowable from replay data alone (e.g., claiming AlliedMCV as `confirmed`).
|
||||||
|
|
||||||
|
### Implemented via Fact Index
|
||||||
|
|
||||||
|
The `ReplayFactIndex` now powers these checks:
|
||||||
|
|
||||||
|
- **Special power contradiction:** Evidence `power|...|SomePower|unitId` is cross-checked against the actual special powers observed for that UnitId. If the power was never used, a `Contradiction` issue is emitted.
|
||||||
|
- **UnitId existence:** Warns if a claim references a UnitId never seen in any replay command.
|
||||||
|
- **Builder consistency:** If a claim describes a unit as MCV/builder but the UnitId was never observed as a builder, emits `WeakEvidence`.
|
||||||
|
|
||||||
|
### Suggested Fact Index
|
||||||
|
|
||||||
|
Useful derived facts:
|
||||||
|
|
||||||
|
- `UnitId -> first observed time`
|
||||||
|
- `UnitId -> observed special powers`
|
||||||
|
- `UnitId -> observed as builder`
|
||||||
|
- `UnitId -> observed as production structure`
|
||||||
|
- `Player -> first production time by asset id`
|
||||||
|
- `Player -> selected UnitIds over time`
|
||||||
|
- `Player -> tech/protocol choices`
|
||||||
|
- `Player -> building placements by asset and position`
|
||||||
|
|
||||||
|
## Current Progress
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- Added `CONTEXT.md` glossary.
|
||||||
|
- Added prompt settings:
|
||||||
|
- `AiPromptSettings`
|
||||||
|
- `UseCustomSystemPrompt`
|
||||||
|
- `CustomSystemPrompt`
|
||||||
|
- `AdditionalRules`
|
||||||
|
- Added prompt editing UI to `AIProviderSettingsControl`.
|
||||||
|
- Connected prompt settings from `EventDump` to `AIChatPanel` to `AIAnalyze`.
|
||||||
|
- Split default prompt construction from prompt composition.
|
||||||
|
- Strengthened default prompt with evidence-first and uncertainty rules.
|
||||||
|
- Added machine-readable JSON claim instructions to system and segment prompts.
|
||||||
|
- Added `Utils/AIAnalysisValidation.cs` with:
|
||||||
|
- evidence level enum
|
||||||
|
- machine-readable claim records
|
||||||
|
- validation issue records
|
||||||
|
- JSON extraction and parsing
|
||||||
|
- initial self-consistency checks
|
||||||
|
- Added per-segment validation logging in `AIChatPanel`.
|
||||||
|
- Fixed inconsistent prompt ↔ parser schema for `eventClaims`/`timelineClaims`:
|
||||||
|
- Added full schema definitions for all three claim types in the system prompt.
|
||||||
|
- Parser now accepts `"event"` as fallback for `"claim"` in `eventClaims`.
|
||||||
|
- Both prompt and parser enforce claim count limits (10 unit, 5 event, 3 timeline) with truncation warnings.
|
||||||
|
- Unknown `evidenceLevel` values now produce an Info-level validation issue (fallback to `Uncertain`).
|
||||||
|
- Created ADR 0001 documenting the hidden revision pass design decision.
|
||||||
|
- Recorded MCV vs Miner ambiguity as a documented validation scenario.
|
||||||
|
- Evidence format changed from free-form text to structured pipe-delimited format:
|
||||||
|
- 7 evidence types: `build`, `place`, `produce`, `sell`, `select`, `move`, `power`.
|
||||||
|
- Prompt updated to require structured format only.
|
||||||
|
- Added `StructuredEvidence` record and `ParseStructuredEvidence()` parser.
|
||||||
|
- Added `ParseAllEvidence()` to convert all evidence strings for a claim.
|
||||||
|
- Added first validation rule `ValidateUnpackAmbiguity()`:
|
||||||
|
- Flags `confirmed`/`highly likely` claims that use `UnpackReplaceSelf` without matching `PackReplaceSelf`.
|
||||||
|
- Emits `WeakEvidence`/`MissingAlternative` — the unpack could be MCV deploy or miner command hub deploy.
|
||||||
|
- If `PackReplaceSelf` IS present in the same claim's evidence, the chain is consistent and no flag.
|
||||||
|
- Created `Utils/ReplayFactIndex.cs` — builds a fact index from raw `CommandChunk` data:
|
||||||
|
- `UnitIdFirstObservedTime`: first time each UnitId appears in any command.
|
||||||
|
- `UnitIdSpecialPowers`: set of special powers used by each UnitId.
|
||||||
|
- `BuilderUnitIds`: UnitIds that appeared as builder in construction commands.
|
||||||
|
- `ProducerUnitIds`: UnitIds that appeared as production structures.
|
||||||
|
- `PlayerFirstProductionTime`: per player, first production time for each unit asset name.
|
||||||
|
- `PlayerSelectedUnitIds`: which UnitIds each player has selected.
|
||||||
|
- Plumbed `ReplayFactIndex` through the analysis pipeline:
|
||||||
|
- Built in `EventDump.ShowPlainText()` from `CommandChunk` + string hash table.
|
||||||
|
- Passed to `AIChatPanel.StartAnalysisAsync()` as new parameter.
|
||||||
|
- Forwarded to `AIAnalysisValidation.ValidateMachineReadableClaims()`.
|
||||||
|
- Added `ValidateTimelineConsistency()` — three checks using fact index:
|
||||||
|
1. **UnitId existence check:** Warns if a claim references a UnitId never seen in the replay.
|
||||||
|
2. **Special power verification:** Cross-references `power|...` evidence entries against actual special powers observed for that UnitId; emits `Contradiction` if the claim says a UnitId used a power it never used.
|
||||||
|
3. **Builder consistency check:** If a claim describes a unit as MCV/builder/Nanocore but that UnitId was never observed as a builder, emits `WeakEvidence`.
|
||||||
|
|
||||||
|
Build status:
|
||||||
|
|
||||||
|
- `dotnet build AnotherReplayReader.csproj --no-restore` succeeds.
|
||||||
|
- Remaining warnings are existing nullable warnings in `AIAnalyze.cs` stream response handling and a `System.Text.Encoding.CodePages` support warning for `net461`.
|
||||||
|
|
||||||
|
## Current Progress (continued)
|
||||||
|
|
||||||
|
This session (2026-07-07 knowledge architecture grilling):
|
||||||
|
|
||||||
|
- Conducted `/grilling` session via `/domain-modeling` skill to analyse knowledge split between prompt and validation.
|
||||||
|
- Reached consensus on knowledge architecture (see "Knowledge Architecture Decisions" above, recorded in ADR 0002):
|
||||||
|
- KnowledgeSet as single source of truth, keyed by mod.
|
||||||
|
- KnowledgeEntry as unified format (id + tags + text), scope inherited from path.
|
||||||
|
- Predefined finite tag taxonomy (capability, type, combat role, specialPower:*).
|
||||||
|
- Prompt rendering order: global → factions → map.
|
||||||
|
- Validation consumes tags instead of hardcoded string matching.
|
||||||
|
- User extensibility via JSON overlay file.
|
||||||
|
- Storage: JSON container with markdown text.
|
||||||
|
- Updated CONTEXT.md glossary with refined KnowledgeScope, plus new KnowledgeSet, KnowledgeEntry, and KnowledgeTag terms.
|
||||||
|
- Created ADR 0002 documenting the structured game knowledge decision.
|
||||||
|
- Updated WIP.md with discussion notes and migration plan.
|
||||||
|
- **Wrote `tools/expand_knowledge.py`** — Python script that extracts the 5 `@""` knowledge strings from `AIAnalyze.cs`, expands all `[MOD:]` / `[MOD:NO:]` tags (both line-level and inline), and outputs per-mod knowledge files.
|
||||||
|
- **Generated `knowledge_default.md`** (732 lines, 22427 chars) — base game knowledge with `[MOD:CORONA]` content stripped, `[MOD:NO:CORONA]` content retained.
|
||||||
|
- **Generated `knowledge_corona.md`** (743 lines, 23177 chars) — Corona mod knowledge with `[MOD:CORONA]` content retained, `[MOD:NO:CORONA]` content stripped.
|
||||||
|
- Verified all 27 `[MOD:]` tag locations across all content sections; confirmed correct expansion for line-level tags, inline tags, and double consecutive inline tags.
|
||||||
|
- **Created `Utils/AiKnowledge.cs`** with core data types:
|
||||||
|
- `KnowledgeTag` — static class with predefined tag constants (capability, type, combat role, `SpecialPower()` helper).
|
||||||
|
- `KnowledgeScope` / `KnowledgeScopeKind` — scope identification (global, faction, map).
|
||||||
|
- `KnowledgeEntry` — record with `Id`, `Tags[]`, `Text`; query methods `HasTag()`, `HasAnyTag()`.
|
||||||
|
- `KnowledgeSet` — collection with `ByScope()`, `ByTag()`, `ByAnyTag()` queries, `RenderAsPrompt()` rendering, and `ForMod()`/`ForReplay()` factory methods that load from `knowledge_{mod}.md` files.
|
||||||
|
- **Updated `AIAnalyze.GetSystemPrompt()`** — tries `KnowledgeSet.ForMod()` with file-based loading first, falls back to legacy `BuildDefaultSystemPrompt()` if file not found.
|
||||||
|
- **Updated `AnotherReplayReader.csproj`** — added `knowledge_*.md` as `<Content>` with `CopyToOutputDirectory=PreserveNewest`.
|
||||||
|
- Build verified: `dotnet build AnotherReplayReader.csproj --no-restore` succeeds (5 pre-existing nullable warnings).
|
||||||
|
- **Created `knowledge_units.json`** — structured JSON knowledge for 盟军 (20 units, 8 buildings), each with assetName, tags, specialPowers, producedBy, and text. First pilot faction.
|
||||||
|
- **Added `UnitKnowledge`/`BuildingKnowledge` records** + `StructuredKnowledge` class to `AiKnowledge.cs` — lazy-loaded singleton, queries by tag, special power, and asset name.
|
||||||
|
- **Updated `ValidateTimelineConsistency()`** — `claimLooksLikeBuilder` now queries `StructuredKnowledge.Instance.UnitsWithTag("builder")` first, falls back to heuristic string matching.
|
||||||
|
- Added `knowledge_units.json` to `.csproj` as `<Content>`.
|
||||||
|
- **Merged `UnitKnowledge`/`BuildingKnowledge` → `EntityKnowledge`** — unified record with nullable `Tier` and `IsBuilding`/`IsUnit` helpers via tag check.
|
||||||
|
- **Added `SpecialPowerInfo`** record (`Name` + `Description`) — special powers now carry descriptions for prompt rendering.
|
||||||
|
- **`knowledge_units.json` format 1.1** — `specialPowers` changed from string array to `[{name, description}]`; `text` de-duplicated (no longer repeats assetName, displayName, specialPowers, producedBy); `produces` field removed (production type expressed via tags); corrected tag semantics (removed `naval` from amphibious land units).
|
||||||
|
- **Structured field-based rendering** — units now render as multi-line entries with explicit `类型`/`技能`/`生产`/`描述` fields instead of dumping raw `text`. Buildings render as `displayName(assetName): text`. Added `TagDisplayName()` helper for Chinese tag labels.
|
||||||
|
- **Refined tag taxonomy** — split type tags from capability/role tags; fixed misapplied `naval` tag on AlliedMiner, AlliedMCV, 激流ACV (these are amphibious vehicles, not naval vessels).
|
||||||
|
|
||||||
|
## Resolved Open Questions
|
||||||
|
|
||||||
|
- "How much game-unit knowledge should live in code versus prompt text?" — **Resolved by ADR 0002.** Knowledge lives in KnowledgeSets (structured data), not in code strings or prompt text. Code renders it to prompt; validation queries it by tag.
|
||||||
|
- "Should the first verifier use hardcoded RA3/Corona knowledge, or should it load a small unit capability table from data files?" — **Resolved by ADR 0002.** The first verifier uses the same KnowledgeSet as the prompt builder, queried by tag.
|
||||||
|
|
||||||
|
## Remaining Open Questions
|
||||||
|
|
||||||
|
- Should AI natural-language output continue streaming live, or should content be buffered until validation and possible revision are complete?
|
||||||
|
- Should reasoning chunks remain visible during hidden revision, or should only final content be shown?
|
||||||
|
- How strict should missing machine-readable claims be?
|
||||||
|
- Current behavior: warning log only.
|
||||||
|
- Possible future behavior: one hidden repair request asking the model to append valid claims.
|
||||||
|
- Should validation issues be visible by default, or only in an advanced/debug foldout?
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
1. ✅ Build a replay fact index from `CommandChunk` — done (`ReplayFactIndex`).
|
||||||
|
2. ✅ Add first deterministic validation rules:
|
||||||
|
- ✅ ambiguous Allied unpack — done (`ValidateUnpackAmbiguity`).
|
||||||
|
- ✅ pack/unpack consistency — covered by unpack rule.
|
||||||
|
- ✅ UnitId used as builder — done (builder consistency check in `ValidateTimelineConsistency`).
|
||||||
|
- ✅ special power contradictions — done (special power verification in `ValidateTimelineConsistency`).
|
||||||
|
- ⬜ first production time vs first operation time — needs game knowledge of unit type names (e.g., "which names are bombers").
|
||||||
|
3. ✅ **Knowledge migration** — implement the KnowledgeSet/KnowledgeEntry model planned in ADR 0002:
|
||||||
|
- ✅ Extract built-in knowledge from `BuildDefaultSystemPrompt()` strings into mod-specific text files (`knowledge_default.md`, `knowledge_corona.md`). `[MOD:]` tags expanded by `tools/expand_knowledge.py`.
|
||||||
|
- ✅ Define C# records (`KnowledgeSet`, `KnowledgeEntry`, `KnowledgeScope`, `KnowledgeTag` constants) in `Utils/AiKnowledge.cs`.
|
||||||
|
- ✅ Wire `KnowledgeSet.ForReplay()` / `ForMod()` into `AIAnalyze.GetSystemPrompt()` — loads `knowledge_{mod}.md` at runtime if available, falls back to legacy `BuildDefaultSystemPrompt()`.
|
||||||
|
- ✅ Added `knowledge_*.md` as `<Content>` in `.csproj` with `CopyToOutputDirectory=PreserveNewest`.
|
||||||
|
- ✅ **Step 3: Structured data participates in rendering.** `KnowledgeSet.ForMod()` now merges flat text (`knowledge_*.md`) with structured entries (`knowledge_units.json`). Unit/building sections are automatically stripped from flat text (via `StripUnitSections()`) and replaced by structured entries rendered by tier. `RenderAsPrompt()` outputs both clean global text and structured faction entries in correct order.
|
||||||
|
- ✅ **Step 2: More validation rules migrated.** `ClaimLooksLikeBuilder()` now queries `StructuredKnowledge.Instance.UnitsWithTag("builder")` first; falls back to heuristic string matching if structured data is unavailable.
|
||||||
|
- ✅ **Step 1: Pilot faction (盟军) in `knowledge_units.json`.** 20 units + 8 buildings with assetName, tags, specialPowers, producedBy. Includes `aliases` support for multi-source units (e.g., 激流ACV).
|
||||||
|
- ✅ **EntityKnowledge unification.** `UnitKnowledge`/`BuildingKnowledge` merged into single `EntityKnowledge` record; `SpecialPowerInfo` added for name+description pairs.
|
||||||
|
- ✅ **JSON format 1.1.** `specialPowers` → object array with `name`/`description`; `text` de-duplicated; `produces` removed; tag semantics corrected.
|
||||||
|
- ✅ **Field-based structured rendering.** Units render with `类型`/`技能`/`生产`/`描述` fields; `TagDisplayName()` maps tags to Chinese labels.
|
||||||
|
- ⬜ Add user knowledge JSON file loading in `AiSettings.Load()`.
|
||||||
|
4. Decide whether to buffer per-segment content before display.
|
||||||
|
5. Add one hidden revision pass for `Contradiction` issues.
|
||||||
|
6. Add validation summary UI, such as "验证器发现并修正 N 个问题".
|
||||||
+9
-5
@@ -8,15 +8,19 @@
|
|||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="Window1" Height="450" Width="800">
|
Title="Window1" Height="450" Width="800">
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBox x:Name="_ipField" HorizontalAlignment="Left" Height="16" Margin="45,27,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="155" TextChanged="OnIPFieldChanged" />
|
<TextBox x:Name="_ipField" HorizontalAlignment="Left" Height="16" Margin="45,27,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="155" TextChanged="OnIpFieldChanged" />
|
||||||
<TextBlock x:Name="textBlock" HorizontalAlignment="Left" Margin="29,28,0,0" TextWrapping="Wrap" Text="IP" VerticalAlignment="Top"/>
|
<TextBlock x:Name="textBlock" HorizontalAlignment="Left" Margin="29,28,0,0" TextWrapping="Wrap" Text="IP" VerticalAlignment="Top"/>
|
||||||
<TextBox x:Name="_idField" Height="16" Margin="294,27,92,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" />
|
<TextBox x:Name="_idField" Height="16" Margin="294,27,92,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" TextChanged="OnIpFieldChanged" />
|
||||||
<TextBlock x:Name="textBlock1" HorizontalAlignment="Left" Margin="205,28,0,0" TextWrapping="Wrap" Text="玩家名称 / 说明" VerticalAlignment="Top"/>
|
<TextBlock x:Name="textBlock1" HorizontalAlignment="Left" Margin="205,28,0,0" TextWrapping="Wrap" Text="玩家名称 / 说明" VerticalAlignment="Top"/>
|
||||||
<Button x:Name="_setIPButton" Content="上传" Margin="705,26,12,0" VerticalAlignment="Top" Click="OnClick"/>
|
<Button x:Name="_setIPButton" Content="上传" Margin="705,26,12,0" VerticalAlignment="Top" Click="OnClick"/>
|
||||||
<DataGrid x:Name="_dataGrid" Margin="20,60,12,19">
|
<DataGrid x:Name="_dataGrid"
|
||||||
|
Margin="20,60,12,19"
|
||||||
|
MouseDoubleClick="OnDataGridMouseDoubleClick"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AutoGenerateColumns="False">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="IP" Binding="{Binding Path=IPString}"/>
|
<DataGridTextColumn Header="IP" Binding="{Binding Path=IpString}"/>
|
||||||
<DataGridTextColumn Header="玩家名称 / 说明" Binding="{Binding Path=ID}"/>
|
<DataGridTextColumn Header="玩家名称 / 说明" Binding="{Binding Path=Id}"/>
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
+58
-76
@@ -1,12 +1,9 @@
|
|||||||
using System;
|
using AnotherReplayReader.Utils;
|
||||||
using System.Collections.Generic;
|
using System;
|
||||||
using System.IO;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
|
||||||
using System.Web.Script.Serialization;
|
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
@@ -16,113 +13,98 @@ namespace AnotherReplayReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal partial class Window1 : Window
|
internal partial class Window1 : Window
|
||||||
{
|
{
|
||||||
private PlayerIdentity _identity;
|
|
||||||
|
|
||||||
public Window1(PlayerIdentity identity)
|
public Window1()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_identity = identity;
|
Refresh(true);
|
||||||
Refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Refresh()
|
private async void Refresh(bool showCached)
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = false);
|
_setIPButton.IsEnabled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() =>
|
var loading = new IpAndPlayer[] { new() { Ip = 0, Id = "正在加载..." } };
|
||||||
|
_dataGrid.ItemsSource = loading;
|
||||||
|
if (showCached)
|
||||||
{
|
{
|
||||||
_dataGrid.Items.Clear();
|
await Display();
|
||||||
_dataGrid.Items.Add(new IPAndPlayer { IP = 0, ID = "正在加载..." });
|
_dataGrid.ItemsSource = loading.Concat(_dataGrid.ItemsSource.Cast<IpAndPlayer>());
|
||||||
});
|
}
|
||||||
|
await Display();
|
||||||
await _identity.Fetch();
|
|
||||||
|
|
||||||
Display();
|
|
||||||
}
|
}
|
||||||
catch(Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() => MessageBox.Show(this, $"无法加载IP表:{e}"));
|
MessageBox.Show(this, $"无法加载IP表:{e}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_setIPButton.IsEnabled = true;
|
||||||
}
|
}
|
||||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Display(string filter = "")
|
private Task Display(string filter = "", string nameFilter = "")
|
||||||
{
|
{
|
||||||
var newList = _identity.AsSortedList().Where(x => x.IPString.StartsWith(filter));
|
return Task.CompletedTask;
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
_dataGrid.Items.Clear();
|
|
||||||
foreach (var item in newList)
|
|
||||||
{
|
|
||||||
_dataGrid.Items.Add(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnClick(object sender, RoutedEventArgs e)
|
private async void OnClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = false);
|
_setIPButton.IsEnabled = false;
|
||||||
|
try
|
||||||
await Task.Run(() =>
|
|
||||||
{
|
{
|
||||||
var ipText = Dispatcher.Invoke(() => _ipField.Text);
|
var ipText = _ipField.Text;
|
||||||
|
|
||||||
if (!IPAddress.TryParse(ipText, out var ip))
|
if (!IPAddress.TryParse(ipText, out var ip))
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() => MessageBox.Show(this, "IP格式不正确"));
|
MessageBox.Show(this, "IP 格式不正确");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var idText = _idField.Text;
|
||||||
var idText = Dispatcher.Invoke(() => _idField.Text);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(idText))
|
if (string.IsNullOrWhiteSpace(idText))
|
||||||
{
|
{
|
||||||
var result = Dispatcher.Invoke(() => MessageBox.Show(this, "你没填输入任何说明,是否确认继续?", "注意", MessageBoxButton.OKCancel));
|
var choice = MessageBox.Show(this, "没有输入任何关于该玩家的说明,是否继续?", "注意", MessageBoxButton.OKCancel);
|
||||||
if(result != MessageBoxResult.OK)
|
if (choice != MessageBoxResult.OK)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
var result = await UpdateIpTable(ip, idText);
|
||||||
|
if (!result)
|
||||||
{
|
{
|
||||||
var bytes = ip.GetAddressBytes();
|
MessageBox.Show(this, "设置 IP 表失败");
|
||||||
var ipNum = (uint)bytes[0] * 256 * 256 * 256 + bytes[1] * 256 * 256 + bytes[2] * 256 + bytes[3];
|
|
||||||
var text = HttpUtility.UrlEncode(idText);
|
|
||||||
|
|
||||||
var key = HttpUtility.UrlEncode(Auth.GetKey());
|
|
||||||
var request = WebRequest.Create($"https://lanyi.altervista.org/playertable/playertable.php?do=setIP&ip={ipNum}&id={text}&key={key}");
|
|
||||||
|
|
||||||
using (var stream = request.GetResponse().GetResponseStream())
|
|
||||||
using (var reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
var response = reader.ReadToEnd();
|
|
||||||
var serializer = new JavaScriptSerializer();
|
|
||||||
var result = serializer.Deserialize<bool>(response);
|
|
||||||
if(!result)
|
|
||||||
{
|
|
||||||
Dispatcher.Invoke(() => MessageBox.Show(this, "设置IP表失败"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Dispatcher.Invoke(() => MessageBox.Show(this, $"设置IP表时发生错误。\r\n{exception}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
Refresh(false);
|
||||||
|
}
|
||||||
Refresh();
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"设置 IP 表时发生错误。\r\n{exception}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_setIPButton.IsEnabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnIPFieldChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
private Task<bool> UpdateIpTable(IPAddress ip, string idText)
|
||||||
{
|
{
|
||||||
await Task.Run(() =>
|
var bytes = ip.GetAddressBytes();
|
||||||
{
|
var ipNum = (uint)bytes[0] * 256 * 256 * 256 + bytes[1] * 256 * 256 + bytes[2] * 256 + bytes[3];
|
||||||
var fieldText = Dispatcher.Invoke(() => _ipField.Text);
|
return Task.FromResult(false);
|
||||||
Display(fieldText);
|
}
|
||||||
});
|
|
||||||
|
private async void OnIpFieldChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var ipText = _ipField.Text;
|
||||||
|
var idText = _idField.Text;
|
||||||
|
await Display(ipText, idText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataGridMouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<!-- UAC Manifest Options
|
||||||
|
If you want to change the Windows User Account Control level replace the
|
||||||
|
requestedExecutionLevel node with one of the following.
|
||||||
|
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||||
|
|
||||||
|
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||||
|
Remove this element if your application requires this virtualization for backwards
|
||||||
|
compatibility.
|
||||||
|
-->
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows Vista -->
|
||||||
|
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
|
||||||
|
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
|
||||||
|
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
|
||||||
|
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
|
||||||
|
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
|
||||||
|
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
|
||||||
|
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config.
|
||||||
|
|
||||||
|
Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
|
||||||
|
<!--
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity
|
||||||
|
type="win32"
|
||||||
|
name="Microsoft.Windows.Common-Controls"
|
||||||
|
version="6.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
publicKeyToken="6595b64144ccf1df"
|
||||||
|
language="*"
|
||||||
|
/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
private async Task AutoSaveReplays()
|
|
||||||
{
|
|
||||||
const string ourPrefix = "自动保存";
|
|
||||||
|
|
||||||
// filename and last write time
|
|
||||||
Dictionary<string, DateTime> previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
// filename and file size
|
|
||||||
Dictionary<string, long> lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
while(true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var changed = from fileName in Directory.GetFiles(_properties.ReplayFolderPath, "*.RA3Replay")
|
|
||||||
let info = new FileInfo(fileName)
|
|
||||||
where !info.Name.StartsWith(ourPrefix)
|
|
||||||
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
|
||||||
select info;
|
|
||||||
|
|
||||||
foreach (var info in changed)
|
|
||||||
{
|
|
||||||
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
|
||||||
}
|
|
||||||
|
|
||||||
var replays = changed.Select(info =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return new Replay(info.FullName);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).Where(replay => replay != null);
|
|
||||||
|
|
||||||
var newLastReplays = from replay in replays
|
|
||||||
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
|
||||||
where threshold < 20
|
|
||||||
select replay;
|
|
||||||
|
|
||||||
var toBeChecked = newLastReplays.ToDictionary(replay => replay.FileName, StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var savedLastReplay in lastReplays)
|
|
||||||
{
|
|
||||||
if (!toBeChecked.ContainsKey(savedLastReplay.Key))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
toBeChecked.Add(savedLastReplay.Key, new Replay(savedLastReplay.Key));
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var kv in toBeChecked)
|
|
||||||
{
|
|
||||||
var replay = kv.Value;
|
|
||||||
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
|
||||||
{
|
|
||||||
if (fileSize == replay.Size)
|
|
||||||
{
|
|
||||||
// skip if size is not changed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastReplays[kv.Key] = replay.Size;
|
|
||||||
|
|
||||||
var date = replay.Date;
|
|
||||||
var numberOfPlayers = replay.NumberOfPlayingPlayers;
|
|
||||||
var playerString = $"{numberOfPlayers}名玩家";
|
|
||||||
if (replay.NumberOfPlayingPlayers <= 2)
|
|
||||||
{
|
|
||||||
var playingPlayers = from player in replay.Players
|
|
||||||
let faction = ModData.GetFaction(replay.Mod, player.FactionID)
|
|
||||||
where faction.Kind != FactionKind.Observer
|
|
||||||
select $"{player}({faction.Name})";
|
|
||||||
playerString = playingPlayers.Aggregate((x, y) => x + y);
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateString = $"{date.Year}-{date.Month}-{date.Day}_{date.Hour}:{date.Minute}";
|
|
||||||
|
|
||||||
File.Copy(replay.FileName, $"{_properties.ReplayFolderPath}/{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
_ = Dispatcher.InvokeAsync(() => MessageBox.Show($"自动保存录像时出现错误:\r\n{e}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(10 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR 0001: Hidden Revision Pass for AI Analysis
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其中的“实现状态”已过时;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
|
||||||
|
|
||||||
|
**Date:** 2026-07-06
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The AI analysis feature sends player operation logs to an LLM and displays the analysis in `AIChatPanel`. LLM output is inherently unreliable — the model may make contradictory claims, use incorrect game knowledge, or miss alternative interpretations.
|
||||||
|
|
||||||
|
We considered several approaches to handle problematic output:
|
||||||
|
|
||||||
|
1. **Show raw output, let the user judge.** Simplest, but puts the burden on the user to spot errors.
|
||||||
|
2. **Show a corrected version alongside the original.** Transparent, but confusing — two competing analyses.
|
||||||
|
3. **Reject the entire segment and retry from scratch.** Wastes the prior reasoning; may produce similar mistakes.
|
||||||
|
4. **Hidden revision pass:** Send the draft, validation issues, and replay facts back to the AI, asking for a clean corrected version without apology text.
|
||||||
|
|
||||||
|
We chose option 4 because:
|
||||||
|
- The user sees only one coherent analysis.
|
||||||
|
- Prior reasoning is preserved and refined, not discarded.
|
||||||
|
- The user is not exposed to "sorry, my previous answer was wrong" chatter.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Implement a **hidden revision pass** for segments that have `Contradiction` or `Fatal` validation issues.
|
||||||
|
- The revision prompt includes: the original draft, the list of validation issues (with severity and kind), and relevant replay facts for the affected claims.
|
||||||
|
- The model is instructed to output a clean corrected analysis (natural language + machine-readable claims) **without** acknowledging the revision.
|
||||||
|
- Limit to **one revision pass per segment** to avoid infinite loops.
|
||||||
|
- If the revision still has `Fatal` issues, fall back to displaying the original with a warning.
|
||||||
|
- Per-segment content is **buffered** during validation — the user sees the final content only after validation and optional revision complete.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Positive: User sees a single, cleaner analysis.
|
||||||
|
- Positive: Prior reasoning is reused, saving tokens vs. re-analyzing from scratch.
|
||||||
|
- Negative: Adds latency (one extra round trip) for segments that need revision.
|
||||||
|
- Negative: Increases token usage for revised segments (draft + revision prompt + corrected output).
|
||||||
|
- Negative: Hidden correction may reduce user trust if they discover it — consider a subtle indicator like "验证器发现并修正 N 个问题".
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Not yet implemented. Validation issues are detected and logged in `AIChatPanel`, but no automatic revision pass is triggered. The revision prompt construction and retry orchestration still need to be wired in.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# ADR 0002: Structured Game Knowledge for Prompt and Validation
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其设想与当前实现存在差异;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
|
||||||
|
|
||||||
|
**Date:** 2026-07-07
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Game knowledge — unit capabilities, faction rules, map geometry, build restrictions, and known exceptions — currently lives in two unconnected places:
|
||||||
|
|
||||||
|
1. **Prompt text** inside `AIAnalyze.BuildDefaultSystemPrompt()` as large hardcoded strings with `[MOD:]` conditional tags.
|
||||||
|
2. **Validation rules** inside `AIAnalysisValidation.cs` as hardcoded string matching (e.g., `claimLooksLikeBuilder` checks for `"MCV"`, `"基地车"`, `"Nanocore"`, etc.).
|
||||||
|
|
||||||
|
This causes several problems:
|
||||||
|
|
||||||
|
- Prompt knowledge and validation knowledge are not synchronized. Adding a new unit type requires editing both the prompt text and the validation code.
|
||||||
|
- Users can only override the entire system prompt or append text. There is no way to add or correct a single unit fact without replacing the whole prompt.
|
||||||
|
- Validation rules use fragile substring matching against natural-language Chinese text, which will drift as the prompt text changes.
|
||||||
|
- There is no reusable data structure that both prompt rendering and validation logic can query.
|
||||||
|
|
||||||
|
We need a unified knowledge architecture that:
|
||||||
|
|
||||||
|
- Serves as the single source of truth for both prompt rendering and deterministic validation.
|
||||||
|
- Lets users add map-, faction-, or mod-specific knowledge without editing code.
|
||||||
|
- Replaces hardcoded substring matching in validation with tag-based queries.
|
||||||
|
- Preserves the built-in game knowledge as the default for each supported mod.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Knowledge Set structure
|
||||||
|
|
||||||
|
Game knowledge is organized into **KnowledgeSets**, each keyed by mod name (e.g., `"default"` for base game, `"corona"` for the Corona mod). Each set is **self-contained and complete** — there is no cross-set inheritance or conditional inclusion. The mod name from the replay directly selects which set to load, replacing the current `[MOD:]` inline tag system entirely.
|
||||||
|
|
||||||
|
Each KnowledgeSet is a hierarchy where **scope is inherited from the path**, not stored in entries:
|
||||||
|
|
||||||
|
```
|
||||||
|
KnowledgeSet (e.g. "default")
|
||||||
|
├── global/ ← applies to all factions and maps
|
||||||
|
│ ├── entries...
|
||||||
|
├── factions/
|
||||||
|
│ ├── 盟军/
|
||||||
|
│ │ ├── entries... ← scope = faction:盟军
|
||||||
|
│ ├── 神州/
|
||||||
|
│ │ ├── entries...
|
||||||
|
├── maps/
|
||||||
|
│ ├── map_mp_2_rao1/
|
||||||
|
│ ├── entries... ← scope = map:map_mp_2_rao1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mod knowledge vs base game:** Since a mod is a self-contained game version, its KnowledgeSet is a complete copy of the relevant knowledge, not a diff. This avoids the complexity of conditional tags (`[MOD:]` / `[MOD:NO:]`) — users editing a mod's knowledge JSON see only that mod's entries without conditional logic. The current `[MOD:]` inline text approach is retired; knowledge sets are now purely data-driven.
|
||||||
|
|
||||||
|
### KnowledgeEntry format
|
||||||
|
|
||||||
|
Every knowledge entry has the same structure whether it is built-in or user-supplied:
|
||||||
|
|
||||||
|
```
|
||||||
|
id: string # unique identifier within the knowledge set
|
||||||
|
tags: string[] # from the predefined tag taxonomy (see below)
|
||||||
|
text: string # markdown description, used for prompt rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag taxonomy (finite, predefined)
|
||||||
|
|
||||||
|
Tags serve as the bridge between prompt knowledge and validation logic. Validation rules query entries by tag instead of matching strings.
|
||||||
|
|
||||||
|
**Capability tags** (what a unit can do):
|
||||||
|
`builder`, `pack`, `unpack`, `amphibious`, `transport`, `returnToProducer`, `cloak`, `toggleWeapon`
|
||||||
|
|
||||||
|
**Type tags** (what a unit is):
|
||||||
|
`infantry`, `vehicle`, `aircraft`, `naval`, `structure`, `hero`, `production`, `defense`, `superweapon`
|
||||||
|
|
||||||
|
**Combat role tags** (what a unit fights):
|
||||||
|
`antiInfantry`, `antiVehicle`, `antiStructure`, `antiAir`, `antiNaval`
|
||||||
|
|
||||||
|
**Special power references** (links to observable replay data):
|
||||||
|
`specialPower:PackReplaceSelf`, `specialPower:UnpackReplaceSelf`, etc.
|
||||||
|
|
||||||
|
### Prompt rendering
|
||||||
|
|
||||||
|
The built-in `BuildDefaultSystemPrompt()` is refactored to render from the knowledge set in this order:
|
||||||
|
|
||||||
|
1. Global entries
|
||||||
|
2. Faction-specific entries for each player's faction (in player order)
|
||||||
|
3. Map-specific entries for the current map
|
||||||
|
|
||||||
|
User customizations still apply as layers on top:
|
||||||
|
- `AdditionalRules` is appended at the end of the rendered prompt.
|
||||||
|
- `UseCustomSystemPrompt` completely replaces the default (as before).
|
||||||
|
|
||||||
|
### User extensibility
|
||||||
|
|
||||||
|
Users can add or overlay knowledge entries via a JSON file (e.g., `AnotherReplayReader.user_knowledge.json`) stored alongside the settings file. The file follows the same KnowledgeSet structure; entries with matching `id` values override built-in entries.
|
||||||
|
|
||||||
|
### Validation consumption
|
||||||
|
|
||||||
|
Validation rules (`AIAnalysisValidation.cs`) are refactored to:
|
||||||
|
|
||||||
|
- Load the active knowledge set and query entries by tag (e.g., `entries.WithTag("builder")`) instead of matching hardcoded substrings.
|
||||||
|
- Use `specialPower:*` tags to verify power-to-unit-type inferences.
|
||||||
|
- Keep deterministic rules (e.g., `ValidateUnpackAmbiguity`) as code, but drive what entries they check from tags rather than hardcoded asset names.
|
||||||
|
|
||||||
|
### Storage format
|
||||||
|
|
||||||
|
JSON container with text fields as markdown. Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "general-rules",
|
||||||
|
"tags": ["rule"],
|
||||||
|
"text": "## 核心原则\n- ..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"factions": {
|
||||||
|
"盟军": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "AlliedMCV",
|
||||||
|
"tags": ["builder", "vehicle", "amphibious", "pack", "unpack"],
|
||||||
|
"text": "### 基地车(AlliedMCV)\n盟军基地车,两栖,...\n可以在陆地或水上展开为主基地。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Positive: Prompt knowledge and validation knowledge share a single source of truth.
|
||||||
|
- Positive: Users can add or correct game knowledge facts without modifying code.
|
||||||
|
- Positive: Validation no longer depends on fragile Chinese substring matching — tag queries are deterministic and language-independent.
|
||||||
|
- Positive: New mods (e.g., "corona") get their own KnowledgeSet without polluting the default.
|
||||||
|
- Negative: Requires migration of ~700 lines of hardcoded prompt text into KnowledgeEntry records — a significant one-time refactoring effort.
|
||||||
|
- Negative: The tag taxonomy must be maintained as the game or mod evolves. Additions must be reviewed to prevent tag proliferation.
|
||||||
|
- Negative: JSON file editing is less user-friendly than a dedicated settings UI (acceptable as an initial step; the `AiPromptSettings` text fields remain available as a simpler escape hatch).
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Not yet implemented. The following migration path is planned:
|
||||||
|
|
||||||
|
1. Define C# records (`KnowledgeSet`, `KnowledgeEntry`, `KnowledgeTag` constants) in a new `Utils/AiKnowledge.cs` file.
|
||||||
|
2. Create the built-in `KnowledgeSet` by extracting data from the current `BuildDefaultSystemPrompt()` strings into structured entries with tags.
|
||||||
|
3. Wire the knowledge set through `GetSystemPrompt()` so it renders entries in the correct order.
|
||||||
|
4. Refactor `AIAnalysisValidation.ValidateTimelineConsistency()` to query the knowledge set by tag instead of hardcoded matching.
|
||||||
|
5. Add user knowledge file loading in `AiSettings.Load()`.
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
你是一位 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游戏,玩家需要建造建筑、生产单位、攻击敌方玩家来取得胜利。
|
||||||
|
阵营:
|
||||||
|
- 神州(Celestial)、盟军(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坐标也依然总是地面或海面,这并不代表玩家在让飞机降落(实际上飞机会停留在该坐标的上方)
|
||||||
|
|
||||||
|
常见攻击方式:
|
||||||
|
|
||||||
|
(无操作):
|
||||||
|
- 单位默认状态下能自行对靠近的敌军单位发起攻击,无需玩家操作(常见于防御塔)
|
||||||
|
|
||||||
|
集火攻击:
|
||||||
|
- 让当前选择的单位(一个或多个)一起攻击玩家指定的某个目标
|
||||||
|
|
||||||
|
行进攻击:
|
||||||
|
- 让当前选择的己方单位移动到目标地点。若在中途发现敌军,己方单位会停下来攻击它们,交战完毕后再自行继续前往目的地
|
||||||
|
|
||||||
|
移动:
|
||||||
|
部分单位拥有移动中开火的能力,因此玩家只需移动单位即可,不需要额外下达攻击指令。但假如想要攻击特定的单位,仍需要集火攻击。
|
||||||
|
- 坦克拥有炮塔,通常可以移动中开火。但攻城载具不能移动中开火
|
||||||
|
- 防空车和防空船通常可以移动中开火,玩家会操作防空车追上敌方飞机,或者与敌方飞机拉开距离避免被敌方飞机攻击,防空车能自行攻击敌方飞机
|
||||||
|
- 坦克以及大部分大型载具在移动中可以压死前方的敌方步兵
|
||||||
|
- 大型船只也能移动中开火
|
||||||
|
- 对空飞行器(例如战斗机)可以在移动中对正前方的飞机开火
|
||||||
|
|
||||||
|
强制攻击:
|
||||||
|
- 用于攻击地图中立建筑物或者友军
|
||||||
|
|
||||||
|
|
||||||
|
建筑一般既可以摆放在陆地上,也可以摆放在海上,除非特殊注明。
|
||||||
|
- 兵营和重工只能摆放在陆地上。
|
||||||
|
- 船厂只能摆放在海上。
|
||||||
|
其他建筑既可以摆放在陆地上,也可以摆放在海上。(例如机场、防御塔)
|
||||||
|
|
||||||
|
步兵、载具只能在陆地上移动,无法下水,除非特殊注明。
|
||||||
|
- 被标为两栖的单位可以在海上移动
|
||||||
|
船只、军舰,只能在海上移动,无法上岸,除非特殊注明。
|
||||||
|
- 被标为两栖的单位可以在陆地上移动
|
||||||
|
|
||||||
|
接下来我还会为你附上各个阵营的介绍
|
||||||
|
|
||||||
|
|
||||||
|
阵营:盟军
|
||||||
|
盟军的建筑只能造在主基地或者指挥中心附近,因此必须通过移动基地车或矿车进行基地扩张。
|
||||||
|
- 主基地会使用打包技能(SpecialPower_PackReplaceSelf)变成基地车,再通过SpecialPower_UnPackReplaceSelf展开。
|
||||||
|
- 矿车不需要打包,它直接使用展开技能(SpecialPower_UnpackReplaceSelf)永久性变成指挥中心。
|
||||||
|
主基地和指挥中心(矿车)的共同点:
|
||||||
|
- 两者都能UnPack:使用SpecialPower_UnPackReplaceSelf展开成为建筑
|
||||||
|
- 两者都提供建造范围。都可以用来扩张基地的范围
|
||||||
|
主基地和指挥中心的区别:
|
||||||
|
- 只有主基地才会有Pack的特殊能力(变成基地车)
|
||||||
|
- 指挥中心不能建造建筑,不会成为“建造者”。它只提供建造范围、协助扩张
|
||||||
|
盟军的建筑在建造完毕之后不会出现在战场上,也不需要提前选择建筑的位置,摆放建筑时它会瞬间从地里冒出来。这个特色让盟军可以预留一个造好的防御塔,但先不摆放建筑,之后可以瞬间让防御塔摆放在需要的位置。
|
||||||
|
因此,盟军的“摆放建筑”代表建造已经完毕,而其他阵营的“摆放建筑”则代表建造还没开始。
|
||||||
|
盟军常用建筑与升级:
|
||||||
|
- 兵营(AlliedBarracks):生产步兵单位。只能摆放在陆地上。
|
||||||
|
- 电厂(AlliedPowerPlant):提供电力,解锁矿场和机场。
|
||||||
|
- 矿厂(AlliedRefinery):提供收入,解锁重工、船厂和科技。
|
||||||
|
- 机场(AlliedAirfield):生产空军单位。
|
||||||
|
- 重工(AlliedWarFactory):生产装甲车辆。只能摆放在陆地上。
|
||||||
|
- 船厂(AlliedNavalYard):生产海军单位。只能摆放在海上。
|
||||||
|
- T2科技(Upgrade_AlliedTech2):解锁T2单位
|
||||||
|
- T3科技(Upgrade_AlliedTech3):解锁T3单位
|
||||||
|
- T4科技(Upgrade_AlliedTech4):解锁T4单位
|
||||||
|
- 拓展车T3科技(Upgrade_AlliedTech3_Outpost):由盟军矿车展开的拓展车来升级T3科技。不占用主基地的建造序列。
|
||||||
|
- 多功能炮台(AlliedBaseDefense):基础防御塔,可以攻击地面、海面或空中目标
|
||||||
|
- 光谱塔(AlliedBaseDefenseAdvanced):高级防御塔,射程更远,可跨越围墙或建筑攻击,只能对陆地或海中目标进行攻击
|
||||||
|
- 小高科(AlliedLowTechStructure):提供升级、解锁高级防御塔和解锁T3科技。
|
||||||
|
盟军基础常用单位:
|
||||||
|
- 矿车(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):是游戏里最强的前期水面防空单位,默认使用防空机枪,但可以在干扰器和防空机枪之间切换(SpecialPower_ToggleWeaponScrambler),切换为干扰器之后,水翼船可以禁止敌方目标开火
|
||||||
|
- 基地车(AlliedMCV):两栖,昂贵且耗时的无武装车辆,但血量很高。可以在陆地或水上展开为主基地。一般不会额外造基地车、只会使用开局本来就有的唯一一个主基地。
|
||||||
|
盟军T2常用单位,需要T2升级(Upgrade_AlliedTech2):
|
||||||
|
- 守护者坦克(AlliedAntiVehicleVehicleTech1):盟军反装甲坦克,切换为激光指示器(SpecialPower_ToggleTargetPainter)来提高友军的输出。由于激光指示器无法叠加,而且盟军步兵和飞机的输出很高,所以守护者的出场率偏低,一般造一两个。但假如数量够多也有奇效。
|
||||||
|
- 光棱坦克(AlliedPrismTank):擅长反步兵和反轻型装甲。移速较慢。假如数量很多,也可以凭借射程优势与坦克对抗。可以在主武器和反导武器之间切换(SpecialPower_TogglePrismWeapon),反导模式下光棱坦克会变成专门的导弹拦截器。
|
||||||
|
- 冷冻直升机(AlliedSupportAircraft):强大的支援直升机,被它攻击的目标不会受到伤害,但会被冻住。冰冻状态下的单位无法开火或移动,而且可被其他单位一击秒杀。冷冻直升机可以对单个敌军或友军目标使用缩小光束(SpecialPower_ShrinkRay),缩小的单位各项属性都被大幅削弱,但速度增加。
|
||||||
|
- 突袭驱逐舰(AlliedAntiNavyShipTech1):两栖,坚固的船厂T2单位,只能使用输出较低的舰炮,但可用高伤害的深水炸弹攻击潜艇。它可以用黑洞装甲(SpecialPower_ToggleMagneticArmor)把敌方火力都吸引到自己身上,从而对友军提供掩护。突袭驱逐舰在陆地上移动,它在岸上同样可以吸收敌方火力,掩护陆军和空军
|
||||||
|
盟军T3常用单位,需要T3升级(Upgrade_AlliedTech3):
|
||||||
|
- 雅典娜炮(AlliedAntiStructureVehicle):盟军远距离对地攻城单位,引导太空卫星激光攻击固定或低速目标,还可以开启巨大的护盾(SpecialPower_ToggleShieldSphere)来掩护附近的友军
|
||||||
|
- 幻影坦克(AlliedAntiVehicleVehicleTech3):使用光谱武器的先进攻击坦克,伤害很高,但射程很低。可以使用隐形技能(SpecialPower_AlliedAntiVehicleVehicleTech3AssassinStateDisguise)潜入到敌方腹地进行袭击,敌方必须造侦察单位来探测隐形才能反制
|
||||||
|
- 世纪轰炸机(AlliedAntiStructureBomberAircraft):擅长攻击建筑、军舰等大型目标的战略轰炸机。攻击后需要返回机场补充弹药。可以使用技能SpecialPowerReturnToProducer来快速返航。
|
||||||
|
- 冷冻军团(AlliedCryoLegionnaire):两栖,高级支援步兵,可以大幅降低敌方单位的速度让敌方难以冲击阵地或逃跑
|
||||||
|
- 波塞冬巡洋舰(AlliedAntiNavyShipTech3):盟军的大型制海巡洋舰
|
||||||
|
- 谭雅(AlliedCommandoTech1):两栖,盟军英雄步兵单位,擅长反步兵和反建筑的无双女士兵。她的时空腰带(SpecialPower_TimeBelt)能让谭雅回溯到一段时间之前的血量和坐标位置。她可以高效炸毁建筑,因此玩家会设法把谭雅进入(或者直接用载具运输到)敌方基地。每个玩家同时只能有一位谭雅
|
||||||
|
盟军T4常用单位,需要T4升级(Upgrade_AlliedTech4):
|
||||||
|
- 航空母舰(AlliedAntiStructureShip):盟军远距离对地攻城单位,可以释放无人机攻击海面或地面目标
|
||||||
|
- 先锋炮艇机(AlliedGunshipAircraft):持久的空对地火力,不需要回到机场补充弹药
|
||||||
|
盟军常用开局:
|
||||||
|
- 常规兵营开局(较为泛用),以下是几种兵营开的变种:
|
||||||
|
A. 兵营、电站、矿场x2、[卖掉兵营 避免摆下机场后电力不足]、机场(卖掉兵营导致步兵较少,但机场更快)
|
||||||
|
B. 兵营、电站、矿场x2、电厂、机场;(先造第二个电厂,这样就不需要卖兵营来省电了,能一直续步兵,但机场更慢)
|
||||||
|
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),对一大片地面目标造成伤害
|
||||||
|
|
||||||
|
|
||||||
|
阵营:神州
|
||||||
|
神州只能拥有一个主基地。神州的建筑不受建造范围的限制,即使不移动主基地,也可以把建筑摆在任何一个地方。
|
||||||
|
建造时,神州需要先摆放建筑,随后主基地会自动产生一个飞行核心朝目标位置飞去。飞行核心抵达目标位置后,自动变成建筑。
|
||||||
|
神州建造特性的优势:
|
||||||
|
- 神州可以直接扩张到其他距离较近的矿脉
|
||||||
|
- 防御塔不再仅仅是“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),杀伤区域内的所有目标
|
||||||
|
|
||||||
|
|
||||||
|
当前交战的地图是:无限岛
|
||||||
|
这张地图是对称的,只有一条陆地进攻路线,也是主要的陆地交战区域,中央高地。
|
||||||
|
中央高地是长条状的,只有两个出入口,位于中央高地的两端,通向两位玩家的出生点。
|
||||||
|
玩家可以在高地战场正面交锋,也可以选择绕海,或者使用空军(不受地形限制)。
|
||||||
|
低地被中央高地分割成两部分,低地都是三面环海(还有一面是高地),两栖单位可以从低地上岸或下水。
|
||||||
|
中央高地也有靠海的地方,但两栖单位无法跨越悬崖从高地直接入海,需要从低地绕道。(额外矿区)
|
||||||
|
每个矿区可以摆放一个矿厂。
|
||||||
|
|
||||||
|
# 地图参数
|
||||||
|
海面高度: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的单位都属于地图物件或中立建筑
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
你是一位 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游戏,玩家需要建造建筑、生产单位、攻击敌方玩家来取得胜利。
|
||||||
|
阵营:
|
||||||
|
- 盟军(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坐标也依然总是地面或海面,这并不代表玩家在让飞机降落(实际上飞机会停留在该坐标的上方)
|
||||||
|
|
||||||
|
常见攻击方式:
|
||||||
|
|
||||||
|
(无操作):
|
||||||
|
- 单位默认状态下能自行对靠近的敌军单位发起攻击,无需玩家操作(常见于防御塔)
|
||||||
|
|
||||||
|
集火攻击:
|
||||||
|
- 让当前选择的单位(一个或多个)一起攻击玩家指定的某个目标
|
||||||
|
|
||||||
|
行进攻击:
|
||||||
|
- 让当前选择的己方单位移动到目标地点。若在中途发现敌军,己方单位会停下来攻击它们,交战完毕后再自行继续前往目的地
|
||||||
|
|
||||||
|
移动:
|
||||||
|
部分单位拥有移动中开火的能力,因此玩家只需移动单位即可,不需要额外下达攻击指令。但假如想要攻击特定的单位,仍需要集火攻击。
|
||||||
|
- 坦克拥有炮塔,通常可以移动中开火。但攻城载具不能移动中开火
|
||||||
|
- 防空车和防空船通常可以移动中开火,玩家会操作防空车追上敌方飞机,或者与敌方飞机拉开距离避免被敌方飞机攻击,防空车能自行攻击敌方飞机
|
||||||
|
- 坦克以及大部分大型载具在移动中可以压死前方的敌方步兵
|
||||||
|
- 大型船只也能移动中开火
|
||||||
|
- 对空飞行器(例如战斗机)可以在移动中对正前方的飞机开火
|
||||||
|
|
||||||
|
强制攻击:
|
||||||
|
- 用于攻击地图中立建筑物或者友军
|
||||||
|
|
||||||
|
|
||||||
|
建筑一般既可以摆放在陆地上,也可以摆放在海上,除非特殊注明。
|
||||||
|
- 兵营和重工只能摆放在陆地上。
|
||||||
|
- 船厂只能摆放在海上。
|
||||||
|
其他建筑既可以摆放在陆地上,也可以摆放在海上。(例如机场、防御塔)
|
||||||
|
|
||||||
|
步兵、载具只能在陆地上移动,无法下水,除非特殊注明。
|
||||||
|
- 被标为两栖的单位可以在海上移动
|
||||||
|
船只、军舰,只能在海上移动,无法上岸,除非特殊注明。
|
||||||
|
- 被标为两栖的单位可以在陆地上移动
|
||||||
|
|
||||||
|
接下来我还会为你附上各个阵营的介绍
|
||||||
|
|
||||||
|
|
||||||
|
阵营:盟军
|
||||||
|
盟军的建筑只能造在主基地或者指挥中心附近,因此必须通过移动基地车或矿车进行基地扩张。
|
||||||
|
- 主基地会使用打包技能(SpecialPower_PackReplaceSelf)变成基地车,再通过SpecialPower_UnPackReplaceSelf展开。
|
||||||
|
- 矿车不需要打包,它直接使用展开技能(SpecialPower_UnpackReplaceSelf)永久性变成指挥中心。
|
||||||
|
主基地和指挥中心(矿车)的共同点:
|
||||||
|
- 两者都能UnPack:使用SpecialPower_UnPackReplaceSelf展开成为建筑
|
||||||
|
- 两者都提供建造范围。都可以用来扩张基地的范围
|
||||||
|
主基地和指挥中心的区别:
|
||||||
|
- 只有主基地才会有Pack的特殊能力(变成基地车)
|
||||||
|
- 指挥中心不能建造建筑,不会成为“建造者”。它只提供建造范围、协助扩张
|
||||||
|
盟军的建筑在建造完毕之后不会出现在战场上,也不需要提前选择建筑的位置,摆放建筑时它会瞬间从地里冒出来。这个特色让盟军可以预留一个造好的防御塔,但先不摆放建筑,之后可以瞬间让防御塔摆放在需要的位置。
|
||||||
|
因此,盟军的“摆放建筑”代表建造已经完毕,而其他阵营的“摆放建筑”则代表建造还没开始。
|
||||||
|
盟军常用建筑与升级:
|
||||||
|
- 兵营(AlliedBarracks):生产步兵单位。只能摆放在陆地上。
|
||||||
|
- 电厂(AlliedPowerPlant):提供电力,解锁矿场和机场。
|
||||||
|
- 矿厂(AlliedRefinery):提供收入,解锁重工、船厂和科技。
|
||||||
|
- 机场(AlliedAirfield):生产空军单位。
|
||||||
|
- 重工(AlliedWarFactory):生产装甲车辆。只能摆放在陆地上。
|
||||||
|
- 船厂(AlliedNavalYard):生产海军单位。只能摆放在海上。
|
||||||
|
- T2科技(Upgrade_AlliedTech2):解锁T2单位
|
||||||
|
- T3科技(Upgrade_AlliedTech3):解锁T3单位
|
||||||
|
- 多功能炮台(AlliedBaseDefense):基础防御塔,可以攻击地面、海面或空中目标
|
||||||
|
- 高科(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):是游戏里最强的水面防空单位,默认使用防空机枪,但可以在干扰器和防空机枪之间切换(SpecialPower_ToggleWeaponScrambler),切换为干扰器之后,水翼船可以禁止敌方目标开火
|
||||||
|
- 基地车(AlliedMCV):两栖,昂贵且耗时的无武装车辆,但血量很高。可以在陆地或水上展开为主基地。一般不会额外造基地车、只会使用开局本来就有的唯一一个主基地。
|
||||||
|
盟军T2常用单位,需要T2升级(Upgrade_AlliedTech2):
|
||||||
|
- 守护者坦克(AlliedAntiVehicleVehicleTech1):盟军反装甲坦克,切换为激光指示器(SpecialPower_ToggleTargetPainter)来提高友军的输出。由于激光指示器无法叠加,而且盟军步兵和飞机的输出很高,所以守护者的出场率偏低,一般造一两个。但假如数量够多也有奇效。
|
||||||
|
- 冷冻直升机(AlliedSupportAircraft):盟军最强大的支援直升机,被它攻击的目标不会受到伤害,但会被冻住。冰冻状态下的单位无法开火或移动,而且可被其他单位一击秒杀。冷冻直升机可以对单个敌军或友军目标使用缩小光束(SpecialPower_ShrinkRay),缩小的单位各项属性都被大幅削弱,但速度增加。
|
||||||
|
- 突袭驱逐舰(AlliedAntiNavyShipTech1):两栖,坚固的船厂T2单位,只能使用输出较低的舰炮。它可以用黑洞装甲(SpecialPower_ToggleMagneticArmor)把敌方火力都吸引到自己身上,从而对友军提供掩护。突袭驱逐舰在陆地上移动,它在岸上同样可以吸收敌方火力,掩护陆军和空军
|
||||||
|
盟军T3常用单位,需要T3升级(Upgrade_AlliedTech3):
|
||||||
|
- 雅典娜炮(AlliedAntiStructureVehicle):盟军远距离对地攻城单位,引导太空卫星激光攻击固定或低速目标,还可以开启巨大的护盾(SpecialPower_ToggleShieldSphere)来掩护附近的友军
|
||||||
|
- 幻影坦克(AlliedAntiVehicleVehicleTech3):使用光谱武器的先进攻击坦克,伤害很高,但射程很低。因此出场率不如雅典娜炮
|
||||||
|
- 世纪轰炸机(AlliedBomberAircraft):擅长攻击建筑等大型目标的战略轰炸机,可用来轰炸敌方基地。攻击后需要返回机场补充弹药。可以运输步兵并使用SpecialPower_EjectPassengersUntargeted让步兵跳伞。
|
||||||
|
- 航空母舰(AlliedAntiStructureShip):盟军远距离对地攻城单位,可以释放无人机攻击海面或地面目标
|
||||||
|
- 谭雅(AlliedCommandoTech1):两栖,盟军英雄步兵单位,擅长反步兵和反建筑的无双女士兵。她的时空腰带(SpecialPower_TimeBelt)能让谭雅回溯到一段时间之前的血量和坐标位置。她可以高效炸毁建筑,因此玩家会设法把谭雅进入(或者直接用载具运输到)敌方基地。每个玩家同时只能有一位谭雅
|
||||||
|
盟军常用开局:
|
||||||
|
- 常规兵营开局(较为泛用),以下是几种兵营开的变种:
|
||||||
|
A. 兵营、电站、矿场x2、[卖掉兵营 避免摆下机场后电力不足]、机场(卖掉兵营导致步兵较少,但机场更快)
|
||||||
|
B. 兵营、电站、矿场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),对一大片地面目标造成伤害
|
||||||
|
|
||||||
|
|
||||||
|
阵营:神州
|
||||||
|
神州只能拥有一个主基地。神州的建筑不受建造范围的限制,即使不移动主基地,也可以把建筑摆在任何一个地方。
|
||||||
|
建造时,神州需要先摆放建筑,随后主基地会自动产生一个飞行核心朝目标位置飞去。飞行核心抵达目标位置后,自动变成建筑。
|
||||||
|
神州建造特性的优势:
|
||||||
|
- 神州可以直接扩张到其他距离较近的矿脉
|
||||||
|
- 防御塔不再仅仅是“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),杀伤区域内的所有目标
|
||||||
|
|
||||||
|
|
||||||
|
当前交战的地图是:无限岛
|
||||||
|
这张地图是对称的,只有一条陆地进攻路线,也是主要的陆地交战区域,中央高地。
|
||||||
|
中央高地是长条状的,只有两个出入口,位于中央高地的两端,通向两位玩家的出生点。
|
||||||
|
玩家可以在高地战场正面交锋,也可以选择绕海,或者使用空军(不受地形限制)。
|
||||||
|
低地被中央高地分割成两部分,低地都是三面环海(还有一面是高地),两栖单位可以从低地上岸或下水。
|
||||||
|
中央高地也有靠海的地方,但两栖单位无法跨越悬崖从高地直接入海,需要从低地绕道。(额外矿区)
|
||||||
|
每个矿区可以摆放一个矿厂。
|
||||||
|
|
||||||
|
# 地图参数
|
||||||
|
海面高度: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
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
{
|
||||||
|
"format": "1.1",
|
||||||
|
"description": "Structured game entity knowledge for prompt rendering and AI validation.",
|
||||||
|
"factions": {
|
||||||
|
"盟军": {
|
||||||
|
"buildings": [
|
||||||
|
{
|
||||||
|
"assetName": "AlliedBarracks",
|
||||||
|
"displayName": "兵营",
|
||||||
|
"tags": ["structure", "land"],
|
||||||
|
"text": "生产步兵单位。只能摆放在陆地上。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedPowerPlant",
|
||||||
|
"displayName": "电厂",
|
||||||
|
"tags": ["structure", "land"],
|
||||||
|
"text": "提供电力,解锁矿场和机场。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedRefinery",
|
||||||
|
"displayName": "矿厂",
|
||||||
|
"tags": ["structure", "land"],
|
||||||
|
"text": "提供收入,解锁重工、船厂和科技。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAirfield",
|
||||||
|
"displayName": "机场",
|
||||||
|
"tags": ["structure", "air", "land"],
|
||||||
|
"text": "生产空军单位。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedWarFactory",
|
||||||
|
"displayName": "重工",
|
||||||
|
"tags": ["structure", "vehicle", "land"],
|
||||||
|
"text": "生产装甲车辆。只能摆放在陆地上。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedNavalYard",
|
||||||
|
"displayName": "船厂",
|
||||||
|
"tags": ["structure", "naval", "sea"],
|
||||||
|
"text": "生产海军单位。只能摆放在海上。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedBaseDefense",
|
||||||
|
"displayName": "多功能炮台",
|
||||||
|
"tags": ["structure", "defense", "land"],
|
||||||
|
"text": "基础防御塔,可以攻击地面、海面或空中目标。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedTechStructure",
|
||||||
|
"displayName": "高科",
|
||||||
|
"tags": ["structure", "land"],
|
||||||
|
"text": "解锁超级武器。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"assetName": "AlliedMiner",
|
||||||
|
"displayName": "矿车",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["vehicle", "amphibious", "unpack", "miner"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_UnpackReplaceSelf", "description": "在陆地或水上展开变成指挥中心" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedRefinery", "AlliedWarFactory", "AlliedNavalYard"],
|
||||||
|
"text": "无武装。矿场自带矿车,一般无需额外生产,除非被摧毁或需要展开指挥中心。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedScoutInfantry",
|
||||||
|
"displayName": "狗",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["infantry", "amphibious", "scout", "antiInfantry"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_Bark", "description": "AOE 瘫痪敌方步兵" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedBarracks"],
|
||||||
|
"text": "侦察单位,非常脆弱,只能攻击步兵。两栖,可利用绕海侦察。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiInfantryInfantry",
|
||||||
|
"displayName": "维和步兵",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["infantry", "antiInfantry"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ToggleRiotShield", "description": "在霰弹枪和防暴盾牌之间切换" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedBarracks"],
|
||||||
|
"text": "数值和造价偏高,可以抗线掩护其他单位。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiVehicleInfantry",
|
||||||
|
"displayName": "标枪兵",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["infantry", "antiVehicle", "antiAir"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_RadarLock", "description": "激光制导,大幅提高输出" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedBarracks"],
|
||||||
|
"text": "反装甲兼防空,无法反步兵。数量多时可成为输出主力。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedEngineer",
|
||||||
|
"displayName": "工程师",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["infantry", "amphibious", "engineer"],
|
||||||
|
"producedBy": ["AlliedBarracks"],
|
||||||
|
"text": "可用于占领建筑或维修己方建筑。开局一般造一个占领油井。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiGroundAircraft",
|
||||||
|
"displayName": "维护者轰炸机",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["aircraft", "antiGround", "returnToProducer", "bomber"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPowerReturnToProducer", "description": "快速返航回机场补充弹药" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedAirfield"],
|
||||||
|
"text": "前线对地轰炸机,对坦克和步兵的伤害都很高。每次轰炸后需要返回机场补充弹药。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedFighterAircraft",
|
||||||
|
"displayName": "阿波罗战斗机",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["aircraft", "antiAir", "fighter", "returnToProducer"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPowerReturnToProducer", "description": "快速返航回机场补充弹药" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedAirfield"],
|
||||||
|
"text": "制空战斗机,只能对空,游戏里最强的战斗机。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiInfantryVehicle",
|
||||||
|
"displayName": "激流ACV",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["vehicle", "amphibious", "antiInfantry", "transport"],
|
||||||
|
"producedBy": ["AlliedNavalYard"],
|
||||||
|
"aliases": ["AlliedAntiInfantryVehicle_Ground"],
|
||||||
|
"alsoProducedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "反步兵气垫船,两栖,可运输步兵。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiAirVehicle",
|
||||||
|
"displayName": "IFV",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["vehicle", "antiAir", "transport"],
|
||||||
|
"producedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "多功能步兵战车,基础陆地防空单位,可装载步兵切换武器。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiNavalScout",
|
||||||
|
"displayName": "海豚",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["naval", "antiNaval", "scout"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_TriggerJump", "description": "跳跃躲避攻击" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedNavalYard"],
|
||||||
|
"text": "搭载声波武器的前期对海单位,速度快,可攻击水面单位或建筑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiAirShip",
|
||||||
|
"displayName": "水翼船",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["naval", "antiAir", "toggleWeapon"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ToggleWeaponScrambler", "description": "在防空机枪和干扰器之间切换;干扰器禁止敌方目标开火" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedNavalYard"],
|
||||||
|
"text": "水面防空单位。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedMCV",
|
||||||
|
"displayName": "基地车",
|
||||||
|
"tier": "基础",
|
||||||
|
"tags": ["vehicle", "amphibious", "builder", "pack", "unpack"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_PackReplaceSelf", "description": "主基地打包变成基地车" },
|
||||||
|
{ "name": "SpecialPower_UnpackReplaceSelf", "description": "基地车展开变成主基地" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "昂贵且耗时,血量很高。一般只有开局自带的唯一一辆。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiVehicleVehicleTech1",
|
||||||
|
"displayName": "守护者坦克",
|
||||||
|
"tier": "T2",
|
||||||
|
"tags": ["vehicle", "antiVehicle", "toggleWeapon"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ToggleTargetPainter", "description": "切换为激光指示器,提高友军输出" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "出场率偏低,一般造一两个辅助。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedSupportAircraft",
|
||||||
|
"displayName": "冷冻直升机",
|
||||||
|
"tier": "T2",
|
||||||
|
"tags": ["aircraft", "support"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ShrinkRay", "description": "缩小光束,削弱目标并加速" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedAirfield"],
|
||||||
|
"text": "被攻击的目标被冻住无法开火或移动,可被一击秒杀。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiNavyShipTech1",
|
||||||
|
"displayName": "突袭驱逐舰",
|
||||||
|
"tier": "T2",
|
||||||
|
"tags": ["naval", "amphibious", "antiVehicle", "antiNaval", "toggleWeapon"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ToggleMagneticArmor", "description": "黑洞装甲,把敌方火力吸引到自己身上" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedNavalYard"],
|
||||||
|
"text": "两栖。在岸上同样可以吸收火力掩护友军。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiStructureVehicle",
|
||||||
|
"displayName": "雅典娜炮",
|
||||||
|
"tier": "T3",
|
||||||
|
"tags": ["vehicle", "antiStructure", "siege"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_ToggleShieldSphere", "description": "开启巨大护盾掩护附近友军" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "远距离对地攻城单位,引导卫星激光攻击固定或低速目标。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiVehicleVehicleTech3",
|
||||||
|
"displayName": "幻影坦克",
|
||||||
|
"tier": "T3",
|
||||||
|
"tags": ["vehicle", "antiVehicle"],
|
||||||
|
"producedBy": ["AlliedWarFactory"],
|
||||||
|
"text": "使用光谱武器,伤害很高但射程很低。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedBomberAircraft",
|
||||||
|
"displayName": "世纪轰炸机",
|
||||||
|
"tier": "T3",
|
||||||
|
"tags": ["aircraft", "antiStructure", "bomber", "returnToProducer", "transport"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPowerReturnToProducer", "description": "快速返航回机场" },
|
||||||
|
{ "name": "SpecialPower_EjectPassengersUntargeted", "description": "让步兵跳伞" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedAirfield"],
|
||||||
|
"text": "战略轰炸机,擅长攻击建筑等大型目标。可运输步兵。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedAntiStructureShip",
|
||||||
|
"displayName": "航空母舰",
|
||||||
|
"tier": "T3",
|
||||||
|
"tags": ["naval", "antiStructure", "siege"],
|
||||||
|
"producedBy": ["AlliedNavalYard"],
|
||||||
|
"text": "远距离对地攻城单位,释放无人机攻击海面或地面目标。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"assetName": "AlliedCommandoTech1",
|
||||||
|
"displayName": "谭雅",
|
||||||
|
"tier": "T3",
|
||||||
|
"tags": ["infantry", "amphibious", "hero", "antiInfantry", "antiStructure"],
|
||||||
|
"specialPowers": [
|
||||||
|
{ "name": "SpecialPower_TimeBelt", "description": "时空腰带,回溯到之前的状态" }
|
||||||
|
],
|
||||||
|
"producedBy": ["AlliedBarracks"],
|
||||||
|
"text": "英雄步兵单位,擅长反步兵和反建筑。可高效炸毁建筑。每个玩家同时只能有一位。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Extract game knowledge strings from AIAnalyze.cs, expand [MOD:] tags,
|
||||||
|
and write mod-specific knowledge files.
|
||||||
|
|
||||||
|
Usage: python expand_knowledge.py [--cs-path PATH]
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
knowledge_default.md — base game (vanilla) knowledge
|
||||||
|
knowledge_corona.md — Corona mod knowledge
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Extract @"" verbatim strings from C# source ─────────────────────────
|
||||||
|
|
||||||
|
def extract_verbatim_string(text: str, start: int) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Extract a C# @"" verbatim string starting after '@"'.
|
||||||
|
Returns (content, end_position).
|
||||||
|
Handles "" as escaped double-quote.
|
||||||
|
"""
|
||||||
|
chars = []
|
||||||
|
i = start
|
||||||
|
while i < len(text):
|
||||||
|
c = text[i]
|
||||||
|
if c == '"':
|
||||||
|
# Escaped quote "" → one literal "
|
||||||
|
if i + 1 < len(text) and text[i + 1] == '"':
|
||||||
|
chars.append('"')
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
# End of verbatim string
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
chars.append(c)
|
||||||
|
i += 1
|
||||||
|
return ''.join(chars), i
|
||||||
|
|
||||||
|
|
||||||
|
def find_strings(cs_path: str) -> dict[str, str]:
|
||||||
|
"""Find all known knowledge string variables in the .cs file and return
|
||||||
|
{variable_name: content}."""
|
||||||
|
with open(cs_path, 'r', encoding='utf-8') as f:
|
||||||
|
source = f.read()
|
||||||
|
|
||||||
|
known_vars = [
|
||||||
|
'generalDescriptions',
|
||||||
|
'alliedDescriptions',
|
||||||
|
'celestialDescriptions',
|
||||||
|
'infinityIsleVanilla',
|
||||||
|
'infinityIsleCorona',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for var in known_vars:
|
||||||
|
# Look for: var {name} = @"
|
||||||
|
pattern = f'var {var} = @"'
|
||||||
|
idx = source.find(pattern)
|
||||||
|
if idx == -1:
|
||||||
|
print(f'[WARN] Could not find "{var}" in source')
|
||||||
|
continue
|
||||||
|
content, end = extract_verbatim_string(source, idx + len(pattern))
|
||||||
|
# content still has the leading newline from @"\n...
|
||||||
|
results[var] = content
|
||||||
|
print(f'[OK] {var}: {len(content)} chars')
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. [MOD:] tag expansion ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def expand_mod_text(text: str, mod_name: str) -> str:
|
||||||
|
"""Expand [MOD:...] and [MOD:NO:...] tags for the given mod.
|
||||||
|
Mimics the logic in AIAnalyze.Process()."""
|
||||||
|
lines = text.replace('\r', '').split('\n')
|
||||||
|
result_lines = []
|
||||||
|
skip_next = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if skip_next:
|
||||||
|
skip_next = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Line-level [MOD:xxx] (must be at start of line, possibly with leading spaces)
|
||||||
|
# Check for [MOD:NO:xxx] first
|
||||||
|
no_match = re.match(r'^(\s*)\[MOD:NO:([^\]]+)\]$', line)
|
||||||
|
if no_match:
|
||||||
|
indent, denied_mod = no_match.groups()
|
||||||
|
if denied_mod.lower() == mod_name.lower():
|
||||||
|
# [MOD:NO:corona] when mod is corona → skip content line
|
||||||
|
skip_next = True
|
||||||
|
# Either way, skip the tag line itself
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for [MOD:xxx]
|
||||||
|
mod_match = re.match(r'^(\s*)\[MOD:([^\]]+)\]$', line)
|
||||||
|
if mod_match:
|
||||||
|
indent, entry_mod = mod_match.groups()
|
||||||
|
if entry_mod.lower() != mod_name.lower():
|
||||||
|
# This mod's content doesn't apply → skip content line
|
||||||
|
skip_next = True
|
||||||
|
# Skip the tag line itself
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inline tags: process [MOD:xxx]content[/MOD] and [MOD:NO:xxx]content[/MOD]
|
||||||
|
# Multiple inline tags can appear on one line (e.g. line 662)
|
||||||
|
processed = line
|
||||||
|
while True:
|
||||||
|
# Find next [MOD: or [MOD:NO:
|
||||||
|
tag_match = re.search(
|
||||||
|
r'\[MOD:(NO:)?([^\]]+)\](.*?)\[/MOD\]',
|
||||||
|
processed,
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
if not tag_match:
|
||||||
|
break
|
||||||
|
|
||||||
|
is_no = tag_match.group(1) is not None
|
||||||
|
entry_mod = tag_match.group(2)
|
||||||
|
inner_text = tag_match.group(3)
|
||||||
|
before = processed[:tag_match.start()]
|
||||||
|
after = processed[tag_match.end():]
|
||||||
|
|
||||||
|
include = False
|
||||||
|
if is_no:
|
||||||
|
# [MOD:NO:corona] → include if mod != corona
|
||||||
|
if entry_mod.lower() != mod_name.lower():
|
||||||
|
include = True
|
||||||
|
else:
|
||||||
|
# [MOD:corona] → include if mod == corona
|
||||||
|
if entry_mod.lower() == mod_name.lower():
|
||||||
|
include = True
|
||||||
|
|
||||||
|
if include:
|
||||||
|
processed = before + inner_text + after
|
||||||
|
else:
|
||||||
|
processed = before + after
|
||||||
|
|
||||||
|
result_lines.append(processed)
|
||||||
|
|
||||||
|
return '\n'.join(result_lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Assemble per-mod knowledge ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def assemble_knowledge(strings: dict[str, str], mod_name: str) -> str:
|
||||||
|
"""Assemble the full knowledge text for a given mod."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# generalDescriptions — most is global rules, but has one inline [MOD:CORONA] tag
|
||||||
|
# on the faction list line; process it to expand that tag.
|
||||||
|
parts.append(expand_mod_text(strings.get('generalDescriptions', ''), mod_name).strip())
|
||||||
|
|
||||||
|
# alliedDescriptions
|
||||||
|
allied = strings.get('alliedDescriptions', '')
|
||||||
|
parts.append(expand_mod_text(allied, mod_name).strip())
|
||||||
|
|
||||||
|
# celestialDescriptions
|
||||||
|
celestial = strings.get('celestialDescriptions', '')
|
||||||
|
parts.append(expand_mod_text(celestial, mod_name).strip())
|
||||||
|
|
||||||
|
# Map description — mod-specific
|
||||||
|
map_key = f'infinityIsle{mod_name.capitalize()}'
|
||||||
|
if map_key in strings:
|
||||||
|
parts.append(strings[map_key].strip())
|
||||||
|
else:
|
||||||
|
# Fallback: default map
|
||||||
|
default_map = strings.get('infinityIsleVanilla', '')
|
||||||
|
parts.append(expand_mod_text(default_map, mod_name).strip())
|
||||||
|
|
||||||
|
return '\n\n\n'.join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Determine paths
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
repo_root = script_dir.parent # tools/ is directly under repo root
|
||||||
|
cs_path = repo_root / 'Utils' / 'AIAnalyze.cs'
|
||||||
|
output_dir = repo_root
|
||||||
|
|
||||||
|
# Override via CLI
|
||||||
|
if '--cs-path' in sys.argv:
|
||||||
|
idx = sys.argv.index('--cs-path')
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
cs_path = Path(sys.argv[idx + 1])
|
||||||
|
|
||||||
|
print(f'Reading: {cs_path}')
|
||||||
|
if not cs_path.exists():
|
||||||
|
print(f'[ERROR] File not found: {cs_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Extract all strings
|
||||||
|
strings = find_strings(str(cs_path))
|
||||||
|
if not strings:
|
||||||
|
print('[ERROR] No strings extracted, aborting.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Generate per-mod files
|
||||||
|
for mod_name in ('default', 'corona'):
|
||||||
|
knowledge = assemble_knowledge(strings, mod_name)
|
||||||
|
out_path = output_dir / f'knowledge_{mod_name}.md'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(knowledge)
|
||||||
|
lines = knowledge.count('\n') + 1
|
||||||
|
print(f'[OK] {out_path.name}: {lines} lines, {len(knowledge)} chars')
|
||||||
|
|
||||||
|
print('\nDone. Files written to:', output_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user