using DorkNet.Server.Services;
using System.Net.Http.Json;
using System.Text.Json;
namespace DorkNet.Server.Tests;
///
/// The March-2023 client budgets every CircuitsV2 network send against
/// RecRoom.CircuitsV2.Config.LimitConfig, which it fills from the
/// UGC.CircuitsV2.Limit.* gameconfig keys. Retail ships 5 RPCs per
/// frame; past that the send is dropped and the room save is flagged
/// cv2_rpc_limited. DorkNet serves budgets that lift the cap, and
/// these tests pin both the wire shape the client's Newtonsoft reader
/// binds and the clamps that keep a hand-edited value from crashing it.
///
public sealed class CircuitsV2LimitsTests : IClassFixture
{
private readonly DorkNetServerFactory _factory;
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Gameconfigs_endpoint_serves_the_network_budget()
{
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
var rows = await client.GetFromJsonAsync>(
"api/gameconfigs/v1/all");
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
// A server that never saved limits (empty column) serves the
// current defaults, not the retail values.
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
}
private static IReadOnlyList Emit(CircuitsV2LimitSettings limits) =>
ServerSettingsService.ToGameConfigurations(
ServerSettingsService.DefaultRecCenterDoors(),
ServerSettingsService.DefaultDiscoveredGameConfigs(),
limits);
private static string ValueOf(IReadOnlyList rows, string key) =>
rows.Single(r => r.Key == key).Value;
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
[Fact]
public void Defaults_lift_the_rpc_and_byte_budgets()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
Assert.Equal(JsonValueKind.Array, network.ValueKind);
var entry = Assert.Single(network.EnumerateArray());
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
// budget never actually got lifted.
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Network_json_uses_the_field_names_the_client_binds()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
.EnumerateArray().First();
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
Assert.True(entry.TryGetProperty("Bytes", out _));
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
Assert.True(entry.TryGetProperty("Rpcs", out _));
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
}
[Fact]
public void Scalar_limits_serialize_invariant_without_separators()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Chips = 123456,
CloudValuesAutosaveSeconds = 300f,
});
// The client parses these with int/float.TryParse under
// InvariantCulture, so a grouped or comma-decimal value silently
// falls back to its baked default.
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Defaults_raise_the_room_size_limits_past_retail()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
// Retail: Compute 50000, Chips 3000, CloudValues 100,
// Unculled/VisibleChips 500, InkPerChip 15.
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
// InkPerChip is a cost, so the lift is downward.
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
// A save cadence, not a capacity limit: left at retail.
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
}
[Fact]
public void Default_visible_chips_do_not_exceed_the_chip_cap()
{
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
// The add-chip path only charges InkPerChip when Chips < VisibleChips
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
// defaults keep that path off as well as zeroing the cost.
Assert.True(limits.VisibleChips <= limits.Chips);
Assert.True(limits.UnculledChips <= limits.Chips);
}
[Fact]
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
InkPerChip = int.MaxValue,
CloudValues = int.MaxValue,
});
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
// also makes a single chip unaffordable.
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal(30_000, ink);
Assert.Equal(10_000, cloud);
Assert.True((long)ink * cloud < int.MaxValue / 2);
}
[Fact]
public void Negative_ink_cost_is_floored_at_zero()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
{
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
// Four List(Chips) at culling-manager construction: 0 or less
// throws, and int.MaxValue is an out-of-memory kill.
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
}
[Fact]
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
}
[Fact]
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
}).EnumerateArray().First();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
}
[Fact]
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
}).EnumerateArray().First();
// Each window frame is one int in a client-side ring buffer, so an
// unbounded window is an out-of-memory kill at circuit init.
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
}
[Fact]
public void Lowest_entry_is_forced_to_activate_at_zero_players()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(20, 4096, 60, 50, 1),
new(8, 2048, 60, 25, 1),
],
});
// Without an entry at 0 the client rejects the whole array and
// falls back to the retail 5-RPC budget.
var entries = network.EnumerateArray().ToList();
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
// The rewrite keeps that entry's own budget.
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(16, 4096, 60, 50, 1),
new(0, 1024, 60, 10, 1),
new(16, 8192, 60, 99, 1),
],
});
var thresholds = network.EnumerateArray()
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
.ToList();
Assert.Equal([0, 16], thresholds);
}
[Fact]
public void Empty_network_list_falls_back_to_the_default_budget()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [],
});
var entry = Assert.Single(network.EnumerateArray());
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
}
}