config in sections

Server config:
- Written in sections, in reading order: server, access, maps, players,
  anti_cheat, network, votes. A config from an older version, flat and
  under older names, is read as it is and written back this way on first
  start; only settings that are really new are reported as new.
- Renamed: no_bail, noclip, boosts, parties, voice_chat to allow_*;
  global_bans to use_global_bans; welcome to welcome_message; speed_check
  to speed_hack; score_check to modified_scoring; score_allow to
  allowed_scoring_mods; map_pool and map_rotation_minutes to maps.pool and
  maps.rotation_minutes. Console and admin commands are unchanged.
- Bans are kept in data/bans.json beside the config. A config that holds
  them has them moved there; a bans file that cannot be read stops the
  server from starting.
- Reserved slots are one list, "reserved_players_slots": a slot is kept
  for each listed player who is not on. "reserved_slots" and the
  "reserved slots <n>" command are gone; admins may take a kept slot but
  none is kept for them.
- "tps" is no longer in the file: dedicated servers run at 20.
- "bone_scale_limit" is 1 by default: resized body parts are off unless a
  server allows them.
- New: "pack_ms" (10), how long Steam may hold a message to pack it with
  others; "finger_distance" (25); "steam_debug", which logs what Steam's
  networking says, for connection faults.
This commit is contained in:
AyeZeeBB committed 2026-10-08 16:18:29 -05:00
1 parent 38d86e1b72
commit 2833ab1f88
9 files changed
+531 -384

No files matched your search

