Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce6dba17e6 | ||
|
|
d1f581d6a8 |
+20
-7
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.8.6"
|
VERSION = "1.8.7"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1643,7 +1643,20 @@ class Agent:
|
|||||||
raise AgentError("Invalid runtime environment")
|
raise AgentError("Invalid runtime environment")
|
||||||
# Runtime download is a separate preparation operation. A deployment
|
# Runtime download is a separate preparation operation. A deployment
|
||||||
# uses the already prepared image even during a registry outage.
|
# uses the already prepared image even during a registry outage.
|
||||||
self.docker.call("image", "inspect", image)
|
image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip()
|
||||||
|
if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id):
|
||||||
|
raise AgentError("Docker did not report a valid runtime image ID")
|
||||||
|
try:
|
||||||
|
raw = managed_read(self.root / "agent", "runtime-images.json")
|
||||||
|
prepared = json.loads(raw) if raw is not None else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise AgentError("Runtime preparation records are unavailable") from None
|
||||||
|
if not isinstance(prepared, dict):
|
||||||
|
raise AgentError("Runtime preparation records are invalid")
|
||||||
|
# The controller also authorizes its configured default without a pull
|
||||||
|
# record. Whenever preparation was recorded, enforce that exact image.
|
||||||
|
if image in prepared and prepared[image] != image_id:
|
||||||
|
raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.")
|
||||||
files = payload.get("files", [])
|
files = payload.get("files", [])
|
||||||
if not isinstance(files, list) or len(files) > 10000:
|
if not isinstance(files, list) or len(files) > 10000:
|
||||||
raise Rejected("Invalid initial file list")
|
raise Rejected("Invalid initial file list")
|
||||||
@@ -1656,7 +1669,7 @@ class Agent:
|
|||||||
total += len(self.decode_file(item))
|
total += len(self.decode_file(item))
|
||||||
if total > 64 * 1024 * 1024:
|
if total > 64 * 1024 * 1024:
|
||||||
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
||||||
return game, library, image, port, bind, env
|
return game, library, image, image_id, port, bind, env
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def plutonium_manifest(data):
|
def plutonium_manifest(data):
|
||||||
@@ -1890,7 +1903,7 @@ class Agent:
|
|||||||
raise AgentError("Instance already exists with a different specification")
|
raise AgentError("Instance already exists with a different specification")
|
||||||
return existing
|
return existing
|
||||||
self.operation_phase = "validating-create-spec"
|
self.operation_phase = "validating-create-spec"
|
||||||
game, library, image, port, bind, env = self.validate_spec(payload)
|
game, library, image, image_id, port, bind, env = self.validate_spec(payload)
|
||||||
app = directory / "app"
|
app = directory / "app"
|
||||||
self.operation_phase = "preparing-create-directory"
|
self.operation_phase = "preparing-create-directory"
|
||||||
app.mkdir(parents=True, exist_ok=True)
|
app.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1905,15 +1918,15 @@ class Agent:
|
|||||||
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
||||||
name = self.container_name(instance_id, generation)
|
name = self.container_name(instance_id, generation)
|
||||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||||
# Do not let Docker fetch a replacement if the prepared image disappears
|
# Use the validated immutable ID so retagging after validation cannot
|
||||||
# between validation and container creation.
|
# substitute another image. A deleted image must not trigger a pull.
|
||||||
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
||||||
if self.config.get("hostId"):
|
if self.config.get("hostId"):
|
||||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||||
for protocol in ("udp", "tcp"):
|
for protocol in ("udp", "tcp"):
|
||||||
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
||||||
args.append(image)
|
args.append(image_id)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "creating-container"
|
self.operation_phase = "creating-container"
|
||||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class FakeDocker:
|
|||||||
self.fail_restart = False
|
self.fail_restart = False
|
||||||
self.lose_reply_after = None
|
self.lose_reply_after = None
|
||||||
self.started = 0
|
self.started = 0
|
||||||
|
self.image_id = "sha256:" + "1" * 64
|
||||||
|
|
||||||
def inspect(self, name):
|
def inspect(self, name):
|
||||||
return self.containers.get(name)
|
return self.containers.get(name)
|
||||||
@@ -34,6 +35,8 @@ class FakeDocker:
|
|||||||
return json.dumps({"ServerVersion": "27.5.1"})
|
return json.dumps({"ServerVersion": "27.5.1"})
|
||||||
if args[:2] == ("image", "ls"):
|
if args[:2] == ("image", "ls"):
|
||||||
return "ghcr.io/ayymoss/plutainer:latest\n"
|
return "ghcr.io/ayymoss/plutainer:latest\n"
|
||||||
|
if args[:4] == ("image", "inspect", "--format", "{{.Id}}"):
|
||||||
|
return self.image_id
|
||||||
if args[:2] == ("container", "create"):
|
if args[:2] == ("container", "create"):
|
||||||
labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"}
|
labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"}
|
||||||
name = args[args.index("--name") + 1]
|
name = args[args.index("--name") + 1]
|
||||||
@@ -989,7 +992,8 @@ class AgentTests(unittest.TestCase):
|
|||||||
payload = self.create()
|
payload = self.create()
|
||||||
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
|
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
|
||||||
self.assertIn("--pull=never", creation)
|
self.assertIn("--pull=never", creation)
|
||||||
self.assertEqual(creation[-1], payload["image"])
|
self.assertEqual(creation[-1], self.docker.image_id)
|
||||||
|
self.assertEqual(self.agent.spec(INSTANCE, 1)["image"], payload["image"])
|
||||||
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
||||||
|
|
||||||
def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self):
|
def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self):
|
||||||
@@ -1008,6 +1012,88 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.docker.started, 0)
|
self.assertEqual(self.docker.started, 0)
|
||||||
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
||||||
|
|
||||||
|
def test_retargeted_prepared_image_requires_a_new_admin_pull_before_creation(self):
|
||||||
|
image = "registry.example/custom-runtime:stable"
|
||||||
|
payload = {"game": "t5", "libraryId": self.library_id, "image": image, "port": 28960,
|
||||||
|
"rconPassword": "rcon-secret", "env": {"PLUTAINER_KEY": "server-secret"}}
|
||||||
|
self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare"))["state"], "completed")
|
||||||
|
prepared_id = self.docker.image_id
|
||||||
|
self.docker.image_id = "sha256:" + "2" * 64
|
||||||
|
original = self.docker.call
|
||||||
|
|
||||||
|
def call(*args, **kwargs):
|
||||||
|
if args[:2] == ("image", "ls"):
|
||||||
|
# The old image still exists, so cached-ID inventory alone
|
||||||
|
# cannot prove that this mutable tag is safe to launch.
|
||||||
|
return (f"registry.example/custom-runtime|stable|<none>|{self.docker.image_id}\n"
|
||||||
|
f"<none>|<none>|<none>|{prepared_id}\n")
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(self.docker, "call", side_effect=call):
|
||||||
|
self.assertIn(image, self.agent.inventory()["preparedRuntimeImages"])
|
||||||
|
result = self.agent.execute(self.operation("instance.create", payload, "retargeted"))
|
||||||
|
self.assertEqual(result["state"], "failed")
|
||||||
|
self.assertIn("Prepared runtime image has changed", result["error"])
|
||||||
|
self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls))
|
||||||
|
self.assertEqual(self.docker.started, 0)
|
||||||
|
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "environment").exists())
|
||||||
|
|
||||||
|
self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare-again"))["state"], "completed")
|
||||||
|
self.assertEqual(self.agent.execute(self.operation("instance.create", payload, "retry"))["state"], "completed")
|
||||||
|
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
|
||||||
|
self.assertEqual(creation[-1], self.docker.image_id)
|
||||||
|
|
||||||
|
def test_retag_after_validation_cannot_replace_the_image_used_for_creation(self):
|
||||||
|
image = "ghcr.io/ayymoss/plutainer:latest"
|
||||||
|
self.agent.dispatch(self.operation("runtime.pull", {"image": image}))
|
||||||
|
prepared_id = self.docker.image_id
|
||||||
|
original = self.agent.write_initial_files
|
||||||
|
|
||||||
|
def retag(*args):
|
||||||
|
self.docker.image_id = "sha256:" + "2" * 64
|
||||||
|
return original(*args)
|
||||||
|
|
||||||
|
with patch.object(self.agent, "write_initial_files", side_effect=retag):
|
||||||
|
payload = self.create()
|
||||||
|
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
|
||||||
|
self.assertIn("--pull=never", creation)
|
||||||
|
self.assertEqual(creation[-1], prepared_id)
|
||||||
|
self.assertNotEqual(creation[-1], self.docker.image_id)
|
||||||
|
self.assertEqual(self.agent.spec(INSTANCE, 1), payload)
|
||||||
|
# Retagging must not invalidate an already created immutable instance.
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
self.assertTrue(self.agent.create(INSTANCE, 1, payload)["exists"])
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
|
def test_rebuild_rejects_retargeted_image_before_stopping_existing_container(self):
|
||||||
|
image = "ghcr.io/ayymoss/plutainer:latest"
|
||||||
|
self.agent.dispatch(self.operation("runtime.pull", {"image": image}))
|
||||||
|
payload = self.create()
|
||||||
|
self.docker.image_id = "sha256:" + "2" * 64
|
||||||
|
self.docker.calls.clear()
|
||||||
|
result = self.agent.execute(self.operation("instance.rebuild", {"nextGeneration": 2, "spec": payload}, "rebuild"))
|
||||||
|
self.assertEqual(result["state"], "failed")
|
||||||
|
self.assertIn("Prepared runtime image has changed", result["error"])
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm"), ("container", "create")) for args in self.docker.calls))
|
||||||
|
self.assertTrue(self.docker.inspect(self.agent.container_name(INSTANCE, 1))["State"]["Running"])
|
||||||
|
|
||||||
|
def test_launch_rejects_invalid_image_ids_and_preparation_records(self):
|
||||||
|
payload = {"game": "t5", "libraryId": self.library_id, "image": "prepared:runtime", "port": 28960}
|
||||||
|
for image_id in ("", "prepared:runtime", "sha256:short"):
|
||||||
|
with self.subTest(image_id=image_id):
|
||||||
|
self.docker.image_id = image_id
|
||||||
|
with self.assertRaisesRegex(AgentError, "valid runtime image ID"):
|
||||||
|
self.agent.create(INSTANCE, 1, payload)
|
||||||
|
self.docker.image_id = "sha256:" + "1" * 64
|
||||||
|
aliases = self.agent.root / "agent/runtime-images.json"
|
||||||
|
for raw in ("invalid json", "[]", "null"):
|
||||||
|
with self.subTest(records=raw):
|
||||||
|
aliases.write_text(raw)
|
||||||
|
with self.assertRaisesRegex(AgentError, "Runtime preparation records"):
|
||||||
|
self.agent.create(INSTANCE, 1, payload)
|
||||||
|
self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls))
|
||||||
|
self.assertEqual(self.docker.started, 0)
|
||||||
|
|
||||||
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
||||||
self.create()
|
self.create()
|
||||||
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ an end closes the match. Receipt order determines the latest report. This small
|
|||||||
protocol does not attempt to identify delayed events from earlier games or
|
protocol does not attempt to identify delayed events from earlier games or
|
||||||
provide exactly-once delivery across games.
|
provide exactly-once delivery across games.
|
||||||
|
|
||||||
|
Match timing is independent of player count: an empty `game_start` still records
|
||||||
|
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
|
||||||
|
fresh reported round even when the start was missed; match duration stays unknown
|
||||||
|
in that case. When match history identifies a new match from subsequent gameplay,
|
||||||
|
Fleet clears the previous match's timing rather than carrying it into the new one.
|
||||||
|
|
||||||
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
|
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
|
||||||
of beta activity. That page documents cumulative stat accounting, round credit,
|
of beta activity. That page documents cumulative stat accounting, round credit,
|
||||||
and the limits of identifying matches from this protocol.
|
and the limits of identifying matches from this protocol.
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ still run the configured default, but must update before accepting custom runtim
|
|||||||
Queued launches and migrations never trigger custom image pulls, and container
|
Queued launches and migrations never trigger custom image pulls, and container
|
||||||
creation cannot implicitly pull a missing image.
|
creation cannot implicitly pull a missing image.
|
||||||
|
|
||||||
|
Agent 1.8.7 also checks a runtime's current image ID against its recorded preparation
|
||||||
|
before creating or rebuilding a container. If another program retags a prepared
|
||||||
|
image, an admin must prepare it again from the host page before launching it.
|
||||||
|
Containers are created using the validated immutable image ID, so a tag change
|
||||||
|
after validation cannot substitute another image. Existing containers keep running.
|
||||||
|
|
||||||
### Resource samples and learned allowances
|
### Resource samples and learned allowances
|
||||||
|
|
||||||
Only provisioning reserves a host and port. Agent **1.4.6** fixes container
|
Only provisioning reserves a host and port. Agent **1.4.6** fixes container
|
||||||
|
|||||||
@@ -105,14 +105,13 @@ export function currentRound(
|
|||||||
(report.event !== 'round_change' && report.event !== 'heartbeat') ||
|
(report.event !== 'round_change' && report.event !== 'heartbeat') ||
|
||||||
!freshServer(server, now) ||
|
!freshServer(server, now) ||
|
||||||
!server.active_since ||
|
!server.active_since ||
|
||||||
!server.game_started_at ||
|
|
||||||
server.game_ended_at ||
|
server.game_ended_at ||
|
||||||
(server.last_players ?? 0) === 0
|
(server.last_players ?? 0) === 0
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
const received = Date.parse(report.receivedAt);
|
const received = Date.parse(report.receivedAt);
|
||||||
return freshTelemetry(report, now, server.telemetry_after) &&
|
return freshTelemetry(report, now, server.telemetry_after) &&
|
||||||
received >= Date.parse(server.game_started_at) &&
|
(!server.game_started_at || received >= Date.parse(server.game_started_at)) &&
|
||||||
received >= Date.parse(server.active_since)
|
received >= Date.parse(server.active_since)
|
||||||
? report.round
|
? report.round
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ export function selectServerObservation<T extends ServerObservation>(
|
|||||||
})),
|
})),
|
||||||
active_since: players > 0 ? timing.activeSince : null,
|
active_since: players > 0 ? timing.activeSince : null,
|
||||||
inactive_since: players === 0 ? timing.inactiveSince : null,
|
inactive_since: players === 0 ? timing.inactiveSince : null,
|
||||||
game_started_at: players === 0 ? null : timing.gameStartedAt,
|
game_started_at: timing.gameStartedAt,
|
||||||
game_ended_at: players === 0 ? null : timing.gameEndedAt
|
game_ended_at: timing.gameEndedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A current authenticated RCON reply can establish game liveness when
|
// A current authenticated RCON reply can establish game liveness when
|
||||||
|
|||||||
@@ -20,15 +20,13 @@ export function successfulStatusFields(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Occupancy timers and match clearing follow the same source as the UI. */
|
/** Occupancy follows the UI's source; only gameplay evidence changes match timing. */
|
||||||
export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> {
|
export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> {
|
||||||
if (status.health !== 'online' || status.last_players === null) return {};
|
if (status.health !== 'online' || status.last_players === null) return {};
|
||||||
return status.last_players > 0
|
return status.last_players > 0
|
||||||
? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null }
|
? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null }
|
||||||
: {
|
: {
|
||||||
active_since: null,
|
active_since: null,
|
||||||
inactive_since: current.inactive_since ?? status.last_seen_at,
|
inactive_since: current.inactive_since ?? status.last_seen_at
|
||||||
game_started_at: null,
|
|
||||||
game_ended_at: null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,46 +25,55 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
|||||||
? JSON.parse(row.payload)
|
? JSON.parse(row.payload)
|
||||||
: undefined;
|
: undefined;
|
||||||
recordLeaderboardTelemetry(handle, report);
|
recordLeaderboardTelemetry(handle, report);
|
||||||
// Record each occupancy transition at receipt, before a heartbeat can
|
const match = handle
|
||||||
// replace game_start or an empty report between status polls.
|
|
||||||
const after = latestGameSubmission(report.server);
|
|
||||||
if (!after || Date.parse(report.receivedAt) > Date.parse(after)) {
|
|
||||||
if (report.players.length > 0) {
|
|
||||||
handle
|
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE servers SET active_since = COALESCE(active_since, ?), inactive_since = NULL WHERE name = ?'
|
'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
|
||||||
)
|
)
|
||||||
.run(report.receivedAt, report.server);
|
.get(report.server) as { id: number; started_at: string; start_observed: number };
|
||||||
if (report.event === 'game_start') {
|
const sameMatch = previous?.timing?.matchId === match.id;
|
||||||
handle
|
|
||||||
.prepare(
|
|
||||||
'UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?'
|
|
||||||
)
|
|
||||||
.run(report.receivedAt, report.server);
|
|
||||||
} else if (report.event === 'game_end') {
|
|
||||||
handle
|
|
||||||
.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?')
|
|
||||||
.run(report.receivedAt, report.server);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handle
|
|
||||||
.prepare(
|
|
||||||
`UPDATE servers SET active_since = NULL, inactive_since = COALESCE(inactive_since, ?),
|
|
||||||
game_started_at = NULL, game_ended_at = NULL WHERE name = ?`
|
|
||||||
)
|
|
||||||
.run(report.receivedAt, report.server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const serverTiming = handle
|
const serverTiming = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT active_since AS activeSince, inactive_since AS inactiveSince,
|
`SELECT active_since AS activeSince, inactive_since AS inactiveSince,
|
||||||
game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?`
|
game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?`
|
||||||
)
|
)
|
||||||
.get(report.server) as ServerTiming;
|
.get(report.server) as ServerTiming;
|
||||||
const match = handle
|
// Record each occupancy transition at receipt, before a heartbeat can
|
||||||
.prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1')
|
// replace game_start or an empty report between status polls.
|
||||||
.get(report.server) as { id: number };
|
const after = latestGameSubmission(report.server);
|
||||||
const sameMatch = previous?.timing?.matchId === match.id;
|
if (!after || Date.parse(report.receivedAt) > Date.parse(after)) {
|
||||||
|
const occupied = report.players.length > 0;
|
||||||
|
serverTiming.activeSince = occupied
|
||||||
|
? (serverTiming.activeSince ?? report.receivedAt)
|
||||||
|
: null;
|
||||||
|
serverTiming.inactiveSince = occupied
|
||||||
|
? null
|
||||||
|
: (serverTiming.inactiveSince ?? report.receivedAt);
|
||||||
|
// Match boundaries follow history, independently of roster occupancy.
|
||||||
|
// Recover starts discarded by older receivers only when a start was
|
||||||
|
// actually observed; an inferred match has no known start time.
|
||||||
|
const startedAt =
|
||||||
|
report.event === 'game_start'
|
||||||
|
? report.receivedAt
|
||||||
|
: ((sameMatch ? serverTiming.gameStartedAt : null) ??
|
||||||
|
(match.start_observed ? match.started_at : null));
|
||||||
|
serverTiming.gameStartedAt =
|
||||||
|
startedAt && (!after || Date.parse(startedAt) > Date.parse(after))
|
||||||
|
? startedAt
|
||||||
|
: null;
|
||||||
|
serverTiming.gameEndedAt = report.event === 'game_end' ? report.receivedAt : null;
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE servers SET active_since = ?, inactive_since = ?,
|
||||||
|
game_started_at = ?, game_ended_at = ? WHERE name = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
serverTiming.activeSince,
|
||||||
|
serverTiming.inactiveSince,
|
||||||
|
serverTiming.gameStartedAt,
|
||||||
|
serverTiming.gameEndedAt,
|
||||||
|
report.server
|
||||||
|
);
|
||||||
|
}
|
||||||
// A gap beyond the normal heartbeat window cannot establish continuity.
|
// A gap beyond the normal heartbeat window cannot establish continuity.
|
||||||
const continuous =
|
const continuous =
|
||||||
previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
|
previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
|
||||||
|
|||||||
@@ -100,10 +100,10 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const currentRound = $derived(
|
const currentRound = $derived(
|
||||||
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null
|
players !== 0 && !gameEndedAt ? (round !== undefined ? round : reportedRound) : null
|
||||||
);
|
);
|
||||||
const gameElapsed = $derived(
|
const gameElapsed = $derived(
|
||||||
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
|
players !== 0 && gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
|
||||||
);
|
);
|
||||||
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
||||||
// These lobbies have four slots when the live status omits the limit.
|
// These lobbies have four slots when the live status omits the limit.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.8.6"
|
VERSION = "1.8.7"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1643,7 +1643,20 @@ class Agent:
|
|||||||
raise AgentError("Invalid runtime environment")
|
raise AgentError("Invalid runtime environment")
|
||||||
# Runtime download is a separate preparation operation. A deployment
|
# Runtime download is a separate preparation operation. A deployment
|
||||||
# uses the already prepared image even during a registry outage.
|
# uses the already prepared image even during a registry outage.
|
||||||
self.docker.call("image", "inspect", image)
|
image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip()
|
||||||
|
if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id):
|
||||||
|
raise AgentError("Docker did not report a valid runtime image ID")
|
||||||
|
try:
|
||||||
|
raw = managed_read(self.root / "agent", "runtime-images.json")
|
||||||
|
prepared = json.loads(raw) if raw is not None else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise AgentError("Runtime preparation records are unavailable") from None
|
||||||
|
if not isinstance(prepared, dict):
|
||||||
|
raise AgentError("Runtime preparation records are invalid")
|
||||||
|
# The controller also authorizes its configured default without a pull
|
||||||
|
# record. Whenever preparation was recorded, enforce that exact image.
|
||||||
|
if image in prepared and prepared[image] != image_id:
|
||||||
|
raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.")
|
||||||
files = payload.get("files", [])
|
files = payload.get("files", [])
|
||||||
if not isinstance(files, list) or len(files) > 10000:
|
if not isinstance(files, list) or len(files) > 10000:
|
||||||
raise Rejected("Invalid initial file list")
|
raise Rejected("Invalid initial file list")
|
||||||
@@ -1656,7 +1669,7 @@ class Agent:
|
|||||||
total += len(self.decode_file(item))
|
total += len(self.decode_file(item))
|
||||||
if total > 64 * 1024 * 1024:
|
if total > 64 * 1024 * 1024:
|
||||||
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
||||||
return game, library, image, port, bind, env
|
return game, library, image, image_id, port, bind, env
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def plutonium_manifest(data):
|
def plutonium_manifest(data):
|
||||||
@@ -1890,7 +1903,7 @@ class Agent:
|
|||||||
raise AgentError("Instance already exists with a different specification")
|
raise AgentError("Instance already exists with a different specification")
|
||||||
return existing
|
return existing
|
||||||
self.operation_phase = "validating-create-spec"
|
self.operation_phase = "validating-create-spec"
|
||||||
game, library, image, port, bind, env = self.validate_spec(payload)
|
game, library, image, image_id, port, bind, env = self.validate_spec(payload)
|
||||||
app = directory / "app"
|
app = directory / "app"
|
||||||
self.operation_phase = "preparing-create-directory"
|
self.operation_phase = "preparing-create-directory"
|
||||||
app.mkdir(parents=True, exist_ok=True)
|
app.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1905,15 +1918,15 @@ class Agent:
|
|||||||
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
||||||
name = self.container_name(instance_id, generation)
|
name = self.container_name(instance_id, generation)
|
||||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||||
# Do not let Docker fetch a replacement if the prepared image disappears
|
# Use the validated immutable ID so retagging after validation cannot
|
||||||
# between validation and container creation.
|
# substitute another image. A deleted image must not trigger a pull.
|
||||||
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
||||||
if self.config.get("hostId"):
|
if self.config.get("hostId"):
|
||||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||||
for protocol in ("udp", "tcp"):
|
for protocol in ("udp", "tcp"):
|
||||||
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
||||||
args.append(image)
|
args.append(image_id)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "creating-container"
|
self.operation_phase = "creating-container"
|
||||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.6",
|
"version": "1.8.7",
|
||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "79702dc8cf80d2521153bfda9241f7240285014fe746fc921f0a7793dd81212b",
|
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
|
||||||
"updaterProtocolVersion": 1,
|
"updaterProtocolVersion": 1,
|
||||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ describe('Fleet discovery and match freshness', () => {
|
|||||||
(event) => {
|
(event) => {
|
||||||
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
|
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
|
||||||
expect(currentRound(server, report, now)).toBe(38);
|
expect(currentRound(server, report, now)).toBe(38);
|
||||||
|
const unknownStart = { ...server, game_started_at: null };
|
||||||
|
expect(currentRound(unknownStart, report, now)).toBe(38);
|
||||||
|
expect(
|
||||||
|
currentRound({ ...unknownStart, telemetry_after: iso() }, report, now)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
currentRound(unknownStart, { ...report, receivedAt: iso(-121_000) }, now)
|
||||||
|
).toBeNull();
|
||||||
for (const change of [
|
for (const change of [
|
||||||
{ receivedAt: iso(-121_000) },
|
{ receivedAt: iso(-121_000) },
|
||||||
{ receivedAt: iso(-61_000) },
|
{ receivedAt: iso(-61_000) },
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ describe('shared server observation', () => {
|
|||||||
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
|
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses game-start rosters without inventing scores and clears match times on an empty report', () => {
|
it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
|
||||||
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
|
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
|
||||||
const active = selectServerObservation(server(), start, now);
|
const active = selectServerObservation(server(), start, now);
|
||||||
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
|
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
|
||||||
@@ -444,8 +444,8 @@ describe('shared server observation', () => {
|
|||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
last_players: 0,
|
last_players: 0,
|
||||||
roster: [],
|
roster: [],
|
||||||
game_started_at: null,
|
game_started_at: at(),
|
||||||
game_ended_at: null
|
game_ended_at: at()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ describe('database fleet and container readiness', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => {
|
it('keeps game_end through RCON polls and recovers when telemetry observes a new match', async () => {
|
||||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||||
const reply = (players: number) => ({
|
const reply = (players: number) => ({
|
||||||
@@ -621,9 +621,11 @@ describe('database fleet and container readiness', () => {
|
|||||||
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
||||||
const ended = getServer('managed')?.game_ended_at;
|
const ended = getServer('managed')?.game_ended_at;
|
||||||
expect(ended).toEqual(expect.any(String));
|
expect(ended).toEqual(expect.any(String));
|
||||||
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
|
|
||||||
await fanoutStatus();
|
await fanoutStatus();
|
||||||
expect(getServer('managed')?.game_ended_at).toBe(ended);
|
expect(getServer('managed')?.game_ended_at).toBe(ended);
|
||||||
|
await postTelemetry(gameEvent('heartbeat', 1, 1, 'managed'));
|
||||||
|
await fanoutStatus();
|
||||||
|
expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null });
|
||||||
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
|
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
|
||||||
expect(getServer('managed')?.game_ended_at).toBeNull();
|
expect(getServer('managed')?.game_ended_at).toBeNull();
|
||||||
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
||||||
@@ -638,6 +640,54 @@ describe('database fleet and container readiness', () => {
|
|||||||
expect(getServer('managed')?.game_ended_at).toBeNull();
|
expect(getServer('managed')?.game_ended_at).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([0, 1])(
|
||||||
|
'preserves a match starting with %i players through empty reports, polls, a gap, and reload',
|
||||||
|
async (startPlayers) => {
|
||||||
|
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||||
|
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||||
|
const { currentRound } = await import('../../src/lib/fleet-overview.js');
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const beginning = Date.parse('2026-10-09T12:00:00Z');
|
||||||
|
const at = (seconds: number) => new Date(beginning + seconds * 1000).toISOString();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(beginning);
|
||||||
|
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||||
|
rcon.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
endpoint: { host: 'localhost', port: 28960 },
|
||||||
|
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
|
||||||
|
});
|
||||||
|
const start = await (
|
||||||
|
await postTelemetry(gameEvent('game_start', 0, startPlayers, 'managed'))
|
||||||
|
).json();
|
||||||
|
expect(start.telemetry.timing.server.gameStartedAt).toBe(at(0));
|
||||||
|
vi.setSystemTime(beginning + 1000);
|
||||||
|
await postTelemetry(gameEvent('round_change', 1, 0, 'managed'));
|
||||||
|
for (const seconds of [30, 121]) {
|
||||||
|
vi.setSystemTime(beginning + seconds * 1000);
|
||||||
|
await fanoutStatus();
|
||||||
|
expect(getServer('managed')).toMatchObject({
|
||||||
|
active_since: null,
|
||||||
|
game_started_at: at(0),
|
||||||
|
game_ended_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.setSystemTime(beginning + 180_000);
|
||||||
|
await postTelemetry(gameEvent('heartbeat', 4, 1, 'managed'));
|
||||||
|
await fanoutStatus();
|
||||||
|
closeDb();
|
||||||
|
const server = (await (await GET()).json()).servers[0];
|
||||||
|
expect(server).toMatchObject({
|
||||||
|
health: 'online',
|
||||||
|
last_players: 1,
|
||||||
|
active_since: at(180),
|
||||||
|
game_started_at: at(0),
|
||||||
|
game_ended_at: null
|
||||||
|
});
|
||||||
|
expect(currentRound(server, server.telemetry, Date.now())).toBe(4);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => {
|
it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => {
|
||||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||||
|
|||||||
@@ -18,6 +18,45 @@ afterEach(async () => fixture.cleanup());
|
|||||||
|
|
||||||
const payload = gameEvent('round_change', 17);
|
const payload = gameEvent('round_change', 17);
|
||||||
|
|
||||||
|
it.each(['game_end', 'round reset', 'map change'])(
|
||||||
|
'drops the previous match timing when game_start is missed after %s',
|
||||||
|
async (boundary) => {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const at = (seconds: number) =>
|
||||||
|
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
|
||||||
|
saveTelemetry({ ...gameEvent('game_start', 0, 1), receivedAt: at(0) });
|
||||||
|
const previous = saveTelemetry({ ...gameEvent('heartbeat', 7, 1), receivedAt: at(30) });
|
||||||
|
if (boundary === 'game_end') {
|
||||||
|
const ended = saveTelemetry({ ...gameEvent('game_end', 7, 0), receivedAt: at(31) });
|
||||||
|
expect(ended.timing?.server?.gameEndedAt).toBe(at(31));
|
||||||
|
}
|
||||||
|
const next = saveTelemetry({
|
||||||
|
...gameEvent('heartbeat', boundary === 'map change' ? 8 : 1, 1),
|
||||||
|
...(boundary === 'map change' ? { map: 'zombie_moon' } : {}),
|
||||||
|
receivedAt: at(60)
|
||||||
|
});
|
||||||
|
expect(next.timing?.matchId).not.toBe(previous.timing?.matchId);
|
||||||
|
expect(next.timing?.server).toMatchObject({ gameStartedAt: null, gameEndedAt: null });
|
||||||
|
expect(getServer(payload.server)).toMatchObject({
|
||||||
|
game_started_at: null,
|
||||||
|
game_ended_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('repairs a previously discarded start from an observed match without inventing one', async () => {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const at = (seconds: number) =>
|
||||||
|
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
|
||||||
|
saveTelemetry({ ...gameEvent('game_start', 0, 0), receivedAt: at(0) });
|
||||||
|
// Simulate the old ingestion behavior for an empty start.
|
||||||
|
db().prepare('UPDATE servers SET game_started_at = NULL WHERE name = ?').run(payload.server);
|
||||||
|
const report = saveTelemetry({ ...gameEvent('heartbeat', 4, 1), receivedAt: at(120) });
|
||||||
|
expect(report.timing?.server?.gameStartedAt).toBe(at(0));
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let a request received before a lifecycle submission restore cleared timers', async () => {
|
it('does not let a request received before a lifecycle submission restore cleared timers', async () => {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ describe('compact server cards', () => {
|
|||||||
expect(body).not.toContain('36ms');
|
expect(body).not.toContain('36ms');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a known round without a match clock and hides match metrics for an empty lobby', () => {
|
||||||
|
const occupied = html({ gameStartedAt: null, round: 31 });
|
||||||
|
expect(occupied).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>31<\/dd>/);
|
||||||
|
expect(occupied).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
|
||||||
|
const empty = html({ players: 0, round: 31, compactEmpty: false });
|
||||||
|
expect(empty).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>—<\/dd>/);
|
||||||
|
expect(empty).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
|
||||||
|
});
|
||||||
|
|
||||||
it('places Pin outside the only link and retains its pressed state and server name', () => {
|
it('places Pin outside the only link and retains its pressed state and server name', () => {
|
||||||
const body = html({ pinned: true, onPin: () => {} });
|
const body = html({ pinned: true, onPin: () => {} });
|
||||||
expect(body).toMatch(/<article\b/);
|
expect(body).toMatch(/<article\b/);
|
||||||
|
|||||||
@@ -460,26 +460,26 @@ describe('Fleet report clock', () => {
|
|||||||
report('heartbeat', 34, 0, {
|
report('heartbeat', 34, 0, {
|
||||||
activeSince: null,
|
activeSince: null,
|
||||||
inactiveSince: at(34),
|
inactiveSince: at(34),
|
||||||
gameStartedAt: null,
|
gameStartedAt: at(32),
|
||||||
gameEndedAt: null
|
gameEndedAt: null
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
active_since: null,
|
active_since: null,
|
||||||
inactive_since: at(34),
|
inactive_since: at(34),
|
||||||
game_started_at: null,
|
game_started_at: at(32),
|
||||||
game_ended_at: null
|
game_ended_at: null
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
report('heartbeat', 35, 1, {
|
report('heartbeat', 35, 1, {
|
||||||
activeSince: at(35),
|
activeSince: at(35),
|
||||||
inactiveSince: null,
|
inactiveSince: null,
|
||||||
gameStartedAt: null,
|
gameStartedAt: at(32),
|
||||||
gameEndedAt: null
|
gameEndedAt: null
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
active_since: at(35),
|
active_since: at(35),
|
||||||
inactive_since: null,
|
inactive_since: null,
|
||||||
game_started_at: null,
|
game_started_at: at(32),
|
||||||
game_ended_at: null
|
game_ended_at: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user