Files
2026-06-08 11:08:23 -05:00

397 lines
17 KiB
JavaScript

const https = require("https");
const fs = require("fs");
const path = require("path");
const ROLE_NAMES = { 0: "T", 1: "D", 2: "H" };
const ROLE_FULL = { 0: "Tank", 1: "DPS", 2: "Healer" };
// Shared in-memory state (all module instances in the same Node.js process)
if (!global.__discordMatching) {
global.__discordMatching = {
matchState: {},
lastMessageId: null,
sending: false,
pendingAction: null
};
}
const G = global.__discordMatching;
module.exports = function DiscordMatchingRelay(mod) {
const config = loadConfig();
let webhookUrl = config.webhook || null;
const showPlayerNames = config.showPlayerNames !== false;
const enabled = config.enabled !== false;
// Data caches
const dungeonNames = {};
const dungeonRoleMap = {};
const battleFieldNames = {};
const battleFieldData = {};
const roleTemplates = {};
// Shared state is in G (global.__discordMatching)
let knownPlayerName = (mod.game && mod.game.me && mod.game.me.name) || "";
function loadConfig() {
try { delete require.cache[require.resolve("./module.config.json")]; return require("./module.config.json"); }
catch (e) { return { enabled: true, webhook: "" }; }
}
function readFileString(filePath) {
try { return fs.readFileSync(filePath, "utf-8"); } catch (e) { mod.error(`[DiscordMatching] Failed to read ${filePath}: ${e.message}`); return ""; }
}
function loadDungeonNames() {
const dir = path.join(__dirname, "data", "StrSheet_Dungeon");
let files;
try { files = fs.readdirSync(dir); } catch (e) { mod.error(`[DiscordMatching] Cannot read StrSheet_Dungeon dir: ${e.message}`); return; }
for (const file of files) {
if (!file.startsWith("StrSheet_Dungeon-") || !file.endsWith(".xml")) continue;
const content = readFileString(path.join(dir, file));
const regex = /<String\s+id="(\d+)"\s+string="([^"]+)"\s*\/>/g;
let m; while ((m = regex.exec(content)) !== null) dungeonNames[parseInt(m[1], 10)] = m[2];
}
mod.log(`[DiscordMatching] Loaded ${Object.keys(dungeonNames).length} dungeon names`);
}
function loadDungeonMatching() {
const content = readFileString(path.join(__dirname, "data", "DungeonMatching.xml"));
const regex = /<Dungeon\s+id="(\d+)"[^>]*>/g;
let m; while ((m = regex.exec(content)) !== null) {
const fullTag = m[0], id = parseInt(m[1], 10);
if (content.substring(Math.max(0, m.index - 10), m.index).includes("<!--")) continue;
const rm = fullTag.match(/matchingRoleId="(\d+)"/);
dungeonRoleMap[id] = rm ? parseInt(rm[1], 10) : 23;
}
mod.log(`[DiscordMatching] Loaded ${Object.keys(dungeonRoleMap).length} dungeon matching entries`);
}
function loadRoleTemplates() {
const content = readFileString(path.join(__dirname, "data", "MatchingRoleTemplate.xml"));
const roleRegex = /<Role\s+id="(\d+)"[^>]*>/g;
let roleMatch; while ((roleMatch = roleRegex.exec(content)) !== null) {
const roleId = parseInt(roleMatch[1], 10);
const remaining = content.substring(roleMatch.index);
const rd = remaining.match(/<RoleData\s+([^>]+)\/>/);
if (!rd) continue;
const attrs = rd[1];
const ga = (name, def) => { const x = attrs.match(new RegExp("\\b" + name + '="([^"]*)"')); return x ? (x[1] === "" ? undefined : parseInt(x[1], 10)) : def; };
roleTemplates[roleId] = {
totalUser: ga("totalUser"), tanker: ga("tanker", ga("tankerMax")), dealer: ga("dealer", ga("dealerMax")), healer: ga("healer", ga("healerMax")),
tankerMin: ga("tankerMin"), tankerMax: ga("tankerMax"), dealerMin: ga("dealerMin"), dealerMax: ga("dealerMax"),
healerMin: ga("healerMin"), healerMax: ga("healerMax")
};
}
mod.log(`[DiscordMatching] Loaded ${Object.keys(roleTemplates).length} role templates`);
}
function loadBattleFieldNames() {
const dir = path.join(__dirname, "data", "StrSheet_BattleField");
let files;
try { files = fs.readdirSync(dir); } catch (e) { mod.error(`[DiscordMatching] Cannot read StrSheet_BattleField dir: ${e.message}`); return; }
for (const file of files) {
if (!file.startsWith("StrSheet_BattleField-") || !file.endsWith(".xml")) continue;
const content = readFileString(path.join(dir, file));
const regex = /<String\s+id="(\d+)"\s+string="([^"]+)"\s*\/>/g;
let m; while ((m = regex.exec(content)) !== null) battleFieldNames[parseInt(m[1], 10)] = m[2];
}
mod.log(`[DiscordMatching] Loaded ${Object.keys(battleFieldNames).length} battlefield strings`);
}
function loadBattleFieldData() {
const content = readFileString(path.join(__dirname, "data", "BattleFieldData.xml"));
const regex = /<BattleField\s+[^>]*id="(\d+)"[^>]*>/g;
let m; while ((m = regex.exec(content)) !== null) {
const id = parseInt(m[1], 10);
if (content.substring(Math.max(0, m.index - 10), m.index).includes("<!--")) continue;
const nm = m[0].match(/name="(\d+)"/);
const nameId = nm ? parseInt(nm[1], 10) : null;
const rm = content.substring(m.index).match(/<RuleData\s+ruleId="(\d+)"/);
battleFieldData[id] = { nameId, ruleId: rm ? parseInt(rm[1], 10) : null };
}
mod.log(`[DiscordMatching] Loaded ${Object.keys(battleFieldData).length} battlefield entries`);
}
function getDungeonName(id) { return dungeonNames[id] || `Unknown (${id})`; }
function getBFName(bfId) {
const bf = battleFieldData[bfId]; const nid = bf ? bf.nameId : null;
return nid ? (battleFieldNames[nid] || `Unknown (${bfId})`) : `Unknown (${bfId})`;
}
function getRoleInfo(instanceId, type) {
const roleId = type === 1 ? (battleFieldData[instanceId] ? battleFieldData[instanceId].ruleId : null) : (dungeonRoleMap[instanceId] || 23);
const tmpl = roleTemplates[roleId];
if (!tmpl) mod.warn(`[DiscordMatching] No matching role template found for roleId ${roleId} (instance ${instanceId})`);
return tmpl;
}
function makeStateKey(instanceId, type) { return (type === 1 ? "b_" : "d_") + instanceId; }
// Build plain text content
function buildContent(mState, action) {
const dungeons = [], bgs = [];
for (const state of Object.values(mState)) {
if (Object.keys(state.players).length === 0) continue;
if (state.type === 1) bgs.push(state); else dungeons.push(state);
}
const allPlayers = new Set();
for (const s of [...dungeons, ...bgs]) for (const n of Object.keys(s.players)) allPlayers.add(n);
const lines = [];
lines.push("**Matching Queue**");
lines.push("");
lines.push(`**${allPlayers.size}** total player${allPlayers.size > 1 ? "s" : ""} in queue.`);
lines.push("");
if (dungeons.length > 0) {
dungeons.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
lines.push("**Instance Matching Queue**");
lines.push("");
const dp = new Set();
for (const state of dungeons) for (const n of Object.keys(state.players)) dp.add(n);
lines.push(`**${dp.size}** player${dp.size > 1 ? "s" : ""} currently in queue.`);
lines.push("");
for (const state of dungeons) {
const groups = splitIntoGroups(state.players, state.tankLimit, state.dealerLimit, state.healerLimit, state.maxPlayers);
for (let gi = 0; gi < groups.length; gi++) {
const g = groups[gi]; const gc = Object.keys(g.players).length;
const tl = state.tankLimit !== undefined ? state.tankLimit : (state.tankerMax || "?");
const dl2 = state.dealerLimit !== undefined ? state.dealerLimit : (state.dealerMax || "?");
const hl = state.healerLimit !== undefined ? state.healerLimit : (state.healerMax || "?");
const rl = `T:${g.tankCount}/${tl} D:${g.dpsCount}/${dl2} H:${g.healerCount}/${hl}`;
const lb = groups.length > 1 ? ` #${gi + 1}` : "";
lines.push(`**${state.name}${lb}**`);
lines.push(`${rl} [${gc}/${state.maxPlayers}]`);
if (showPlayerNames) {
for (const [n, p] of Object.entries(g.players)) lines.push(`**${n}** (${ROLE_FULL[p.role]})`);
}
lines.push("");
}
}
}
if (bgs.length > 0) {
bgs.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
lines.push("**Battleground Matching Queue**");
lines.push("");
const bp = new Set();
for (const state of bgs) for (const n of Object.keys(state.players)) bp.add(n);
lines.push(`**${bp.size}** player${bp.size > 1 ? "s" : ""} currently in queue.`);
lines.push("");
for (const state of bgs) {
const entries = Object.entries(state.players); const bfTotal = state.maxPlayers * 2;
let tc = 0, dc = 0, hc = 0;
for (const [, p] of entries) { if (p.role === 0) tc++; else if (p.role === 1) dc++; else if (p.role === 2) hc++; }
const fm = (cnt, min, max, lim) => { if (min !== undefined) return `${cnt}/${min}/${max || "?"}`; if (lim) return `${cnt}/${lim}`; return `${cnt}/?`; };
const rl = `T:${fm(tc, state.tankerMin, state.tankerMax, state.tankLimit)} D:${fm(dc, state.dealerMin, state.dealerMax, state.dealerLimit)} H:${fm(hc, state.healerMin, state.healerMax, state.healerLimit)}`;
lines.push(`**${state.name}**`);
lines.push(`${rl} [${entries.length}/${bfTotal}]`);
if (showPlayerNames) {
for (const [n, p] of entries) lines.push(`**${n}** (${ROLE_FULL[p.role]})`);
}
lines.push("");
}
}
if (dungeons.length === 0 && bgs.length === 0) {
lines.push("*No players currently in any instance queue.*");
lines.push("");
}
lines.push(`Last action: ${action} | ${new Date().toLocaleTimeString()}`);
return lines.join("\n");
}
function splitIntoGroups(players, tankLimit, dealerLimit, healerLimit, maxPlayers) {
const partyGroups = new Map();
for (const [name, p] of Object.entries(players)) {
const pid = p.partyId || name;
if (!partyGroups.has(pid)) partyGroups.set(pid, { players: {}, tankCount: 0, dpsCount: 0, healerCount: 0 });
const grp = partyGroups.get(pid);
grp.players[name] = { role: p.role };
if (p.role === 0) grp.tankCount++; else if (p.role === 1) grp.dpsCount++; else if (p.role === 2) grp.healerCount++;
}
const sorted = [...partyGroups.values()].sort((a, b) => {
const ar = a.healerCount + a.tankCount, br = b.healerCount + b.tankCount;
if (br !== ar) return br - ar;
return (b.tankCount + b.dpsCount + b.healerCount) - (a.tankCount + a.dpsCount + a.healerCount);
});
const groups = [];
for (const party of sorted) {
let placed = false;
for (const g of groups) {
const nt = g.tankCount + party.tankCount, nd = g.dpsCount + party.dpsCount, nh = g.healerCount + party.healerCount;
if (nt <= tankLimit && nd <= dealerLimit && nh <= healerLimit && (nt + nd + nh) <= maxPlayers) {
Object.assign(g.players, party.players); g.tankCount = nt; g.dpsCount = nd; g.healerCount = nh; placed = true; break;
}
}
if (!placed) groups.push({ players: { ...party.players }, tankCount: party.tankCount, dpsCount: party.dpsCount, healerCount: party.healerCount });
}
return groups;
}
// Main update: build text -> send with mutex (one HTTP request at a time)
function sendDiscordUpdate(action) {
if (!webhookUrl) return;
// If already sending, just queue the next action
if (G.sending) {
G.pendingAction = action;
return;
}
G.sending = true;
const text = buildContent(G.matchState, action).substring(0, 2000);
const payload = JSON.stringify({ content: text });
const isEdit = !!G.lastMessageId;
const url = isEdit ? `${webhookUrl}/messages/${G.lastMessageId}` : `${webhookUrl}?wait=true`;
const opts = {
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
};
function onDone() {
G.sending = false;
if (G.pendingAction !== null) {
const next = G.pendingAction;
G.pendingAction = null;
sendDiscordUpdate(next);
}
}
try {
const req = https.request(url, opts, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
if (res.statusCode === 404 && isEdit) {
res.on("end", () => {
G.lastMessageId = null;
G.sending = false;
sendDiscordUpdate(action);
});
return;
}
res.on("end", () => {
if (res.statusCode === 200 || res.statusCode === 204) {
if (!isEdit && res.statusCode === 200) {
try { const d = JSON.parse(body); if (d && d.id) G.lastMessageId = d.id; } catch (_) {}
}
} else {
mod.error(`[DiscordMatching] Webhook error ${res.statusCode}: ${body.substring(0, 200)}`);
}
onDone();
});
});
req.on("error", (err) => {
mod.error(`[DiscordMatching] Webhook request error: ${err.message}`);
onDone();
});
req.write(payload);
req.end();
} catch (e) {
mod.error(`[DiscordMatching] Webhook exception: ${e.message}`);
onDone();
}
}
// ---------- INIT ----------
if (!enabled) { mod.log("[DiscordMatching] Mod disabled"); return; }
loadDungeonNames(); loadDungeonMatching(); loadRoleTemplates(); loadBattleFieldNames(); loadBattleFieldData();
mod.log(`[DiscordMatching] Webhook: ${webhookUrl ? "Configured" : "NOT configured"}${G.lastMessageId ? " (msgId: " + G.lastMessageId + ")" : ""}`);
if (!webhookUrl) mod.warn('[DiscordMatching] No webhook URL configured! Set "webhook" in module.config.json');
// ---------- HOOKS ----------
try {
mod.hook("C_ADD_INTER_PARTY_MATCH_POOL", "raw", () => {
try {
const pName = (mod.game && mod.game.me && mod.game.me.name) || knownPlayerName || "Unknown";
if (pName !== "Unknown") knownPlayerName = pName;
} catch (e) { mod.error(`[DiscordMatching] C_ADD error: ${e.message}`); }
});
mod.log("[DiscordMatching] Hooked C_ADD_INTER_PARTY_MATCH_POOL");
} catch (e) { mod.warn(`[DiscordMatching] Could not hook C_ADD: ${e.message}`); }
try {
mod.hook("C_DEL_INTER_PARTY_MATCH_POOL", 1, (event) => {
try {
const pName = knownPlayerName || (mod.game && mod.game.me && mod.game.me.name) || "";
if (!pName) return true;
const mState = G.matchState;
const evType = event.type;
let changed = false;
// Remove only this player from queues matching evType
for (const [key, state] of Object.entries(mState)) {
if (evType === 2 || state.type === evType) {
if (state.players[pName]) { delete state.players[pName]; changed = true; }
}
}
if (changed) sendDiscordUpdate("REMOVE");
} catch (e) { mod.error(`[DiscordMatching] C_DEL error: ${e.message}`); }
return true;
});
mod.log("[DiscordMatching] Hooked C_DEL_INTER_PARTY_MATCH_POOL");
} catch (e) { mod.warn(`[DiscordMatching] Could not hook C_DEL: ${e.message}`); }
try {
mod.hook("S_ADD_INTER_PARTY_MATCH_POOL", 1, (event) => {
try {
const type = event.type || 0;
if (type !== 0 && type !== 1) return true;
const mState = G.matchState;
let changed = false;
for (const player of event.players) {
const pName = player.name || "Unknown";
for (const rawId of event.instances) {
const id = rawId.id;
const key = makeStateKey(id, type);
if (!mState[key]) {
const info = getRoleInfo(id, type);
mState[key] = {
name: type === 1 ? getBFName(id) : getDungeonName(id), type,
maxPlayers: info.totalUser, tankLimit: info.tanker, dealerLimit: info.dealer, healerLimit: info.healer,
tankerMin: info.tankerMin, tankerMax: info.tankerMax, dealerMin: info.dealerMin, dealerMax: info.dealerMax,
healerMin: info.healerMin, healerMax: info.healerMax, players: {}
};
}
if (!mState[key].players[pName]) { mState[key].players[pName] = { role: player.role, partyId: 0 }; changed = true; }
}
}
if (changed) sendDiscordUpdate("ADD");
} catch (e) { mod.error(`[DiscordMatching] S_ADD error: ${e.message}`); }
return true;
});
mod.log("[DiscordMatching] Hooked S_ADD_INTER_PARTY_MATCH_POOL");
} catch (e) { mod.warn(`[DiscordMatching] Could not hook S_ADD: ${e.message}`); }
try {
mod.hook("S_DEL_INTER_PARTY_MATCH_POOL", 1, (event) => {
try {
const evType = event.type;
if (evType !== 0 && evType !== 1 && evType !== 2) return true;
// C_DEL already handles removal by player name for normal leaves.
// When the game closes abruptly, the server sends S_DEL with type=2
// (remove from all pools). Handle this to clean up the Discord queue.
const pName = knownPlayerName || (mod.game && mod.game.me && mod.game.me.name) || "";
if (!pName) return true;
const mState = G.matchState;
let changed = false;
for (const [key, state] of Object.entries(mState)) {
if (evType === 2 || state.type === evType) {
if (state.players[pName]) { delete state.players[pName]; changed = true; }
}
}
if (changed) sendDiscordUpdate("REMOVE");
} catch (e) { mod.error(`[DiscordMatching] S_DEL error: ${e.message}`); }
return true;
});
mod.log("[DiscordMatching] Hooked S_DEL_INTER_PARTY_MATCH_POOL");
} catch (e) { mod.warn(`[DiscordMatching] Could not hook S_DEL: ${e.message}`); }
mod.log("[DiscordMatching] Mod loaded successfully");
};