Game/LocalLevels.cs
using System.Collections.Generic;
using Sandbox;
namespace BlockParty;
/// <summary>
/// STANDALONE (non-editor) local level files. A packaged build has no editor assembly, so the
/// in-game level editor can't write the project's <c>Assets/levels</c> folder — instead levels save
/// as <c>levels/{id}.json</c> in the game data folder (<see cref="FileSystem.Data"/>), beside the
/// <c>gifs/</c> export folder, so a player's creations are easy to find, hand-edit and share.
/// <see cref="Levels.Reload"/> folds these files into the registry (marked
/// <see cref="LevelDef.IsLocal"/>), which makes a local level loadable BY ID everywhere the editor
/// loads levels (the picker, <c>LoadFromLevel</c>, the last-opened restore) and browsable in the
/// level picker's Local grid view. A local file whose id collides with a shipped level is skipped —
/// it must never shadow map/leaderboard content.
/// </summary>
public static class LocalLevels
{
/// <summary>Player level files live in their own subfolder of the game data directory, beside
/// the <see cref="GifExporter.DirName"/> export folder.</summary>
public const string DirName = "levels";
/// <summary>Whether local level files are in play: standalone builds only — in the editor the
/// project's Assets folder is the single source of truth.</summary>
public static bool Enabled => !Game.IsEditor;
/// <summary>Data-folder path of a local level's JSON file.</summary>
public static string PathFor( string id ) => $"{DirName}/{id}.json";
/// <summary>Read every parseable level JSON in the data folder's <see cref="DirName"/> directory.
/// A hand-dropped file that isn't a level either fails to parse or comes out with no id, so it's
/// skipped without a warning. Registry policy (collision handling, timestamps) belongs to the
/// caller (<see cref="Levels.Reload"/>).</summary>
public static List<LevelDef> LoadAll()
{
var result = new List<LevelDef>();
if ( !Enabled ) return result;
try
{
foreach ( var name in FileSystem.Data.FindFile( DirName, "*.json" ) )
{
var path = $"{DirName}/{name}";
try
{
var e = EditorLevel.FromJsonString( FileSystem.Data.ReadAllText( path ), out _ );
if ( e is null || string.IsNullOrWhiteSpace( e.Id ) ) continue;
var def = e.ToLevelDef();
def.SourcePath = path;
def.IsLocal = true;
result.Add( def );
}
catch { /* not a level file — skip quietly */ }
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[BlockParty] scanning local levels: {ex.Message}" );
}
return result;
}
/// <summary>Standalone counterpart of the editor-assembly <c>level_save</c>: write the open
/// editor level to <c>levels/{id}.json</c> in the game data folder and fold it into the live
/// registry so it can be re-opened / test-played immediately. Same refusals as the editor path:
/// unnamed or unsafe ids, and overwriting an EXISTING file that this document wasn't loaded from.</summary>
public static void Save( LevelEditorStage stage )
{
var id = stage.Level.Id?.Trim();
if ( !IsSafeLevelId( id ) )
{
stage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? "level has no id" : $"invalid level id '{id}'" );
return;
}
// Shipped ids are permanently taken: a local file must never shadow map/leaderboard content
// (the loader skips such files, so allowing the save would just write an unloadable file).
if ( Levels.IsShippedLevel( id ) )
{
stage.ReportProjectError( $"'{id}' is a built-in level; pick another id" );
return;
}
// The ws{fileId} namespace belongs to installed workshop levels — a local file there could
// shadow (or be shadowed by) a downloaded level at the registry fold-in, depending on order.
if ( WorkshopLevels.IsWorkshopId( id ) )
{
stage.ReportProjectError( $"'{id}' is a reserved workshop id; pick another id" );
return;
}
var path = PathFor( id );
bool updatingSource = stage.OwnsProjectFile( id );
if ( FileSystem.Data.FileExists( path ) && !updatingSource )
{
stage.ReportProjectError( $"level '{id}' already exists; load it before overwriting" );
return;
}
// The document id IS the file name from here on: serialize and register the trimmed form so a
// reopened level's id (and ProjectSourceId) matches the file it came from.
stage.Level.Id = id;
FileSystem.Data.CreateDirectory( DirName );
FileSystem.Data.WriteAllText( path, stage.Level.ToJsonString() );
stage.MarkSavedToProject( id );
// Stamp the save time so the picker's Recent sort works for local levels too (standalone has
// no editor assembly to stat files, so the save moment IS the timestamp).
EditorPrefs.Current.LevelBrowserLevelTimes ??= new();
EditorPrefs.Current.LevelBrowserLevelTimes[id] = System.DateTimeOffset.UtcNow.Ticks;
EditorPrefs.Save();
var def = stage.Level.ToLevelDef();
def.SourcePath = path;
def.IsLocal = true;
Levels.ApplySavedLevel( def );
Log.Info( $"[BlockParty] Saved local level '{id}' -> {FileSystem.Data.GetFullPath( path )}." );
}
/// <summary>Standalone counterpart of the editor-assembly <c>level_delete</c>: remove the open
/// level's local JSON file and drop it from the live registry.</summary>
public static void Delete( LevelEditorStage stage )
{
var id = stage.Level.Id?.Trim();
if ( !IsSafeLevelId( id ) )
{
stage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? "level has no id" : $"invalid level id '{id}'" );
return;
}
// Same claim rule as Save: only the file this document came from can be deleted.
if ( !stage.OwnsProjectFile( id ) )
{
stage.ReportProjectError( $"'{id}' isn't the file this level was opened from; load it before deleting" );
return;
}
var path = PathFor( id );
if ( !FileSystem.Data.FileExists( path ) )
{
stage.ReportProjectError( $"no local file for level '{id}'" );
return;
}
FileSystem.Data.DeleteFile( path );
EditorPrefs.Current.LevelBrowserLevelTimes?.Remove( id );
EditorPrefs.Save();
// Only drop a LOCAL registry entry: if the id somehow matches a shipped level (its local
// file was collision-skipped at load), the registry entry isn't ours to remove.
if ( Levels.Get( id )?.IsLocal == true )
Levels.RemoveDeletedLevel( id );
stage.ResetToDefault();
Log.Info( $"[BlockParty] Deleted local level '{id}'." );
}
/// <summary>Filename- and console-token-safe id: ASCII letters, digits, '-', '_' and '.', never
/// "." / "..". The game assembly can't consult Path.GetInvalidFileNameChars, so this is a
/// stricter allowlist mirror of the editor writer's rules (which also refuse whitespace).</summary>
public static bool IsSafeLevelId( string id )
{
if ( string.IsNullOrWhiteSpace( id ) || id == "." || id == ".." ) return false;
foreach ( var c in id )
{
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.';
if ( !ok ) return false;
}
return true;
}
}