+140 -128
View File
@@ -42,93 +42,18 @@ straight away (players are told to rejoin). Turn this off with
ReSkateServer.json
------------------
The settings are in sections; each setting below is listed under the section it is in,
like "server": {"name": "My server"}. A file from an older version, with every setting at
the top and some under older names, is read as it is and written back this way the first
time the server starts.
"server" - The server itself: its name, who it lets in by password, and its ports.
name Shown in the browser: 1-64 letters, numbers, spaces and - _ / [ ] ( ).
map The map everyone skates, named like the game's load command:
"San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort",
"Stadium 1", or a custom map such as "bbcity" (see Custom maps).
map_pool The maps players may vote for and the rotation goes through,
in order, e.g. ["San Vansterdam", "Isle of Grom", "bbcity"].
Empty (the default) allows every map the server knows.
Admins can still change to any map.
map_rotation_minutes Minutes on each map before the server moves to the next
one in map_pool (default 0: off). Players get a minute's
warning; the clock waits while nobody is on, and starts
over whenever the map changes (by a vote or an admin too).
max_players 1-249.
send_rate The most the server sends one player, in KB/s (default 900,
128-16384). About 1100 is what reaches a player through
Steam's relays: set higher, what is lost is resent until the
connection is full of resends and half of everything is
lost. Console: rate <KB/s>, also for the players already on.
finger_distance Past this many metres (default 25) a player's fingers are not
sent moving: they stay as they were, and move again when the
player is nearer. Fingers are nearly half of every position
update and cannot be made out at that distance. 0 always
sends them.
pack_ms How long a message to a player may wait to go in the same
packet as the next ones, in milliseconds (default 10, 0 to
50). A full server sends each player hundreds of small
messages a second, each in a packet of its own with its own
headers; packed, the same updates take fewer packets, less
bandwidth and less CPU. It adds up to that long to when an
update arrives. Voice is never held back. 0 sends every
message at once, as before. Takes effect on restart.
connection How players reach the server: "relay" (default) or "direct".
relay Through Steam's relay network. Nothing to open, and
the server never sees a player's address. The route
is Steam's choice and can be a long way round.
direct Straight to this server's "port" over UDP, which
must be open to the internet. The shortest route,
so the lowest ping. The server sees the addresses
of the players who connect this way, as any
dedicated server does; players never see each
other's.
A direct server still answers through the relays: a game
tries the port first and uses the relays if it gets no
answer in a few seconds, and a player can turn direct
connections off in their own settings. So "direct" never
keeps anyone out. Steam vouches for who each player is
either way. "net" shows who is direct, and the log has a
[direct] line for each player who asks to connect that way.
direct_address For "direct": the address players connect to, like
203.0.113.7, when it is not the one the server's start-up
line calls its public IP. Some hosts send from one address
and take connections on another; use the one your panel
shows for the server. Empty (default): the public IP.
steam_debug true logs what Steam's own networking says it is doing: each
connection asked for, and what it refused or ignored and
why. For finding out why players cannot connect; it is a
lot of text, so turn it off again afterwards.
relay_everything true sends poses through the server even between players
whose games have linked to each other. For testing with a
few players; leave it false otherwise.
direct_port For "direct": the UDP port to listen on when it is not
"port" (default 0: "port"). Must differ from query_port.
bone_scale_limit How far a mod may resize part of a skater for the other
players (a "big head" mod and the like): the most a bone may
be scaled, 1 to 8. 1 (default) shows every skater at the
game's own proportions; 0 is no limit. The player with the
mod still sees it on their own screen. Console:
bone-scale <1-8>|off.
crowd_budget The most position updates a second one player is sent
(default 600, 0 for no limit). Players near each other are
sent at the full rate (tps); this only matters once more are
in one place than budget / tps, about 30 at 20 TPS. Then the
nearest stay at the full rate and the farthest of the crowd
drop to 10 and 5 a second, instead of everyone's connection
filling up. About 1000 is what reaches a player through
Steam's relays; more than that was seen to lose half.
Console: crowd <n>|off.
reserved_slots How many of those are kept for the players in "reserved" and
the admins (default 0). With 64 players and 4 reserved slots,
anyone can join until 60 are on; the last 4 only they can
take. The browser still shows 64, and everyone else is told
the remaining slots are reserved.
reserved SteamID64s of the players with a reserved slot, e.g.
["76561198000000000"]. Admins always have one.
password Empty for anyone; otherwise players type it to join.
welcome A chat line sent to each player as they join.
welcome_message A chat line sent to each player as they join.
listed false hides the server; players then need the code.
max_players 1-249.
port, query_port Steam game server ports (default 27015, 27016).
steam_token A Steam game server login token, or empty (default). Without one
the server signs in anonymously and gets a new Steam ID every
start. With one it keeps the same Steam ID, printed at startup.
@@ -139,36 +64,70 @@ steam_token A Steam game server login token, or empty (default). Without
only servers that have one; the server says so in its log when
that hides it. Players can always join with the code.
auto_update Install new ReSkate releases when nobody is on (default true).
global_bans Turn away players the ReSkate team has banned from multiplayer
activity_log Log what players do (default true): throwdown drops placed,
joins, starts, turns and results; objects placed or removed;
how long players take to load.
"access" - Who runs the server and who always has a place on it.
admins SteamID64s (as strings) who may change settings in-game.
reserved_players_slots
SteamID64s of the players with a reserved slot, e.g.
["76561198000000000"] (default none). A slot is kept free
for each of them who is not on: with 64 players and 4
listed, none of them on, anyone can join until 60 are on
and only they can take the last 4. One who is on has taken
theirs. The browser still shows 64, and everyone else is
told the remaining slots are reserved. Admins may take a
kept slot too, but none is kept for them. Must list fewer
players than max_players.
use_global_bans Turn away players the ReSkate team has banned from multiplayer
(default true). The list is read from api.reskate.dev at startup
and every ten minutes. false lets them in; the server's own
"bans" apply either way.
votes Player votes, each off until turned on:
"map": {"enabled": true, "percent": 60} /vote map <map>
(a map in map_pool)
"kick": {"enabled": true, "percent": 60} /vote kick <player>
"time_of_day": {"enabled": true, "percent": 50} /vote tod <time>
(needs world_layer_sync)
"percent" is the share of connected players whose yes passes
it. "seconds" (default 30) is how long a vote runs and
"cooldown_seconds" (default 60) how long a player waits before
starting another. Players vote with /yes and /no in chat;
admins cannot be vote-kicked.
announce_throwdowns Tell everyone in chat when a throwdown drop is placed
(default true).
parties Let players form parties (default true): invite each other
bans (data/bans.json) apply either way.
"maps" - What is skated.
map The map everyone skates, named like the game's load command:
"San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort",
"Stadium 1", or a custom map such as "bbcity" (see Custom maps).
pool The maps players may vote for and the rotation goes through,
in order, e.g. ["San Vansterdam", "Isle of Grom", "bbcity"].
Empty (the default) allows every map the server knows.
Admins can still change to any map.
rotation_minutes Minutes on each map before the server moves to the next
one in pool (default 0: off). Players get a minute's
warning; the clock waits while nobody is on, and starts
over whenever the map changes (by a vote or an admin too).
parks Layout for each park lot, e.g. "skatepark_01", or "empty".
world_layer_sync Force the "layers" below on every player.
layers World layer key -> "on" / "off".
"players" - What players may do.
allow_noclip, allow_no_bail, allow_boosts
Let players use noclip (and tp) / No Bail / the forward and up
boosts (default true; admins always can).
allow_parties Let players form parties (default true): invite each other
from the game's Social menu, a player card, the ReSkate
Multiplayer menu or chat (/party invite <player>). Party
members join each other's coop challenges, see each other on
the map and talk with /p <message>.
party_size Most players in one party, 2-8 (default 8).
speed_check Catch players whose game runs faster than normal (Cheat
allow_voice_chat Allow voice chat.
voice_range How far proximity voice reaches, 50-1000 m.
object_placement everyone, admins (only admins can build), or nobody.
object_limit How many objects each player may have placed, 1-1024
(default 100), or 0 for no limit. Admins are not limited.
A player at the limit deletes one to place another.
announce_throwdowns Tell everyone in chat when a throwdown drop is placed
(default true).
"anti_cheat" - What the server checks, and what it does about it.
speed_hack Catch players whose game runs faster than normal (Cheat
Engine's speedhack and the like), measured from the timing
of what their game sends: "warn" (default) takes them out of
throwdowns and coop challenges until their speed is normal
again and tells them so (the log has it for the admins), "kick" removes them from the
server, "off" does not check.
score_check Catch players whose mods change how many points tricks
modified_scoring Catch players whose mods change how many points tricks
score (per-trick points, the scoring multipliers, the
throwdown scoring logic) or how the skater handles (core
physics, wipeouts, trick gestures). Each player's ReSkate checks its
@@ -178,37 +137,91 @@ score_check Catch players whose mods change how many points tricks
messages on) and everyone is told in chat, "kick" removes
them, "off" does not check. A player has to restart Skate
without the mod to take part again.
score_allow Scoring fingerprints accepted like the game's own, for a
allowed_scoring_mods Scoring fingerprints accepted like the game's own, for a
server that runs on a scoring mod everyone installs
(16 hex digits each; score-check lists each player's).
activity_log Log what players do (default true): throwdown drops placed,
joins, starts, turns and results; objects placed or removed;
how long players take to load.
port, query_port Steam game server ports (default 27015, 27016).
tps Network updates per second. Fixed at 20 on dedicated servers
for now: whatever is set here is read as 20.
voice_chat Allow voice chat.
voice_range How far proximity voice reaches, 50-1000 m.
distances When far-away players update less often (metres).
object_placement everyone, admins (only admins can build), or nobody.
object_limit How many objects each player may have placed, 1-1024
(default 100), or 0 for no limit. Admins are not limited.
A player at the limit deletes one to place another.
noclip, no_bail, Let players use noclip (and tp) / No Bail / the forward and up
boosts boosts (default true; admins always can).
enforce_tuning Players skate with the game's own Gameplay/SkatePhysicsTuning,
not copies they edited (default true). Edited tuning (truck
positions and the rest) otherwise shows on their skater for
everyone.
parks Layout for each park lot, e.g. "skatepark_01", or "empty".
world_layer_sync Force the "layers" below on every player.
layers World layer key -> "on" / "off".
admins SteamID64s (as strings) who may change settings in-game.
bans Players who can never join. Managed with ban / unban.
The ReSkate team's own list is separate: see global_bans.
bone_scale_limit How far a mod may resize part of a skater for the other
players (a "big head" mod and the like): the most a bone may
be scaled, 1 to 8. 1 (default) shows every skater at the
game's own proportions; 0 is no limit. The player with the
mod still sees it on their own screen. Console:
bone-scale <1-8>|off.
"network" - How players connect and how much they are sent. The defaults suit most servers.
use_steam_relay How players reach the server: true (default) or false.
true Through Steam's relay network. Nothing to open, and
the server never sees a player's address. The route
is Steam's choice and can be a long way round.
false Straight to this server's "port" over UDP, which
must be open to the internet. The shortest route,
so the lowest ping. The server sees the addresses
of the players who connect this way, as any
dedicated server does; players never see each
other's.
With false the server still answers through the relays: a
game tries the port first and uses the relays if it gets no
answer in a few seconds, and a player can turn direct
connections off in their own settings. So false never
keeps anyone out. Steam vouches for who each player is
either way. "net" shows who is direct, and the log has a
[direct] line for each player who asks to connect that way.
send_rate The most the server sends one player, in KB/s (default 900,
128-16384). About 1100 is what reaches a player through
Steam's relays: set higher, what is lost is resent until the
connection is full of resends and half of everything is
lost. Console: rate <KB/s>, also for the players already on.
crowd_budget The most position updates a second one player is sent
(default 600, 0 for no limit). Players near each other are
sent at the full rate, 20 a second; this only matters once
more are in one place than budget / 20, about 30. Then the
nearest stay at the full rate and the farthest of the crowd
drop to 10 and 5 a second, instead of everyone's connection
filling up. About 1000 is what reaches a player through
Steam's relays; more than that was seen to lose half.
Console: crowd <n>|off.
pack_ms How long a message to a player may wait to go in the same
packet as the next ones, in milliseconds (default 10, 0 to
50). A full server sends each player hundreds of small
messages a second, each in a packet of its own with its own
headers; packed, the same updates take fewer packets, less
bandwidth and less CPU. It adds up to that long to when an
update arrives. Voice is never held back. 0 sends every
message at once, as before. Takes effect on restart.
finger_distance Past this many metres (default 25) a player's fingers are not
sent moving: they stay as they were, and move again when the
player is nearer. Fingers are nearly half of every position
update and cannot be made out at that distance. 0 always
sends them.
distances When far-away players update less often (metres).
steam_debug true logs what Steam's own networking says it is doing: each
connection asked for, and what it refused or ignored and
why. For finding out why players cannot connect; it is a
lot of text, so turn it off again afterwards.
"votes" - What players may vote on.
votes Player votes, each off until turned on:
"map": {"enabled": true, "percent": 60} /vote map <map>
(a map in maps.pool)
"kick": {"enabled": true, "percent": 60} /vote kick <player>
"time_of_day": {"enabled": true, "percent": 50} /vote tod <time>
(needs world_layer_sync)
"percent" is the share of connected players whose yes passes
it. "seconds" (default 30) is how long a vote runs and
"cooldown_seconds" (default 60) how long a player waits before
starting another. Players vote with /yes and /no in chat;
admins cannot be vote-kicked.
Every change made from the console or by an admin is saved back to this file.
Bans are kept in data/bans.json, beside this file: the players who can never
join, managed with ban / unban. The ReSkate team's own list is separate: see
use_global_bans. A config from an older version that still has "bans" in it has
them moved there the first time the server starts.
Console and admin commands
--------------------------
Type these in the server window. Admins run the same commands in the game with
@@ -220,8 +233,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
help A short list of every command.
status Name, map, players, code.
players Connected players and their SteamID64s.
reserved Who has a reserved slot and how many are kept.
reserved slots <n> Keep n slots for them (console only), 0 for none.
reserved Who has a reserved slot.
reserved add|remove <player or id> (console only)
net [player] How the connections are doing right now: traffic,
queues, the server's own loop timing, and the twelve
@@ -241,8 +253,8 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
map <name> e.g. map San Vansterdam, map grom, map bbcity
maps The maps this server knows.
map-pool [add|remove <map>|clear] The maps players vote between and the
rotation uses (see map_pool).
rotation [<minutes>|off] Change the map on a timer (see map_rotation_minutes).
rotation uses (see maps.pool).
rotation [<minutes>|off] Change the map on a timer (see maps.rotation_minutes).
name <text> password <text|off> welcome <text|off> listed on|off
voice on|off voice-range <m>
distances <full> <half> <half-return> <low>
+85 -27
View File
@@ -35,10 +35,12 @@ int run() {
std::vector<std::string> added;
const auto config = load_config(file, &added);
const auto has = [&](std::string_view name) { return std::ranges::find(added, name) != added.end(); };
check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow") &&
has("global_bans") && has("map_pool") && has("map_rotation_minutes"),
check(has("anti_cheat.enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("anti_cheat.modified_scoring") && has("anti_cheat.allowed_scoring_mods") &&
has("access.use_global_bans") && has("maps.pool") && has("maps.rotation_minutes"),
"New settings not reported");
check(!has("name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new");
// A setting under its old name is read, written back under its new one, and not called new.
check(!has("players.allow_boosts") && has("players.allow_noclip"), "A renamed setting was reported as new, or a new one was not");
check(!has("server.name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new");
const auto written = text(file);
check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos,
"New settings not written into the file");
@@ -51,7 +53,7 @@ int run() {
// steam_token: empty (anonymous) unless set, kept on a rewrite, and only letters and digits.
// (The maps are not loaded yet, so config_error has another complaint: look for this one.)
const auto token_refused = [](const ServerConfig &c) { return config_error(c).find("steam_token") != std::string::npos; };
check(has("steam_token") && config.steam_token.empty() && !token_refused(config), "steam_token is not a new, empty setting");
check(has("server.steam_token") && config.steam_token.empty() && !token_refused(config), "steam_token is not a new, empty setting");
auto tokened = reloaded;
tokened.steam_token = "0123456789ABCDEF0123456789ABCDEF";
save_config(tokened);
@@ -61,56 +63,52 @@ int run() {
tokened.steam_token = std::string(65, 'A');
check(token_refused(tokened), "An overlong steam_token was accepted");
{
check(has("crowd_budget") && config.crowd_budget == 600, "crowd_budget is not a new setting of 600");
check(has("send_rate") && config.send_rate == 900, "send_rate is not a new setting of 900");
check(has("network.crowd_budget") && config.crowd_budget == 600, "crowd_budget is not a new setting of 600");
check(has("network.send_rate") && config.send_rate == 900, "send_rate is not a new setting of 900");
ServerConfig rate;
rate.send_rate = 64;
check(config_error(rate).find("send_rate") != std::string::npos, "A send rate too low to play with was accepted");
check(has("bone_scale_limit") && config.bone_scale_limit == 1, "bone_scale_limit is not a new setting of 1");
check(has("anti_cheat.bone_scale_limit") && config.bone_scale_limit == 1, "bone_scale_limit is not a new setting of 1");
ServerConfig scaled;
scaled.bone_scale_limit = 0.5f;
check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted");
check(has("pack_ms") && config.pack_ms == 10, "pack_ms is not a new setting of 10");
check(has("finger_distance") && config.finger_distance == 25, "finger_distance is not a new setting of 25");
check(has("connection") && config.connection == "relay", "connection is not a new setting of relay");
ServerConfig how;
how.connection = "carrier pigeon";
check(config_error(how).find("connection") != std::string::npos, "An unknown connection was accepted");
check(has("network.pack_ms") && config.pack_ms == 10, "pack_ms is not a new setting of 10");
check(has("network.finger_distance") && config.finger_distance == 25, "finger_distance is not a new setting of 25");
check(has("network.use_steam_relay") && config.use_steam_relay, "use_steam_relay is not a new setting of true");
ServerConfig crowd;
crowd.crowd_budget = 50;
check(config_error(crowd).find("crowd_budget") != std::string::npos, "A crowd budget too small to play with was accepted");
}
// Reserved slots: none unless set; the last of them only the listed players and admins take.
{
check(has("reserved_slots") && has("reserved") && config.reserved_slots == 0 && config.reserved.empty(),
"Reserved slots are not new settings that reserve nothing");
check(has("access.reserved_players_slots") && config.reserved.empty(), "Reserved slots are not a new setting that reserves nothing");
ServerConfig slots;
slots.max_players = 4;
const std::uint64_t vip = 76561198000000001ULL, admin = 76561198000000002ULL, anyone = 76561198000000003ULL;
check(may_join(slots, anyone, 3) && !may_join(slots, anyone, 4) && !may_join(slots, vip, 4), "A server with nothing reserved did not fill up plainly");
slots.reserved_slots = 2;
slots.reserved = {vip};
check(may_join(slots, anyone, 3, 0) && !may_join(slots, anyone, 4, 0) && !may_join(slots, vip, 4, 0), "A server with nothing reserved did not fill up plainly");
// A slot for each reserved player who is not on; one who is on has taken theirs.
const std::uint64_t vip2 = 76561198000000004ULL;
slots.reserved = {vip, vip2};
slots.admins = {admin};
check(may_join(slots, anyone, 1) && !may_join(slots, anyone, 2) && !may_join(slots, anyone, 3), "Reserved slots were given to anyone");
check(may_join(slots, vip, 2) && may_join(slots, vip, 3) && may_join(slots, admin, 3), "A reserved player or an admin was kept out of a reserved slot");
check(!may_join(slots, vip, 4) && !may_join(slots, admin, 4), "A full server let someone in");
check(may_join(slots, anyone, 1, 0) && !may_join(slots, anyone, 2, 0) && !may_join(slots, anyone, 3, 0), "Reserved slots were given to anyone");
check(may_join(slots, anyone, 2, 1) && !may_join(slots, anyone, 3, 1) && may_join(slots, anyone, 3, 2), "A slot was kept for a reserved player who is already on");
check(may_join(slots, vip, 2, 0) && may_join(slots, vip, 3, 1) && may_join(slots, admin, 3, 0), "A reserved player or an admin was kept out of a reserved slot");
check(!may_join(slots, vip, 4, 1) && !may_join(slots, admin, 4, 2), "A full server let someone in");
const auto refused = [](const ServerConfig &c) { return config_error(c).find("reserved") != std::string::npos; };
check(!refused(slots), "Valid reserved slots were refused");
slots.reserved_slots = 4;
slots.reserved = {vip, vip2, 76561198000000005ULL, 76561198000000006ULL};
check(refused(slots), "A server with every slot reserved was accepted");
slots.reserved_slots = 2;
slots.reserved.push_back(42);
slots.reserved = {vip, 42};
check(refused(slots), "A reserved entry that is not a player was accepted");
auto kept = reloaded;
kept.reserved_slots = 3;
kept.reserved = {vip};
save_config(kept);
const auto back = load_config(file);
check(back.reserved_slots == 3 && back.reserved == std::vector<std::uint64_t>{vip}, "Reserved slots were not kept");
check(back.reserved == std::vector<std::uint64_t>{vip} && text(file).find("\"reserved_slots\"") == std::string::npos, "Reserved slots were not kept");
save_config(reloaded);
}
// object_limit: 100 unless set (0 is no limit), and kept on a rewrite.
check(has("object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100");
check(has("players.object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100");
auto limited = reloaded;
limited.object_limit = 50;
save_config(limited);
@@ -152,6 +150,66 @@ int run() {
const auto odd = load_config(file);
check(odd.score_check == "warn" && odd.score_allow == std::vector<std::uint64_t>{0xabc}, "Bad scoring settings not cleaned");
// A config from before the sections: every setting is read from where it was, under the name
// it had, and the file is written back in sections, in reading order, with nothing called new
// that was only moved.
{
std::ofstream(file, std::ios::binary) << R"({"name": "Flat", "no_bail": false, "score_check": "kick", "score_allow": ["abc"],
"speed_check": "off", "map": "Isle of Grom", "map_pool": ["Isle of Grom"], "map_rotation_minutes": 30, "welcome": "Hi",
"admins": ["76561198000000002"], "reserved": ["76561198000000001"], "reserved_slots": 2, "tps": 30, "global_bans": false,
"voice_chat": false, "send_rate": 700, "distances": {"half_rate_start": 70}, "parks": {"financial": "empty"},
"votes": {"map": {"enabled": false, "percent": 40}, "kick": {"enabled": true, "percent": 60},
"time_of_day": {"enabled": true, "percent": 50}, "seconds": 30, "cooldown_seconds": 60}})";
std::vector<std::string> fresh;
const auto flat = load_config(file, &fresh);
check(flat.name == "Flat" && !flat.no_bail && flat.score_check == "kick" && flat.score_allow == std::vector<std::uint64_t>{0xabc} &&
flat.speed_check == "off" && flat.map == "Isle of Grom" && flat.map_pool.size() == 1 && flat.map_rotation == 30 && flat.welcome == "Hi" &&
flat.admins.size() == 1 && flat.reserved.size() == 1 && !flat.global_bans && !flat.voice_chat && flat.send_rate == 700 &&
flat.distances.half_rate_start == 70 && !flat.votes.map.enabled && flat.votes.map.percent == 40,
"A config from before the sections lost a setting");
const auto fresh_has = [&](std::string_view name) { return std::ranges::find(fresh, name) != fresh.end(); };
check(!fresh_has("players.allow_no_bail") && !fresh_has("anti_cheat.modified_scoring") && !fresh_has("maps.pool") && !fresh_has("server.name") &&
fresh_has("network.pack_ms"),
"A moved setting was called new, or a new one was not");
const auto now = text(file);
const auto at = [&](std::string_view what) { return now.find(what); };
check(at("\"server\"") < at("\"access\"") && at("\"access\"") < at("\"maps\"") && at("\"maps\"") < at("\"players\"") &&
at("\"players\"") < at("\"anti_cheat\"") && at("\"anti_cheat\"") < at("\"network\"") && at("\"network\"") < at("\"votes\"") &&
at("\"votes\"") != std::string::npos && at("\"name\"") < at("\"password\"") && at("\"password\"") < at("\"welcome_message\""),
"The config was not written in sections, in reading order");
check(at("\"no_bail\"") == std::string::npos && at("\"tps\"") == std::string::npos && at("\"score_check\"") == std::string::npos &&
at("\"reserved_slots\"") == std::string::npos && at("\"modified_scoring\": \"kick\"") != std::string::npos,
"Old names were left in the config");
std::vector<std::string> again;
const auto back = load_config(file, &again);
check(again.empty() && back.name == "Flat" && !back.no_bail && back.score_check == "kick" && back.map_pool.size() == 1 && back.reserved.size() == 1 &&
back.distances.half_rate_start == 70 && !back.votes.map.enabled && text(file) == now,
"A config in sections did not read back as it was written");
std::filesystem::remove_all(folder / "data");
}
// Bans have a file of their own; a config that still holds them has them moved there.
{
std::filesystem::remove_all(folder / "data");
std::ofstream(file, std::ios::binary) << R"({"name": "Old", "bans": [{"id": "76561198000000001", "name": "A", "added": 5}]})";
auto old = load_config(file);
check(old.bans.size() == 1 && old.bans[0].id == 76561198000000001ULL && old.bans[0].name == "A", "An old config's bans were not read");
std::stringstream now;
now << std::ifstream(file, std::ios::binary).rdbuf();
check(std::filesystem::exists(bans_file(old)) && now.str().find("\"bans\"") == std::string::npos, "Bans were not moved to data/bans.json");
old.bans.push_back({76561198000000002ULL, "B", 6});
save_config(old);
const auto again = load_config(file);
check(again.bans.size() == 2 && again.bans[1].name == "B" && again.bans[1].added == 6, "Bans were not kept in their own file");
std::ofstream(bans_file(old), std::ios::binary) << "{not a list";
bool refused_bans{};
try {
static_cast<void>(load_config(file));
} catch (const std::exception &) {
refused_bans = true;
}
check(refused_bans, "A server started without its bans");
std::filesystem::remove_all(folder / "data");
}
// A port outside 1-65535 is refused, not wrapped to another port, and the file keeps the typo.
const auto refused = [&](const char *json) {
std::ofstream(file, std::ios::binary) << json;
+2 -2
View File
@@ -482,7 +482,7 @@ int run(int argc, char **argv, bool skip_update) {
std::future<BanListCheck> ban_check;
auto next_ban_check = next_advertise;
bool bans_unread{};
if (!config.global_bans) write_log("Global bans are off (\"global_bans\": false): only this server's own bans apply.");
if (!config.global_bans) write_log("Global bans are off (\"use_global_bans\": false): only this server's own bans apply.");
// Signed in to Steam, as last logged, and when it is looked at again. Players already on stay
// connected through a lost sign-in, but nobody new can join: Steam carries the first messages
// of a connection, and theirs time out ("negotiate rendezvous").
@@ -592,7 +592,7 @@ int run(int argc, char **argv, bool skip_update) {
name_allowed = allowed;
}
steam.advertise({config.name, host.map_name(), host.players(), config.max_players, !config.password.empty(),
config.listed && allowed, host.secret(), host.direct_port(), host.direct_address()});
config.listed && allowed, host.secret(), host.direct_port()});
}
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
+277 -185
View File
@@ -4,6 +4,8 @@
#include "Extension/Multiplayer/Net/protocol.h"
#include "Engine/Game/World/world_names.h"
#include <algorithm>
#include <initializer_list>
#include <array>
#include <cctype>
#include <fstream>
#include <sstream>
@@ -14,85 +16,130 @@ namespace {
std::string placement_text(ObjectPlacement policy) {
return policy == ObjectPlacement::everyone ? "everyone" : policy == ObjectPlacement::host_only ? "admins" : "nobody";
}
Json to_json(const ServerConfig &c) {
auto root = Json::object();
root["name"] = c.name;
root["map"] = c.map;
// The config file as it is written: sections, and the settings in each, in the order an owner
// reads them (a Json object alone would put them in alphabetical order).
struct Layout {
std::vector<std::string> names;
std::vector<Layout> members;
Json value;
bool leaf{};
Layout &section(std::string name) {
names.push_back(std::move(name));
return members.emplace_back();
}
void set(std::string name, Json json) {
names.push_back(std::move(name));
auto &member = members.emplace_back();
member.value = std::move(json);
member.leaf = true;
}
};
void write(std::string &out, const Layout &layout, std::size_t depth) {
const std::string inner(2 * (depth + 1), ' ');
out += "{\n";
for (std::size_t i = 0; i < layout.names.size(); ++i) {
out += inner + Json(layout.names[i]).dump() + ": ";
if (layout.members[i].leaf) {
// A list or an object of its own spans lines: each goes in by this setting's depth.
for (const char c : layout.members[i].value.dump(2)) {
out += c;
if (c == '\n') out += inner;
}
} else {
write(out, layout.members[i], depth + 1);
}
out += i + 1 < layout.names.size() ? ",\n" : "\n";
}
out += std::string(2 * depth, ' ') + "}";
}
Layout layout(const ServerConfig &c) {
// SteamID64s are written as strings: JSON readers often lose 64-bit precision.
const auto ids = [](const std::vector<std::uint64_t> &list) {
auto out = Json::array();
for (const auto id : list) out.push_back(std::to_string(id));
return out;
};
Layout root;
auto &server = root.section("server");
server.set("name", c.name);
server.set("password", c.password);
server.set("welcome_message", c.welcome);
server.set("listed", c.listed);
server.set("max_players", c.max_players);
server.set("port", static_cast<unsigned>(c.port));
server.set("query_port", static_cast<unsigned>(c.query_port));
server.set("steam_token", c.steam_token);
server.set("auto_update", c.auto_update);
server.set("activity_log", c.activity_log);
auto &access = root.section("access");
access.set("admins", ids(c.admins));
access.set("reserved_players_slots", ids(c.reserved));
access.set("use_global_bans", c.global_bans);
auto &maps = root.section("maps");
maps.set("map", c.map);
auto pool = Json::array();
for (const auto &map : c.map_pool) pool.push_back(map);
root["map_pool"] = std::move(pool);
root["map_rotation_minutes"] = c.map_rotation;
root["max_players"] = c.max_players;
root["reserved_slots"] = c.reserved_slots;
root["crowd_budget"] = c.crowd_budget;
root["bone_scale_limit"] = c.bone_scale_limit;
root["connection"] = c.connection;
root["direct_address"] = c.direct_address;
root["steam_debug"] = c.steam_debug;
root["relay_everything"] = c.relay_everything;
root["direct_port"] = c.direct_port;
root["pack_ms"] = c.pack_ms;
root["finger_distance"] = c.finger_distance;
root["send_rate"] = c.send_rate;
auto reserved = Json::array();
for (const auto id : c.reserved) reserved.push_back(std::to_string(id)); // as strings, like the admins
root["reserved"] = std::move(reserved);
root["password"] = c.password;
root["welcome"] = c.welcome;
root["listed"] = c.listed;
root["steam_token"] = c.steam_token;
root["auto_update"] = c.auto_update;
root["global_bans"] = c.global_bans;
root["activity_log"] = c.activity_log;
root["announce_throwdowns"] = c.announce_throwdowns;
root["parties"] = c.parties;
root["party_size"] = c.party_size;
root["speed_check"] = c.speed_check;
root["score_check"] = c.score_check;
auto allowed = Json::array();
for (const auto fingerprint : c.score_allow) allowed.push_back(scoring_text(fingerprint));
root["score_allow"] = std::move(allowed);
root["port"] = static_cast<unsigned>(c.port);
root["query_port"] = static_cast<unsigned>(c.query_port);
root["tps"] = c.tps;
root["voice_chat"] = c.voice_chat;
root["voice_range"] = static_cast<double>(c.voice_range);
auto distances = Json::object();
distances["full_rate_return"] = c.distances.full_rate_return;
distances["half_rate_start"] = c.distances.half_rate_start;
distances["half_rate_return"] = c.distances.half_rate_return;
distances["low_rate_start"] = c.distances.low_rate_start;
root["distances"] = std::move(distances);
root["object_placement"] = placement_text(c.object_placement);
root["object_limit"] = c.object_limit;
root["noclip"] = c.noclip;
root["no_bail"] = c.no_bail;
root["boosts"] = c.boosts;
root["enforce_tuning"] = c.enforce_tuning;
auto votes = Json::object();
const auto vote = [](const VoteSetting &v) {
auto item = Json::object();
item["enabled"] = v.enabled;
item["percent"] = v.percent;
return item;
};
votes["map"] = vote(c.votes.map);
votes["kick"] = vote(c.votes.kick);
votes["time_of_day"] = vote(c.votes.time);
votes["seconds"] = c.votes.seconds;
votes["cooldown_seconds"] = c.votes.cooldown;
root["votes"] = std::move(votes);
maps.set("pool", std::move(pool));
maps.set("rotation_minutes", c.map_rotation);
auto parks = Json::object();
for (unsigned lot = 0; lot < park_lots.size(); ++lot) parks[park_lots[lot].key] = c.parks[lot];
root["parks"] = std::move(parks);
root["world_layer_sync"] = c.world_layer_sync;
maps.set("parks", std::move(parks));
maps.set("world_layer_sync", c.world_layer_sync);
auto layers = Json::object();
for (const auto &[key, mode] : c.layers) layers[key] = mode;
root["layers"] = std::move(layers);
// SteamID64s are written as strings: JSON readers often lose 64-bit precision.
auto admins = Json::array();
for (const auto id : c.admins) admins.push_back(std::to_string(id));
root["admins"] = std::move(admins);
maps.set("layers", std::move(layers));
auto &players = root.section("players");
players.set("allow_boosts", c.boosts);
players.set("allow_no_bail", c.no_bail);
players.set("allow_noclip", c.noclip);
players.set("allow_parties", c.parties);
players.set("party_size", c.party_size);
players.set("allow_voice_chat", c.voice_chat);
players.set("voice_range", static_cast<double>(c.voice_range));
players.set("object_placement", placement_text(c.object_placement));
players.set("object_limit", c.object_limit);
players.set("announce_throwdowns", c.announce_throwdowns);
auto &anti_cheat = root.section("anti_cheat");
anti_cheat.set("speed_hack", c.speed_check);
anti_cheat.set("modified_scoring", c.score_check);
auto allowed = Json::array();
for (const auto fingerprint : c.score_allow) allowed.push_back(scoring_text(fingerprint));
anti_cheat.set("allowed_scoring_mods", std::move(allowed));
anti_cheat.set("enforce_tuning", c.enforce_tuning);
anti_cheat.set("bone_scale_limit", c.bone_scale_limit);
auto &network = root.section("network");
network.set("use_steam_relay", c.use_steam_relay);
network.set("send_rate", c.send_rate);
network.set("crowd_budget", c.crowd_budget);
network.set("pack_ms", c.pack_ms);
network.set("finger_distance", c.finger_distance);
auto &distances = network.section("distances");
distances.set("full_rate_return", c.distances.full_rate_return);
distances.set("half_rate_start", c.distances.half_rate_start);
distances.set("half_rate_return", c.distances.half_rate_return);
distances.set("low_rate_start", c.distances.low_rate_start);
network.set("steam_debug", c.steam_debug);
auto &votes = root.section("votes");
const auto vote = [&](const char *name, const VoteSetting &v) {
auto &item = votes.section(name);
item.set("enabled", v.enabled);
item.set("percent", v.percent);
};
vote("map", c.votes.map);
vote("kick", c.votes.kick);
vote("time_of_day", c.votes.time);
votes.set("seconds", c.votes.seconds);
votes.set("cooldown_seconds", c.votes.cooldown);
return root;
}
// Bans have a file of their own, beside the config.
Json bans_json(const ServerConfig &c) {
auto bans = Json::array();
for (const auto &ban : c.bans) {
auto row = Json::object();
@@ -101,20 +148,22 @@ Json to_json(const ServerConfig &c) {
row["added"] = ban.added;
bans.push_back(std::move(row));
}
root["bans"] = std::move(bans);
return root;
return bans;
}
void write_file(const std::filesystem::path &file, const std::string &text) {
if (file.has_parent_path()) std::filesystem::create_directories(file.parent_path());
const auto temporary = std::filesystem::path(file).concat(".tmp");
{
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
out << text << '\n';
if (!out) throw std::runtime_error("Cannot write " + path_utf8(temporary));
}
std::filesystem::rename(temporary, file);
}
std::uint64_t steam_id(const Json &value) {
if (value.is_string()) return std::stoull(value.string());
return value.get<std::uint64_t>();
}
// The settings `expected` has that `file` lacks, nested objects included.
void missing_settings(const Json &file, const Json &expected, const std::string &prefix, std::vector<std::string> &out) {
for (const auto &[key, value] : expected.items()) {
if (!file.contains(key)) out.push_back(prefix + key);
else if (value.is_object() && file.at(key).is_object()) missing_settings(file.at(key), value, prefix + key + ".", out);
}
}
} // namespace
ServerConfig load_config(const std::filesystem::path &file, std::vector<std::string> *added) {
ServerConfig c;
@@ -131,118 +180,162 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
}
const auto root = Json::parse(text.str());
if (!root.is_object()) throw std::runtime_error("The server config must be a JSON object.");
c.name = root.value("name", c.name);
c.map = root.value("map", c.map);
if (root.contains("map_pool") && root.at("map_pool").is_array())
for (const auto &map : root.at("map_pool"))
if (map.is_string() && !map.string().empty()) c.map_pool.push_back(map.string());
c.map_rotation = std::min(root.value("map_rotation_minutes", c.map_rotation), max_map_rotation);
c.max_players = root.value("max_players", c.max_players);
c.reserved_slots = root.value("reserved_slots", c.reserved_slots);
c.crowd_budget = root.value("crowd_budget", c.crowd_budget);
c.bone_scale_limit = root.value("bone_scale_limit", c.bone_scale_limit);
c.connection = root.value("connection", c.connection);
c.direct_address = root.value("direct_address", c.direct_address);
c.steam_debug = root.value("steam_debug", c.steam_debug);
c.relay_everything = root.value("relay_everything", c.relay_everything);
c.direct_port = root.value("direct_port", c.direct_port);
c.pack_ms = root.value("pack_ms", c.pack_ms);
c.finger_distance = root.value("finger_distance", c.finger_distance);
c.send_rate = root.value("send_rate", c.send_rate);
if (root.contains("reserved") && root.at("reserved").is_array())
for (const auto &id : root.at("reserved")) c.reserved.push_back(steam_id(id));
c.password = root.value("password", c.password);
c.welcome = root.value("welcome", c.welcome);
c.listed = root.value("listed", c.listed);
c.steam_token = root.value("steam_token", c.steam_token);
c.auto_update = root.value("auto_update", c.auto_update);
c.global_bans = root.value("global_bans", c.global_bans);
c.activity_log = root.value("activity_log", c.activity_log);
c.announce_throwdowns = root.value("announce_throwdowns", c.announce_throwdowns);
c.parties = root.value("parties", c.parties);
c.party_size = std::clamp(root.value("party_size", c.party_size), 2U, 8U);
c.speed_check = root.value("speed_check", c.speed_check);
if (c.speed_check != "off" && c.speed_check != "warn" && c.speed_check != "kick") c.speed_check = "warn";
c.score_check = root.value("score_check", c.score_check);
if (c.score_check != "off" && c.score_check != "warn" && c.score_check != "kick") c.score_check = "warn";
if (root.contains("score_allow") && root.at("score_allow").is_array())
for (const auto &value : root.at("score_allow"))
if (value.is_string())
if (const auto fingerprint = parse_scoring(value.string()))
c.score_allow.push_back(*fingerprint);
// A setting is in its section under its name. A config from before the sections has it at the
// top, under that name or an older one: it is read from there, and the file is written back
// as it is laid out now. A setting found nowhere is new, and the owner is told of it.
std::vector<std::string> missing;
bool moved{};
using Old = std::initializer_list<std::string_view>;
const auto find = [&](std::string_view section, std::string_view key, Old old = {}) -> const Json * {
if (root.contains(section) && root.at(section).is_object() && root.at(section).contains(key)) return &root.at(section).at(key);
if (root.contains(key) && !root.at(key).is_null() && key != "votes") {
moved = true;
return &root.at(key);
}
for (const auto name : old)
if (root.contains(name)) {
moved = true;
return &root.at(name);
}
missing.push_back(std::string(section) + "." + std::string(key));
return nullptr;
};
const auto get = [&]<typename T>(std::string_view section, std::string_view key, T fallback, Old old = {}) {
const auto *found = find(section, key, old);
return found ? found->template get<T>() : fallback;
};
c.name = get("server", "name", c.name);
c.password = get("server", "password", c.password);
c.welcome = get("server", "welcome_message", c.welcome, {"welcome"});
c.listed = get("server", "listed", c.listed);
c.max_players = get("server", "max_players", c.max_players);
// Checked here, not in config_error: once narrowed, 70000 is just port 4464, and the
// next save would write that over the owner's typo.
const auto read_port = [&](const char *key, std::uint16_t fallback) {
const auto value = root.value(key, static_cast<unsigned>(fallback));
const auto value = get("server", key, static_cast<unsigned>(fallback));
if (value < 1 || value > 65535) throw std::runtime_error(std::string(key) + " must be 1 to 65535.");
return static_cast<std::uint16_t>(value);
};
c.port = read_port("port", c.port);
c.query_port = read_port("query_port", c.query_port);
c.tps = dedicated_tps; // not the file's (see ServerConfig::tps)
c.voice_chat = root.value("voice_chat", c.voice_chat);
c.voice_range = root.value("voice_range", c.voice_range);
if (root.contains("distances")) {
const auto &d = root.at("distances");
c.distances.full_rate_return = d.value("full_rate_return", c.distances.full_rate_return);
c.distances.half_rate_start = d.value("half_rate_start", c.distances.half_rate_start);
c.distances.half_rate_return = d.value("half_rate_return", c.distances.half_rate_return);
c.distances.low_rate_start = d.value("low_rate_start", c.distances.low_rate_start);
}
c.noclip = root.value("noclip", c.noclip);
c.no_bail = root.value("no_bail", c.no_bail);
c.boosts = root.value("boosts", c.boosts);
c.enforce_tuning = root.value("enforce_tuning", c.enforce_tuning);
if (root.contains("votes") && root.at("votes").is_object()) {
const auto &votes = root.at("votes");
const auto read_vote = [&](const char *key, VoteSetting &v) {
if (!votes.contains(key) || !votes.at(key).is_object()) return;
const auto &item = votes.at(key);
v.enabled = item.value("enabled", v.enabled);
v.percent = std::clamp(item.value("percent", v.percent), 1U, 100U);
};
read_vote("map", c.votes.map);
read_vote("kick", c.votes.kick);
read_vote("time_of_day", c.votes.time);
c.votes.seconds = std::clamp(votes.value("seconds", c.votes.seconds), 10U, 300U);
c.votes.cooldown = std::clamp(votes.value("cooldown_seconds", c.votes.cooldown), 0U, 3600U);
}
c.object_limit = root.value("object_limit", c.object_limit);
const auto placement = root.value("object_placement", placement_text(c.object_placement));
c.steam_token = get("server", "steam_token", c.steam_token);
c.auto_update = get("server", "auto_update", c.auto_update);
c.activity_log = get("server", "activity_log", c.activity_log);
if (const auto *admins = find("access", "admins"); admins && admins->is_array())
for (const auto &id : *admins) c.admins.push_back(steam_id(id));
if (const auto *reserved = find("access", "reserved_players_slots", {"reserved"}); reserved && reserved->is_array())
for (const auto &id : *reserved) c.reserved.push_back(steam_id(id));
c.global_bans = get("access", "use_global_bans", c.global_bans, {"global_bans"});
c.map = get("maps", "map", c.map);
if (const auto *pool = find("maps", "pool", {"map_pool"}); pool && pool->is_array())
for (const auto &map : *pool)
if (map.is_string() && !map.string().empty()) c.map_pool.push_back(map.string());
c.map_rotation = std::min(get("maps", "rotation_minutes", c.map_rotation, {"map_rotation_minutes"}), max_map_rotation);
if (const auto *parks = find("maps", "parks"); parks && parks->is_object())
for (unsigned lot = 0; lot < park_lots.size(); ++lot) c.parks[lot] = parks->value(park_lots[lot].key, c.parks[lot]);
c.world_layer_sync = get("maps", "world_layer_sync", c.world_layer_sync);
if (const auto *layers = find("maps", "layers"); layers && layers->is_object())
for (const auto &[key, mode] : layers->items())
if (mode.is_string()) c.layers[key] = mode.string();
c.boosts = get("players", "allow_boosts", c.boosts, {"boosts"});
c.no_bail = get("players", "allow_no_bail", c.no_bail, {"no_bail"});
c.noclip = get("players", "allow_noclip", c.noclip, {"noclip"});
c.parties = get("players", "allow_parties", c.parties, {"parties"});
c.party_size = std::clamp(get("players", "party_size", c.party_size), 2U, 8U);
c.voice_chat = get("players", "allow_voice_chat", c.voice_chat, {"voice_chat"});
c.voice_range = get("players", "voice_range", c.voice_range);
const auto placement = get("players", "object_placement", placement_text(c.object_placement));
// On a dedicated server the protocol's "host only" means its admins.
c.object_placement = placement == "nobody" ? ObjectPlacement::nobody
: placement == "admins" || placement == "host" ? ObjectPlacement::host_only : ObjectPlacement::everyone;
if (root.contains("parks"))
for (unsigned lot = 0; lot < park_lots.size(); ++lot)
c.parks[lot] = root.at("parks").value(park_lots[lot].key, c.parks[lot]);
c.world_layer_sync = root.value("world_layer_sync", c.world_layer_sync);
if (root.contains("layers") && root.at("layers").is_object())
for (const auto &[key, mode] : root.at("layers").items())
if (mode.is_string()) c.layers[key] = mode.string();
if (root.contains("admins"))
for (const auto &id : root.at("admins")) c.admins.push_back(steam_id(id));
if (root.contains("bans"))
for (const auto &row : root.at("bans"))
c.bans.push_back({steam_id(row.at("id")), row.value("name", std::string{}),
row.value("added", std::int64_t{})});
// A config from an older version: write the new settings into it so owners can see them.
std::vector<std::string> missing;
missing_settings(root, to_json(c), {}, missing);
if (!missing.empty()) {
c.object_limit = get("players", "object_limit", c.object_limit);
c.announce_throwdowns = get("players", "announce_throwdowns", c.announce_throwdowns);
c.speed_check = get("anti_cheat", "speed_hack", c.speed_check, {"speed_check"});
if (c.speed_check != "off" && c.speed_check != "warn" && c.speed_check != "kick") c.speed_check = "warn";
c.score_check = get("anti_cheat", "modified_scoring", c.score_check, {"score_check"});
if (c.score_check != "off" && c.score_check != "warn" && c.score_check != "kick") c.score_check = "warn";
if (const auto *allowed = find("anti_cheat", "allowed_scoring_mods", {"score_allow"}); allowed && allowed->is_array())
for (const auto &value : *allowed)
if (value.is_string())
if (const auto fingerprint = parse_scoring(value.string())) c.score_allow.push_back(*fingerprint);
c.enforce_tuning = get("anti_cheat", "enforce_tuning", c.enforce_tuning);
c.bone_scale_limit = get("anti_cheat", "bone_scale_limit", c.bone_scale_limit);
c.use_steam_relay = get("network", "use_steam_relay", c.use_steam_relay);
c.send_rate = get("network", "send_rate", c.send_rate);
c.crowd_budget = get("network", "crowd_budget", c.crowd_budget);
c.pack_ms = get("network", "pack_ms", c.pack_ms);
c.finger_distance = get("network", "finger_distance", c.finger_distance);
if (const auto *d = find("network", "distances"); d && d->is_object()) {
c.distances.full_rate_return = d->value("full_rate_return", c.distances.full_rate_return);
c.distances.half_rate_start = d->value("half_rate_start", c.distances.half_rate_start);
c.distances.half_rate_return = d->value("half_rate_return", c.distances.half_rate_return);
c.distances.low_rate_start = d->value("low_rate_start", c.distances.low_rate_start);
}
c.steam_debug = get("network", "steam_debug", c.steam_debug);
// Votes were a section from the start.
const Json no_votes = Json::object();
const auto &votes = root.contains("votes") && root.at("votes").is_object() ? root.at("votes") : no_votes;
const auto read_vote = [&](const char *key, VoteSetting &v) {
if (!votes.contains(key) || !votes.at(key).is_object()) return missing.push_back(std::string("votes.") + key);
const auto &item = votes.at(key);
v.enabled = item.value("enabled", v.enabled);
v.percent = std::clamp(item.value("percent", v.percent), 1U, 100U);
};
read_vote("map", c.votes.map);
read_vote("kick", c.votes.kick);
read_vote("time_of_day", c.votes.time);
if (!votes.contains("seconds")) missing.push_back("votes.seconds");
if (!votes.contains("cooldown_seconds")) missing.push_back("votes.cooldown_seconds");
c.votes.seconds = std::clamp(votes.value("seconds", c.votes.seconds), 10U, 300U);
c.votes.cooldown = std::clamp(votes.value("cooldown_seconds", c.votes.cooldown), 0U, 3600U);
const auto read_bans = [&](const Json &rows) {
if (!rows.is_array()) throw std::runtime_error("The bans must be a JSON list.");
for (const auto &row : rows) {
const auto id = steam_id(row.at("id"));
if (std::none_of(c.bans.begin(), c.bans.end(), [&](const auto &ban) { return ban.id == id; }))
c.bans.push_back({id, row.value("name", std::string{}), row.value("added", std::int64_t{})});
}
};
if (const auto bans = bans_file(c); std::filesystem::exists(bans)) {
std::stringstream held;
{
std::ifstream in(bans, std::ios::binary);
held << in.rdbuf();
}
try {
read_bans(Json::parse(held.str()));
} catch (const std::exception &e) {
// Never started without its bans because their file has a typo in it.
throw std::runtime_error("Cannot read " + path_utf8(bans) + ": " + e.what());
}
}
// A config from an older version holds the bans itself: they are moved to their own file.
const bool bans_moved = root.contains("bans");
if (bans_moved) read_bans(root.at("bans"));
// New settings, an older layout, or settings that are no longer any: the file is written
// as it is now, so owners see what there is.
const bool gone = root.contains("tps") || root.contains("reserved_slots") || root.contains("relay_everything") ||
(root.contains("network") && root.at("network").contains("relay_everything"));
if (!missing.empty() || moved || bans_moved || gone) {
save_config(c);
if (added) *added = std::move(missing);
}
return c;
}
std::filesystem::path bans_file(const ServerConfig &c) { return c.file.parent_path() / "data" / "bans.json"; }
void save_config(const ServerConfig &c) {
if (c.file.has_parent_path()) std::filesystem::create_directories(c.file.parent_path());
const auto temporary = std::filesystem::path(c.file).concat(".tmp");
{
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
out << to_json(c).dump(2) << '\n';
if (!out) throw std::runtime_error("Cannot write " + path_utf8(temporary));
}
std::filesystem::rename(temporary, c.file);
// The bans first: a config that held them is only rewritten without them once they are safe.
write_file(bans_file(c), bans_json(c).dump(2));
std::string text;
write(text, layout(c), 0);
write_file(c.file, text);
}
std::string scoring_text(std::uint64_t fingerprint) {
static constexpr char digits[] = "0123456789abcdef";
@@ -261,24 +354,23 @@ std::optional<std::uint64_t> parse_scoring(std::string_view text) {
if (!value) return std::nullopt;
return value;
}
bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on) noexcept {
bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on, std::size_t reserved_on) noexcept {
if (on >= config.max_players) return false;
const auto listed = [&](const std::vector<std::uint64_t> &ids) { return std::find(ids.begin(), ids.end(), id) != ids.end(); };
return on + config.reserved_slots < config.max_players || listed(config.reserved) || listed(config.admins);
if (listed(config.reserved) || listed(config.admins)) return true;
const auto kept = config.reserved.size() - std::min(reserved_on, config.reserved.size());
return on + kept < config.max_players;
}
std::string config_error(const ServerConfig &c) {
using namespace multiplayer;
if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + ".";
for (const auto id : c.reserved)
if (!individual_steam_id(id)) return "reserved must be SteamID64s (17 digits starting 7656119).";
if (c.reserved_slots >= c.max_players) return "reserved_slots must be less than max_players, so that anyone can join at all.";
if (c.reserved.size() > 1024) return "reserved holds at most 1024 players.";
if (!individual_steam_id(id)) return "reserved_players_slots must be SteamID64s (17 digits starting 7656119).";
if (c.reserved.size() >= c.max_players)
return "reserved_players_slots must list fewer players than max_players: a slot is kept for each, so nobody else could join.";
if (c.send_rate < 128 || c.send_rate > 16384) return "send_rate must be 128 to 16384 (KB/s for each player).";
if (c.bone_scale_limit != 0 && !(c.bone_scale_limit >= 1.f && c.bone_scale_limit <= 8.f))
return "bone_scale_limit must be 0 (no limit) or 1 to 8 (1: no resized body parts at all).";
if (c.connection != "relay" && c.connection != "direct") return "connection must be \"relay\" or \"direct\".";
if (!c.direct_address.empty() && !direct_ipv4(c.direct_address)) return "direct_address must be an address like 203.0.113.7, or empty.";
if (c.direct_port && c.direct_port == c.query_port) return "direct_port and query_port must differ.";
if (c.pack_ms > 50) return "pack_ms must be 0 (off) to 50.";
if (c.finger_distance > 10000) return "finger_distance must be 0 (fingers always sent) to 10000.";
if (!valid_crowd_budget(c.crowd_budget))
+12 -18
View File
@@ -37,29 +37,18 @@ struct ServerConfig {
std::vector<std::string> map_pool; // maps for votes and the rotation, in order; empty: every map
unsigned map_rotation = 0; // minutes per map before the next pool map (0: off)
unsigned max_players = 16; // players; the server itself is not one
// Of those, how many are kept for the players in `reserved` and the admins: everyone else
// is told the server is full once only these are left. 0: none are kept.
unsigned reserved_slots = 0;
// The most poses a second one player is sent (crowd_limits); 0: no limit.
unsigned crowd_budget = crowd_pose_budget;
// The most a mod may resize part of a skater for the other players, as a factor (and its
// inverse the least): 1, the default, shows every skater at the game's own proportions;
// 0 is no limit.
float bone_scale_limit = 1;
// How players reach the server: "relay", through Steam's relay network only, or "direct",
// straight to `port` (UDP). A direct server still answers through the relays, for a player
// the port does not reach, one who has turned direct connections off, or an older game.
std::string connection = "relay";
// The address players connect straight to, a.b.c.d, when it is not the one Steam lists the
// server under (a host that sends from one address and takes connections on another).
// Empty: the listed one. And the port, when it is not `port`; 0: `port`.
std::string direct_address;
// How players reach the server: true, through Steam's relay network only; false, straight
// to `port` (UDP). A direct server still answers through the relays, for a player the port
// does not reach, one who has turned direct connections off, or an older game.
bool use_steam_relay = true;
// Logs what Steam's networking says it is doing, to find out why connections fail.
bool steam_debug = false;
// For testing with few players: poses go through the server even between players whose
// games have linked to each other.
bool relay_everything = false;
std::uint16_t direct_port = 0;
// How long a message to a player may wait to share a packet with the next ones, in
// milliseconds (0: each goes at once in a packet of its own). Fewer, fuller packets:
// less sent for the same updates, and less work sending it.
@@ -69,6 +58,7 @@ struct ServerConfig {
unsigned finger_distance = 25;
// What the server may send each player, in KB/s (128-16384).
unsigned send_rate = 900;
// The players with a reserved slot: one is kept free for each of them who is not on.
std::vector<std::uint64_t> reserved;
std::string password; // empty: anyone may join
std::string welcome; // sent to each player as they join
@@ -125,9 +115,13 @@ struct ServerConfig {
// their names go to `added` ("votes.seconds" for a nested one).
ServerConfig load_config(const std::filesystem::path &file, std::vector<std::string> *added = nullptr);
void save_config(const ServerConfig &config);
// Whether a player who is not yet on may join a server with `on` players on it: anyone while
// an unreserved slot is free, then only the reserved players and the admins until it is full.
bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on) noexcept;
// Where the bans are kept: data/bans.json, beside the config.
std::filesystem::path bans_file(const ServerConfig &config);
// Whether a player who is not yet on may join a server with `on` players on it, `reserved_on`
// of them players with a reserved slot. A slot is kept for each reserved player who is not on:
// anyone joins while a slot beyond those is free, then only the reserved players and the admins
// until the server is full.
bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on, std::size_t reserved_on) noexcept;
// Why `config` cannot run, or empty.
std::string config_error(const ServerConfig &config);
// A scoring fingerprint as the config and console write it (16 hex digits), and read back
+14 -20
View File
@@ -149,19 +149,18 @@ bool Host::start(std::string &error) {
return false;
}
direct_port_ = 0;
if (config_.connection == "direct") {
const auto port = config_.direct_port ? config_.direct_port : config_.port;
if (!config_.use_steam_relay) {
const auto port = config_.port;
if (transport_.listen_direct(port)) {
direct_port_ = port;
log_("Connection: direct, on UDP port " + std::to_string(port) +
(config_.direct_address.empty() ? std::string{} : " at " + config_.direct_address) +
". The port must be open to the internet; players it does not reach come through Steam's relays.");
} else {
log_("Connection: direct was asked for, but Steam could not listen on UDP port " + std::to_string(port) +
" (in use, or not allowed here). Players connect through Steam's relays only.");
}
} else {
log_("Connection: through Steam's relays (\"connection\": \"direct\" lets players connect straight to the server).");
log_("Connection: through Steam's relays (\"use_steam_relay\": false lets players connect straight to the server).");
}
id_ = transport_.status().local_id;
secret_ = nonce();
@@ -412,7 +411,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6
if (distance > p.crowd.half || (config_.distances.valid() && distance > reach * reach)) continue;
}
// Frequent gameplay streams skip the server once the receiver confirms a direct route.
if (source && !config_.relay_everything && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) &&
if (source && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) &&
!needs_relay(p.direct_routes, p.route_reported, source->member, now_))
continue;
// Sound is sent with everyone else's they hear, once a pass (flush_poses).
@@ -1399,7 +1398,9 @@ void Host::tick(std::uint64_t now) {
continue;
}
if (!guest) {
if (!individual_steam_id(link.id) || !may_join(config_, link.id, guests_.size())) {
const auto reserved_on = static_cast<std::size_t>(std::count_if(
config_.reserved.begin(), config_.reserved.end(), [&](std::uint64_t id) { return guests_.contains(id); }));
if (!individual_steam_id(link.id) || !may_join(config_, link.id, guests_.size(), reserved_on)) {
// Full for them: for everyone, or with only the reserved slots left.
transport_.disconnect(link.id, guests_.size() >= config_.max_players || !individual_steam_id(link.id)
? "The server is full."
@@ -2113,34 +2114,27 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
(config_.world_layer_sync ? "." : ". Turn on layer-sync to apply it to everyone."));
}
if (name == "reserved") {
// reserved | reserved slots <n> | reserved add|remove <player or SteamID64>
// reserved | reserved add|remove <player or SteamID64>
if (!console) return "Only the server console manages reserved slots.";
const auto [sub, who] = split(argument);
if (sub.empty()) {
std::string text = std::to_string(config_.reserved_slots) + " of " + std::to_string(config_.max_players) +
" slots are reserved for " + std::to_string(config_.reserved.size()) + " players and the admins";
std::string text = std::to_string(config_.reserved.size()) + " of " + std::to_string(config_.max_players) +
" slots are reserved, one for each of these players while they are not on";
for (const auto id : config_.reserved) {
const auto *guest = find(id);
text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{});
}
return text;
}
if (sub == "slots") {
const auto slots = number(who);
if (!slots || *slots >= config_.max_players) return "reserved slots <0-" + std::to_string(config_.max_players - 1) + ">";
config_.reserved_slots = static_cast<unsigned>(*slots);
return changed(*slots ? std::to_string(*slots) + " slots are kept for reserved players and admins."
: std::string("No slots are reserved."));
}
auto *guest = target(who);
const auto id = guest ? guest->member.id : number(who).value_or(0);
if (!individual_steam_id(id) || (sub != "add" && sub != "remove")) return "reserved | reserved slots <n> | reserved add|remove <player or SteamID64>";
if (!individual_steam_id(id) || (sub != "add" && sub != "remove")) return "reserved | reserved add|remove <player or SteamID64>";
const bool listed = std::find(config_.reserved.begin(), config_.reserved.end(), id) != config_.reserved.end();
if (sub == "add") {
if (!listed && config_.reserved.size() >= 1024) return "The reserved list is full.";
if (!listed && config_.reserved.size() + 1 >= config_.max_players)
return "Every slot but one is reserved already: a slot is kept for each reserved player.";
if (!listed) config_.reserved.push_back(id);
return changed(std::to_string(id) + " has a reserved slot." +
(config_.reserved_slots ? std::string{} : " No slots are reserved yet: reserved slots <n>."));
return changed(std::to_string(id) + " has a reserved slot.");
}
std::erase(config_.reserved, id);
return changed(std::to_string(id) + " no longer has a reserved slot.");
-1
View File
@@ -44,7 +44,6 @@ class Host {
std::uint64_t secret() const { return secret_; }
// The UDP port players may connect straight to, or 0 (config connection).
std::uint16_t direct_port() const { return direct_port_; }
const std::string &direct_address() const { return config_.direct_address; }
enum class VoteKind { map, kick, time };
+1 -2
View File
@@ -127,8 +127,7 @@ std::string server_tags(const Advertisement &a) {
for (int shift = 60; shift >= 0; shift -= 4) secret += hex[(a.secret >> shift) & 15];
auto tags = "reskate,v" + std::to_string(multiplayer::protocol_version) + ",k" + secret + ",p" +
std::to_string(a.players) + ",c" + std::to_string(a.max_players) + (a.password ? ",w1" : ",w0");
// "d27015", or "d203.0.113.7:27015" for a server reached at another address than it is listed under.
if (a.direct_port) tags += ",d" + (a.direct_address.empty() ? std::string{} : a.direct_address + ":") + std::to_string(a.direct_port);
if (a.direct_port) tags += ",d" + std::to_string(a.direct_port);
tags += ",m" + tag_text(a.map, 24);
tags += ",n" + tag_text(a.name, 127 - tags.size() - 2);
return tags;
-1
View File
@@ -13,7 +13,6 @@ struct Advertisement {
bool password{}, listed{true};
std::uint64_t secret{};
std::uint16_t direct_port{}; // players may connect straight to this UDP port; 0: relays only
std::string direct_address; // at this address, when it is not the one Steam lists the server under
};
std::string server_tags(const Advertisement &);