Game/WorkshopLevels.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace BlockParty;
/// <summary>
/// Steam Workshop player levels, built on the engine's Storage/UGC API.
/// <para>
/// PUBLISH (standalone editor only): the open level's JSON goes into a <see cref="Storage.Entry"/>
/// (one per published local level, matched by the <c>levelId</c> meta) and <c>entry.Publish</c> opens
/// the engine's workshop modal — the player confirms title/description/visibility there, so publishing
/// is never silent. Republishing the same level reuses its entry, and the engine's <c>_workshopId</c>
/// meta then updates the existing workshop item in place.
/// </para><para>
/// PLAY: <see cref="InstallAsync"/> downloads an item, keeps a copy at <c>workshop/{fileId}.json</c>
/// in the data folder, and registers it as <c>ws{fileId}</c> — the workshop file id is the global
/// identity; the author's own id inside the JSON is never trusted. <see cref="LoadAll"/> folds the
/// copies back into <see cref="Levels.Reload"/> at boot so boards and replays resolve offline.
/// Leaderboard stats append <see cref="ContentHash"/> (see <see cref="Leaderboard.StatNameForLevel"/>),
/// so an author update starts a fresh board instead of mixing scores across layouts.
/// </para>
/// </summary>
public static class WorkshopLevels
{
/// <summary>Storage entry type (publisher side). Letters only per the engine's type rule; also the
/// workshop tag + <c>type</c> keyvalue every published item carries.</summary>
public const string EntryType = "level";
/// <summary>Data-folder directory holding installed copies (<c>workshop/{fileId}.json</c>), beside
/// <see cref="LocalLevels.DirName"/>.</summary>
public const string DirName = "workshop";
/// <summary>Registry-id prefix: workshop levels register as <c>ws{fileId}</c>.</summary>
public const string IdPrefix = "ws";
/// <summary>Steam's developer-metadata cap (k_cchDeveloperMetadataMax = 5000 bytes), with slack —
/// the whole <c>_meta.json</c> ships as the item's Metadata, so the embedded level must share it.</summary>
private const int MAX_ITEM_METADATA = 4900;
/// <summary>Steam-side item details (author id + display name, description) by registry id,
/// remembered as browse results arrive. None of it is part of the downloaded level JSON (which must
/// stay byte-identical for <see cref="ContentHash"/>), and every route to a workshop board goes
/// through the browser first, so a session-lifetime cache is enough — the board just shows no
/// author / description if it's ever missed.</summary>
private static readonly Dictionary<string, (ulong AuthorId, string Author, string Description)> ItemDetails = new();
/// <summary>s&box's Steam AppID: every game's workshop items share it, so an author's item list
/// is filtered to it (not to this game — Steam has no per-game filter).</summary>
private const ulong SBOX_APP_ID = 590830;
/// <summary>Longest description a board shows before a hard cut (well past what one line fits, so
/// the CSS ellipsis is what the player actually sees; this just keeps a wall of text out of the label).</summary>
private const int MAX_BOARD_DESCRIPTION = 200;
/// <summary>Remember a browsed item's author and description.</summary>
public static void RememberItem( Storage.QueryItem item )
{
if ( item is null ) return;
string author = string.IsNullOrWhiteSpace( item.Owner?.Name ) ? null : item.Owner.Name.Trim();
ulong authorId = item.Owner is null ? 0UL : (ulong)item.Owner.Id;
ItemDetails[LevelIdFor( item.Id )] = (authorId, author, CollapseDescription( item.Description ));
}
/// <summary>Author display name for a workshop level id, or null if we haven't seen the item.</summary>
public static string AuthorFor( string levelId )
=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? d.Author : null;
/// <summary>Author SteamId for a workshop level id, or 0 (unseen item, or Steam gave no owner id).</summary>
public static long AuthorIdFor( string levelId )
=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? (long)d.AuthorId : 0L;
/// <summary>Web page listing the author's s&box workshop items, or null (unseen item, or Steam
/// gave no owner id).</summary>
public static string AuthorWorkshopUrlFor( string levelId )
=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) && d.AuthorId != 0
? AuthorWorkshopUrl( d.AuthorId )
: null;
/// <summary>Web page listing a player's s&box workshop items.</summary>
public static string AuthorWorkshopUrl( ulong steamId )
=> $"https://steamcommunity.com/profiles/{steamId}/myworkshopfiles/?appid={SBOX_APP_ID}";
/// <summary>Single-line description for a workshop level id, or null if blank / unseen.</summary>
public static string DescriptionFor( string levelId )
=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? d.Description : null;
// Steam descriptions are free multi-line text; the board shows one nowrap line, so fold every
// whitespace run (newlines included) to a space and hard-cap the length.
private static string CollapseDescription( string text )
{
if ( string.IsNullOrWhiteSpace( text ) ) return null;
var sb = new System.Text.StringBuilder( text.Length );
bool space = false;
foreach ( char c in text.Trim() )
{
if ( char.IsWhiteSpace( c ) ) { space = true; continue; }
if ( space ) { sb.Append( ' ' ); space = false; }
sb.Append( c );
if ( sb.Length >= MAX_BOARD_DESCRIPTION ) { sb.Append( "..." ); break; }
}
return sb.ToString();
}
/// <summary>Registry id for a workshop file id.</summary>
public static string LevelIdFor( ulong fileId ) => $"{IdPrefix}{fileId}";
/// <summary>The item's Steam Workshop web page.</summary>
public static string ItemPageUrl( ulong fileId )
=> $"https://steamcommunity.com/sharedfiles/filedetails/?id={fileId}";
/// <summary>The Steam Workshop web page for a workshop level id, or null for any other id.</summary>
public static string ItemPageUrlFor( string levelId )
=> IsWorkshopId( levelId ) && ulong.TryParse( levelId.AsSpan( IdPrefix.Length ), out var fileId )
? ItemPageUrl( fileId )
: null;
/// <summary>Whether an id is in the reserved workshop namespace: "ws" + digits, nothing else.</summary>
public static bool IsWorkshopId( string id )
{
if ( string.IsNullOrEmpty( id ) || id.Length <= IdPrefix.Length ) return false;
if ( !id.StartsWith( IdPrefix, StringComparison.Ordinal ) ) return false;
for ( int i = IdPrefix.Length; i < id.Length; i++ )
if ( id[i] < '0' || id[i] > '9' ) return false;
return true;
}
/// <summary>FNV-1a 32 over the level JSON's UTF-8 bytes, as 8 lowercase hex chars. Publisher and
/// installer hash the same byte-identical text, so the stat suffix agrees on every client.</summary>
public static string ContentHash( string json )
{
uint h = 2166136261u;
foreach ( byte b in System.Text.Encoding.UTF8.GetBytes( json ?? "" ) )
h = (h ^ b) * 16777619u;
return h.ToString( "x8", System.Globalization.CultureInfo.InvariantCulture );
}
// ---- publisher side ----
/// <summary>The storage entry already holding this local level's published files, or a fresh one.
/// The mapping lives on the entries themselves (meta <c>levelId</c>) — no separate index to drift.</summary>
public static Storage.Entry FindOrCreateEntry( string levelId )
{
var existing = Storage.GetAll( EntryType )
.FirstOrDefault( e => e.GetMeta<string>( "levelId" ) == levelId );
return existing ?? Storage.CreateEntry( EntryType );
}
/// <summary>
/// Publish the editor's open level: save it first (published content must match the saved level),
/// stage its JSON + meta + thumbnail into its storage entry, and hand off to the engine's workshop
/// modal. <paramref name="onComplete"/> fires with the workshop file id if the player goes through
/// with it; refusals report through <see cref="LevelEditorStage.ReportProjectError"/>.
/// <para>
/// Works from the DEV editor too (for testing the pipeline) — but note items are stamped with the
/// running <c>Game.Ident</c> at publish, and <see cref="BuildQuery"/> filters on it, so editor
/// (<c>local.*</c>) and published-game items live in separate, mutually invisible pools.
/// </para>
/// </summary>
public static void Publish( LevelEditorStage stage, Action<ulong> onComplete )
{
// Save first: same refusal ladder as a plain save (id validity, shipped/workshop-id collisions,
// overwrite protection), and the published JSON matches the saved level.
stage.SaveToProject();
if ( !string.IsNullOrEmpty( stage.LastError ) )
return;
var id = stage.Level.Id?.Trim();
var json = stage.Level.ToJsonString();
var def = stage.Level.ToLevelDef();
Storage.Entry entry;
try
{
entry = FindOrCreateEntry( id );
entry.Files.WriteAllText( "level.json", json );
entry.SetMeta( "levelId", id );
entry.SetMeta( "name", string.IsNullOrWhiteSpace( def.Name ) ? id : def.Name );
entry.SetMeta( "blocks", def.Count );
// Embed the level itself when the whole _meta.json (which becomes the item's queryable
// Metadata verbatim) stays under Steam's 5000-byte developer-metadata cap: the browser can
// then draw the SAME live animated preview as the local pickers without downloading.
// Oversized levels drop the key (a republish may shrink OR grow past the cap) and their
// tiles fall back to the uploaded thumbnail image. The final SetMeta below persists the
// removal — a null SetMeta alone edits the dict without rewriting the file.
entry.SetMeta( "level", json );
bool fits = System.Text.Encoding.UTF8.GetByteCount( entry.Files.ReadAllText( "_meta.json" ) ) <= MAX_ITEM_METADATA;
if ( !fits )
entry.SetMeta<string>( "level", null );
entry.SetMeta( "hash", ContentHash( json ) );
}
catch ( Exception ex )
{
stage.ReportProjectError( $"couldn't stage workshop files: {ex.Message}" );
return;
}
// A thumbnail failure shouldn't block publishing — the modal just shows no preview.
try { entry.SetThumbnail( WorkshopThumbnail.Render( def ) ); }
catch ( Exception ex ) { Log.Warning( $"[BlockParty] workshop thumbnail failed: {ex.Message}" ); }
entry.Publish( new Sandbox.Modals.WorkshopPublishOptions
{
Title = string.IsNullOrWhiteSpace( def.Name ) ? id : def.Name,
// OUR game discriminator: every game's UGC shares the s&box AppID and the engine stamps
// only type/source keyvalues (no package ident), so without this a query couldn't tell a
// blockparty level from another game's "level"-typed content. BuildQuery requires it back.
// "schema" makes the level's wire version query-filterable: BuildQuery requires the exact
// version this build reads, so content a client can't parse (older unversioned items, or
// future-schema items seen by an old client) is INVISIBLE in its browser rather than a
// FAILED tile.
KeyValues = new() { ["game"] = Game.Ident, ["schema"] = EditorLevel.SCHEMA_VERSION.ToString() },
// The engine modal invokes OnComplete UNCONDITIONALLY after the submit — a failed Steam
// upload (network drop, quota) reports item id 0. Only a real id is success; 0 surfaces as
// an error instead of "PUBLISHED #0". (A cancelled modal never calls back at all.)
OnComplete = fileId =>
{
if ( fileId != 0 )
{
Achievements.AwardWorkshopPublished();
onComplete?.Invoke( fileId );
return;
}
stage.ReportProjectError( "Steam publish failed — nothing was uploaded; try again" );
Log.Warning( "[BlockParty] workshop publish failed (engine modal reported item id 0)." );
},
} );
}
// ---- consumer side ----
/// <summary>A browse query for this game's published levels. The engine stamps the entry-type tag
/// and a <c>type</c> keyvalue at publish; the <c>game</c> keyvalue is ours (see
/// <see cref="Publish"/>) and is what actually keeps other games' UGC (everything shares the
/// s&box workshop AppID) out of the results. NOTE: it also splits dev-editor
/// (<c>local.*</c>) items from published-game items, since it holds the publishing ident.</summary>
public static Storage.Query BuildQuery( string searchText, Storage.SortOrder sort )
{
var query = new Storage.Query
{
TagsRequired = { EntryType },
// game/type keep other games' UGC out; schema keeps content THIS build can't parse out
// (see Publish) — each client browses only levels written in the wire version it reads.
KeyValues = { ["game"] = Game.Ident, ["type"] = EntryType, ["schema"] = EditorLevel.SCHEMA_VERSION.ToString() },
SortOrder = sort,
RankTrendDays = 7,
};
if ( !string.IsNullOrWhiteSpace( searchText ) )
query.SearchText = searchText.Trim();
return query;
}
/// <summary>
/// Download a workshop level, persist the local copy, and register it as <c>ws{fileId}</c> in the
/// live registry. Returns the registered def, or null (with a log) on any failure. Always re-runs
/// for an already-installed level — Steam serves its cache when fresh, and this self-heals the
/// local copy/hash after an author republish.
/// </summary>
public static async Task<LevelDef> InstallAsync( Storage.QueryItem item, CancellationToken token = default )
{
if ( item is null ) return null;
Storage.Entry entry;
try { entry = await item.Install( token ); }
catch ( Exception ex )
{
Log.Warning( $"[BlockParty] workshop install {item.Id} failed: {ex.Message}" );
return null;
}
if ( entry is null || !entry.Files.FileExists( "level.json" ) )
{
Log.Warning( $"[BlockParty] workshop item {item.Id} has no level.json." );
return null;
}
var json = entry.Files.ReadAllText( "level.json" );
var def = BuildDef( item.Id, json, out var error );
if ( def is null )
{
Log.Warning( $"[BlockParty] workshop item {item.Id}: {error}" );
return null;
}
try
{
FileSystem.Data.CreateDirectory( DirName );
FileSystem.Data.WriteAllText( $"{DirName}/{item.Id}.json", json );
}
catch ( Exception ex )
{
// Playable this session either way; only boot-time re-registration is lost.
Log.Warning( $"[BlockParty] couldn't persist workshop level {item.Id}: {ex.Message}" );
}
Levels.RegisterWorkshop( def );
return def;
}
/// <summary>Read every installed workshop copy for the registry fold-in (see
/// <see cref="Levels.Reload"/>). Identity comes from the FILENAME; unparseable files skip quietly,
/// mirroring <see cref="LocalLevels.LoadAll"/>.</summary>
public static List<LevelDef> LoadAll()
{
var result = new List<LevelDef>();
try
{
foreach ( var name in FileSystem.Data.FindFile( DirName, "*.json" ) )
{
var stem = name.EndsWith( ".json", StringComparison.OrdinalIgnoreCase ) ? name[..^5] : name;
if ( !ulong.TryParse( stem, out ulong fileId ) ) continue;
try
{
var def = BuildDef( fileId, FileSystem.Data.ReadAllText( $"{DirName}/{name}" ), out _ );
if ( def is not null ) result.Add( def );
}
catch { /* not a level file — skip quietly */ }
}
}
catch ( Exception ex )
{
Log.Warning( $"[BlockParty] scanning workshop levels: {ex.Message}" );
}
return result;
}
// The slice of an item's Metadata (the raw _meta.json: StorageMeta with a string-dict) we read back.
private sealed class ItemMeta
{
public System.Collections.Generic.Dictionary<string, string> Meta { get; set; }
}
/// <summary>Rebuild a browse tile's level from the item's METADATA (no download): the level JSON
/// embedded at publish, when it fit the metadata cap. Null for oversized levels, pre-embed items
/// and anything unparseable — the tile falls back to the uploaded thumbnail image.</summary>
public static LevelDef PreviewDefFromMetadata( string metadata )
{
if ( string.IsNullOrWhiteSpace( metadata ) ) return null;
try
{
var meta = Json.Deserialize<ItemMeta>( metadata )?.Meta;
if ( meta is null || !meta.TryGetValue( "level", out var encoded ) ) return null;
// SetMeta stores each value JSON-encoded, so the level JSON arrives as a string literal.
var json = Json.Deserialize<string>( encoded );
return EditorLevel.FromJsonString( json, out _ )?.ToLevelDef();
}
catch { return null; }
}
/// <summary>The publish-time content hash carried in an item's metadata, or null (unparseable
/// metadata — every published level carries the <c>hash</c> meta).</summary>
public static string HashFromMetadata( string metadata )
{
if ( string.IsNullOrWhiteSpace( metadata ) ) return null;
try
{
var meta = Json.Deserialize<ItemMeta>( metadata )?.Meta;
return meta is not null && meta.TryGetValue( "hash", out var encoded )
? Json.Deserialize<string>( encoded )
: null;
}
catch { return null; }
}
/// <summary>The installed, registered def for this item when it's already CURRENT — its content
/// hash matches the item's publish-time metadata hash (two in-memory strings). The play/board
/// fast path: a hit skips the whole Steam install round-trip, and works offline. Null = not
/// installed, stale (author republished), or no hash to compare — install instead.</summary>
public static LevelDef TryGetInstalledCurrent( Storage.QueryItem item )
{
string hash = HashFromMetadata( item?.Metadata );
if ( hash is null ) return null;
var def = Levels.Get( LevelIdFor( item.Id ) );
return def is { IsWorkshop: true } && def.WorkshopContentHash == hash ? def : null;
}
/// <summary>Dev diagnostic: with no argument, dump what the game's browse filter matches AND what
/// a fully unfiltered newest-first query sees (so a filter mismatch is obvious); with a workshop
/// file id, dump that item's actual tags/keyvalues/metadata as Steam returns them.</summary>
[ConCmd( "workshop_debug" )]
public static void DebugCmd( string fileId = "" )
{
if ( !Game.IsEditor ) return;
_ = DebugAsync( fileId );
}
private static async Task DebugAsync( string fileIdStr )
{
try
{
if ( ulong.TryParse( fileIdStr, out ulong fileId ) )
{
var details = await new Storage.Query { FileIds = new() { fileId } }.Run();
if ( details?.Items is not { Count: > 0 } items )
{
Log.Info( $"[BlockParty] workshop_debug: no details for {fileId}." );
return;
}
foreach ( var i in items )
Log.Info( $"[BlockParty] item {i.Id} '{i.Title}' vis={i.Visibility} banned={i.Banned} " +
$"owner={i.Owner?.Name} tags=[{string.Join( ",", i.Tags ?? new() )}] " +
$"kv={{{string.Join( ",", (i.KeyValues ?? new()).Select( kv => $"{kv.Key}={kv.Value}" ) )}}} " +
$"meta={i.Metadata}" );
return;
}
var filtered = await BuildQuery( null, Storage.SortOrder.RankedByPublicationDate ).Run();
Log.Info( $"[BlockParty] workshop_debug: game filter (tag '{EntryType}' + game={Game.Ident} + type={EntryType}) → {filtered?.ResultCount ?? 0} of {filtered?.TotalCount ?? 0}." );
var all = await new Storage.Query { SortOrder = Storage.SortOrder.RankedByPublicationDate }.Run();
Log.Info( $"[BlockParty] workshop_debug: UNFILTERED newest → {all?.ResultCount ?? 0} of {all?.TotalCount ?? 0}; first items:" );
foreach ( var i in (all?.Items ?? new()).Take( 10 ) )
Log.Info( $"[BlockParty] {i.Id} '{i.Title}' tags=[{string.Join( ",", i.Tags ?? new() )}] " +
$"kv={{{string.Join( ",", (i.KeyValues ?? new()).Select( kv => $"{kv.Key}={kv.Value}" ) )}}}" );
}
catch ( Exception ex )
{
Log.Warning( $"[BlockParty] workshop_debug failed: {ex.Message}" );
}
}
// Parse + brand a downloaded level. The workshop file id is the identity — the author's own id in
// the JSON is overwritten, so it can never collide with shipped/local content or another author.
private static LevelDef BuildDef( ulong fileId, string json, out string error )
{
var e = EditorLevel.FromJsonString( json, out error );
if ( e is null ) return null;
var def = e.ToLevelDef();
def.Id = LevelIdFor( fileId );
def.IsWorkshop = true;
def.WorkshopContentHash = ContentHash( json );
def.SourcePath = $"{DirName}/{fileId}.json";
return def;
}
}