test(upserver): 归档存档迁移与玩法闭环回归工具
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
|
||||
<DefineConstants>$(DefineConstants);SERVICE_HOST;UNITY_EDITOR</DefineConstants>
|
||||
<DefineConstants Condition="'$(AndroidProbe)'=='true'">$(DefineConstants);UNITY_ANDROID;AGG</DefineConstants>
|
||||
<DefineConstants Condition="'$(PlayerProbe)'=='true'">SERVICE_HOST</DefineConstants>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
|
||||
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs" Link="Production/PlayerKVGameSessionBridge.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs" Link="Production/PlayerKVSyncModels.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs" Link="Production/PlayerKVCacheStore.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs" Link="Production/PlayerKVSyncState.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs" Link="Production/PlayerKVSyncCoordinator.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs" Link="Production/PlayerKVSessionOwner.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs" Link="Production/PlayerKVLocalReadResult.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" Link="Production/S101AccountSession.cs" />
|
||||
<Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
|
||||
<Compile Include="../../../Client/Assets/Scripts/Core/PlayFabMgr.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs" Condition="Exists('../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs')" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
88
CodexOutput/upserver/KVSaveRouteTests/Program.cs
Normal file
88
CodexOutput/upserver/KVSaveRouteTests/Program.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
partial class RouteTests
|
||||
{
|
||||
internal static readonly string Root=Path.GetFullPath(Path.Combine("CodexOutput/upserver/KVSaveRouteTests/runs",DateTime.UtcNow.ToString("yyyyMMddHHmmss")+"_"+Guid.NewGuid().ToString("N")));
|
||||
static readonly Uri Api=new("http://kv-route.invalid/");
|
||||
static int assertions,failed; static readonly List<object> Results=new();
|
||||
static async Task<int> Main(string[] args) {
|
||||
Directory.CreateDirectory(Root);
|
||||
if(args.Contains("--diagnostic")) AppDomain.CurrentDomain.FirstChanceException += (_,e)=>{
|
||||
if(e.Exception is IOException || e.Exception is UnauthorizedAccessException)
|
||||
Console.WriteLine("IO DIAGNOSTIC "+e.Exception);
|
||||
};
|
||||
await Run("C1-ReadKV-contract",ReadContract);
|
||||
if(args.Contains("--all")) await More();
|
||||
File.WriteAllText(Path.Combine(Root,"result.json"),System.Text.Json.JsonSerializer.Serialize(new{groups=Results.Count,assertions,failed,results=Results},new System.Text.Json.JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine($"RESULT groups={Results.Count} assertions={assertions} failed={failed} run={Root}");return failed==0?0:1;
|
||||
}
|
||||
static async Task Run(string name,Func<Task> action) { int before=assertions;try{await action();Results.Add(new{name,passed=true,assertions=assertions-before});Console.WriteLine("PASS "+name);}catch(Exception e){failed++;Results.Add(new{name,passed=false,error=e.ToString()});Console.WriteLine("FAIL "+name+" "+e);} }
|
||||
static void Check(bool ok,string message){Interlocked.Increment(ref assertions);if(!ok)throw new InvalidOperationException(message);}
|
||||
static object Call(string name,params object[] args) {
|
||||
var method=typeof(PlayFabMgr).GetMethod(name,BindingFlags.Static|BindingFlags.NonPublic);
|
||||
Check(method!=null,"Required fixed access missing: "+name);
|
||||
try{return method.Invoke(null,args);}catch(TargetInvocationException e){ExceptionDispatchInfo.Capture(e.InnerException).Throw();throw;}
|
||||
}
|
||||
static dynamic Read(PlayerKVGameSessionHandle h,string key)=>Call("ReadKV",h,key);
|
||||
static void ReadIs(dynamic r,string error,bool? found,string value){Check(r.Error.ToString()==error,"read error "+error);Check((bool?)r.Found==found,"found tri-state");Check((string)r.Value==value,"read value");}
|
||||
static async Task ReadContract(){
|
||||
// Missing method fails this behavioral test before implementation, without a compiler error.
|
||||
ReadIs(Read(null,"k"),"InvalidInput",null,null);
|
||||
await using var f=new Fixture(); var h=await f.Open();
|
||||
ReadIs(Read(h,"k"),"None",true,"base");ReadIs(Read(h,"missing"),"None",false,null);ReadIs(Read(h,"empty"),"None",true,"");
|
||||
ReadIs(Read(h,"K"),"None",false,null);
|
||||
foreach(var key in new[]{" ","姹夊瓧",new string('x',128)})ReadIs(Read(h,key),"None",false,null);
|
||||
foreach(var key in new[]{null,"",new string('x',129)})ReadIs(Read(h,key),"InvalidInput",null,null);
|
||||
Check(f.Fake.Gets==1,"reads do not get network");
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"own guard");
|
||||
try{ReadIs(Read(null,null),"LoadGuardActive",null,null);Check(guard.Count(PlayerKVGuardPoint.ReadLocal)==1,"guard precedes input");}finally{guard.Dispose();}
|
||||
await f.Bridge.CloseAsync();ReadIs(Read(h,"k"),"StaleOrClosing",null,null);
|
||||
Check(UnityEngine.PlayerPrefs.Calls==0&&PlayFab.PlayFabClientAPI.Calls==0,"no PF/Prefs");
|
||||
}
|
||||
sealed class Handler:HttpMessageHandler {
|
||||
readonly Func<HttpRequestMessage,CancellationToken,Task<HttpResponseMessage>> send;
|
||||
internal Handler(Func<HttpRequestMessage,CancellationToken,Task<HttpResponseMessage>> send){this.send=send;}
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r,CancellationToken t)=>send(r,t);
|
||||
}
|
||||
static HttpResponseMessage Json(HttpStatusCode status,object o)=>new(status){Content=new StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(o))};
|
||||
sealed class Fake {
|
||||
internal readonly Dictionary<string,Dictionary<string,string>> Remote=new(){["101"]=new(){{"k","base"},{"empty",""}},["102"]=new(){{"k","B"}}};
|
||||
internal int Gets,Writes; internal bool DropResponse,Unauthorized;
|
||||
internal Func<HttpRequestMessage,CancellationToken,Task> Before;
|
||||
internal HttpMessageHandler Create(CookieContainer jar){var id=jar.GetCookies(Api)["sid"].Value;return new Handler(async(r,t)=>{
|
||||
Check(r.RequestUri.Host=="kv-route.invalid"&&r.RequestUri.Port==80,"synthetic host only");
|
||||
var path=r.RequestUri.AbsolutePath;Check(path is "/playerdata/Get" or "/playerdata/Set" or "/playerdata/Delete","fixed KV path");
|
||||
if(path.EndsWith("Get"))Interlocked.Increment(ref Gets);else Interlocked.Increment(ref Writes);
|
||||
if(Before!=null)await Before(r,t);
|
||||
if(Unauthorized)return Json(HttpStatusCode.Unauthorized,new{});
|
||||
var body=JObject.Parse(await r.Content.ReadAsStringAsync());
|
||||
lock(Remote){
|
||||
if(path.EndsWith("Get"))return Json(HttpStatusCode.OK,new{data=new Dictionary<string,string>(Remote[id])});
|
||||
if(path.EndsWith("Set"))foreach(var p in ((JObject)body["data"]).Properties())Remote[id][p.Name]=(string)p.Value;
|
||||
else foreach(var k in body["keys"].Values<string>())Remote[id].Remove(k);
|
||||
}
|
||||
if(DropResponse)throw new HttpRequestException("Synthetic response lost after apply");
|
||||
return Json(HttpStatusCode.OK,new{});
|
||||
});}
|
||||
}
|
||||
sealed class Fixture:IAsyncDisposable {
|
||||
internal readonly string Cache=Path.Combine(Root,Guid.NewGuid().ToString("N"));
|
||||
internal readonly Fake Fake=new(); internal readonly PlayerKVGameSessionBridge Bridge;
|
||||
readonly List<S101AccountSession> sessions=new(); internal long Current=1; internal bool ThrowCurrent;
|
||||
internal Fixture(){Directory.CreateDirectory(Cache);Bridge=new PlayerKVGameSessionBridge(Fake.Create);}
|
||||
internal async Task<PlayerKVGameSessionHandle> Open(string id="101"){
|
||||
var s=new S101AccountSession(Api,jar=>new Handler((r,t)=>{
|
||||
Check(r.RequestUri.Host=="kv-route.invalid"&&r.RequestUri.AbsolutePath=="/account/Login","synthetic auth only");jar.Add(Api,new Cookie("sid",id));
|
||||
return Task.FromResult(Json(HttpStatusCode.OK,new{state=0,internalId=int.Parse(id),newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"}));}));
|
||||
sessions.Add(s);Check((await s.LoginAsync(id)).Success,"login");
|
||||
var result=await Bridge.OpenAsync(s,Cache,id,Api,Current,e=>ThrowCurrent?throw new InvalidOperationException("current-predicate-fault"):e==Current);
|
||||
Check(result.Success,"open "+result.Error);return result.Handle;
|
||||
}
|
||||
public async ValueTask DisposeAsync(){await Bridge.CloseAsync();foreach(var s in sessions)s.Dispose();}
|
||||
}
|
||||
}
|
||||
31
CodexOutput/upserver/KVSaveRouteTests/Stubs.cs
Normal file
31
CodexOutput/upserver/KVSaveRouteTests/Stubs.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
namespace asap.core { public sealed class UnusedNamespaceMarker {} }
|
||||
namespace UnityEngine {
|
||||
public class MonoBehaviour {}
|
||||
public static class Application { public static string persistentDataPath => RouteTests.Root; }
|
||||
public static class Debug { public static void LogError(object o) {} }
|
||||
public static class PlayerPrefs {
|
||||
public static int Calls;
|
||||
public static string GetString(string key,string value="") { Calls++;return value; }
|
||||
public static void SetString(string key,string value) { Calls++; }
|
||||
public static void Save() { Calls++; }
|
||||
}
|
||||
}
|
||||
public static class GameDebug { public static void LogError(string s,params object[] a) {} }
|
||||
public static class ZZTimeHelper { public static DateTime UtcNow()=>DateTime.UtcNow; }
|
||||
namespace LitJson { public static class JsonMapper { public static string ToJson(object o)=>Newtonsoft.Json.JsonConvert.SerializeObject(o); } }
|
||||
namespace PlayFab.ClientModels {
|
||||
public sealed class UserDataRecord { public DateTime LastUpdated; public string Value; }
|
||||
public enum UserDataPermission { Public }
|
||||
public sealed class UpdateUserDataRequest { public Dictionary<string,string> Data; public UserDataPermission Permission; }
|
||||
public sealed class UpdateUserDataResult {}
|
||||
}
|
||||
namespace PlayFab {
|
||||
public sealed class PlayFabError {}
|
||||
public static class PlayFabClientAPI {
|
||||
public static int Calls;
|
||||
public static bool IsClientLoggedIn(){Calls++;return false;}
|
||||
public static void UpdateUserData(ClientModels.UpdateUserDataRequest r,Action<ClientModels.UpdateUserDataResult> ok,Action<PlayFabError> error){Calls++;throw new InvalidOperationException("Unexpected PF call");}
|
||||
}
|
||||
}
|
||||
137
CodexOutput/upserver/KVSaveRouteTests/WriteCases.cs
Normal file
137
CodexOutput/upserver/KVSaveRouteTests/WriteCases.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
partial class RouteTests
|
||||
{
|
||||
static PlayerKVEditResult Write(PlayerKVGameSessionHandle h,string k,string v,bool remote=true)=>(PlayerKVEditResult)Call("WriteKV",h,k,v,remote);
|
||||
static Task<PlayerKVFlushResult> Flush(PlayerKVGameSessionHandle h,CancellationToken t=default)=>(Task<PlayerKVFlushResult>)Call("FlushKVAsync",h,t);
|
||||
static Task<PlayerKVTicketResult> Ticket(PlayerKVGameSessionHandle h,PlayerKVSyncTicket k,CancellationToken t=default)=>(Task<PlayerKVTicketResult>)Call("WaitForKVTicketAsync",h,k,t);
|
||||
static TaskCompletionSource<bool> Gate()=>new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
static async Task More(){
|
||||
await Run("C2-write-localonly-and-existing-pending",LocalOnly);
|
||||
await Run("C2-input-and-guard-priority",Guards);
|
||||
await Run("C2-real-storage-error-cleanup",Storage);
|
||||
await Run("C2-conflict-and-unknown-read",NonReady);
|
||||
await Run("C2-superseded-and-watermark",Watermark);
|
||||
await Run("C2-response-lost-and-401",TransportFaults);
|
||||
await Run("C2-cancelled-wait-does-not-cancel-drain",CancelDrain);
|
||||
await Run("C2-ticket-cancellation-and-foreign-owner",TicketOwnership);
|
||||
await Run("C2-stale-cross-account-and-same-account",Stale);
|
||||
await Run("C2-close-versus-write-admission",CloseRace);
|
||||
await Run("C2-unexpected-exception-not-disguised",Unexpected);
|
||||
await Run("C3-internal-static-surface",Surface);
|
||||
}
|
||||
static async Task LocalOnly(){
|
||||
await using var f=new Fixture();var h=await f.Open();
|
||||
var remote=Write(h,"k","remote");Check(remote.Accepted&&remote.Ticket!=null&&f.Fake.Writes==0,"accepted locally only: "+remote.StateResult.Error+" uncertain="+remote.StateResult.LocalCommitMayHaveSucceeded+" cache="+f.Cache);
|
||||
var local=Write(h,"k","local",false);Check(local.Accepted&&local.Ticket==null,"localonly no ticket");
|
||||
ReadIs(Read(h,"k"),"None",true,"local");await Flush(h);
|
||||
Check(f.Fake.Remote["101"]["k"]=="remote","localonly does not erase remote pending");ReadIs(Read(h,"k"),"None",true,"local");
|
||||
Check((await Ticket(h,remote.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"original remote ticket confirmed");
|
||||
Check(Write(h,"k","newRemote").Accepted,"remote after localonly");ReadIs(Read(h,"k"),"None",true,"newRemote");await Flush(h);
|
||||
Check(f.Fake.Remote["101"]["k"]=="newRemote","remote clears localonly shadow");
|
||||
Check(!Write(h,"k",null).Accepted&&Write(h,"","").StateResult.Error==PlayerKVChangeError.Validation,"invalid values");
|
||||
Check(Write(h,"k","").Accepted,"empty string is value");await Flush(h);Check(f.Fake.Remote["101"].ContainsKey("k")&&f.Fake.Remote["101"]["k"]=="","not deleted");
|
||||
}
|
||||
static async Task Guards(){
|
||||
Check(Write(null,"k","v").StateResult.Error==PlayerKVChangeError.Validation,"null write validation");
|
||||
Check((await Flush(null)).TransportError==PlayerKVError.Validation,"null flush validation");
|
||||
Check((await Ticket(null,null)).TransportError==PlayerKVError.Validation,"null wait validation");
|
||||
await using var f=new Fixture();var h=await f.Open();Check(PlayerKVGameLoadGuard.TryEnter(out var g),"own guard");
|
||||
try{
|
||||
ReadIs(Read(h,"k"),"LoadGuardActive",null,null);
|
||||
Check(Write(null,null,null).StateResult.Error==PlayerKVChangeError.Blocked,"guard before input");
|
||||
var flush=await Flush(h);Check(flush.Blocked==1&&flush.Confirmed==0&&flush.Unknown==0&&flush.Superseded==0&&flush.TransportError==PlayerKVError.Cancelled,"guard flush");
|
||||
Check((await Ticket(h,null)).Status==PlayerKVTicketStatus.Blocked,"guard wait");
|
||||
Check(g.Violations==4&&g.Count(PlayerKVGuardPoint.ReadLocal)==1&&g.Count(PlayerKVGuardPoint.Update)==1&&g.Count(PlayerKVGuardPoint.Sync)==2,"guard exact counters");
|
||||
Check(f.Fake.Writes==0&&f.Fake.Gets==1,"no network when guarded");
|
||||
}finally{g.Dispose();}
|
||||
Check(UnityEngine.PlayerPrefs.Calls==0&&PlayFab.PlayFabClientAPI.Calls==0,"no legacy side effects");
|
||||
}
|
||||
static async Task Storage(){
|
||||
await using var f=new Fixture();var h=await f.Open();var temporary=Directory.GetFiles(f.Cache,"state.json",SearchOption.AllDirectories).Single()+".tmp";
|
||||
Check(Path.GetFullPath(temporary).StartsWith(Path.GetFullPath(f.Cache)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase),"own temporary path");
|
||||
Directory.CreateDirectory(temporary);
|
||||
try{
|
||||
var result=Write(h,"k","fault");Check(!result.Accepted&&result.StateResult.Error==PlayerKVChangeError.StorageError,"real IO storage failure");
|
||||
Check(!result.StateResult.LocalCommitMayHaveSucceeded&&result.StateResult.AttemptSequence>0&&result.StateResult.Revision>0&&result.Ticket==null,"original storage fields");
|
||||
ReadIs(Read(h,"k"),"NotReady",null,null);Check(f.Fake.Writes==0,"no storage retry/send");
|
||||
}finally{Directory.Delete(temporary,false);Check(!Directory.Exists(temporary),"P3-1 no fault directory remains");}
|
||||
}
|
||||
static async Task NonReady(){
|
||||
foreach(var unknown in new[]{false,true}){
|
||||
await using var f=new Fixture();var seeded=PlayerKVSyncState.Open(f.Cache,Api,"101");Check(seeded.Success,"seed cache");
|
||||
using(var state=seeded.State){state.Reconcile(new Dictionary<string,string>{{"k","base"}});state.Update("k","pending");if(unknown){state.BeginBatch(long.MaxValue,out var batch);state.FailBatch(batch.Id,true);}}
|
||||
f.Fake.Remote["101"]["k"]="conflicting";var h=await f.Open();Check(h.Status==(unknown?PlayerKVSyncStatus.UnknownOutcome:PlayerKVSyncStatus.Conflict),"nonready actual state");
|
||||
ReadIs(Read(h,"k"),"NotReady",null,null);Check(!Write(h,"k","blocked").Accepted,"notready blocks write");
|
||||
}
|
||||
}
|
||||
static async Task Watermark(){
|
||||
await using var f=new Fixture();var h=await f.Open();var first=Write(h,"k","v1");var second=Write(h,"k","v2");var summary=await Flush(h);
|
||||
Check((await Ticket(h,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"superseded not confirmed");Check((await Ticket(h,second.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"new ticket confirmed");Check(summary.Superseded==1&&summary.Confirmed==1,"flush counts");
|
||||
var entered=Gate();var release=Gate();f.Fake.Before=async(r,t)=>{if(r.RequestUri.AbsolutePath.EndsWith("Set")){entered.TrySetResult(true);await release.Task;}};
|
||||
var a=Write(h,"k","before");var flush=Flush(h);await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));var b=Write(h,"k","after");release.TrySetResult(true);await flush;
|
||||
Check(f.Fake.Remote["101"]["k"]=="before","old watermark excludes later edit");Check((await Ticket(h,a.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"first dispatched ticket");
|
||||
await Flush(h);Check((await Ticket(h,b.Ticket)).Status==PlayerKVTicketStatus.Confirmed&&f.Fake.Remote["101"]["k"]=="after","next watermark confirms later ticket");
|
||||
}
|
||||
static async Task TransportFaults(){
|
||||
foreach(var unauthorized in new[]{false,true}){
|
||||
await using var f=new Fixture();var h=await f.Open();f.Fake.Unauthorized=unauthorized;f.Fake.DropResponse=!unauthorized;
|
||||
var edit=Write(h,"k","attempt");var result=await Flush(h);var ticket=await Ticket(h,edit.Ticket);
|
||||
Check(ticket.Status==PlayerKVTicketStatus.Unknown&&result.Unknown>0,"uncertain transport preserved");
|
||||
Check(f.Fake.Writes==1,"no automatic resend");Check(!Read(h,"k").Success&&Read(h,"k").Found==null,"failure not default save");
|
||||
if(!unauthorized)Check(f.Fake.Remote["101"]["k"]=="attempt","lost response after remote applied");
|
||||
}
|
||||
}
|
||||
static async Task CancelDrain(){
|
||||
await using var f=new Fixture();var h=await f.Open();var entered=Gate();var release=Gate();
|
||||
f.Fake.Before=async(r,t)=>{if(r.RequestUri.AbsolutePath.EndsWith("Set")){entered.TrySetResult(true);await release.Task;}};
|
||||
Write(h,"k","held");using var c=new CancellationTokenSource();var wait=Flush(h,c.Token);await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));c.Cancel();
|
||||
var result=await wait;Check(result.TransportError==PlayerKVError.Cancelled,"cancel observer");
|
||||
var close=f.Bridge.CloseAsync();try{Check(!close.IsCompleted,"close waits ignored cancellation transport");Check(!Write(h,"k","late").Accepted,"synchronous close revocation");}finally{release.TrySetResult(true);}
|
||||
await close.WaitAsync(TimeSpan.FromSeconds(10));Check(f.Fake.Writes==1,"no retry from cancelled observer");
|
||||
await using var f2=new Fixture();var h2=await f2.Open();var edit=Write(h2,"k","pre-cancel");using var pre=new CancellationTokenSource();pre.Cancel();
|
||||
var cancelled=await Flush(h2,pre.Token);Check(cancelled.TransportError==PlayerKVError.Cancelled,"pre-cancel observer");await f2.Bridge.CloseAsync();Check(f2.Fake.Writes<=1,"pre-cancel no retry, may dispatch");
|
||||
}
|
||||
static async Task TicketOwnership(){
|
||||
await using var f=new Fixture();var h=await f.Open();var edit=Write(h,"k","v");using var c=new CancellationTokenSource();c.Cancel();
|
||||
Check((await Ticket(h,edit.Ticket,c.Token)).Status==PlayerKVTicketStatus.Cancelled&&f.Fake.Writes==0,"ticket cancellation no flush");
|
||||
Check((await Ticket(h,null)).TransportError==PlayerKVError.Validation,"null ticket");
|
||||
await using var other=new Fixture();var otherHandle=await other.Open();Check((await Ticket(otherHandle,edit.Ticket)).TransportError==PlayerKVError.Validation,"foreign owner same player/epoch");
|
||||
await Flush(h);Check((await Ticket(h,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"cancel did not erase ticket");
|
||||
}
|
||||
static async Task Stale(){
|
||||
foreach(var nextId in new[]{"101","102"}){
|
||||
await using var f=new Fixture();var a=await f.Open();var edit=Write(a,"k","A");await Flush(a);Check((await Ticket(a,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"A historical confirmed");
|
||||
await f.Bridge.CloseAsync();f.Current++;var b=await f.Open(nextId);var expected=(string)Read(b,"k").Value;var writes=f.Fake.Writes;var gets=f.Fake.Gets;
|
||||
ReadIs(Read(a,"k"),"StaleOrClosing",null,null);Check(!Write(a,"k","bad").Accepted,"old write blocked");Check((await Flush(a)).Blocked>0,"old flush blocked");
|
||||
Check((await Ticket(b,edit.Ticket)).Status==PlayerKVTicketStatus.Blocked,"A ticket not B authority");Check((await Ticket(a,edit.Ticket)).Status==PlayerKVTicketStatus.Blocked,"old handle cannot observe as current");
|
||||
var businessMemory=expected;var lateRead=Read(a,"k");if(lateRead.Success)businessMemory="late mutation";
|
||||
Check(businessMemory==expected,"sample callback rejects stale authority before changing shared memory");
|
||||
ReadIs(Read(b,"k"),"None",true,expected);Check(f.Fake.Writes==writes&&f.Fake.Gets==gets,"B requests and cache unchanged");Check(a.OwnerInstanceId!=b.OwnerInstanceId&&a.Epoch!=b.Epoch,"new owner and epoch");
|
||||
}
|
||||
}
|
||||
static async Task CloseRace(){
|
||||
for(int i=0;i<16;i++){
|
||||
await using var f=new Fixture();var h=await f.Open();var begin=Gate();var edit=Task.Run(async()=>{await begin.Task;return Write(h,"k","race");});var close=Task.Run(async()=>{await begin.Task;await f.Bridge.CloseAsync();});
|
||||
var read=Task.Run(async()=>{await begin.Task;return (object)Read(h,"k");});
|
||||
begin.SetResult(true);await Task.WhenAll(edit,close,read);Check(edit.Result.Accepted||edit.Result.StateResult.Error==PlayerKVChangeError.Blocked,"accepted on A or refused, no false compensation");
|
||||
dynamic observed=read.Result;Check(observed.Error==PlayerKVKeyReadError.StaleOrClosing || (observed.Success && observed.Found==true && (observed.Value=="base" || observed.Value=="race")),"read racing close observes only admitted A snapshot or stale");
|
||||
Check(!Write(h,"k","after").Accepted&&f.Fake.Writes==0,"late writes refused/no network");
|
||||
}
|
||||
}
|
||||
static async Task Unexpected(){
|
||||
await using var f=new Fixture();var h=await f.Open();f.ThrowCurrent=true;
|
||||
try{foreach(var action in new Action[]{()=>Read(h,"k"),()=>Write(h,"k","v"),()=>Flush(h),()=>Ticket(h,null)}){
|
||||
bool threw=false;try{action();}catch(InvalidOperationException e){threw=e.Message=="current-predicate-fault";}Check(threw,"unexpected fault must not become default/false success");
|
||||
}}finally{f.ThrowCurrent=false;}
|
||||
}
|
||||
static Task Surface(){
|
||||
foreach(var name in new[]{"ReadKV","WriteKV","FlushKVAsync","WaitForKVTicketAsync"}){
|
||||
var m=typeof(PlayFabMgr).GetMethod(name,System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Static);
|
||||
Check(m!=null&&m.IsAssembly&&m.IsStatic,"internal static ordinary method "+name);
|
||||
}
|
||||
Check(typeof(PlayFabMgr).Assembly.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost")==null,"editor host absent from service/player probe");
|
||||
Check(typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic).GetValue(null)==null,"never instantiated PF component");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
45
CodexOutput/upserver/KVSaveRouteTests/audit_delivery.py
Normal file
45
CodexOutput/upserver/KVSaveRouteTests/audit_delivery.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Read-only scope audit against reviewed baselines; writes evidence in this directory."""
|
||||
from pathlib import Path
|
||||
import hashlib, json, re
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[2]
|
||||
UP = HERE.parent
|
||||
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest().upper()
|
||||
def read(path): return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
def changed(entries):
|
||||
return [p for p,h in entries.items() if not (ROOT/p).exists() or sha(ROOT/p) != h.upper()]
|
||||
|
||||
inventory = read(UP/'B3DesignAudit/source-hashes.json')
|
||||
design = read(UP/'阶段4B3方案受审基线-2026-09-24.json')
|
||||
b2 = read(UP/'KVGameLoadTests/checkpoint4-delivery-hashes.json')
|
||||
pf = 'Client/Assets/Scripts/Core/PlayFabMgr.cs'
|
||||
current = (ROOT/pf).read_bytes()
|
||||
begin = b'\r\n // BEGIN KV explicit fixed-handle access\r\n'
|
||||
end = b' // END KV explicit fixed-handle access\r\n'
|
||||
assert current.count(begin) == current.count(end) == 1
|
||||
start = current.index(begin)
|
||||
finish = current.index(end) + len(end)
|
||||
old_equal = current[:start] + current[finish:] == (HERE/'PlayFabMgr.before.txt').read_bytes()
|
||||
callers = {}
|
||||
for path in (ROOT/'Client/Assets/Scripts').rglob('*.cs'):
|
||||
# Identifier-only scan: Latin-1 preserves ASCII in both legacy GBK and UTF-8 sources.
|
||||
lines = [i for i,s in enumerate(path.read_bytes().decode('latin-1').splitlines(),1)
|
||||
if re.search(r'\b(?:ReadKV|WriteKV|FlushKVAsync|WaitForKVTicketAsync)\s*\(',s)]
|
||||
if lines: callers[path.relative_to(ROOT).as_posix()] = lines
|
||||
new = sorted(p.relative_to(ROOT).as_posix() for p in (ROOT/'Client/Assets/Scripts').rglob('*.cs')
|
||||
if p.relative_to(ROOT).as_posix() not in inventory)
|
||||
result = dict(sourceBaselineCount=len(inventory), sourceChanged=changed(inventory), newScriptSources=new,
|
||||
oldPlayFabBytesUnchanged=old_equal,
|
||||
designMaterialChanged=changed({f['path']:f['sha256'] for f in design['files']}),
|
||||
oldB2DeliveryChanged=changed({f['path']:f['sha256'] for f in b2['files']}),
|
||||
facadeReferences=callers,
|
||||
limitation='Scope is reviewed baselines, not all pre-existing working tree changes. Static/conditional probes do not prove Unity Player linking or stripping.')
|
||||
assert result['sourceChanged'] == [pf], result
|
||||
assert result['designMaterialChanged'] == [pf], result
|
||||
assert result['oldB2DeliveryChanged'] == [pf], result
|
||||
assert old_equal
|
||||
assert len(new) == 2
|
||||
assert set(callers) == {pf,'Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs'}
|
||||
(HERE/'checkpoint4-boundary-audit.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
102
CodexOutput/upserver/KVSaveRouteTests/prepare_delivery.py
Normal file
102
CodexOutput/upserver/KVSaveRouteTests/prepare_delivery.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Main-session documentation checkpoint and immutable review package creation."""
|
||||
from pathlib import Path
|
||||
import hashlib, json, re
|
||||
from datetime import datetime, timezone
|
||||
HERE=Path(__file__).resolve().parent
|
||||
UP=HERE.parent
|
||||
ROOT=HERE.parents[2]
|
||||
def read(p): return json.loads(p.read_text(encoding='utf-8-sig'))
|
||||
def write(p,v): p.write_text(json.dumps(v,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest().upper()
|
||||
|
||||
checkpoint='''
|
||||
|
||||
## 2026-09-24 4B3A检查点4:限定自审、范围与交付封板(主会话部分)
|
||||
|
||||
自审补齐示例迟到回调共享内存不变与Read/Close竞争断言,新入口默认/Android/非Editor三条件最终各13组544断言、退出0;原检查点3的508断言日志保留。生产实现未随补测变动,故已通过的Unity与旧全量回归不重复执行。
|
||||
|
||||
2101项原源码仅PlayFabMgr变化;剥离新增块后原PF字节完全一致;39项设计材料与221项原B2交付也仅PF变化。新增Scripts仅键结果与Editor host,另加Editor转接及三个meta;业务调用、旧测试/工程、底层协议和场景资源未改。范围审计保留机器证据。
|
||||
|
||||
已形成交付矩阵、限定自审、独立实施复核提示词、冻结实施记录与交付SHA256清单;入口/状态/TODO/机器todoItem同步为“4B3A实现与自验完成,待独立实施复核”。B3A-19外部实施复核及主会话关闭裁决尚未完成,A/B3/G4未关闭,B/C不获编码批准。KVSync首败及3C L7历史问题保持开放;P2-α正式启用门保持。外部接受并经主会话核hash/逐条裁决后,若无需复审则自动进入B完整方案准备。
|
||||
'''
|
||||
for name in ['阶段4B3A实施记录-2026-09-24.md','开发日志.md']:
|
||||
path=UP/name
|
||||
assert '2026-09-24 4B3A检查点4:' not in path.read_text(encoding='utf-8-sig')
|
||||
with path.open('a',encoding='utf-8') as f: f.write(checkpoint)
|
||||
|
||||
current='''## 当前有效状态:4B3A实现与自验完成,待独立实施复核(2026-09-24)
|
||||
|
||||
4B3分期与A方案独立审查双PASS,主会话已核对39项材料+2101项源码hash,逐条裁决九项合同和四条P3并[批准限定A实施](阶段4B3分期与4B3A方案审查裁决与实施批准-2026-09-24.md)。检查点0~3及检查点4主会话交付部分已完成,见[交付矩阵](阶段4B3A交付与验收矩阵-2026-09-24.md)、[限定自审](阶段4B3A限定代码自审-2026-09-24.md)与[实施记录](阶段4B3A实施记录-2026-09-24.md)。
|
||||
|
||||
改动内容:PlayFabMgr新增固定句柄Read/Write/Flush/Wait四入口,新增键读取三态结果及两个Editor验证文件。2101项原源码仅PlayFabMgr变化,去掉新增块后旧PF字节完全一致;31业务模块、User/Agg/Context/Owner/Guard、旧工程和场景未改。其余新增文件是测试、验证日志与审查文档;没有接通普通玩法保存。
|
||||
|
||||
验证:新入口三个条件各13组544断言;Unity新入口34断言、B2-20真实31对象双epoch83断言,均恢复成功,编译0错误0警告;4B2离线75组1713断言、关闭故障21断言、服务双条件各8组73断言,4A/4B1及旧核心回归最终通过。**KVSync首轮85/86、重跑86/86,根因未定;旧3C L7历史问题仍开放。** NetworkTransport沿用旧依赖targets仅构建通过,未运行正式代理。
|
||||
|
||||
### 当前todoItem与责任
|
||||
|
||||
- [x] B3-Review:设计受审hash核对、逐条裁决及A实施批准。
|
||||
- [x] B3A-C0~C3:四入口/键结果/限定Editor宿主、故障竞争测试、Unity及全量回归。
|
||||
- [x] B3A-C4作者部分:范围/hash、限定自审、交付矩阵、送审提示词、每检查点日志。
|
||||
- [ ] **B3A-19:外部独立实施复核→主会话核hash/逐条裁决→必要修复→A正式关闭。** 当前待办在外部评审侧,按[实施复核提示词](其他AI审查提示词-4B3A实施-2026-09-24.md)落盘报告;审核者不修改共享状态。
|
||||
- [ ] B3B/B3C各自完整方案及独立评审,未批准编码。A关闭且无需复审后自动进入B完整方案准备,不再询问是否继续。
|
||||
- [ ] KVSync StorageError、3C L7根因定位;P2-α真实档专项独立审查与用户产品确认;4C、身份、tickets有界历史、4D/G4启用门。
|
||||
|
||||
**4B1/4B2已关闭;4B3A已实现但未关闭,整个4B3/4B/G4未通过。** 正式地址固定http://s101.local:18080/;本轮正式请求0,Unity仅CLI Loop,不用UnitySkills,无PlayMode、打包或Git提交。封板见[交付hash](KVSaveRouteTests/checkpoint4-delivery-hashes.json),机器状态见[当前todoItem](阶段4B3当前TodoItem-2026-09-24.json)。
|
||||
|
||||
### 以下为历史快照(当前以本节为准)
|
||||
|
||||
'''
|
||||
for name,title in [('接手入口.md','# 接手入口:s101客户端迁移'),('开发状态.md','# 开发状态:4B3A待独立实施复核'),('TODO.md','# TODO:4B3A实施复核与关闭')]:
|
||||
path=UP/name
|
||||
old=path.read_text(encoding='utf-8-sig')
|
||||
path.write_text(title+'\n\n'+current+old.split('\n',1)[1].lstrip(),encoding='utf-8')
|
||||
|
||||
todo=read(UP/'阶段4B3当前TodoItem-2026-09-24.json')
|
||||
todo.update(phase='4B3A-implementation-awaiting-review',implementationApproved=True,phase4B3AClosed=False,
|
||||
nextResponsibleSide='independent-reviewer',implementationScope='4B3A only; 4B3B/C not approved')
|
||||
for item in todo['todoItem']:
|
||||
if item['id']=='B3-Review': item.update(status='completed',detail='39+2101项开工hash通过;独立双PASS及四P3已裁决')
|
||||
if item['id']=='B3A-Implementation': item.update(status='implemented-awaiting-independent-review',detail='四入口/键三态/Editor验证;三条件各13组544断言,Unity34及旧B2-20 83;未关闭')
|
||||
if item['id']=='KVSync-StorageError': item['detail']='本轮85/86→86/86;首败保留、根因未定'
|
||||
for identifier,detail in [('B3A-C0','设计裁决与批准'),('B3A-C1','ReadKV与三态键结果'),('B3A-C2','Write/Flush/Wait直接透传与故障竞争测试'),('B3A-C3','条件宿主/Unity/旧全量回归;保留首败'),('B3A-C4-author','范围/hash/自审/交付/提示词封板')]:
|
||||
todo['todoItem'].append(dict(id=identifier,status='completed',detail=detail))
|
||||
todo['todoItem'].append(dict(id='B3A-19',status='awaiting-independent-implementation-review',detail='外部复核后主会话核受审hash逐条裁决,才可关闭A并自动进入B完整设计'))
|
||||
write(UP/'阶段4B3当前TodoItem-2026-09-24.json',todo)
|
||||
write(HERE/'checkpoint4-todo-snapshot.json',todo)
|
||||
(HERE/'checkpoint4-implementation-record.md').write_bytes((UP/'阶段4B3A实施记录-2026-09-24.md').read_bytes())
|
||||
|
||||
def unity(name):
|
||||
o=read(HERE/name)
|
||||
assert o['Success'] is True
|
||||
v=json.loads(o['Result'])
|
||||
assert v['success'] is True and v['restored'] is True
|
||||
return v
|
||||
finalRuns={}
|
||||
for variant in ['default','android','player']:
|
||||
content=(HERE/f'checkpoint4-route-{variant}.log').read_text(encoding='utf-8-sig')
|
||||
m=re.search(r'RESULT groups=(\d+) assertions=(\d+) failed=(\d+) run=(.+)',content)
|
||||
assert m and m.group(1,2,3)==('13','544','0')
|
||||
finalRuns[variant]=dict(groups=13,assertions=544,failed=0,resultPath=(Path(m[4].strip())/'result.json').relative_to(ROOT).as_posix())
|
||||
assert all(r['exitCode']==0 for r in read(HERE/'checkpoint4-route-exits.json'))
|
||||
assert read(HERE/'checkpoint3-unity-compile-permitted.log')['ErrorCount']==0
|
||||
write(HERE/'checkpoint4-validation-summary.json',dict(route=finalRuns,unityRoute=unity('checkpoint3-unity-route.log'),unityB220=unity('checkpoint3-unity-b220.log'),networkBuildWithExistingDependenciesExitCode=0,formalRequests=0,knownIssues=['KVSync 85/86 then 86/86; cause unresolved','Historical Stage3C L7 unresolved; this run 8/8'],playerLinkOrBuildValidated=False))
|
||||
|
||||
paths=set()
|
||||
for base in [UP/'阶段4B3方案受审基线-2026-09-24.json',UP/'KVGameLoadTests/checkpoint4-delivery-hashes.json']:
|
||||
paths.add(base)
|
||||
paths.update(ROOT/f['path'] for f in read(base)['files'])
|
||||
for name in ['阶段4B3分期与4B3A方案独立审查-2026-09-24.md','阶段4B3分期与4B3A方案审查裁决与实施批准-2026-09-24.md','阶段4B3A交付与验收矩阵-2026-09-24.md','阶段4B3A限定代码自审-2026-09-24.md','其他AI审查提示词-4B3A实施-2026-09-24.md','B3DesignAudit/review-ruling-hashes-2026-09-24.json']:
|
||||
paths.add(UP/name)
|
||||
for relative in ['Client/Assets/Scripts/Core/PlayFabMgr.cs','Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs','Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs','Client/Assets/Editor/KVSaveRouteValidation.cs']:
|
||||
paths.add(ROOT/relative)
|
||||
if not relative.endswith('PlayFabMgr.cs'): paths.add(ROOT/(relative+'.meta'))
|
||||
paths.update(p for p in HERE.iterdir() if p.is_file() and p.name!='checkpoint4-delivery-hashes.json')
|
||||
paths.update(HERE.glob('runs/*/result.json'))
|
||||
assert all(p.is_file() for p in paths)
|
||||
manifest=dict(schemaVersion=1,phase='4B3A-implementation',status='awaiting-independent-implementation-review',
|
||||
createdAt=datetime.now(timezone.utc).isoformat(),stageClosed=False,formalRequests=0,gitCommitCreated=False,
|
||||
note='Includes dependencies at delivery state, frozen records, original failures and final evidence. Mutable entry/status/TODO/live logs excluded; frozen implementation/todo included. Do not overwrite this manifest during review.',
|
||||
files=[dict(path=p.relative_to(ROOT).as_posix(),sha256=sha(p)) for p in sorted(paths)])
|
||||
manifest['fileCount']=len(manifest['files'])
|
||||
write(HERE/'checkpoint4-delivery-hashes.json',manifest)
|
||||
print(json.dumps(dict(fileCount=manifest['fileCount'],manifestSha256=sha(HERE/'checkpoint4-delivery-hashes.json'),finalRuns=finalRuns),ensure_ascii=False,indent=2))
|
||||
Reference in New Issue
Block a user