A wiped/fresh client (NukeIdentityOnStartup, new install, or any path that clears PlayerPrefs) defaults its stored RES_VER to "00000000" per Cute/SavedataManager.GetResourceVersion. The client builds the Akamai manifest URL as dl/Manifest/<RES_VER>/<lang>/<Platform>/, and Akamai 404s the "00000000" path -> Toolbox.AssetManager.InitializeManifest fails -> the title screen shows "Connection Error / Reconnect" before any tutorial UI loads. Fix: - New ResourceConfig [ConfigSection] in SVSim.Database — single field RequiredResVer defaulting to "4670rPsPMVlRTd2" (the value prod returned in data_dumps/traffic_prod_tutorial.ndjson and was still returning at 2026-05-28 21:00 UTC). Lives in GameConfigs so it can be tuned via DB / appsettings without code edits. - ShadowverseTranslationMiddleware injects IGameConfigService and emits required_res_ver in data_headers ONLY on /check/game_start responses. NetworkTask.Parse opens a "new data is available" popup whenever required_res_ver is present and the URL is anything other than GameStartCheck (NetworkTask.cs:128-138); the suppression on game_start is what lets us silently bump PlayerPrefs["RES_VER"] before ResourceDownloader runs. - DataHeaders gains a nullable RequiredResVer field. DataWrapper.DataHeaders is now Dictionary<string, object?> instead of the typed DataHeaders POCO directly — the construction site stays type-safe (the middleware builds the typed POCO, then projects through the same STJ + ConvertJsonTreeToPlainObject pipeline that DataWrapper.Data uses) so null-valued optional fields are absent from the wire instead of being written as "key":null. Without this, MessagePack's ContractlessStandardResolver walked the typed properties and wrote required_res_ver=null on every non-game_start response, tripping the popup on every boot. - GameConfigurationJsonbTests updated to expect the 9th config section. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
158 lines
8.1 KiB
C#
158 lines
8.1 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Models.Config;
|
|
using SVSim.UnitTests.Infrastructure;
|
|
|
|
namespace SVSim.UnitTests.Models;
|
|
|
|
/// <summary>
|
|
/// Round-trip tests for the GameConfigs key/value table — one row per section, raw jsonb payload
|
|
/// deserialised by IGameConfigService rather than EF Core. Replaces the prior single-row
|
|
/// GameConfigurations / GameConfigRoot jsonb-tree shape (2026-05-24 refactor).
|
|
/// </summary>
|
|
public class GameConfigurationJsonbTests
|
|
{
|
|
[Test]
|
|
public async Task EnsureSeedData_writes_one_row_per_ConfigSection_with_ShippedDefaults_payload()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
|
var byName = rows.ToDictionary(r => r.SectionName);
|
|
|
|
// One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants,
|
|
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig).
|
|
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
|
{
|
|
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
|
"MyRotationSchedule", "Story", "ResourceConfig",
|
|
}));
|
|
|
|
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
|
Assert.That(resources.RequiredResVer, Is.EqualTo("4670rPsPMVlRTd2"),
|
|
"ShippedDefaults RES_VER is the prod-captured (2026-05-28) Akamai manifest path " +
|
|
"— required by the client to load the asset manifest after a wiped/fresh install.");
|
|
|
|
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
|
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
|
|
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");
|
|
Assert.That(mrSchedule.FreeBattle.End, Is.EqualTo(new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc)));
|
|
|
|
var packRates = JsonSerializer.Deserialize<PackRateConfig>(byName["PackRates"].ValueJson)!;
|
|
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic AnimatedRate");
|
|
Assert.That(packRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
|
var slot8 = packRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
|
Assert.That(slot8, Is.Not.Null, "ShippedDefaults() includes the slot-8 Silver-or-better entry");
|
|
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
|
|
|
|
var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!;
|
|
Assert.That(grants.Crystals, Is.EqualTo(50000UL));
|
|
Assert.That(grants.Rupees, Is.EqualTo(50000UL));
|
|
Assert.That(grants.Ether, Is.EqualTo(50000UL));
|
|
|
|
var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!;
|
|
Assert.That(player.MaxFriends, Is.EqualTo(20));
|
|
|
|
var loadout = JsonSerializer.Deserialize<DefaultLoadoutConfig>(byName["DefaultLoadout"].ValueJson)!;
|
|
Assert.That(loadout.SleeveId, Is.EqualTo(3000011));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Section_row_round_trips_through_jsonb_via_raw_json()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
|
// Hydrate, mutate, re-serialise — the pattern RotationConfigImporter and any admin-write
|
|
// path will use.
|
|
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
|
value.TsRotationId = "99999";
|
|
value.IsBattlePassPeriod = true;
|
|
rotation.ValueJson = JsonSerializer.Serialize(value);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
|
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
|
Assert.That(value.TsRotationId, Is.EqualTo("99999"));
|
|
Assert.That(value.IsBattlePassPeriod, Is.True);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Operator-edited PerSlot override (e.g. 100% Legendary for testing) must survive a DB
|
|
/// round-trip and produce exactly ONE entry per slot — not stack on top of any default seed.
|
|
/// The 2026-05-24 bug shape: pre-refactor PackRateConfig.PerSlot shipped with a Classic
|
|
/// slot-8 seed in its initialiser; EF Core 8's OwnsMany jsonb path appended the operator's
|
|
/// override on top instead of replacing it, and the seed won the FirstOrDefault in
|
|
/// ResolveWeights. Post-refactor this can't happen (PerSlot defaults to empty,
|
|
/// IGameConfigService uses pure STJ which replaces) but the round-trip assertion stays.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Operator_PerSlot_override_round_trips_as_sole_entry_for_that_slot()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
|
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
|
// Operator wipes the seeded slot-8 entry and replaces it with a 100%-Legendary override.
|
|
rates.PerSlot.Clear();
|
|
rates.PerSlot.Add(new SlotRarityWeights
|
|
{
|
|
Slot = "8", Bronze = 0, Silver = 0, Gold = 0, Legendary = 1,
|
|
});
|
|
row.ValueJson = JsonSerializer.Serialize(rates);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
|
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
|
|
|
var slot8Entries = rates.PerSlot.Where(s => s.Slot == "8").ToList();
|
|
Assert.That(slot8Entries, Has.Count.EqualTo(1),
|
|
"exactly one PerSlot[8] entry must round-trip — duplicates mean the loader appended " +
|
|
"instead of replacing (the 2026-05-24 bug pattern).");
|
|
Assert.That(slot8Entries[0].Legendary, Is.EqualTo(1.0).Within(1e-9),
|
|
"the surviving PerSlot[8] entry must be the operator's override, not a stale seed.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task RotationConfigImporter_updates_Rotation_without_clobbering_other_sections()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
await factory.SeedGlobalsAsync(); // imports load-index which has ts_rotation_id="10015"
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
var rotation = JsonSerializer.Deserialize<RotationConfig>(
|
|
(await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation")).ValueJson)!;
|
|
Assert.That(rotation.TsRotationId, Is.EqualTo("10015"),
|
|
"RotationConfigImporter should set Rotation.TsRotationId from the seed.");
|
|
|
|
// PackRates is NOT in the load-index capture; its row must keep ShippedDefaults values.
|
|
var packRates = JsonSerializer.Deserialize<PackRateConfig>(
|
|
(await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates")).ValueJson)!;
|
|
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
|
"RotationConfigImporter must not clobber PackRates while updating Rotation.");
|
|
}
|
|
}
|