Core/NetworkStorageRevisionInit.cs
using System;
using System.Text.Json;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Sandbox;
/// <summary>
/// Result of the one-time revision init handshake with the backend.
/// </summary>
public struct RevisionInitResult
{
/// <summary>True when the backend acknowledged the init successfully.</summary>
public bool Ok { get; set; }
/// <summary>True when the server reports a newer revision is available.</summary>
public bool IsOutdatedRevision { get; set; }
/// <summary>The revision this client is running, if known.</summary>
public long? PlayerRevision { get; set; }
/// <summary>The latest revision reported by the backend.</summary>
public long? CurrentRevision { get; set; }
/// <summary>Human-readable status or warning message from the server.</summary>
public string Message { get; set; }
}
/// <summary>
/// Sends a one-time revision-init packet at game startup so the backend
/// knows which revision this client is running.
/// Call once after <see cref="NetworkStorage.Configure"/>.
/// </summary>
public static class NetworkStorageRevisionInit
{
/// <summary>Cached result from the last successful <see cref="SendInitAsync"/> call.</summary>
public static RevisionInitResult? LastInitResult { get; private set; }
/// <summary>
/// Send the revision init packet to the backend.
/// Safe to call even when package/revision data is unavailable — the request
/// proceeds without revision headers and the result reflects the gap.
/// </summary>
public static async Task<RevisionInitResult> SendInitAsync()
{
NetworkStorage.EnsureConfigured();
// ── Detect package info if not already done ──
if ( !NetworkStoragePackageInfo.IsDetected )
{
try
{
await NetworkStoragePackageInfo.DetectAsync();
}
catch ( Exception ex )
{
Log.Warning( $"[NetworkStorage] revision-init: package detection failed — {ex.Message}" );
}
}
// ── Build request body ──
var clientType = NetworkStorage.GetClientType();
var revisionId = NetworkStoragePackageInfo.RuntimeRevisionId;
var body = new Dictionary<string, object>
{
{ "projectId", NetworkStorage.ProjectId },
{ "clientType", clientType },
{ "networkStorageVersion", "1.0" },
{ "isPublishedGameBundle", NetworkStoragePackageInfo.IsPublishedGameBundle }
};
if ( !string.IsNullOrEmpty( NetworkStoragePackageInfo.PackageIdent ) )
body["packageIdent"] = NetworkStoragePackageInfo.PackageIdent;
if ( revisionId.HasValue )
body["revisionId"] = revisionId.Value;
// ── Build headers ──
var headers = new Dictionary<string, string>
{
{ "x-public-key", NetworkStorage.ApiKey ?? "" }
};
if ( revisionId.HasValue )
headers["x-ns-revision-id"] = revisionId.Value.ToString();
headers["x-ns-client-type"] = clientType;
// ── Send POST ──
var path = $"/{NetworkStorage.ApiVersion}/manage/{Uri.EscapeDataString( NetworkStorage.ProjectId )}/revision-init";
var url = $"{NetworkStorage.BaseUrl}{path}?apiKey={Uri.EscapeDataString( NetworkStorage.ApiKey ?? "" )}";
var tag = "revision-init";
try
{
var content = Http.CreateJsonContent( body );
if ( NetworkStorageLogConfig.LogRequests )
Log.Info( $"[NetworkStorage] {tag}: POST {NetworkStorage.ApiRoot}/manage/{NetworkStorage.ProjectId}/revision-init" );
var raw = await Http.RequestStringAsync( url, "POST", content, headers );
if ( NetworkStorageLogConfig.LogResponses )
Log.Info( $"[NetworkStorage] {tag} → {TruncateForLog( raw, 300 )}" );
var result = ParseInitResponse( raw, revisionId );
LastInitResult = result;
Log.Info( $"[NetworkStorage] Game revision: {NetworkStoragePackageInfo.RuntimeRevisionId?.ToString() ?? "unknown"}, server latest: {result.CurrentRevision?.ToString() ?? "unknown"}, clientType={clientType}" );
if ( result.IsOutdatedRevision )
Log.Warning( $"[NetworkStorage] Running outdated revision. {result.Message}" );
else
Log.Info( $"[NetworkStorage] {tag}: ok={result.Ok} revision={result.PlayerRevision?.ToString() ?? "unknown"} message={result.Message ?? "none"}" );
return result;
}
catch ( System.Net.Http.HttpRequestException httpEx )
{
var status = httpEx.StatusCode.HasValue ? $"{(int)httpEx.StatusCode.Value} {httpEx.StatusCode.Value}" : "unknown";
Log.Warning( $"[NetworkStorage] {tag} FAILED — HTTP {status}" );
var fail = new RevisionInitResult
{
Ok = false,
IsOutdatedRevision = false,
PlayerRevision = revisionId,
CurrentRevision = null,
Message = $"HTTP error: {status}"
};
LastInitResult = fail;
return fail;
}
catch ( Exception ex )
{
Log.Warning( $"[NetworkStorage] {tag} FAILED — {ex.Message}" );
var fail = new RevisionInitResult
{
Ok = false,
IsOutdatedRevision = false,
PlayerRevision = revisionId,
CurrentRevision = null,
Message = $"Request failed: {ex.Message}"
};
LastInitResult = fail;
return fail;
}
}
/// <summary>
/// Parse the backend revision-init response into a <see cref="RevisionInitResult"/>.
/// </summary>
private static RevisionInitResult ParseInitResponse( string raw, long? clientRevision )
{
if ( string.IsNullOrEmpty( raw ) )
{
return new RevisionInitResult
{
Ok = false,
PlayerRevision = clientRevision,
Message = "Server returned empty response"
};
}
JsonElement json;
try
{
json = JsonSerializer.Deserialize<JsonElement>( raw );
}
catch
{
return new RevisionInitResult
{
Ok = false,
PlayerRevision = clientRevision,
Message = "Server returned invalid JSON"
};
}
var ok = json.TryGetProperty( "ok", out var okProp ) && okProp.ValueKind == JsonValueKind.True;
long? serverRevision = null;
if ( json.TryGetProperty( "currentRevisionId", out var crProp ) && crProp.TryGetInt64( out var crVal ) )
serverRevision = crVal;
var isOutdated = false;
if ( json.TryGetProperty( "revisionOutdated", out var outdatedProp ) && outdatedProp.ValueKind == JsonValueKind.True )
isOutdated = true;
else if ( clientRevision.HasValue && serverRevision.HasValue && clientRevision.Value < serverRevision.Value )
isOutdated = true;
string message = null;
if ( json.TryGetProperty( "message", out var msgProp ) && msgProp.ValueKind == JsonValueKind.String )
message = msgProp.GetString();
return new RevisionInitResult
{
Ok = ok,
IsOutdatedRevision = isOutdated,
PlayerRevision = clientRevision,
CurrentRevision = serverRevision,
Message = message
};
}
private static string TruncateForLog( string s, int max = 120 )
=> s != null && s.Length > max ? s[..max] + "..." : s ?? "";
}