feat(config): wire /config/update + /config/update_challenge_config (closes family 4/4)

is_skip_gacha_effect, use_challenge_two_pick_premium_card, and
challenge_two_pick_sleeve_id all persist on ViewerInfo and round-trip
through /load/index.

The two challenge fields move from the global ChallengeConfig section to
ViewerInfo — they're viewer preferences, not server-wide policy. Premium
card defaults to 0; sleeve falls back to DefaultLoadoutConfig.SleeveId
(3000011) when unset. MatchContextBuilder's TK2 sleeve read switches to
the same viewer-scoped path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-13 11:25:40 -04:00
parent b5e13551cd
commit 715dd4dea8
17 changed files with 4860 additions and 16 deletions

View File

@@ -1,6 +1,4 @@
{
"use_two_pick_premium_card": false,
"two_pick_sleeve_id": 3000011,
"last_card_pack_set_id": 10015,
"card_pool_name": "Throwback Rotation",
"card_pool_url": "",

View File

@@ -40,8 +40,6 @@ public class RotationConfigImporter
{
await UpsertSection<ChallengeConfig>(context, ChallengeConfig.ShippedDefaults, c =>
{
c.UseTwoPickPremiumCard = cc.UseTwoPickPremiumCard;
c.TwoPickSleeveId = cc.TwoPickSleeveId;
c.LastCardPackSetId = cc.LastCardPackSetId;
c.CardPoolName = cc.CardPoolName;
c.CardPoolUrl = cc.CardPoolUrl;

View File

@@ -19,9 +19,6 @@ public sealed class RotationConfigSeed
/// <summary>Mirrors <c>seeds/challenge-config.json</c>. Drives the Challenge <c>GameConfigSection</c>.</summary>
public sealed class ChallengeConfigSeed
{
[JsonPropertyName("use_two_pick_premium_card")] public bool UseTwoPickPremiumCard { get; set; }
[JsonPropertyName("two_pick_sleeve_id")] public long TwoPickSleeveId { get; set; }
[JsonPropertyName("last_card_pack_set_id")] public int LastCardPackSetId { get; set; }
[JsonPropertyName("card_pool_name")] public string CardPoolName { get; set; } = "";
[JsonPropertyName("card_pool_url")] public string CardPoolUrl { get; set; } = "";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class ConfigViewerOverrides : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "Info_ChallengeTwoPickSleeveId",
table: "Viewers",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<bool>(
name: "Info_IsSkipGachaEffect",
table: "Viewers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Info_UseChallengeTwoPickPremiumCard",
table: "Viewers",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Info_ChallengeTwoPickSleeveId",
table: "Viewers");
migrationBuilder.DropColumn(
name: "Info_IsSkipGachaEffect",
table: "Viewers");
migrationBuilder.DropColumn(
name: "Info_UseChallengeTwoPickPremiumCard",
table: "Viewers");
}
}
}

View File

@@ -4336,6 +4336,9 @@ namespace SVSim.Database.Migrations
b1.Property<DateTime>("BirthDate")
.HasColumnType("timestamp with time zone");
b1.Property<long>("ChallengeTwoPickSleeveId")
.HasColumnType("bigint");
b1.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("text");
@@ -4352,6 +4355,9 @@ namespace SVSim.Database.Migrations
b1.Property<bool>("IsPrizePreferred")
.HasColumnType("boolean");
b1.Property<bool>("IsSkipGachaEffect")
.HasColumnType("boolean");
b1.Property<int>("MaxFriends")
.HasColumnType("integer");
@@ -4361,6 +4367,9 @@ namespace SVSim.Database.Migrations
b1.Property<int>("SelectedEmblemId")
.HasColumnType("integer");
b1.Property<bool>("UseChallengeTwoPickPremiumCard")
.HasColumnType("boolean");
b1.HasKey("ViewerId");
b1.HasIndex("SelectedDegreeId");

View File

@@ -3,9 +3,6 @@ namespace SVSim.Database.Models.Config;
[ConfigSection("Challenge")]
public class ChallengeConfig
{
public bool UseTwoPickPremiumCard { get; set; }
public long TwoPickSleeveId { get; set; }
// Wire-mirrored fields from format_info (ChallengeData.cs parser)
public int LastCardPackSetId { get; set; }
public string CardPoolName { get; set; } = "";

View File

@@ -12,6 +12,9 @@ public class ViewerInfo
public bool IsOfficialMarkDisplayed { get; set; }
public bool IsFoilPreferred { get; set; }
public bool IsPrizePreferred { get; set; }
public bool IsSkipGachaEffect { get; set; }
public bool UseChallengeTwoPickPremiumCard { get; set; }
public long ChallengeTwoPickSleeveId { get; set; }
#region Navigation Properties

View File

@@ -39,4 +39,29 @@ public class ConfigController : SVSimController
await _db.SaveChangesAsync(ct);
return new EmptyResponse();
}
[HttpPost("update")]
public async Task<ActionResult<EmptyResponse>> Update(
ConfigUpdateRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await _db.Viewers.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null) return Unauthorized();
viewer.Info.IsSkipGachaEffect = request.IsSkipGachaEffect != 0;
await _db.SaveChangesAsync(ct);
return new EmptyResponse();
}
[HttpPost("update_challenge_config")]
public async Task<ActionResult<EmptyResponse>> UpdateChallengeConfig(
ConfigUpdateChallengeConfigRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await _db.Viewers.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null) return Unauthorized();
viewer.Info.UseChallengeTwoPickPremiumCard = request.UseChallengeTwoPickPremiumCard != 0;
viewer.Info.ChallengeTwoPickSleeveId = request.ChallengeTwoPickSleeveId;
await _db.SaveChangesAsync(ct);
return new EmptyResponse();
}
}

View File

@@ -248,8 +248,10 @@ public class LoadController : SVSimController
}).ToList(),
ArenaConfig = new ArenaConfig
{
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
UseChallengePickTwoPremiumCard = viewer.Info.UseChallengeTwoPickPremiumCard ? 1 : 0,
ChallengePickTwoCardSleeve = (int)(viewer.Info.ChallengeTwoPickSleeveId != 0
? viewer.Info.ChallengeTwoPickSleeveId
: defaultLoadout.SleeveId),
},
ArenaInfos = await BuildArenaInfosAsync(viewer.Id),
RotationSets = rotationSets,
@@ -257,6 +259,7 @@ public class LoadController : SVSimController
{
IsFoilPreferred = viewer.Info.IsFoilPreferred ? 1 : 0,
IsPrizePreferred = viewer.Info.IsPrizePreferred ? 1 : 0,
IsSkipGachaEffect = viewer.Info.IsSkipGachaEffect ? 1 : 0,
},
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
.Select(bf => bf.Id.ToString()).ToList(),

View File

@@ -115,6 +115,7 @@ public class MyPageController : SVSimController
{
IsFoilPreferred = viewer.Info.IsFoilPreferred ? 1 : 0,
IsPrizePreferred = viewer.Info.IsPrizePreferred ? 1 : 0,
IsSkipGachaEffect = viewer.Info.IsSkipGachaEffect ? 1 : 0,
},
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),

View File

@@ -0,0 +1,16 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
[MessagePackObject]
public class ConfigUpdateChallengeConfigRequest : BaseRequest
{
[JsonPropertyName("use_challenge_two_pick_premium_card")]
[Key("use_challenge_two_pick_premium_card")]
public int UseChallengeTwoPickPremiumCard { get; set; }
[JsonPropertyName("challenge_two_pick_sleeve_id")]
[Key("challenge_two_pick_sleeve_id")]
public long ChallengeTwoPickSleeveId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
[MessagePackObject]
public class ConfigUpdateRequest : BaseRequest
{
[JsonPropertyName("is_skip_gacha_effect")]
[Key("is_skip_gacha_effect")]
public int IsSkipGachaEffect { get; set; }
}

View File

@@ -27,4 +27,7 @@ public class UserConfig
[JsonPropertyName("is_prize_preferred")]
[Key("is_prize_preferred")]
public int IsPrizePreferred { get; set; }
[JsonPropertyName("is_skip_gacha_effect")]
[Key("is_skip_gacha_effect")]
public int IsSkipGachaEffect { get; set; }
}

View File

@@ -39,7 +39,6 @@ public class MatchContextBuilder : IMatchContextBuilder
var viewer = await _viewers.LoadForMatchContextAsync(viewerId)
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
var challenge = _config.Get<ChallengeConfig>();
var defaults = _config.Get<DefaultLoadoutConfig>();
var emblemId = viewer.Info.SelectedEmblem.Id != 0
@@ -51,6 +50,11 @@ public class MatchContextBuilder : IMatchContextBuilder
var charaId = run.LeaderSkinId != 0
? run.LeaderSkinId.ToString()
: run.ClassId.ToString();
// TK2-specific cosmetic source; falls back to the global default sleeve if the
// viewer hasn't set a challenge-specific one via /config/update_challenge_config.
var twoPickSleeveId = viewer.Info.ChallengeTwoPickSleeveId != 0
? viewer.Info.ChallengeTwoPickSleeveId
: defaults.SleeveId;
return new MatchContext(
SelfDeckCardIds: deck,
@@ -61,7 +65,7 @@ public class MatchContextBuilder : IMatchContextBuilder
CountryCode: viewer.Info.CountryCode ?? string.Empty,
UserName: viewer.DisplayName,
// TK2-specific cosmetic source; other modes will use the deck row's SleeveId.
SleeveId: challenge.TwoPickSleeveId.ToString(),
SleeveId: twoPickSleeveId.ToString(),
EmblemId: emblemId,
DegreeId: degreeId,
// Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred).

View File

@@ -53,4 +53,74 @@ public class ConfigControllerPreferencesTests
var cfg = doc.RootElement.GetProperty("user_config");
Assert.That(cfg.GetProperty("is_prize_preferred").GetInt32(), Is.EqualTo(1));
}
[Test]
public async Task Update_persists_is_skip_gacha_effect_and_appears_in_load_index()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var setJson = """{"is_skip_gacha_effect":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var setResp = await client.PostAsync("/config/update",
new StringContent(setJson, Encoding.UTF8, "application/json"));
var setBody = await setResp.Content.ReadAsStringAsync();
Assert.That(setResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), setBody);
var loadJson = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"none","card_master_hash":""}""";
var loadResp = await client.PostAsync("/load/index",
new StringContent(loadJson, Encoding.UTF8, "application/json"));
var body = await loadResp.Content.ReadAsStringAsync();
Assert.That(loadResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var cfg = doc.RootElement.GetProperty("user_config");
Assert.That(cfg.GetProperty("is_skip_gacha_effect").GetInt32(), Is.EqualTo(1), body);
}
[Test]
public async Task UpdateChallengeConfig_round_trips_through_load_index()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var setJson = """{"use_challenge_two_pick_premium_card":1,"challenge_two_pick_sleeve_id":3000099,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var setResp = await client.PostAsync("/config/update_challenge_config",
new StringContent(setJson, Encoding.UTF8, "application/json"));
var setBody = await setResp.Content.ReadAsStringAsync();
Assert.That(setResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), setBody);
var loadJson = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"none","card_master_hash":""}""";
var loadResp = await client.PostAsync("/load/index",
new StringContent(loadJson, Encoding.UTF8, "application/json"));
var body = await loadResp.Content.ReadAsStringAsync();
Assert.That(loadResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var challenge = doc.RootElement.GetProperty("challenge_config");
Assert.That(challenge.GetProperty("use_challenge_two_pick_premium_card").GetInt32(), Is.EqualTo(1), body);
Assert.That(challenge.GetProperty("challenge_two_pick_sleeve_id").GetInt32(), Is.EqualTo(3000099), body);
}
[Test]
public async Task LoadIndex_challenge_config_falls_back_to_default_sleeve_when_viewer_unset()
{
// Fresh viewer never touched /config/update_challenge_config — premium card defaults
// to 0 and sleeve falls back to DefaultLoadoutConfig.SleeveId (3000011).
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var loadJson = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"none","card_master_hash":""}""";
var loadResp = await client.PostAsync("/load/index",
new StringContent(loadJson, Encoding.UTF8, "application/json"));
var body = await loadResp.Content.ReadAsStringAsync();
Assert.That(loadResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var challenge = doc.RootElement.GetProperty("challenge_config");
Assert.That(challenge.GetProperty("use_challenge_two_pick_premium_card").GetInt32(), Is.EqualTo(0), body);
Assert.That(challenge.GetProperty("challenge_two_pick_sleeve_id").GetInt32(), Is.EqualTo(3000011), body);
}
}

View File

@@ -68,8 +68,9 @@ public class MatchContextBuilderTests
// Hardcoded v1 fixtures (see spec §Deferred plumbing)
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
Assert.That(ctx.FieldId, Is.EqualTo(43));
// Sleeve comes from ChallengeConfig.TwoPickSleeveId — ShippedDefaults is 0.
Assert.That(ctx.SleeveId, Is.EqualTo("0"));
// Sleeve falls back to DefaultLoadoutConfig.SleeveId when the viewer hasn't set
// a challenge-specific one via /config/update_challenge_config.
Assert.That(ctx.SleeveId, Is.EqualTo("3000011"));
}
[Test]