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:
@@ -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">»»</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="co-tab-pane" data-pane="favorites">
|
||||
<header class="co-subpanel-header">
|
||||
<h2 class="co-subpanel-title">Favorites</h2>
|
||||
|
||||
Reference in New Issue
Block a user