397 lines
17 KiB
JavaScript
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");
|
|
}; |