using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace XFEEDToolBox.Core.Data;
public sealed record GameJournalRecord(string Source,long Offset,DateTimeOffset Time,string Event,string Json);
public sealed record GameEventCursor(long Ticks,long Id);
public sealed record GameEventEntry(long Id,DateTimeOffset Time,string Event,string Json,string Source)
{
public string TimeDisplay=>Time.ToLocalTime().ToString("MM-dd HH:mm:ss");
public string Name=>GameEventNames.Translate(Event);
public string Summary
{
get
{
using var doc=JsonDocument.Parse(Json);var root=doc.RootElement;
if(Event=="Status")
{
var flags=root.Long("Flags");var parts=new List<string>();
if((flags&(1L<<24))!=0)parts.Add("主船");
parts.Add((flags&8)!=0?"护盾正常":"护盾未启用");parts.Add((flags&1)!=0?"已停靠":(flags&2)!=0?"已着陆":(flags&(1L<<30))!=0?"正在跃迁":(flags&16)!=0?"超巡中":"常规空间");
if(root.Get("Fuel").Number("FuelMain") is {} fuel)parts.Add($"燃油 {fuel:N2} t");
if(root.Number("Balance") is {} cash)parts.Add($"余额 {cash:N0} Cr");
return string.Join(" · ",parts);
}
if(Event=="ReceiveText")return root.Text("Message_Localised",root.Text("Message"));
if(Event=="DockingDenied")return root.Text("Reason") switch{"TooLarge"=>"飞船尺寸过大,站点没有合适的停机坪。","NoSpace"=>"站点暂无空闲停机坪。","Distance"=>"距离站点过远。","Hostile"=>"站点拒绝敌对舰船停靠。",var reason=>"停靠被拒 · "+reason};
var summary=string.Join(" · ",root.EnumerateObject().Where(p=>p.Name is not("timestamp" or "event")&&p.Value.ValueKind is not(JsonValueKind.Object or JsonValueKind.Array)).Take(5).Select(p=>$"{GameEventNames.Field(p.Name)}: {p.Value}"));
return summary.Length>320?summary[..320]+"…":summary;
}
}
public GameEventCursor Cursor=>new(Time.UtcTicks,Id);
}
public sealed record GameEventPage(IReadOnlyList<GameEventEntry> Entries,bool HasMore,long Count);
public sealed record GameEventCacheRecovery(string PreservedDatabase,string RebuiltDatabase,string Failure);
public static class GameEventNames
{
private static readonly string[] known="LoadGame Fileheader FileHeader Commander Loadout Statistics Rank Progress Reputation Promotion Location FSDJump StartJump FSDTarget SupercruiseEntry SupercruiseExit Docked Undocked DockingRequested DockingGranted DockingDenied NavRoute NavRouteClear Scan FSSDiscoveryScan FSSAllBodiesFound SAAScanComplete ScanOrganic CodexEntry Materials MaterialCollected MaterialDiscarded EngineerCraft EngineerProgress EngineerContribution Cargo Market MarketBuy MarketSell ModuleBuy ModuleSell ModuleSwap ModuleStore ModuleRetrieve StoredModules StoredShips FuelScoop RefuelAll RefuelPartial Status ShieldState HullDamage UnderAttack Interdicted Bounty ShipTargeted Died Resurrect Shutdown Music ReceiveText SendText MissionAccepted MissionCompleted MissionFailed ShipLocker Backpack Disembark Embark Touchdown Liftoff".Split(' ');
public static string SearchAliases(string search)=>"|"+string.Join('|',known.Where(e=>Translate(e).Contains(search,StringComparison.OrdinalIgnoreCase)))+"|";
public static string Translate(string value)=>value switch
{
"LoadGame"=>"载入游戏","Fileheader" or "FileHeader"=>"会话记录头","Commander"=>"指挥官档案","Loadout"=>"飞船装配","Statistics"=>"玩家统计","Rank"=>"玩家等级","Progress"=>"等级进度","Reputation"=>"阵营声望","Promotion"=>"等级晋升","Location"=>"当前位置","FSDJump"=>"跃迁抵达","StartJump"=>"开始跃迁","FSDTarget"=>"跃迁目标","SupercruiseEntry"=>"进入超巡","SupercruiseExit"=>"退出超巡","Docked"=>"已停靠","Undocked"=>"离开停靠","DockingRequested"=>"请求停靠","DockingGranted"=>"获准停靠","DockingDenied"=>"停靠被拒","NavRoute"=>"游戏航线","NavRouteClear"=>"航线清除","Scan"=>"扫描天体","FSSDiscoveryScan"=>"发现扫描","FSSAllBodiesFound"=>"发现全部天体","SAAScanComplete"=>"地表测绘完成","ScanOrganic"=>"生物采样","CodexEntry"=>"发现日志","Materials"=>"工程材料快照","MaterialCollected"=>"收集材料","MaterialDiscarded"=>"丢弃材料","EngineerCraft"=>"工程改装","EngineerProgress"=>"工程师进度","EngineerContribution"=>"工程师捐献","Cargo"=>"货舱快照","Market"=>"商品市场","MarketBuy"=>"买入商品","MarketSell"=>"卖出商品","ModuleBuy"=>"买入模块","ModuleSell"=>"卖出模块","ModuleSwap"=>"交换模块","ModuleStore"=>"寄存模块","ModuleRetrieve"=>"取回模块","StoredModules"=>"寄存模块快照","StoredShips"=>"飞船仓库","FuelScoop"=>"恒星吸油","RefuelAll"=>"加满燃油","RefuelPartial"=>"补充燃油","Status"=>"座舱状态","ShieldState"=>"护盾状态","HullDamage"=>"船体损伤","UnderAttack"=>"遭到攻击","Interdicted"=>"遭到拦截","Bounty"=>"赏金收入","ShipTargeted"=>"锁定舰船","Died"=>"死亡","Resurrect"=>"重新部署","Shutdown"=>"游戏会话结束","Music"=>"背景音乐","ReceiveText"=>"收到游戏通讯","SendText"=>"发送游戏通讯","MissionAccepted"=>"接受任务","MissionCompleted"=>"完成任务","MissionFailed"=>"任务失败","ShipLocker"=>"飞船储物柜","Backpack"=>"背包快照","Disembark"=>"下船","Embark"=>"登船","Touchdown"=>"着陆","Liftoff"=>"起飞",_=>"游戏事件"
};
public static string Field(string key)=>key switch{"StarSystem"=>"星系","BodyName"=>"天体","StationName"=>"站点","StationType"=>"站点类型","SystemAddress"=>"星系编号","MarketID"=>"市场编号","JumpDist"=>"距离","FuelLevel"=>"燃油","ShieldsUp"=>"护盾恢复","Level"=>"等级","Quality"=>"完成度","Credits" or "Balance"=>"余额","Count"=>"数量","Cost"=>"费用","Message_Localised" or "Message"=>"内容","From"=>"发件人","Type"=>"类型","Reason"=>"原因","Channel"=>"频道","Ship"=>"飞船","ShipID"=>"飞船编号","ShipName"=>"船名","Engineer"=>"工程师","BlueprintName"=>"工程蓝图","Health"=>"完整度","MusicTrack"=>"曲目","Name"=>"名称","Commander"=>"指挥官",_=>key};
}
public sealed class GameEventRepository(string path)
{
private readonly string originalPath=Path.GetFullPath(path);
private string databasePath=Path.GetFullPath(path);
public string DatabasePath=>databasePath;
public GameEventCacheRecovery? LastRecovery{get;private set;}
private SqliteConnection Open(){var connection=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=databasePath,Pooling=true}.ToString());try{connection.Open();return connection;}catch{connection.Dispose();throw;}}
public void Initialize()
{
var marker=originalPath+".active";
if(File.Exists(marker))
{
var name=File.ReadAllText(marker).Trim();
if(name!=Path.GetFileName(name)||!name.StartsWith(Path.GetFileName(originalPath)+".rebuilt-",StringComparison.Ordinal)||!name.EndsWith(".db",StringComparison.Ordinal))
throw new InvalidDataException("事件索引恢复记录中的文件名无效。");
databasePath=Path.Combine(Path.GetDirectoryName(originalPath)!,name);
}
var previous=databasePath;
try{InitializeSchema();Check();return;}
catch(Exception error)when(error is InvalidDataException||error is SqliteException sql&&sql.SqliteErrorCode is 11 or 26)
{
// This is a derived index. Preserve the damaged database and its WAL
// in place, then let JournalMonitor replay the original game files
// into a separate healthy generation before the first UI queries.
using(var pool=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=previous,Pooling=true}.ToString()))SqliteConnection.ClearPool(pool);
databasePath=originalPath+".rebuilt-"+DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss")+"-"+Guid.NewGuid().ToString("N")[..8]+".db";
try
{
InitializeSchema();Check();
var temporary=marker+"."+Guid.NewGuid().ToString("N")+".tmp";
File.WriteAllText(temporary,Path.GetFileName(databasePath));File.Move(temporary,marker,true);
LastRecovery=new(previous,databasePath,error.ToString());
}
catch{databasePath=previous;throw;}
}
}
private void Check()
{
using var db=Open();using var command=db.CreateCommand();command.CommandText="PRAGMA quick_check(1)";
var health=command.ExecuteScalar() as string;
if(health!="ok")throw new InvalidDataException("事件索引完整性检查失败:"+health);
command.CommandText="SELECT count(*) FROM game_events";command.ExecuteScalar();
}
private void InitializeSchema()
{
using var db=Open();using var command=db.CreateCommand();command.CommandText="""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS game_events(id INTEGER PRIMARY KEY,time_ticks INTEGER NOT NULL,event TEXT NOT NULL,source TEXT NOT NULL,offset INTEGER NOT NULL,hash TEXT NOT NULL,json TEXT NOT NULL,UNIQUE(source,offset,hash));
CREATE INDEX IF NOT EXISTS game_events_time ON game_events(time_ticks DESC,id DESC);
CREATE INDEX IF NOT EXISTS game_events_event ON game_events(event);
""";command.ExecuteNonQuery();
}
public int Insert(IReadOnlyList<GameJournalRecord> rows)
{
using var db=Open();using var transaction=db.BeginTransaction();using var command=db.CreateCommand();command.Transaction=transaction;
command.CommandText="INSERT OR IGNORE INTO game_events(time_ticks,event,source,offset,hash,json) VALUES($time,$event,$source,$offset,$hash,$json)";
foreach(var key in new[]{"$time","$event","$source","$offset","$hash","$json"})command.Parameters.Add(new SqliteParameter(key,null));
var changed=0;
foreach(var row in rows)
{
command.Parameters["$time"].Value=row.Time.UtcTicks;command.Parameters["$event"].Value=row.Event;command.Parameters["$source"].Value=row.Source;command.Parameters["$offset"].Value=row.Offset;
command.Parameters["$hash"].Value=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(row.Json)));command.Parameters["$json"].Value=row.Json;changed+=command.ExecuteNonQuery();
}
transaction.Commit();return changed;
}
public IReadOnlyList<string> EventTypes()
{
using var db=Open();using var command=db.CreateCommand();
command.CommandText="SELECT DISTINCT event FROM game_events ORDER BY event COLLATE NOCASE";
using var reader=command.ExecuteReader();var result=new List<string>();
while(reader.Read())result.Add(reader.GetString(0));
return result;
}
public GameEventPage Query(string search="",GameEventCursor? before=null,int limit=200,string eventName="",bool oldestFirst=false)
{
limit=Math.Clamp(limit,1,500);using var db=Open();using var command=db.CreateCommand();
var term=search.Trim();if(term.Length>128)term=term[..128];var pattern="%"+term.Replace("!","!!").Replace("%","!%").Replace("_","!_")+"%";
const string match="($event='' OR event=$event) AND ($all=1 OR event LIKE $search ESCAPE '!' OR json LIKE $search ESCAPE '!' OR instr($aliases,'|'||event||'|')>0)";
command.Parameters.AddWithValue("$event",eventName);
command.Parameters.AddWithValue("$all",term.Length==0?1:0);command.Parameters.AddWithValue("$search",pattern);
command.Parameters.AddWithValue("$aliases",term.Length==0?"":GameEventNames.SearchAliases(term));
command.CommandText="SELECT count(*) FROM game_events WHERE "+match;var count=(long)command.ExecuteScalar()!;
var comparison=oldestFirst?">":"<";var direction=oldestFirst?"ASC":"DESC";
command.CommandText=$"SELECT id,time_ticks,event,json,source FROM game_events WHERE {match} AND ($before=0 OR time_ticks{comparison}$ticks OR (time_ticks=$ticks AND id{comparison}$id)) ORDER BY time_ticks {direction},id {direction} LIMIT $limit";
command.Parameters.AddWithValue("$before",before is null?0:1);command.Parameters.AddWithValue("$ticks",before?.Ticks??0);command.Parameters.AddWithValue("$id",before?.Id??0);command.Parameters.AddWithValue("$limit",limit+1);
using var reader=command.ExecuteReader();var result=new List<GameEventEntry>();
while(reader.Read())result.Add(new(reader.GetInt64(0),new DateTimeOffset(reader.GetInt64(1),TimeSpan.Zero),reader.GetString(2),reader.GetString(3),reader.GetString(4)));
var more=result.Count>limit;if(more)result.RemoveAt(result.Count-1);return new(result,more,count);
}
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace XFEEDToolBox.Core.Data;
public sealed record GameJournalRecord(string Source,long Offset,DateTimeOffset Time,string Event,string Json);
public sealed record GameEventCursor(long Ticks,long Id);
public sealed record GameEventEntry(long Id,DateTimeOffset Time,string Event,string Json,string Source)
{
public string TimeDisplay=>Time.ToLocalTime().ToString("MM-dd HH:mm:ss");
public string Name=>GameEventNames.Translate(Event);
public string Summary
{
get
{
using var doc=JsonDocument.Parse(Json);var root=doc.RootElement;
if(Event=="Status")
{
var flags=root.Long("Flags");var parts=new List<string>();
if((flags&(1L<<24))!=0)parts.Add("主船");
parts.Add((flags&8)!=0?"护盾正常":"护盾未启用");parts.Add((flags&1)!=0?"已停靠":(flags&2)!=0?"已着陆":(flags&(1L<<30))!=0?"正在跃迁":(flags&16)!=0?"超巡中":"常规空间");
if(root.Get("Fuel").Number("FuelMain") is {} fuel)parts.Add($"燃油 {fuel:N2} t");
if(root.Number("Balance") is {} cash)parts.Add($"余额 {cash:N0} Cr");
return string.Join(" · ",parts);
}
if(Event=="ReceiveText")return root.Text("Message_Localised",root.Text("Message"));
if(Event=="DockingDenied")return root.Text("Reason") switch{"TooLarge"=>"飞船尺寸过大,站点没有合适的停机坪。","NoSpace"=>"站点暂无空闲停机坪。","Distance"=>"距离站点过远。","Hostile"=>"站点拒绝敌对舰船停靠。",var reason=>"停靠被拒 · "+reason};
var summary=string.Join(" · ",root.EnumerateObject().Where(p=>p.Name is not("timestamp" or "event")&&p.Value.ValueKind is not(JsonValueKind.Object or JsonValueKind.Array)).Take(5).Select(p=>$"{GameEventNames.Field(p.Name)}: {p.Value}"));
return summary.Length>320?summary[..320]+"…":summary;
}
}
public GameEventCursor Cursor=>new(Time.UtcTicks,Id);
}
public sealed record GameEventPage(IReadOnlyList<GameEventEntry> Entries,bool HasMore,long Count);
public sealed record GameEventCacheRecovery(string PreservedDatabase,string RebuiltDatabase,string Failure);
public static class GameEventNames
{
private static readonly string[] known="LoadGame Fileheader FileHeader Commander Loadout Statistics Rank Progress Reputation Promotion Location FSDJump StartJump FSDTarget SupercruiseEntry SupercruiseExit Docked Undocked DockingRequested DockingGranted DockingDenied NavRoute NavRouteClear Scan FSSDiscoveryScan FSSAllBodiesFound SAAScanComplete ScanOrganic CodexEntry Materials MaterialCollected MaterialDiscarded EngineerCraft EngineerProgress EngineerContribution Cargo Market MarketBuy MarketSell ModuleBuy ModuleSell ModuleSwap ModuleStore ModuleRetrieve StoredModules StoredShips FuelScoop RefuelAll RefuelPartial Status ShieldState HullDamage UnderAttack Interdicted Bounty ShipTargeted Died Resurrect Shutdown Music ReceiveText SendText MissionAccepted MissionCompleted MissionFailed ShipLocker Backpack Disembark Embark Touchdown Liftoff".Split(' ');
public static string SearchAliases(string search)=>"|"+string.Join('|',known.Where(e=>Translate(e).Contains(search,StringComparison.OrdinalIgnoreCase)))+"|";
public static string Translate(string value)=>value switch
{
"LoadGame"=>"载入游戏","Fileheader" or "FileHeader"=>"会话记录头","Commander"=>"指挥官档案","Loadout"=>"飞船装配","Statistics"=>"玩家统计","Rank"=>"玩家等级","Progress"=>"等级进度","Reputation"=>"阵营声望","Promotion"=>"等级晋升","Location"=>"当前位置","FSDJump"=>"跃迁抵达","StartJump"=>"开始跃迁","FSDTarget"=>"跃迁目标","SupercruiseEntry"=>"进入超巡","SupercruiseExit"=>"退出超巡","Docked"=>"已停靠","Undocked"=>"离开停靠","DockingRequested"=>"请求停靠","DockingGranted"=>"获准停靠","DockingDenied"=>"停靠被拒","NavRoute"=>"游戏航线","NavRouteClear"=>"航线清除","Scan"=>"扫描天体","FSSDiscoveryScan"=>"发现扫描","FSSAllBodiesFound"=>"发现全部天体","SAAScanComplete"=>"地表测绘完成","ScanOrganic"=>"生物采样","CodexEntry"=>"发现日志","Materials"=>"工程材料快照","MaterialCollected"=>"收集材料","MaterialDiscarded"=>"丢弃材料","EngineerCraft"=>"工程改装","EngineerProgress"=>"工程师进度","EngineerContribution"=>"工程师捐献","Cargo"=>"货舱快照","Market"=>"商品市场","MarketBuy"=>"买入商品","MarketSell"=>"卖出商品","ModuleBuy"=>"买入模块","ModuleSell"=>"卖出模块","ModuleSwap"=>"交换模块","ModuleStore"=>"寄存模块","ModuleRetrieve"=>"取回模块","StoredModules"=>"寄存模块快照","StoredShips"=>"飞船仓库","FuelScoop"=>"恒星吸油","RefuelAll"=>"加满燃油","RefuelPartial"=>"补充燃油","Status"=>"座舱状态","ShieldState"=>"护盾状态","HullDamage"=>"船体损伤","UnderAttack"=>"遭到攻击","Interdicted"=>"遭到拦截","Bounty"=>"赏金收入","ShipTargeted"=>"锁定舰船","Died"=>"死亡","Resurrect"=>"重新部署","Shutdown"=>"游戏会话结束","Music"=>"背景音乐","ReceiveText"=>"收到游戏通讯","SendText"=>"发送游戏通讯","MissionAccepted"=>"接受任务","MissionCompleted"=>"完成任务","MissionFailed"=>"任务失败","ShipLocker"=>"飞船储物柜","Backpack"=>"背包快照","Disembark"=>"下船","Embark"=>"登船","Touchdown"=>"着陆","Liftoff"=>"起飞",_=>"游戏事件"
};
public static string Field(string key)=>key switch{"StarSystem"=>"星系","BodyName"=>"天体","StationName"=>"站点","StationType"=>"站点类型","SystemAddress"=>"星系编号","MarketID"=>"市场编号","JumpDist"=>"距离","FuelLevel"=>"燃油","ShieldsUp"=>"护盾恢复","Level"=>"等级","Quality"=>"完成度","Credits" or "Balance"=>"余额","Count"=>"数量","Cost"=>"费用","Message_Localised" or "Message"=>"内容","From"=>"发件人","Type"=>"类型","Reason"=>"原因","Channel"=>"频道","Ship"=>"飞船","ShipID"=>"飞船编号","ShipName"=>"船名","Engineer"=>"工程师","BlueprintName"=>"工程蓝图","Health"=>"完整度","MusicTrack"=>"曲目","Name"=>"名称","Commander"=>"指挥官",_=>key};
}
public sealed class GameEventRepository(string path)
{
private readonly string originalPath=Path.GetFullPath(path);
private string databasePath=Path.GetFullPath(path);
public string DatabasePath=>databasePath;
public GameEventCacheRecovery? LastRecovery{get;private set;}
private SqliteConnection Open(){var connection=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=databasePath,Pooling=true}.ToString());try{connection.Open();return connection;}catch{connection.Dispose();throw;}}
public void Initialize()
{
var marker=originalPath+".active";
if(File.Exists(marker))
{
var name=File.ReadAllText(marker).Trim();
if(name!=Path.GetFileName(name)||!name.StartsWith(Path.GetFileName(originalPath)+".rebuilt-",StringComparison.Ordinal)||!name.EndsWith(".db",StringComparison.Ordinal))
throw new InvalidDataException("事件索引恢复记录中的文件名无效。");
databasePath=Path.Combine(Path.GetDirectoryName(originalPath)!,name);
}
var previous=databasePath;
try{InitializeSchema();Check();return;}
catch(Exception error)when(error is InvalidDataException||error is SqliteException sql&&sql.SqliteErrorCode is 11 or 26)
{
// This is a derived index. Preserve the damaged database and its WAL
// in place, then let JournalMonitor replay the original game files
// into a separate healthy generation before the first UI queries.
using(var pool=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=previous,Pooling=true}.ToString()))SqliteConnection.ClearPool(pool);
databasePath=originalPath+".rebuilt-"+DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss")+"-"+Guid.NewGuid().ToString("N")[..8]+".db";
try
{
InitializeSchema();Check();
var temporary=marker+"."+Guid.NewGuid().ToString("N")+".tmp";
File.WriteAllText(temporary,Path.GetFileName(databasePath));File.Move(temporary,marker,true);
LastRecovery=new(previous,databasePath,error.ToString());
}
catch{databasePath=previous;throw;}
}
}
private void Check()
{
using var db=Open();using var command=db.CreateCommand();command.CommandText="PRAGMA quick_check(1)";
var health=command.ExecuteScalar() as string;
if(health!="ok")throw new InvalidDataException("事件索引完整性检查失败:"+health);
command.CommandText="SELECT count(*) FROM game_events";command.ExecuteScalar();
}
private void InitializeSchema()
{
using var db=Open();using var command=db.CreateCommand();command.CommandText="""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS game_events(id INTEGER PRIMARY KEY,time_ticks INTEGER NOT NULL,event TEXT NOT NULL,source TEXT NOT NULL,offset INTEGER NOT NULL,hash TEXT NOT NULL,json TEXT NOT NULL,UNIQUE(source,offset,hash));
CREATE INDEX IF NOT EXISTS game_events_time ON game_events(time_ticks DESC,id DESC);
CREATE INDEX IF NOT EXISTS game_events_event ON game_events(event);
""";command.ExecuteNonQuery();
}
public int Insert(IReadOnlyList<GameJournalRecord> rows)
{
using var db=Open();using var transaction=db.BeginTransaction();using var command=db.CreateCommand();command.Transaction=transaction;
command.CommandText="INSERT OR IGNORE INTO game_events(time_ticks,event,source,offset,hash,json) VALUES($time,$event,$source,$offset,$hash,$json)";
foreach(var key in new[]{"$time","$event","$source","$offset","$hash","$json"})command.Parameters.Add(new SqliteParameter(key,null));
var changed=0;
foreach(var row in rows)
{
command.Parameters["$time"].Value=row.Time.UtcTicks;command.Parameters["$event"].Value=row.Event;command.Parameters["$source"].Value=row.Source;command.Parameters["$offset"].Value=row.Offset;
command.Parameters["$hash"].Value=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(row.Json)));command.Parameters["$json"].Value=row.Json;changed+=command.ExecuteNonQuery();
}
transaction.Commit();return changed;
}
public IReadOnlyList<string> EventTypes()
{
using var db=Open();using var command=db.CreateCommand();
command.CommandText="SELECT DISTINCT event FROM game_events ORDER BY event COLLATE NOCASE";
using var reader=command.ExecuteReader();var result=new List<string>();
while(reader.Read())result.Add(reader.GetString(0));
return result;
}
public GameEventPage Query(string search="",GameEventCursor? before=null,int limit=200,string eventName="",bool oldestFirst=false)
{
limit=Math.Clamp(limit,1,500);using var db=Open();using var command=db.CreateCommand();
var term=search.Trim();if(term.Length>128)term=term[..128];var pattern="%"+term.Replace("!","!!").Replace("%","!%").Replace("_","!_")+"%";
const string match="($event='' OR event=$event) AND ($all=1 OR event LIKE $search ESCAPE '!' OR json LIKE $search ESCAPE '!' OR instr($aliases,'|'||event||'|')>0)";
command.Parameters.AddWithValue("$event",eventName);
command.Parameters.AddWithValue("$all",term.Length==0?1:0);command.Parameters.AddWithValue("$search",pattern);
command.Parameters.AddWithValue("$aliases",term.Length==0?"":GameEventNames.SearchAliases(term));
command.CommandText="SELECT count(*) FROM game_events WHERE "+match;var count=(long)command.ExecuteScalar()!;
var comparison=oldestFirst?">":"<";var direction=oldestFirst?"ASC":"DESC";
command.CommandText=$"SELECT id,time_ticks,event,json,source FROM game_events WHERE {match} AND ($before=0 OR time_ticks{comparison}$ticks OR (time_ticks=$ticks AND id{comparison}$id)) ORDER BY time_ticks {direction},id {direction} LIMIT $limit";
command.Parameters.AddWithValue("$before",before is null?0:1);command.Parameters.AddWithValue("$ticks",before?.Ticks??0);command.Parameters.AddWithValue("$id",before?.Id??0);command.Parameters.AddWithValue("$limit",limit+1);
using var reader=command.ExecuteReader();var result=new List<GameEventEntry>();
while(reader.Read())result.Add(new(reader.GetInt64(0),new DateTimeOffset(reader.GetInt64(1),TimeSpan.Zero),reader.GetString(2),reader.GetString(3),reader.GetString(4)));
var more=result.Count>limit;if(more)result.RemoveAt(result.Count-1);return new(result,more,count);
}
}