Add game version management system with manifest support

Features:
- Version dropdown in single/ZIP upload (9.3.0, 9.2.0, etc.)
- Patch-compatible matching (9.3.x assets work with 9.3.0)
- manifest.json/xml support for automatic metadata detection
- Smart category auto-detection from folder structure
- Version field stored in GameAssets table

Manifest support:
- JSON format with gameVersion, category, assets array
- Per-file metadata overrides (type, required, description)
- Auto-detect falls back if no manifest present
- See ASSET-MANIFEST-SPECIFICATION.md for full spec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-20 09:55:05 -08:00
parent f289cdfce9
commit dd2c23000f
21 changed files with 1409 additions and 32 deletions

View File

@@ -6,6 +6,7 @@ using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using System.IO.Compression;
using System.Text.Json;
namespace RR3CommunityServer.Pages;
@@ -46,6 +47,7 @@ public class AssetsModel : PageModel
string category,
string assetType,
bool isRequired,
string gameVersion,
string? description)
{
try
@@ -108,6 +110,7 @@ public class AssetsModel : PageModel
existingAsset.IsRequired = isRequired;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
Message = $"Asset '{fileName}' updated successfully!";
@@ -128,6 +131,7 @@ public class AssetsModel : PageModel
IsRequired = isRequired,
Description = description,
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -154,6 +158,7 @@ public class AssetsModel : PageModel
public async Task<IActionResult> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
string? gameVersion,
bool isRequired)
{
try
@@ -190,6 +195,47 @@ public class AssetsModel : PageModel
int extractedCount = 0;
int skippedCount = 0;
var errors = new List<string>();
// Try to parse manifest file if exists
AssetManifest? manifest = null;
using (var archive = ZipFile.OpenRead(tempZipPath))
{
var manifestEntry = archive.Entries.FirstOrDefault(e =>
e.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
e.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase));
if (manifestEntry != null)
{
try
{
using var stream = manifestEntry.Open();
using var reader = new StreamReader(stream);
var manifestContent = await reader.ReadToEndAsync();
if (manifestEntry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifest = JsonSerializer.Deserialize<AssetManifest>(manifestContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
if (manifest != null)
{
_logger.LogInformation("Loaded manifest: Version={Version}, GameVersion={GameVersion}",
manifest.Version, manifest.GameVersion);
// Override parameters from manifest if provided
if (!string.IsNullOrEmpty(manifest.GameVersion))
gameVersion = manifest.GameVersion;
if (!string.IsNullOrEmpty(manifest.Category) && baseCategory == "auto")
baseCategory = manifest.Category;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse manifest file, using defaults");
}
}
}
// Extract ZIP and process each file
using (var archive = ZipFile.OpenRead(tempZipPath))
@@ -198,18 +244,31 @@ public class AssetsModel : PageModel
{
try
{
// Skip directories
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
// Skip directories and manifest files
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/") ||
entry.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase))
continue;
// Determine category from path in ZIP
// Check if this file has manifest metadata
ManifestAsset? manifestAsset = manifest?.Assets?.FirstOrDefault(a =>
a.File.Replace("\\", "/").Equals(entry.FullName.Replace("\\", "/"),
StringComparison.OrdinalIgnoreCase));
// Determine category from manifest or path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = baseCategory;
var category = manifestAsset?.Category ?? baseCategory;
// If ZIP has folders, use first folder as subcategory
if (pathParts.Length > 1)
// Auto-detect if category is "auto"
if (category == "auto")
{
category = Path.Combine(baseCategory, pathParts[0]);
category = SmartDetectCategory(entry.FullName) ?? "base";
}
// If ZIP has folders and no manifest, use first folder as subcategory
if (manifestAsset == null && pathParts.Length > 1 && category != "auto")
{
category = Path.Combine(category, pathParts[0]);
}
// Create category subdirectory
@@ -233,6 +292,11 @@ public class AssetsModel : PageModel
// Build EA CDN path from ZIP structure
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
// Determine asset type from manifest or filename
var assetType = manifestAsset?.Type ?? DetermineAssetType(fileName);
var required = manifestAsset?.Required ?? isRequired;
var description = manifestAsset?.Description;
// Check if asset already exists
var existingAsset = await _context.GameAssets
@@ -247,8 +311,11 @@ public class AssetsModel : PageModel
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.IsRequired = isRequired;
existingAsset.AssetType = assetType;
existingAsset.IsRequired = required;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
@@ -264,10 +331,11 @@ public class AssetsModel : PageModel
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = DetermineAssetType(fileName),
IsRequired = isRequired,
Description = $"Extracted from {zipFile.FileName}",
AssetType = assetType,
IsRequired = required,
Description = description ?? $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -446,6 +514,36 @@ public class AssetsModel : PageModel
};
}
private string? SmartDetectCategory(string fullPath)
{
var lowerPath = fullPath.ToLowerInvariant();
// Check for known keywords in path
if (lowerPath.Contains("car") || lowerPath.Contains("vehicle") || lowerPath.Contains("automobile"))
return "cars";
if (lowerPath.Contains("track") || lowerPath.Contains("circuit") || lowerPath.Contains("course"))
return "tracks";
if (lowerPath.Contains("audio") || lowerPath.Contains("sound") || lowerPath.Contains("music"))
return "audio";
if (lowerPath.Contains("texture") || lowerPath.Contains("material") || lowerPath.Contains("skin"))
return "textures";
if (lowerPath.Contains("ui") || lowerPath.Contains("hud") || lowerPath.Contains("menu"))
return "ui";
if (lowerPath.Contains("event") || lowerPath.Contains("race") || lowerPath.Contains("challenge"))
return "events";
if (lowerPath.Contains("dlc") || lowerPath.Contains("expansion") || lowerPath.Contains("addon"))
return "dlc";
if (lowerPath.Contains("update") || lowerPath.Contains("patch"))
return "updates";
// Fall back to first folder name
var pathParts = fullPath.Split('/', '\\');
if (pathParts.Length > 1)
return pathParts[0].ToLowerInvariant();
return null; // Will default to "base"
}
private string DetermineAssetType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
@@ -467,3 +565,23 @@ public class AssetStats
public long TotalSizeMB { get; set; }
public int TotalDownloads { get; set; }
}
// Manifest models for automatic ZIP metadata detection
public class AssetManifest
{
public string? Version { get; set; }
public string? GameVersion { get; set; }
public string? Description { get; set; }
public string? Author { get; set; }
public string? Category { get; set; }
public List<ManifestAsset>? Assets { get; set; }
}
public class ManifestAsset
{
public string File { get; set; } = string.Empty;
public string? Category { get; set; }
public string? Type { get; set; }
public bool Required { get; set; } = true;
public string? Description { get; set; }
}