NEO-51: Fail-fast item catalog load at server startup.
Mirror NEO-34/validate_content.py: *_items.json schema validation, Slice 1 gate, ItemDefinitionCatalog DI, loader/host tests, Bruno health smoke.pull/86/head
parent
b461f3bf47
commit
2ba129dca1
|
|
@ -0,0 +1,25 @@
|
||||||
|
meta {
|
||||||
|
name: GET health (item catalog boot NEO-51)
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/health
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-51 loads content/items/*_items.json at startup (fail-fast). No item HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("status 200", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service identity", function () {
|
||||||
|
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: item-catalog
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Items;
|
||||||
|
|
||||||
|
internal static class ItemCatalogTestPaths
|
||||||
|
{
|
||||||
|
internal static string DiscoverRepoItemsDirectory() =>
|
||||||
|
ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
|
||||||
|
internal static string DiscoverRepoItemDefSchemaPath() =>
|
||||||
|
ItemCatalogPathResolution.ResolveItemDefSchemaPath(
|
||||||
|
DiscoverRepoItemsDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Items;
|
||||||
|
|
||||||
|
public class ItemDefinitionCatalogLoaderTests
|
||||||
|
{
|
||||||
|
private const string ValidPrototypeCatalogJson =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "scrap_metal_bulk",
|
||||||
|
"displayName": "Scrap Metal (Bulk)",
|
||||||
|
"prototypeRole": "material",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "refined_plate_stock",
|
||||||
|
"displayName": "Refined Plate Stock",
|
||||||
|
"prototypeRole": "intermediate",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "field_stim_mk0",
|
||||||
|
"displayName": "Field Stim Mk0",
|
||||||
|
"prototypeRole": "consumable",
|
||||||
|
"stackMax": 20,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "survey_drone_kit",
|
||||||
|
"displayName": "Survey Drone Kit",
|
||||||
|
"prototypeRole": "utility",
|
||||||
|
"stackMax": 1,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "contract_handoff_token",
|
||||||
|
"displayName": "Contract Handoff Token",
|
||||||
|
"prototypeRole": "quest_token",
|
||||||
|
"stackMax": 1,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_armor_shell",
|
||||||
|
"displayName": "Prototype Armor Shell",
|
||||||
|
"prototypeRole": "equip_stub",
|
||||||
|
"stackMax": 1,
|
||||||
|
"inventorySlotKind": "equipment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static (string Root, string ItemsDir, string SchemaPath) CreateTempContentLayout()
|
||||||
|
{
|
||||||
|
var root = Directory.CreateTempSubdirectory("neon-sprawl-itemcat-");
|
||||||
|
var itemsDir = Path.Combine(root.FullName, "content", "items");
|
||||||
|
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||||
|
Directory.CreateDirectory(itemsDir);
|
||||||
|
Directory.CreateDirectory(schemaDir);
|
||||||
|
var schemaPath = Path.Combine(schemaDir, "item-def.schema.json");
|
||||||
|
File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true);
|
||||||
|
return (root.FullName, itemsDir, schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var catalog = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(6, catalog.DistinctItemCount);
|
||||||
|
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||||
|
Assert.True(catalog.TryGetItem("scrap_metal_bulk", out var row));
|
||||||
|
Assert.NotNull(row);
|
||||||
|
Assert.Equal("material", row!.PrototypeRole);
|
||||||
|
Assert.Equal(999, row.StackMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenItemsIsNotArray()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(itemsDir, "bad_items.json"),
|
||||||
|
"""{"schemaVersion": 1, "items": "nope"}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("expected top-level 'items' array", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(itemsDir, "bad_items.json"),
|
||||||
|
"""{"schemaVersion": 2, "items": []}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenRowViolatesSchema()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var bad = """
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "scrap_metal_bulk",
|
||||||
|
"displayName": "",
|
||||||
|
"prototypeRole": "material",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "bad_items.json"), bad, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Item catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var singleItem = """
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "scrap_metal_bulk",
|
||||||
|
"displayName": "Scrap Metal (Bulk)",
|
||||||
|
"prototypeRole": "material",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "a_items.json"), singleItem, Encoding.UTF8);
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "b_items.json"), singleItem, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("duplicate item id", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("a_items.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("b_items.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenSlice1IdsIncomplete()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var twoOnly = """
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "scrap_metal_bulk",
|
||||||
|
"displayName": "Scrap Metal (Bulk)",
|
||||||
|
"prototypeRole": "material",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "refined_plate_stock",
|
||||||
|
"displayName": "Refined Plate Stock",
|
||||||
|
"prototypeRole": "intermediate",
|
||||||
|
"stackMax": 999,
|
||||||
|
"inventorySlotKind": "bag"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "partial_items.json"), twoOnly, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("prototype Slice 1 expects exactly item ids", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenEquipStubUsesBagSlot()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var badEquip = ValidPrototypeCatalogJson.Replace(
|
||||||
|
"\"inventorySlotKind\": \"equipment\"",
|
||||||
|
"\"inventorySlotKind\": \"bag\"",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), badEquip, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("equip_stub", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("inventorySlotKind 'equipment'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/health");
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var catalog = factory.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
|
Assert.Equal(6, catalog.DistinctItemCount);
|
||||||
|
Assert.True(catalog.TryGetItem("prototype_armor_shell", out var shell));
|
||||||
|
Assert.Equal("equipment", shell!.InventorySlotKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-items-" + Guid.NewGuid().ToString("n"));
|
||||||
|
Directory.CreateDirectory(badDir);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() =>
|
||||||
|
{
|
||||||
|
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
b.UseSetting("Content:ItemsDirectory", badDir));
|
||||||
|
factory.CreateClient();
|
||||||
|
});
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(ex);
|
||||||
|
Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -30,8 +31,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>Resolves item catalog paths for local dev, tests, and container layouts (NEO-51).</summary>
|
||||||
|
public static class ItemCatalogPathResolution
|
||||||
|
{
|
||||||
|
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/items</c> directory.</summary>
|
||||||
|
public static string? TryDiscoverItemsDirectory(string startDirectory)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "content", "items");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
return Path.GetFullPath(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the items catalog directory.
|
||||||
|
/// Empty <paramref name="configuredItemsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveItemsDirectory(string? configuredItemsDirectory, string contentRootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredItemsDirectory))
|
||||||
|
{
|
||||||
|
var discovered = TryDiscoverItemsDirectory(AppContext.BaseDirectory);
|
||||||
|
if (discovered is not null)
|
||||||
|
return discovered;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Content:ItemsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/items'). " +
|
||||||
|
"Set Content:ItemsDirectory in configuration or environment (e.g. Content__ItemsDirectory).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = configuredItemsDirectory.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single item row (Draft 2020-12).</summary>
|
||||||
|
public static string ResolveItemDefSchemaPath(
|
||||||
|
string itemsDirectory,
|
||||||
|
string? configuredSchemaPath,
|
||||||
|
string contentRootPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||||
|
{
|
||||||
|
var trimmed = configuredSchemaPath.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(itemsDirectory, "..", "schemas", "item-def.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>DI registration for the fail-fast item catalog (NEO-51).</summary>
|
||||||
|
public static class ItemCatalogServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> as a singleton.</summary>
|
||||||
|
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<ItemDefinitionCatalog>(sp =>
|
||||||
|
{
|
||||||
|
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||||
|
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||||
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("NeonSprawl.Server.Game.Items.ItemCatalog");
|
||||||
|
|
||||||
|
var itemsDir = ItemCatalogPathResolution.ResolveItemsDirectory(opts.ItemsDirectory, hostEnv.ContentRootPath);
|
||||||
|
var schemaPath = ItemCatalogPathResolution.ResolveItemDefSchemaPath(
|
||||||
|
itemsDir,
|
||||||
|
opts.ItemDefSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
|
||||||
|
return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>One validated <c>ItemDef</c> row from <c>content/items/*_items.json</c> (NEO-51).</summary>
|
||||||
|
public sealed record ItemDefRow(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
string PrototypeRole,
|
||||||
|
int StackMax,
|
||||||
|
string InventorySlotKind,
|
||||||
|
string? Rarity,
|
||||||
|
string? BindPolicy,
|
||||||
|
int? DurabilityMax);
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52).</summary>
|
||||||
|
public sealed class ItemDefinitionCatalog
|
||||||
|
{
|
||||||
|
public ItemDefinitionCatalog(
|
||||||
|
string itemsDirectory,
|
||||||
|
IReadOnlyDictionary<string, ItemDefRow> byId,
|
||||||
|
int catalogJsonFileCount)
|
||||||
|
{
|
||||||
|
ItemsDirectory = itemsDirectory;
|
||||||
|
ById = new ReadOnlyDictionary<string, ItemDefRow>(new Dictionary<string, ItemDefRow>(byId, StringComparer.Ordinal));
|
||||||
|
CatalogJsonFileCount = catalogJsonFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Absolute path to the directory that was enumerated for <c>*_items.json</c> catalogs.</summary>
|
||||||
|
public string ItemsDirectory { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ItemDefRow> ById { get; }
|
||||||
|
|
||||||
|
public int DistinctItemCount => ById.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_items.json</c> files under <see cref="ItemsDirectory"/>.</summary>
|
||||||
|
public int CatalogJsonFileCount { get; }
|
||||||
|
|
||||||
|
/// <summary>Resolves a catalog row by stable <paramref name="id"/>.</summary>
|
||||||
|
public bool TryGetItem(string id, out ItemDefRow? row) =>
|
||||||
|
ById.TryGetValue(id, out row);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Json.Schema;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>Loads and validates <c>content/items/*_items.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-51).</summary>
|
||||||
|
public static class ItemDefinitionCatalogLoader
|
||||||
|
{
|
||||||
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||||
|
public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger)
|
||||||
|
{
|
||||||
|
itemsDirectory = Path.GetFullPath(itemsDirectory);
|
||||||
|
schemaPath = Path.GetFullPath(schemaPath);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (!File.Exists(schemaPath))
|
||||||
|
errors.Add($"error: missing schema file {schemaPath}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(itemsDirectory))
|
||||||
|
errors.Add($"error: missing directory {itemsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var jsonFiles = Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(p => p, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
errors.Add($"error: no *_items.json files under {itemsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||||
|
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||||
|
|
||||||
|
var itemIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var idToRole = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var idToSlotKind = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var idToStackMax = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var path in jsonFiles)
|
||||||
|
{
|
||||||
|
JsonNode? root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonNode.Parse(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root is not JsonObject rootObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected JSON object at root");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaVersionNode = rootObj["schemaVersion"];
|
||||||
|
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||||
|
schemaVersionValue.TryGetValue<int>(out var schemaVersion) == false ||
|
||||||
|
schemaVersion != 1)
|
||||||
|
{
|
||||||
|
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||||
|
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsNode = rootObj["items"];
|
||||||
|
if (itemsNode is not JsonArray itemsArray)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected top-level 'items' array");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < itemsArray.Count; i++)
|
||||||
|
{
|
||||||
|
var item = itemsArray[i];
|
||||||
|
if (item is not JsonObject rowObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: items[{i}] must be an object");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eval = schema.Evaluate(rowObj, evalOptions);
|
||||||
|
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||||
|
if (!eval.IsValid)
|
||||||
|
{
|
||||||
|
if (schemaMsgs.Count == 0)
|
||||||
|
schemaMsgs.Add($"error: {path} items[{i}] (root): schema validation failed");
|
||||||
|
|
||||||
|
errors.AddRange(schemaMsgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowSchemaErrors = schemaMsgs.Count;
|
||||||
|
|
||||||
|
var iid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (iid is not null && rowSchemaErrors == 0)
|
||||||
|
{
|
||||||
|
if (itemIdToSourceFile.TryGetValue(iid, out var prevPath))
|
||||||
|
{
|
||||||
|
errors.Add($"error: duplicate item id '{iid}' in {prevPath} and {path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemIdToSourceFile[iid] = path;
|
||||||
|
|
||||||
|
var role = (rowObj["prototypeRole"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (role is not null)
|
||||||
|
idToRole[iid] = role;
|
||||||
|
|
||||||
|
var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (slotKind is not null)
|
||||||
|
idToSlotKind[iid] = slotKind;
|
||||||
|
|
||||||
|
if (rowObj["stackMax"] is JsonValue stackMaxValue &&
|
||||||
|
stackMaxValue.TryGetValue<int>(out var stackMax))
|
||||||
|
idToStackMax[iid] = stackMax;
|
||||||
|
|
||||||
|
rows[iid] = ParseRow(rowObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var slice1 = PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError(
|
||||||
|
itemIdToSourceFile,
|
||||||
|
idToRole,
|
||||||
|
idToSlotKind,
|
||||||
|
idToStackMax);
|
||||||
|
if (slice1 is not null)
|
||||||
|
{
|
||||||
|
errors.Add(slice1);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Loaded item catalog from {ItemsDirectory}: {ItemCount} item(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||||
|
itemsDirectory,
|
||||||
|
rows.Count,
|
||||||
|
jsonFiles.Length);
|
||||||
|
|
||||||
|
return new ItemDefinitionCatalog(itemsDirectory, rows, jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemDefRow ParseRow(JsonObject rowObj)
|
||||||
|
{
|
||||||
|
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
|
var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue<string>();
|
||||||
|
var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue<int>();
|
||||||
|
var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue<string>();
|
||||||
|
string? rarity = null;
|
||||||
|
if (rowObj["rarity"] is JsonValue rarityValue)
|
||||||
|
rarity = rarityValue.GetValue<string>();
|
||||||
|
string? bindPolicy = null;
|
||||||
|
if (rowObj["bindPolicy"] is JsonValue bindPolicyValue)
|
||||||
|
bindPolicy = bindPolicyValue.GetValue<string>();
|
||||||
|
int? durabilityMax = null;
|
||||||
|
if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue &&
|
||||||
|
durabilityMaxValue.TryGetValue<int>(out var dmax))
|
||||||
|
durabilityMax = dmax;
|
||||||
|
|
||||||
|
return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||||
|
{
|
||||||
|
var sink = new List<string>();
|
||||||
|
AppendSchemaMessages(eval, filePath, index, sink);
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||||
|
{
|
||||||
|
if (r.HasDetails)
|
||||||
|
{
|
||||||
|
foreach (var d in r.Details!)
|
||||||
|
AppendSchemaMessages(d, filePath, index, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!r.HasErrors)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var kv in r.Errors!)
|
||||||
|
{
|
||||||
|
var loc = r.InstanceLocation?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||||
|
loc = "(root)";
|
||||||
|
|
||||||
|
sink.Add($"error: {filePath} items[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfAny(List<string> errors)
|
||||||
|
{
|
||||||
|
if (errors.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Item catalog validation failed:");
|
||||||
|
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||||
|
sb.AppendLine(e);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prototype Slice 1 roster gate (NEO-50), mirrored from <c>scripts/validate_content.py</c>
|
||||||
|
/// <c>PROTOTYPE_SLICE1_ITEM_IDS</c> / <c>_prototype_slice1_item_gate</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class PrototypeSlice1ItemCatalogRules
|
||||||
|
{
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedItemIds = FrozenSet.ToFrozenSet(
|
||||||
|
[
|
||||||
|
"scrap_metal_bulk",
|
||||||
|
"refined_plate_stock",
|
||||||
|
"field_stim_mk0",
|
||||||
|
"survey_drone_kit",
|
||||||
|
"contract_handoff_token",
|
||||||
|
"prototype_armor_shell",
|
||||||
|
],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_ROLES</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedPrototypeRoles = FrozenSet.ToFrozenSet(
|
||||||
|
["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the Slice 1 contract fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetSlice1GateError(
|
||||||
|
IReadOnlyDictionary<string, string> itemIdToSourceFile,
|
||||||
|
IReadOnlyDictionary<string, string> idToRole,
|
||||||
|
IReadOnlyDictionary<string, string> idToSlotKind,
|
||||||
|
IReadOnlyDictionary<string, int> idToStackMax)
|
||||||
|
{
|
||||||
|
var ids = itemIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
if (!ids.SetEquals(ExpectedItemIds))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
"error: prototype Slice 1 expects exactly item ids " +
|
||||||
|
$"[{string.Join(", ", ExpectedItemIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||||
|
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = idToRole.Values.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (!roles.SetEquals(ExpectedPrototypeRoles))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
"error: prototype Slice 1 requires exactly one row per prototypeRole " +
|
||||||
|
$"[{string.Join(", ", ExpectedPrototypeRoles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}], " +
|
||||||
|
$"roles seen: [{string.Join(", ", roles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
string? equipStubId = null;
|
||||||
|
foreach (var kv in idToRole)
|
||||||
|
{
|
||||||
|
if (kv.Value == "equip_stub")
|
||||||
|
{
|
||||||
|
equipStubId = kv.Key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipStubId is not null)
|
||||||
|
{
|
||||||
|
if (!idToSlotKind.TryGetValue(equipStubId, out var slotKind) || slotKind != "equipment")
|
||||||
|
{
|
||||||
|
idToSlotKind.TryGetValue(equipStubId, out slotKind);
|
||||||
|
return
|
||||||
|
$"error: '{equipStubId}' (equip_stub) must use inventorySlotKind 'equipment', got '{slotKind}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!idToStackMax.TryGetValue(equipStubId, out var stackMax) || stackMax != 1)
|
||||||
|
{
|
||||||
|
idToStackMax.TryGetValue(equipStubId, out stackMax);
|
||||||
|
return $"error: '{equipStubId}' (equip_stub) must use stackMax 1, got {stackMax}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,4 +28,16 @@ public sealed class ContentPathsOptions
|
||||||
/// When unset, resolved as <c>{parent of mastery directory}/schemas/mastery-catalog.schema.json</c>.
|
/// When unset, resolved as <c>{parent of mastery directory}/schemas/mastery-catalog.schema.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? MasteryCatalogSchemaPath { get; set; }
|
public string? MasteryCatalogSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||||
|
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/items</c> directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? ItemsDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>item-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of items directory}/schemas/item-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? ItemDefSchemaPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Interaction;
|
using NeonSprawl.Server.Game.Interaction;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -13,10 +14,12 @@ builder.Services.AddPerkStateStore(builder.Configuration);
|
||||||
builder.Services.AddAbilityCooldownStore();
|
builder.Services.AddAbilityCooldownStore();
|
||||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||||
|
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
||||||
|
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Content": {
|
"Content": {
|
||||||
"SkillsDirectory": "",
|
"SkillsDirectory": "",
|
||||||
"SkillDefSchemaPath": ""
|
"SkillDefSchemaPath": "",
|
||||||
|
"ItemsDirectory": "",
|
||||||
|
"ItemDefSchemaPath": ""
|
||||||
},
|
},
|
||||||
"Game": {
|
"Game": {
|
||||||
"DevPlayerId": "dev-local-1",
|
"DevPlayerId": "dev-local-1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue