Add game configuration management: implement endpoints for retrieving and updating discovered game configurations; enhance database schema and UI integration

This commit is contained in:
Alexa
2026-06-02 17:14:39 +03:00
parent 810ba0992c
commit ef440bedaa
6 changed files with 457 additions and 13 deletions
@@ -1531,6 +1531,19 @@ public class AdminController(
return Ok(doors);
}
[HttpGet("settings/gameconfigs")]
public async Task<ActionResult> GetDiscoveredGameConfigs()
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
[HttpPost("settings/gameconfigs")]
public async Task<ActionResult> SetDiscoveredGameConfigs([FromBody] DiscoveredGameConfigSettings body)
{
var settings = await serverSettings.SetDiscoveredGameConfigsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "discovered");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
+1
View File
@@ -611,6 +611,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty);
e.Property(s => s.RecCenterDoorsJson).HasDefaultValue(string.Empty);
e.Property(s => s.DiscoveredGameConfigsJson).HasDefaultValue(string.Empty);
});
modelBuilder.Entity<RoomKeyEntity>(e =>
@@ -48,5 +48,11 @@ public class ServerSettingsEntity
/// Door.{Category}.Title and Door.{Category}.Query entries.</summary>
public string RecCenterDoorsJson { get; set; } = string.Empty;
/// <summary>Admin-managed values for GameConfig keys confirmed in
/// the 2020.12 decomp. Kept typed in the admin API; stored as JSON
/// here so newly discovered built-in keys do not require a table
/// migration each time.</summary>
public string DiscoveredGameConfigsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+130 -12
View File
@@ -66,10 +66,18 @@ public class ServerSettingsService(DorkNetDbContext db)
return ToRecCenterDoors(row);
}
public async Task<DiscoveredGameConfigSettings> GetDiscoveredGameConfigsAsync()
{
var row = await GetAsync();
return ToDiscoveredGameConfigs(row);
}
public async Task<IReadOnlyList<GameConfigurationSetting>> GetGameConfigurationsAsync()
{
var doors = await GetRecCenterDoorsAsync();
return ToGameConfigurations(doors);
var row = await GetAsync();
return ToGameConfigurations(
ToRecCenterDoors(row),
ToDiscoveredGameConfigs(row));
}
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
@@ -95,6 +103,17 @@ public class ServerSettingsService(DorkNetDbContext db)
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<DiscoveredGameConfigSettings> SetDiscoveredGameConfigsAsync(
DiscoveredGameConfigSettings settings)
{
var normalized = NormalizeDiscoveredGameConfigs(settings);
var existing = await GetTrackedRowAsync();
existing.DiscoveredGameConfigsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
bool completedRequired,
IReadOnlyList<WeeklyChallengeTemplate> challenges,
@@ -209,6 +228,28 @@ public class ServerSettingsService(DorkNetDbContext db)
return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt };
}
private static DiscoveredGameConfigSettings ToDiscoveredGameConfigs(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.DiscoveredGameConfigsJson))
{
try
{
var parsed = JsonSerializer.Deserialize<DiscoveredGameConfigSettings>(
row.DiscoveredGameConfigsJson,
JsonOptions);
if (parsed is not null)
return NormalizeDiscoveredGameConfigs(parsed)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break gameconfig bootstrap.
}
}
return DefaultDiscoveredGameConfigs() with { UpdatedAt = row.UpdatedAt };
}
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
IReadOnlyList<WeeklyChallengeTemplate> challenges)
{
@@ -323,8 +364,27 @@ public class ServerSettingsService(DorkNetDbContext db)
],
UpdatedAt: DateTime.UtcNow);
public static DiscoveredGameConfigSettings DefaultDiscoveredGameConfigs() =>
new(
FriendsPostGamePromptUnderFriendCount: 3,
FriendsSuggestFriendCodeOnFriendsScreenCount: 5,
ScreensForceVerification: false,
VrForceVerification: false,
RewardsUseRewardSelection: false,
RewardsSelectionTimeout: 30,
RoomDetailsPhotoRollEnabled: false,
LoadingNetworkTimeout: 30,
RunningNetworkTimeout: 30,
SynchronizedFieldRemoveDefaultEntries: false,
RenderingDisableSrpBatcher: false,
SplitTestSoftOverrides: "{}",
SplitTestHardOverrides: "{}",
SplitTestSegmentProbabilities: "{}",
UpdatedAt: DateTime.UtcNow);
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
RecCenterDoorSettings doors)
RecCenterDoorSettings doors,
DiscoveredGameConfigSettings discovered)
{
var rows = new List<GameConfigurationSetting>();
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
@@ -333,16 +393,22 @@ public class ServerSettingsService(DorkNetDbContext db)
rows.Add(new($"Door.{door.Key}.Query", door.Query));
}
var gameConfig = NormalizeDiscoveredGameConfigs(discovered);
rows.AddRange([
new("Rewards.UseRewardSelection", "false"),
new("RoomDetails.PhotoRollEnabled", "false"),
new("SynchronizedField.RemoveDefaultEntries", "false"),
new("Rendering.DisableSrpBatcher", "false"),
new("splitTestSoftOverrides", "{}"),
new("splitTestHardOverrides", "{}"),
new("splitTestSegmentProbabilities", "{}"),
new("loadingNetworkTimeout", "30"),
new("runningNetworkTimeout", "30"),
new("Friends.PostGamePromptUnderFriendCount", gameConfig.FriendsPostGamePromptUnderFriendCount.ToString()),
new("Friends.SuggestFriendCodeOnFriendsScreenCount", gameConfig.FriendsSuggestFriendCodeOnFriendsScreenCount.ToString()),
new("Screens.ForceVerification", gameConfig.ScreensForceVerification ? "1" : "0"),
new("VR.ForceVerification", gameConfig.VrForceVerification ? "1" : "0"),
new("Rewards.UseRewardSelection", gameConfig.RewardsUseRewardSelection.ToString().ToLowerInvariant()),
new("Rewards.SelectionTimeout", gameConfig.RewardsSelectionTimeout.ToString()),
new("RoomDetails.PhotoRollEnabled", gameConfig.RoomDetailsPhotoRollEnabled.ToString().ToLowerInvariant()),
new("loadingNetworkTimeout", gameConfig.LoadingNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("runningNetworkTimeout", gameConfig.RunningNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("SynchronizedField.RemoveDefaultEntries", gameConfig.SynchronizedFieldRemoveDefaultEntries.ToString().ToLowerInvariant()),
new("Rendering.DisableSrpBatcher", gameConfig.RenderingDisableSrpBatcher.ToString().ToLowerInvariant()),
new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides),
new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides),
new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities),
]);
return rows;
}
@@ -415,6 +481,41 @@ public class ServerSettingsService(DorkNetDbContext db)
normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors,
DateTime.UtcNow);
}
private static DiscoveredGameConfigSettings NormalizeDiscoveredGameConfigs(
DiscoveredGameConfigSettings settings)
{
static string JsonObjectOrDefault(string value)
{
value = string.IsNullOrWhiteSpace(value) ? "{}" : value.Trim();
try
{
using var doc = JsonDocument.Parse(value);
return doc.RootElement.ValueKind == JsonValueKind.Object ? value : "{}";
}
catch
{
return "{}";
}
}
return new DiscoveredGameConfigSettings(
FriendsPostGamePromptUnderFriendCount: Math.Clamp(settings.FriendsPostGamePromptUnderFriendCount, 0, 100),
FriendsSuggestFriendCodeOnFriendsScreenCount: Math.Clamp(settings.FriendsSuggestFriendCodeOnFriendsScreenCount, 0, 100),
ScreensForceVerification: settings.ScreensForceVerification,
VrForceVerification: settings.VrForceVerification,
RewardsUseRewardSelection: settings.RewardsUseRewardSelection,
RewardsSelectionTimeout: Math.Clamp(settings.RewardsSelectionTimeout, 0, 300),
RoomDetailsPhotoRollEnabled: settings.RoomDetailsPhotoRollEnabled,
LoadingNetworkTimeout: Math.Clamp(settings.LoadingNetworkTimeout, 1, 300),
RunningNetworkTimeout: Math.Clamp(settings.RunningNetworkTimeout, 1, 300),
SynchronizedFieldRemoveDefaultEntries: settings.SynchronizedFieldRemoveDefaultEntries,
RenderingDisableSrpBatcher: settings.RenderingDisableSrpBatcher,
SplitTestSoftOverrides: JsonObjectOrDefault(settings.SplitTestSoftOverrides),
SplitTestHardOverrides: JsonObjectOrDefault(settings.SplitTestHardOverrides),
SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities),
UpdatedAt: DateTime.UtcNow);
}
}
public sealed record WeeklyChallengeSettings(
@@ -461,3 +562,20 @@ public sealed record GameConfigurationSetting(
string Value,
DateTime? StartTime = null,
DateTime? EndTime = null);
public sealed record DiscoveredGameConfigSettings(
int FriendsPostGamePromptUnderFriendCount,
int FriendsSuggestFriendCodeOnFriendsScreenCount,
bool ScreensForceVerification,
bool VrForceVerification,
bool RewardsUseRewardSelection,
int RewardsSelectionTimeout,
bool RoomDetailsPhotoRollEnabled,
float LoadingNetworkTimeout,
float RunningNetworkTimeout,
bool SynchronizedFieldRemoveDefaultEntries,
bool RenderingDisableSrpBatcher,
string SplitTestSoftOverrides,
string SplitTestHardOverrides,
string SplitTestSegmentProbabilities,
DateTime UpdatedAt);
@@ -246,6 +246,8 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';");
return;
}
@@ -272,6 +274,18 @@ public static class DatabaseBootstrap
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""DiscoveredGameConfigsJson"" TEXT NOT NULL DEFAULT '';");
}
catch
{
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
}
/// <summary>SQLite migration-history reconciliation. Handles two
+293 -1
View File
@@ -54,9 +54,28 @@ interface RewardOptions {
equipment: RewardOption[];
}
interface DiscoveredGameConfigSettings {
friendsPostGamePromptUnderFriendCount: number;
friendsSuggestFriendCodeOnFriendsScreenCount: number;
screensForceVerification: boolean;
vrForceVerification: boolean;
rewardsUseRewardSelection: boolean;
rewardsSelectionTimeout: number;
roomDetailsPhotoRollEnabled: boolean;
loadingNetworkTimeout: number;
runningNetworkTimeout: number;
synchronizedFieldRemoveDefaultEntries: boolean;
renderingDisableSrpBatcher: boolean;
splitTestSoftOverrides: string;
splitTestHardOverrides: string;
splitTestSegmentProbabilities: string;
updatedAt: string;
}
export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [gameConfigs, setGameConfigs] = useState<DiscoveredGameConfigSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
avatarItems: [],
consumables: [],
@@ -65,18 +84,21 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [weeklyBusy, setWeeklyBusy] = useState(false);
const [gameConfigBusy, setGameConfigBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const [s, w, rewards] = await Promise.all([
const [s, w, rewards, configs] = await Promise.all([
get<ServerSettings>('/settings'),
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
get<RewardOptions>('/settings/weekly-challenges/reward-options'),
get<DiscoveredGameConfigSettings>('/settings/gameconfigs'),
]);
setSettings(s);
setWeekly(w);
setRewardOptions(rewards);
setGameConfigs(configs);
setErr(null);
} catch (e) {
setErr((e as Error).message);
@@ -252,6 +274,28 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const updateGameConfigs = (patch: Partial<DiscoveredGameConfigSettings>) => {
setGameConfigs((current) => current ? { ...current, ...patch } : current);
};
const saveGameConfigs = async (reset = false) => {
if (!gameConfigs && !reset) return;
setGameConfigBusy(true);
try {
const body = reset ? defaultGameConfigs() : gameConfigs;
const updated = await api<DiscoveredGameConfigSettings>('/settings/gameconfigs', {
method: 'POST',
body,
});
setGameConfigs(updated);
toast.push(reset ? 'GameConfig settings reset.' : 'GameConfig settings saved.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setGameConfigBusy(false);
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
@@ -308,6 +352,16 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div>
)}
{gameConfigs && (
<DiscoveredGameConfigsPanel
settings={gameConfigs}
busy={gameConfigBusy}
onChange={updateGameConfigs}
onSave={() => saveGameConfigs(false)}
onReset={() => saveGameConfigs(true)}
/>
)}
{weekly && (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -507,3 +561,241 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div>
);
}
function DiscoveredGameConfigsPanel({
settings,
busy,
onChange,
onSave,
onReset,
}: {
settings: DiscoveredGameConfigSettings;
busy: boolean;
onChange: (patch: Partial<DiscoveredGameConfigSettings>) => void;
onSave: () => void;
onReset: () => void;
}) {
const number = (key: keyof DiscoveredGameConfigSettings, value: string) => {
const n = Number.parseFloat(value);
onChange({ [key]: Number.isFinite(n) ? n : 0 } as Partial<DiscoveredGameConfigSettings>);
};
return (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">GameConfig</h2>
<p className="mt-1 text-xs text-ink-400">
Typed keys confirmed in the 2020.12 client decomp.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={onReset} disabled={busy} className="btn-secondary text-xs">
<RefreshCw />
Defaults
</button>
<button onClick={onSave} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Social</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="Friends.PostGamePromptUnderFriendCount"
value={settings.friendsPostGamePromptUnderFriendCount}
onChange={(v) => number('friendsPostGamePromptUnderFriendCount', v)}
/>
<NumberConfig
label="Friends.SuggestFriendCodeOnFriendsScreenCount"
value={settings.friendsSuggestFriendCodeOnFriendsScreenCount}
onChange={(v) => number('friendsSuggestFriendCodeOnFriendsScreenCount', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Account gates</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<BoolConfig
label="Screens.ForceVerification"
checked={settings.screensForceVerification}
onChange={(v) => onChange({ screensForceVerification: v })}
/>
<BoolConfig
label="VR.ForceVerification"
checked={settings.vrForceVerification}
onChange={(v) => onChange({ vrForceVerification: v })}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Rewards</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<BoolConfig
label="Rewards.UseRewardSelection"
checked={settings.rewardsUseRewardSelection}
onChange={(v) => onChange({ rewardsUseRewardSelection: v })}
/>
<NumberConfig
label="Rewards.SelectionTimeout"
value={settings.rewardsSelectionTimeout}
onChange={(v) => number('rewardsSelectionTimeout', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Room details</h3>
<div className="mt-3">
<BoolConfig
label="RoomDetails.PhotoRollEnabled"
checked={settings.roomDetailsPhotoRollEnabled}
onChange={(v) => onChange({ roomDetailsPhotoRollEnabled: v })}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Networking</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="loadingNetworkTimeout"
value={settings.loadingNetworkTimeout}
onChange={(v) => number('loadingNetworkTimeout', v)}
/>
<NumberConfig
label="runningNetworkTimeout"
value={settings.runningNetworkTimeout}
onChange={(v) => number('runningNetworkTimeout', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Rendering / sync</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<BoolConfig
label="SynchronizedField.RemoveDefaultEntries"
checked={settings.synchronizedFieldRemoveDefaultEntries}
onChange={(v) => onChange({ synchronizedFieldRemoveDefaultEntries: v })}
/>
<BoolConfig
label="Rendering.DisableSrpBatcher"
checked={settings.renderingDisableSrpBatcher}
onChange={(v) => onChange({ renderingDisableSrpBatcher: v })}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4 lg:col-span-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Split tests</h3>
<div className="mt-3 grid gap-3 lg:grid-cols-3">
<JsonConfig
label="splitTestSoftOverrides"
value={settings.splitTestSoftOverrides}
onChange={(v) => onChange({ splitTestSoftOverrides: v })}
/>
<JsonConfig
label="splitTestHardOverrides"
value={settings.splitTestHardOverrides}
onChange={(v) => onChange({ splitTestHardOverrides: v })}
/>
<JsonConfig
label="splitTestSegmentProbabilities"
value={settings.splitTestSegmentProbabilities}
onChange={(v) => onChange({ splitTestSegmentProbabilities: v })}
/>
</div>
</div>
</div>
</div>
);
}
function NumberConfig({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="label font-mono normal-case tracking-normal">{label}</span>
<input className="input mt-1" type="number" value={value} onChange={(e) => onChange(e.target.value)} />
</label>
);
}
function BoolConfig({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex min-h-10 items-center justify-between gap-3 rounded-md border border-ink-800 bg-ink-900/50 px-3 py-2">
<span className="font-mono text-xs text-ink-100">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="size-4 rounded border-ink-700 bg-ink-950 text-brand-500 focus:ring-brand-500/30"
/>
</label>
);
}
function JsonConfig({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="label font-mono normal-case tracking-normal">{label}</span>
<textarea
className="input mt-1 min-h-28 font-mono text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
function defaultGameConfigs(): DiscoveredGameConfigSettings {
return {
friendsPostGamePromptUnderFriendCount: 3,
friendsSuggestFriendCodeOnFriendsScreenCount: 5,
screensForceVerification: false,
vrForceVerification: false,
rewardsUseRewardSelection: false,
rewardsSelectionTimeout: 30,
roomDetailsPhotoRollEnabled: false,
loadingNetworkTimeout: 30,
runningNetworkTimeout: 30,
synchronizedFieldRemoveDefaultEntries: false,
renderingDisableSrpBatcher: false,
splitTestSoftOverrides: '{}',
splitTestHardOverrides: '{}',
splitTestSegmentProbabilities: '{}',
updatedAt: new Date().toISOString(),
};
}