Add LAN discovery and Local servers UI

Adds LAN server discovery and a Local tab in the multiplayer UI so clients can find relays on the same LAN. Introduces a new F4T::LanDiscovery module (plugin/include/F4TLanDiscovery.h, plugin/src/F4TLanDiscovery.cpp) that probes LAN hosts via UDP discover (port 7778) and TCP welcome fallback (port 7777). Server-side support includes a UDP responder (server/lan_discovery.py) and integration in server_core.py to start/stop discovery.

Plugin changes (F4TServerBrowserBridge/Data) add background scanning, dispatch results to the game thread, new local-server storage and events (scanLocalServers, localServersUpdated, localScanStarted/localScanFinished), and small join/recent handling updates. UI changes (ui/.../app.js and components) add localServers state, scanning UX, Recent persistence (localStorage), and join logic for local/direct entries. Docs updated (docs/protocol.md, docs/dev-log.md, server/README.md) and build script links iphlpapi. Also removes two unused exported sprite PNGs.
This commit is contained in:
2026-06-22 15:53:17 +12:00
parent 7efe407421
commit 9ceb6601ae
18 changed files with 1318 additions and 25 deletions
+220 -17
View File
@@ -12,6 +12,8 @@
var repeatTimer = null;
var repeatAction = null;
var RECENT_STORAGE_KEY = "co_recent_servers";
var MAX_RECENT = 8;
var state = {
activeTab: "server-browser",
@@ -33,8 +35,10 @@
servers: [],
favorites: [],
recent: [],
localServers: [],
connectionStatus: "Online",
isRefreshing: false,
isScanningLocal: false,
directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null
@@ -55,6 +59,9 @@
case "refreshServerList":
refreshLocalMock();
break;
case "scanLocalServers":
scanLocalMock();
break;
case "setFilter":
state.filters[payload.filterName] = payload.value;
applyLocalFilters();
@@ -73,7 +80,10 @@
joinDirectLocal(payload.address, payload.port);
break;
case "switchTab":
switchTab(payload.tabName);
switchTab(payload.tabName, { skipAction: true });
if (payload.tabName === "local") {
scanLocalMock();
}
break;
case "closeBrowser":
if (typeof window.closeBrowser === "function") window.closeBrowser();
@@ -94,6 +104,35 @@
}, 600);
}
function scanLocalMock() {
state.isScanningLocal = true;
render();
setTimeout(function () {
state.localServers = (window.CO_MOCK_SERVERS || []).filter(function (s) {
return s.region === "Local" ||
(s.host && window.CO_ServerList && CO_ServerList.isLocalHost(s.host));
}).map(function (s) {
return {
id: s.id || ("local:" + (s.host || "127.0.0.1") + ":" + portNumFrom(s.port)),
name: s.name || "",
host: s.host || "127.0.0.1",
port: portNumFrom(s.port),
region: "Local",
players: s.players || 0,
maxPlayers: s.maxPlayers || 16,
ping: s.ping || 1,
worldspace: s.worldspace || "",
mode: s.mode || ""
};
});
state.isScanningLocal = false;
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
render();
}, 800);
}
function resetLocalFilters() {
state.filters = {
search: "",
@@ -156,6 +195,28 @@
}
}
function updateLocalCount() {
var countEl = document.getElementById("coLocalCount");
var titleEl = document.getElementById("coLocalTitle");
if (!countEl && !titleEl) return;
var count = state.localServers.length;
if (countEl) {
if (state.isScanningLocal) {
countEl.textContent = "Scanning";
} else {
countEl.textContent = count + " Found";
}
}
if (titleEl && state.activeTab === "local") {
if (state.isScanningLocal) {
titleEl.textContent = "Scanning...";
} else {
titleEl.textContent = count === 1 ? "1 Local Server" : count + " Local Servers";
}
}
}
function toggleFavoriteLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
@@ -172,6 +233,13 @@
return null;
}
function findLocalServerById(id) {
for (var i = 0; i < state.localServers.length; i++) {
if (state.localServers[i].id === id) return state.localServers[i];
}
return null;
}
function getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
@@ -179,14 +247,98 @@
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
if (state.activeTab === "local") return state.localServers;
return state.servers;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId);
function recentEntryKey(server) {
if (!server) return "";
var host = server.host || server.address || "";
if (host) {
return host + ":" + portNumFrom(server.port);
}
return server.id || "";
}
function snapshotRecentEntry(server) {
return {
id: server.id || recentEntryKey(server),
name: server.name || "",
host: server.host || server.address || "",
port: portNumFrom(server.port),
region: server.region || "",
players: server.players || 0,
maxPlayers: server.maxPlayers || 0,
ping: server.ping || 0,
worldspace: server.worldspace || "",
mode: server.mode || "",
favorite: !!server.favorite
};
}
function loadRecent() {
try {
var raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (!raw) return;
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
state.recent = parsed.slice(0, MAX_RECENT);
}
} catch (err) {
console.warn("[CO] Failed to load recent servers:", err);
}
}
function saveRecent() {
try {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(state.recent));
} catch (err) {
console.warn("[CO] Failed to save recent servers:", err);
}
}
function joinServerEntry(server) {
if (!server) return;
showJoinOverlay("Connecting to " + server.name + "...");
sendAction("joinServer", { serverId: serverId });
var label = window.CO_ServerList
? CO_ServerList.displayName(server)
: (server.name || server.host || "server");
showJoinOverlay("Connecting to " + label + "...");
var browserEntry = server.id ? (findServerById(server.id) || findLocalServerById(server.id)) : null;
if (browserEntry) {
sendAction("joinServer", { serverId: server.id });
return;
}
var host = server.host || server.address;
if (host) {
sendAction("openDirectConnect", {
address: host,
port: String(portNumFrom(server.port))
});
return;
}
if (server.id) {
sendAction("joinServer", { serverId: server.id });
return;
}
showJoinOverlay("Server connection details unavailable.", true);
}
function findServerInActiveList(serverId) {
var list = getActiveList();
for (var i = 0; i < list.length; i++) {
if (list[i].id === serverId) return list[i];
}
return null;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId) || findServerInActiveList(serverId);
if (!server) return;
joinServerEntry(server);
}
function validateDirectConnect(address, port) {
@@ -232,7 +384,8 @@
render();
}
function switchTab(tabName) {
function switchTab(tabName, options) {
options = options || {};
state.activeTab = tabName;
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
@@ -240,15 +393,28 @@
else if (tabName === "direct-connect") {
state.focusZone = "tabs";
state.directConnectFieldIndex = 0;
} else if (tabName === "favorites" || tabName === "recent" || tabName === "local") {
var list = state.servers;
if (tabName === "favorites") list = state.favorites;
else if (tabName === "recent") list = state.recent;
else if (tabName === "local") list = state.localServers;
if (state.selectedServerIndex >= list.length) {
state.selectedServerIndex = Math.max(0, list.length - 1);
}
if (tabName === "local") {
state.focusZone = "server-list";
}
}
if (!options.skipAction) {
sendAction("switchTab", { tabName: tabName });
}
sendAction("switchTab", { tabName: tabName });
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing);
panel.classList.toggle("co-loading", state.isRefreshing || state.isScanningLocal);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
@@ -268,9 +434,10 @@
var listServers = state.servers;
if (state.activeTab === "favorites") listServers = state.favorites;
if (state.activeTab === "recent") listServers = state.recent;
if (state.activeTab === "local") listServers = state.localServers;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent") {
if (state.activeTab === "favorites" || state.activeTab === "recent" || state.activeTab === "local") {
listState.focusZone = state.focusZone === "filters" ? "server-list" : state.focusZone;
}
@@ -297,6 +464,7 @@
}
updateServerCount();
updateLocalCount();
}
function bindUi() {
@@ -406,7 +574,11 @@
return;
}
if (button === "x") {
sendAction("refreshServerList", {});
if (state.activeTab === "local") {
sendAction("scanLocalServers", {});
} else {
sendAction("refreshServerList", {});
}
return;
}
if (button === "y") {
@@ -450,7 +622,7 @@
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
joinServerLocal(sel.id);
joinServerEntry(sel);
}
function nextFocusZone(fromIdx, direction) {
@@ -545,6 +717,21 @@
console.warn("[CO] Refresh failed:", event.error);
}
break;
case "localScanStarted":
state.isScanningLocal = true;
break;
case "localServersUpdated":
state.localServers = event.servers || [];
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
break;
case "localScanFinished":
state.isScanningLocal = false;
if (!event.success) {
console.warn("[CO] Local scan found no servers.", event.error || "");
}
break;
case "joinStarted":
showJoinOverlay("Connecting...");
break;
@@ -556,7 +743,7 @@
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(event.serverId);
addRecent(event.server || event.serverId);
break;
case "connectionStatusChanged":
state.connectionStatus = event.status || "Online";
@@ -590,12 +777,27 @@
render();
};
function addRecent(serverId) {
var server = findServerById(serverId);
function addRecent(serverOrId, serverSnapshot) {
var server = serverSnapshot;
if (!server) {
if (serverOrId && typeof serverOrId === "object") {
server = serverOrId;
} else if (serverOrId) {
server = findServerById(serverOrId);
}
}
if (!server) return;
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
state.recent.unshift(server);
if (state.recent.length > 8) state.recent.pop();
var entry = snapshotRecentEntry(server);
var key = recentEntryKey(entry);
state.recent = state.recent.filter(function (s) {
return recentEntryKey(s) !== key && s.id !== entry.id;
});
state.recent.unshift(entry);
if (state.recent.length > MAX_RECENT) {
state.recent.length = MAX_RECENT;
}
saveRecent();
}
window.setConnectionStatus = function (connected, logPrefix) {
@@ -628,6 +830,7 @@
}
};
loadRecent();
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
@@ -51,6 +51,14 @@
]);
}
if (state.activeTab === "local") {
return TAB_SWITCH_PROMPTS.concat([
{ key: "A", label: "Join" },
{ key: "X", label: "Scan" },
{ key: "B", label: "Back" }
]);
}
if (state.focusZone === "filters") {
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
return TAB_SWITCH_PROMPTS.concat([
@@ -8,28 +8,82 @@
return "co-ping-bad";
},
isLocalHost: function (host) {
if (!host) return false;
return /^(127\.0\.0\.1|localhost|::1)$/i.test(String(host).trim());
},
displayName: function (server) {
if (!server) return "";
var host = server.host || server.address || "";
var port = server.port > 0 ? server.port : 7777;
var name = String(server.name || "").trim();
var isLocal = server.region === "Local" || CO_ServerList.isLocalHost(host);
var isDirect = server.id === "direct" || name === "Direct Connect";
if (!name || isLocal || isDirect) {
return (host || "127.0.0.1") + ":" + port;
}
return name;
},
render: function (root, state) {
var wrap = root.querySelector(".co-server-list-wrap");
var list = root.querySelector(".co-server-list");
if (!wrap || !list) return;
wrap.className = "co-server-list-wrap" + (state.focusZone === "server-list" ? " focused" : "");
var isRecent = state.activeTab === "recent";
var isLocal = state.activeTab === "local";
if (!state.servers.length) {
list.innerHTML = '<div class="co-empty">No servers found</div>';
var emptyText = "No servers found";
if (isRecent) emptyText = "No recent servers";
else if (isLocal) {
emptyText = state.isScanningLocal ?
"Scanning local network..." :
"No local servers found. Check the host PC is running server.py and firewall allows UDP 7778 / TCP 7777.";
}
list.innerHTML = '<div class="co-empty">' + emptyText + "</div>";
CO_ServerList.updateScrollIndicator(wrap, list);
return;
}
list.innerHTML = state.servers.map(function (server, index) {
var selected = index === state.selectedServerIndex ? " selected" : "";
var label = CO_ServerList.displayName(server);
if (isRecent) {
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id || "") + '">' +
'<div class="co-server-row-left">' +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
"</div>"
);
}
if (isLocal) {
var pingCls = CO_ServerList.pingClass(server.ping || 0);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id || "") + '">' +
'<div class="co-server-row-left">' +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
'<div class="co-server-row-right">' +
"<span>" + (server.players || 0) + "/" + (server.maxPlayers || 16) + "</span>" +
'<span class="co-ping ' + pingCls + '">' + (server.ping || 0) + " ms</span>" +
"</div>" +
"</div>"
);
}
var fav = server.favorite ? " on" : "";
var pingCls = CO_ServerList.pingClass(server.ping);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id) + '">' +
'<div class="co-server-row-left">' +
'<span class="co-fav' + fav + '">' + (server.favorite ? "\u2605" : "\u2606") + "</span>" +
'<span class="co-server-row-name">' + escapeHtml(server.name) + "</span>" +
'<span class="co-server-row-name">' + escapeHtml(label) + "</span>" +
"</div>" +
'<div class="co-server-row-right">' +
"<span>" + server.players + "/" + server.maxPlayers + "</span>" +
@@ -1,7 +1,7 @@
(function () {
"use strict";
var TAB_ORDER = ["direct-connect", "server-browser", "favorites", "recent"];
var TAB_ORDER = ["direct-connect", "server-browser", "local", "favorites", "recent"];
window.CO_Tabs = {
TAB_ORDER: TAB_ORDER,
@@ -13,6 +13,7 @@
var labels = {
"direct-connect": "Direct Connect",
"server-browser": "Browse",
local: "Local",
favorites: "Favorites",
recent: "Recent"
};
@@ -43,6 +43,19 @@
</div>
</div>
<div class="co-tab-pane" data-pane="local">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title" id="coLocalTitle">Local Servers</h2>
<div class="co-subpanel-meta">
<span class="co-count" id="coLocalCount"></span>
</div>
</header>
<div class="co-server-list-wrap">
<div class="co-server-list co-local-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
<div class="co-tab-pane" data-pane="favorites">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title">Favorites</h2>