Loading internal/api/handlers_internal.go +12 −0 Changes for internal/api/handlers_internal.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -130,6 +130,18 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { a.writeLookupError(w, r, err) return } // A server that is up and meant to stay up has nothing to wake, and joining it // is open to every linked player: host routing admits them on the link check // alone. Putting the no-op through autostartPolicy answered a friend's menu // "join" with "you may not start this server". Nothing changes here, so there // is no cooldown to spend and nothing to audit. if info.Ready && info.DesiredState == string(v1alpha1.DesiredRunning) { writeJSON(w, http.StatusAccepted, map[string]any{ "name": name, "desiredState": info.DesiredState, "phase": info.Phase, "ready": true, }) return } rec, err := a.Repo.ServerByName(r.Context(), name) if err != nil && !errors.Is(err, ErrNotFound) { writeError(w, r, err) Loading internal/api/handlers_internal_wake_test.go +53 −0 Changes for internal/api/handlers_internal_wake_test.go: 53 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -153,6 +153,59 @@ func TestInternalWakeAutostartGate(t *testing.T) { }) } // A running server is joined, not woken: the menu and /felis go wake before they // move anyone, and a friend who is not the owner of a running ownerOnly server was // told "you may not start it". Only an up-and-staying-up server skips the gate; one // that is on its way down is a real start and stays policy-gated. func TestInternalWakeOfRunningServerIsNotGated(t *testing.T) { body := `{"mc_uuid":"` + wakeUUID + `"}` running := func(desired v1alpha1.DesiredState) (*API, *fakeCluster, *fakeRepo) { api, cl := newInternalWakeAPI("ownerOnly") cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Running", Ready: true, AutostartPolicy: "ownerOnly", DesiredState: string(desired)} repo := api.Repo.(*fakeRepo) repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.links[wakeUUID] = "someone-else" api.WakeCooldown = time.Minute return api, cl, repo } t.Run("up: a non-owner gets 202 ready and nothing changes", func(t *testing.T) { api, cl, repo := running(v1alpha1.DesiredRunning) w := internalWake(api, body) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (body %s)", w.Code, w.Body.String()) } var got map[string]any if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if got["ready"] != true || got["phase"] != "Running" { t.Fatalf("reply = %v, want ready true and phase Running", got) } if _, set := cl.desired["survival"]; set { t.Fatal("a no-op wake must not write desiredState") } if len(repo.audits) != 0 { t.Fatalf("a no-op wake must not be audited as a wake: %+v", repo.audits) } // Nothing was woken, so the cooldown is untouched: a second join is not a 429. if w := internalWake(api, body); w.Code != http.StatusAccepted { t.Fatalf("second join code = %d, want 202", w.Code) } }) t.Run("stopping: a non-owner's wake is still a start and still forbidden", func(t *testing.T) { api, cl, _ := running(v1alpha1.DesiredStopped) if w := internalWake(api, body); w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if _, set := cl.desired["survival"]; set { t.Fatal("desiredState must not change on a forbidden wake") } }) } func TestInternalWakeCooldownIsShared(t *testing.T) { api, _ := newInternalWakeAPI("public") api.WakeCooldown = time.Minute Loading plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +63 −47 Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java: 63 added lines, 47 removed lines. Original line number Diff line number Diff line Loading @@ -144,51 +144,35 @@ public final class WaitingRouter { } /** * enqueueFromMenu parks a player who is already on the proxy (sitting in the * lobby) on a server they asked for through the felis-paper {@code /menu}, then * wakes it and lets {@link #tick()} transfer them when ready — the same shared * waiting queue used by host-based autostart routing (spec §12, §27 scenario 10). * It differs from initial-server routing only in origin: the player drove it from * a GUI button rather than a connecting virtual host, so the waiter is flagged to * fire {@link MenuTransferListener} on transfer. * enqueueFromMenu moves a player who is already on the proxy (sitting in the * lobby) to a server they asked for through the felis-paper {@code /menu}: straight * in when it is running, otherwise woken and transferred by {@link #tick()} once * ready — the same shared waiting queue used by host-based autostart routing (spec * §12, §27 scenario 10). It differs from initial-server routing only in origin: the * player drove it from a GUI button rather than a connecting virtual host, so the * move fires {@link MenuTransferListener}. */ void enqueueFromMenu(Player player, String serverName) { authorizeAndWait(player, serverName, true, false); authorizeAndWait(player, serverName, true); } /** * enqueueFromCommand parks a player who drove {@code /felis go <server>} from chat * onto the server they named, then wakes it and lets {@link #tick()} transfer them * when ready — the same shared waiting queue as host-based routing and the menu * path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven * go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is * emitted on readiness. The wake stays autostartPolicy-gated on the verified UUID * exactly as the other origins, so this adds a new entry point, not a new authority. * enqueueFromCommand is {@link #enqueueFromMenu} for {@code /felis go <server>} typed * in chat, differing only in that it is NOT flagged {@code fromMenu}: a command has no * felis-paper GUI tile to notify, so no {@code TransferReady} frame is emitted. A wake * stays autostartPolicy-gated on the verified UUID exactly as for the other origins, * so this adds a new entry point, not a new authority. */ void enqueueFromCommand(Player player, String serverName) { authorizeAndWait(player, serverName, false, false); authorizeAndWait(player, serverName, false); } /** * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite, differing in * one thing: a server that is ALREADY RUNNING is joined directly instead of woken. * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite. An invite can * only name the server its sender is standing on, so the target is running by * construction and the invitee is joined straight onto it. * * <p>An invite can only name the server its sender is standing on, so the target is * running by construction — and a running felis server is already reachable by any * linked player through {@code <name>.<root-domain>}, which * {@link #onServerPreConnect} admits on the link check alone: no wake, no * autostartPolicy consultation. Routing an accept through {@link #wakeAndWaitLinked} * instead asks the API to wake a server that needs no waking, and autostartPolicy * defaults to ownerOnly, so the API answers 403 and the invitee is turned away from a * place they could have walked into unaided — the green button does nothing for * exactly the people you would invite. * * <p>Joining a live backend therefore grants no authority the invitee did not already * have. WAKING a stopped one still does, which is why the not-ready case falls through * to the policy-gated path unchanged: only the owner may start a stopped ownerOnly * server, invite or no invite. * * <p>It does leave a mark, though, and one that outlives the invite: landing here fires * <p>It does leave a mark, though, and one that outlives the invite: landing there fires * {@link #onServerConnected}, whose join-event appends the player to the server's * allowlist. On an autostartPolicy=allowlist server that row is the wake permission, so * an accepted invite ends in the invitee being able to start the server later. That is Loading @@ -197,7 +181,7 @@ public final class WaitingRouter { * up front (FelisVelocityPlugin#accessNotice), because they are the one causing it. */ void enqueueFromInvite(Player player, String serverName) { authorizeAndWait(player, serverName, false, true); authorizeAndWait(player, serverName, false); } @Subscribe Loading Loading @@ -449,8 +433,7 @@ public final class WaitingRouter { } } private void authorizeAndWait(Player player, String serverName, boolean fromMenu, boolean joinIfReady) { private void authorizeAndWait(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); // Asking for the server you are standing on is a no-op, and it has to be caught Loading Loading @@ -485,29 +468,60 @@ public final class WaitingRouter { NamedTextColor.RED)); return; } // Same ready-or-wake split as the host path above, for the one caller whose // target is running by construction. See enqueueFromInvite for why joining a // live backend is not an escalation and waking a stopped one still is. if (joinIfReady) { ServerView view = registry.view(serverName); Optional<RegisteredServer> backend = registry.registered(serverName); if (view != null && view.ready() && backend.isPresent()) { transfer(player, serverName, backend.get()); if (joinIfRunning(player, serverName, fromMenu)) { return; } } wakeAndWaitLinked(player, serverName, fromMenu); }); } /** * joinIfRunning is the ready-or-wake split of the host path, for the entries that * start from inside the proxy: a server a fresh status poll reports up is joined * directly, at the address the poll carries. * * <p>A running felis server is already reachable by any linked player through * {@code <name>.<root-domain>}, which {@link #onServerPreConnect} admits on the link * check alone, so joining one grants nothing. Waking it instead asks the API to * start a server that needs no starting, and autostartPolicy defaults to ownerOnly: * a friend's green "join" tile answered "you're not allowed to start it". WAKING a * stopped server is still a start, and still policy-gated. A poll that fails falls * through to the wake, which reports the failure its own way. */ private boolean joinIfRunning(Player player, String serverName, boolean fromMenu) { ServerView status; try { status = api.serverStatus(serverName); } catch (LinkException e) { return false; } if (!status.ready()) { return false; } registry.observe(status); Optional<RegisteredServer> backend = registry.registered(serverName); if (backend.isEmpty()) { return false; // up, but not listed or addressed yet → the queue waits for it } MenuTransferListener listener = menuListener; if (fromMenu && listener != null) { listener.onReady(player, serverName); } transfer(player, serverName, backend.get()); return true; } // Caller already ran the authoritative link-status check and is off the event // thread. Keep the wake and queue mutation together so every entry has passed // both the account gate and the server-side autostart policy. private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); boolean up = false; try { api.wake(serverName, id); // An up server answers ready without waking anything; the queue still // does the move, so the player just is not told it is starting. up = api.wake(serverName, id).ready(); } catch (LinkException e) { switch (e.statusCode()) { case 403: Loading Loading @@ -551,10 +565,12 @@ public final class WaitingRouter { return; } } if (!up) { player.sendMessage(Component.text( zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。" : "Starting « " + serverName + " » — you'll be moved in automatically.", NamedTextColor.GRAY)); } waiting.put(id, new Waiter( serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); } Loading plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +22 −1 Changes for plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java: 22 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -342,6 +342,25 @@ public final class WaitingRouterTest { assertEq("both moved (command)", List.of("epsilon"), List.copyOf(command.connects)); assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified)); // A friend's running server: the menu and /felis go join it without waking it. // The API refuses a non-owner's wake of an ownerOnly server, and that refusal // was all a friend got from the green tile while every entry woke first. api.wakeError.put("beta", "403 forbidden"); Fakes.FakePlayer friend = player(null, true); friend.current = lobby; router.enqueueFromMenu(friend.player, "beta"); Fakes.await("friend moved (menu)", () -> friend.connects.size() == 1); assertEq("running server via the menu: joined", List.of("beta"), List.copyOf(friend.connects)); assertEq("running server via the menu: the lobby is told", true, notified.contains(friend.name + "@beta")); Fakes.FakePlayer friend2 = player(null, true); friend2.current = lobby; router.enqueueFromCommand(friend2.player, "beta"); Fakes.await("friend moved (command)", () -> friend2.connects.size() == 1); assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects)); assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake")); assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed")); api.wakeError.remove("beta"); // Asking for the server you stand on is answered at once, case-insensitively. Fakes.FakePlayer there = player(null, true); there.current = beta; Loading Loading @@ -498,8 +517,10 @@ public final class WaitingRouterTest { // view is a server as GET /servers lists it: up at its direct address, or down on // the fallback, where the operator writes the fallback server's name ("login") // into the address. addr is also what the stub API reports once the server is up. // into the address. The stub API's status route answers the same: ready as listed, // and addr as the address it reports once the server is up. private static ServerView view(String name, boolean ready, String addr) { api.ready.put(name, ready); if (addr != null) { api.address.put(name, addr); } Loading Loading
internal/api/handlers_internal.go +12 −0 Changes for internal/api/handlers_internal.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -130,6 +130,18 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { a.writeLookupError(w, r, err) return } // A server that is up and meant to stay up has nothing to wake, and joining it // is open to every linked player: host routing admits them on the link check // alone. Putting the no-op through autostartPolicy answered a friend's menu // "join" with "you may not start this server". Nothing changes here, so there // is no cooldown to spend and nothing to audit. if info.Ready && info.DesiredState == string(v1alpha1.DesiredRunning) { writeJSON(w, http.StatusAccepted, map[string]any{ "name": name, "desiredState": info.DesiredState, "phase": info.Phase, "ready": true, }) return } rec, err := a.Repo.ServerByName(r.Context(), name) if err != nil && !errors.Is(err, ErrNotFound) { writeError(w, r, err) Loading
internal/api/handlers_internal_wake_test.go +53 −0 Changes for internal/api/handlers_internal_wake_test.go: 53 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -153,6 +153,59 @@ func TestInternalWakeAutostartGate(t *testing.T) { }) } // A running server is joined, not woken: the menu and /felis go wake before they // move anyone, and a friend who is not the owner of a running ownerOnly server was // told "you may not start it". Only an up-and-staying-up server skips the gate; one // that is on its way down is a real start and stays policy-gated. func TestInternalWakeOfRunningServerIsNotGated(t *testing.T) { body := `{"mc_uuid":"` + wakeUUID + `"}` running := func(desired v1alpha1.DesiredState) (*API, *fakeCluster, *fakeRepo) { api, cl := newInternalWakeAPI("ownerOnly") cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Running", Ready: true, AutostartPolicy: "ownerOnly", DesiredState: string(desired)} repo := api.Repo.(*fakeRepo) repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.links[wakeUUID] = "someone-else" api.WakeCooldown = time.Minute return api, cl, repo } t.Run("up: a non-owner gets 202 ready and nothing changes", func(t *testing.T) { api, cl, repo := running(v1alpha1.DesiredRunning) w := internalWake(api, body) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (body %s)", w.Code, w.Body.String()) } var got map[string]any if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if got["ready"] != true || got["phase"] != "Running" { t.Fatalf("reply = %v, want ready true and phase Running", got) } if _, set := cl.desired["survival"]; set { t.Fatal("a no-op wake must not write desiredState") } if len(repo.audits) != 0 { t.Fatalf("a no-op wake must not be audited as a wake: %+v", repo.audits) } // Nothing was woken, so the cooldown is untouched: a second join is not a 429. if w := internalWake(api, body); w.Code != http.StatusAccepted { t.Fatalf("second join code = %d, want 202", w.Code) } }) t.Run("stopping: a non-owner's wake is still a start and still forbidden", func(t *testing.T) { api, cl, _ := running(v1alpha1.DesiredStopped) if w := internalWake(api, body); w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if _, set := cl.desired["survival"]; set { t.Fatal("desiredState must not change on a forbidden wake") } }) } func TestInternalWakeCooldownIsShared(t *testing.T) { api, _ := newInternalWakeAPI("public") api.WakeCooldown = time.Minute Loading
plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +63 −47 Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java: 63 added lines, 47 removed lines. Original line number Diff line number Diff line Loading @@ -144,51 +144,35 @@ public final class WaitingRouter { } /** * enqueueFromMenu parks a player who is already on the proxy (sitting in the * lobby) on a server they asked for through the felis-paper {@code /menu}, then * wakes it and lets {@link #tick()} transfer them when ready — the same shared * waiting queue used by host-based autostart routing (spec §12, §27 scenario 10). * It differs from initial-server routing only in origin: the player drove it from * a GUI button rather than a connecting virtual host, so the waiter is flagged to * fire {@link MenuTransferListener} on transfer. * enqueueFromMenu moves a player who is already on the proxy (sitting in the * lobby) to a server they asked for through the felis-paper {@code /menu}: straight * in when it is running, otherwise woken and transferred by {@link #tick()} once * ready — the same shared waiting queue used by host-based autostart routing (spec * §12, §27 scenario 10). It differs from initial-server routing only in origin: the * player drove it from a GUI button rather than a connecting virtual host, so the * move fires {@link MenuTransferListener}. */ void enqueueFromMenu(Player player, String serverName) { authorizeAndWait(player, serverName, true, false); authorizeAndWait(player, serverName, true); } /** * enqueueFromCommand parks a player who drove {@code /felis go <server>} from chat * onto the server they named, then wakes it and lets {@link #tick()} transfer them * when ready — the same shared waiting queue as host-based routing and the menu * path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven * go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is * emitted on readiness. The wake stays autostartPolicy-gated on the verified UUID * exactly as the other origins, so this adds a new entry point, not a new authority. * enqueueFromCommand is {@link #enqueueFromMenu} for {@code /felis go <server>} typed * in chat, differing only in that it is NOT flagged {@code fromMenu}: a command has no * felis-paper GUI tile to notify, so no {@code TransferReady} frame is emitted. A wake * stays autostartPolicy-gated on the verified UUID exactly as for the other origins, * so this adds a new entry point, not a new authority. */ void enqueueFromCommand(Player player, String serverName) { authorizeAndWait(player, serverName, false, false); authorizeAndWait(player, serverName, false); } /** * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite, differing in * one thing: a server that is ALREADY RUNNING is joined directly instead of woken. * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite. An invite can * only name the server its sender is standing on, so the target is running by * construction and the invitee is joined straight onto it. * * <p>An invite can only name the server its sender is standing on, so the target is * running by construction — and a running felis server is already reachable by any * linked player through {@code <name>.<root-domain>}, which * {@link #onServerPreConnect} admits on the link check alone: no wake, no * autostartPolicy consultation. Routing an accept through {@link #wakeAndWaitLinked} * instead asks the API to wake a server that needs no waking, and autostartPolicy * defaults to ownerOnly, so the API answers 403 and the invitee is turned away from a * place they could have walked into unaided — the green button does nothing for * exactly the people you would invite. * * <p>Joining a live backend therefore grants no authority the invitee did not already * have. WAKING a stopped one still does, which is why the not-ready case falls through * to the policy-gated path unchanged: only the owner may start a stopped ownerOnly * server, invite or no invite. * * <p>It does leave a mark, though, and one that outlives the invite: landing here fires * <p>It does leave a mark, though, and one that outlives the invite: landing there fires * {@link #onServerConnected}, whose join-event appends the player to the server's * allowlist. On an autostartPolicy=allowlist server that row is the wake permission, so * an accepted invite ends in the invitee being able to start the server later. That is Loading @@ -197,7 +181,7 @@ public final class WaitingRouter { * up front (FelisVelocityPlugin#accessNotice), because they are the one causing it. */ void enqueueFromInvite(Player player, String serverName) { authorizeAndWait(player, serverName, false, true); authorizeAndWait(player, serverName, false); } @Subscribe Loading Loading @@ -449,8 +433,7 @@ public final class WaitingRouter { } } private void authorizeAndWait(Player player, String serverName, boolean fromMenu, boolean joinIfReady) { private void authorizeAndWait(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); // Asking for the server you are standing on is a no-op, and it has to be caught Loading Loading @@ -485,29 +468,60 @@ public final class WaitingRouter { NamedTextColor.RED)); return; } // Same ready-or-wake split as the host path above, for the one caller whose // target is running by construction. See enqueueFromInvite for why joining a // live backend is not an escalation and waking a stopped one still is. if (joinIfReady) { ServerView view = registry.view(serverName); Optional<RegisteredServer> backend = registry.registered(serverName); if (view != null && view.ready() && backend.isPresent()) { transfer(player, serverName, backend.get()); if (joinIfRunning(player, serverName, fromMenu)) { return; } } wakeAndWaitLinked(player, serverName, fromMenu); }); } /** * joinIfRunning is the ready-or-wake split of the host path, for the entries that * start from inside the proxy: a server a fresh status poll reports up is joined * directly, at the address the poll carries. * * <p>A running felis server is already reachable by any linked player through * {@code <name>.<root-domain>}, which {@link #onServerPreConnect} admits on the link * check alone, so joining one grants nothing. Waking it instead asks the API to * start a server that needs no starting, and autostartPolicy defaults to ownerOnly: * a friend's green "join" tile answered "you're not allowed to start it". WAKING a * stopped server is still a start, and still policy-gated. A poll that fails falls * through to the wake, which reports the failure its own way. */ private boolean joinIfRunning(Player player, String serverName, boolean fromMenu) { ServerView status; try { status = api.serverStatus(serverName); } catch (LinkException e) { return false; } if (!status.ready()) { return false; } registry.observe(status); Optional<RegisteredServer> backend = registry.registered(serverName); if (backend.isEmpty()) { return false; // up, but not listed or addressed yet → the queue waits for it } MenuTransferListener listener = menuListener; if (fromMenu && listener != null) { listener.onReady(player, serverName); } transfer(player, serverName, backend.get()); return true; } // Caller already ran the authoritative link-status check and is off the event // thread. Keep the wake and queue mutation together so every entry has passed // both the account gate and the server-side autostart policy. private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); boolean up = false; try { api.wake(serverName, id); // An up server answers ready without waking anything; the queue still // does the move, so the player just is not told it is starting. up = api.wake(serverName, id).ready(); } catch (LinkException e) { switch (e.statusCode()) { case 403: Loading Loading @@ -551,10 +565,12 @@ public final class WaitingRouter { return; } } if (!up) { player.sendMessage(Component.text( zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。" : "Starting « " + serverName + " » — you'll be moved in automatically.", NamedTextColor.GRAY)); } waiting.put(id, new Waiter( serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); } Loading
plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +22 −1 Changes for plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java: 22 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -342,6 +342,25 @@ public final class WaitingRouterTest { assertEq("both moved (command)", List.of("epsilon"), List.copyOf(command.connects)); assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified)); // A friend's running server: the menu and /felis go join it without waking it. // The API refuses a non-owner's wake of an ownerOnly server, and that refusal // was all a friend got from the green tile while every entry woke first. api.wakeError.put("beta", "403 forbidden"); Fakes.FakePlayer friend = player(null, true); friend.current = lobby; router.enqueueFromMenu(friend.player, "beta"); Fakes.await("friend moved (menu)", () -> friend.connects.size() == 1); assertEq("running server via the menu: joined", List.of("beta"), List.copyOf(friend.connects)); assertEq("running server via the menu: the lobby is told", true, notified.contains(friend.name + "@beta")); Fakes.FakePlayer friend2 = player(null, true); friend2.current = lobby; router.enqueueFromCommand(friend2.player, "beta"); Fakes.await("friend moved (command)", () -> friend2.connects.size() == 1); assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects)); assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake")); assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed")); api.wakeError.remove("beta"); // Asking for the server you stand on is answered at once, case-insensitively. Fakes.FakePlayer there = player(null, true); there.current = beta; Loading Loading @@ -498,8 +517,10 @@ public final class WaitingRouterTest { // view is a server as GET /servers lists it: up at its direct address, or down on // the fallback, where the operator writes the fallback server's name ("login") // into the address. addr is also what the stub API reports once the server is up. // into the address. The stub API's status route answers the same: ready as listed, // and addr as the address it reports once the server is up. private static ServerView view(String name, boolean ready, String addr) { api.ready.put(name, ready); if (addr != null) { api.address.put(name, addr); } Loading