const JOB_EMOJIS = {
mining: "⛏️",
woodcutting: "🪓",
fishing: "🎣",
herbalism: "🌿",
hunting: "🏹",
lumber: "🪵",
masonry: "🧱",
animal: "🏇",
alchemy: "⚗️",
metal : "⚒️",
weaving: "🧶",
taming: "🐔",
farming: "🌾",
cooking: "🍲",
weapon: "🗡️",
carpentry: "🪚",
arcane: "🪄",
armor: "🛡️",
leather: "🐂",
tailoring: "🪡",
jewel: "💎",
scribe: "📜",
};
const LEVEL_RANK = {
apprentice: 1,
journeyman: 2,
expert: 3,
master: 4,
};
function levelScore(val) {
const k = (val || '').toLowerCase();
for (const name in LEVEL_RANK) {
if (k.includes(name)) return LEVEL_RANK[name];
}
return 0;
}
function emojiFor(jobName) {
const key = jobName.toLowerCase();
for (const k in JOB_EMOJIS) {
if (key.includes(k)) return JOB_EMOJIS[k];
}
return "🔹";
}
import 'dotenv/config';
import { Client, GatewayIntentBits, EmbedBuilder } from 'discord.js';
const TOKEN = process.env.DISCORD_TOKEN;
const CSV_URL = process.env.SHEET_CSV_URL;
const PREFIX = process.env.PREFIX || '!';
const CACHE_SECONDS = Number(process.env.CACHE_SECONDS || 180);
if (!TOKEN || !CSV_URL) {
console.error("❌ Il manque DISCORD_TOKEN ou SHEET_CSV_URL dans .env");
process.exit(1);
}
// ---- Cache en mémoire ----
let cache = {
at: 0,
header: [],
rows: [],
};
function nowMs() { return Date.now(); }
// CSV parser simple qui gère les guillemets
function parseCSV(text) {
const rows = [];
let row = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
const next = text[i + 1];
if (c === '"' ) {
if (inQuotes && next === '"') { // échappement ""
cur += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (c === ',' && !inQuotes) {
row.push(cur);
cur = '';
} else if ((c === '\n' || c === '\r') && !inQuotes) {
if (c === '\r' && next === '\n') i++;
row.push(cur);
cur = '';
// ignore lignes vides
if (row.some(cell => cell.trim() !== '')) rows.push(row);
row = [];
} else {
cur += c;
}
}
// dernier champ
row.push(cur);
if (row.some(cell => cell.trim() !== '')) rows.push(row);
return rows;
}
async function fetchSheet(force = false) {
const age = (nowMs() - cache.at) / 1000;
if (!force && cache.at && age < CACHE_SECONDS) return cache;
const res = await fetch(CSV_URL, { headers: { 'cache-control': 'no-cache' }});
if (!res.ok) throw new Error(`HTTP ${res.status} en lisant le CSV`);
const text = await res.text();
const parsed = parseCSV(text);
const header = (parsed[0] || []).map(h => (h || '').trim());
const rows = parsed.slice(1).map(r => r.map(x => (x || '').trim()));
cache = { at: nowMs(), header, rows };
return cache;
}
function normalize(s) {
return (s || '')
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // retire accents
.replace(/[^a-z0-9 ]/g, '') // retire ponctuation
.trim();
}
function bestColumnMatch(header, query) {
const q = normalize(query);
// match exact d'abord
let idx = header.findIndex(h => normalize(h) === q);
if (idx !== -1) return idx;
// match "contient"
idx = header.findIndex(h => normalize(h).includes(q));
if (idx !== -1) return idx;
// match sur mots (ex: "metal working" vs "metal")
const qWords = q.split(/\s+/).filter(Boolean);
idx = header.findIndex(h => {
const hh = normalize(h);
return qWords.every(w => hh.includes(w));
});
return idx;
}
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once('ready', () => {
console.log(`✅ Connecté en tant que ${client.user.tag}`);
console.log(`📄 CSV: ${CSV_URL}`);
});
function makeEmbed(title, description) {
return new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0x57F287); // vert sympa (tu peux changer)
}
client.on('messageCreate', async (msg) => {
if (msg.author.bot) return;
if (!msg.content.startsWith(PREFIX)) return;
const content = msg.content.slice(PREFIX.length).trim();
const [cmdRaw, ...rest] = content.split(/\s+/);
const command = (cmdRaw || '').toLowerCase();
// Helper: liste des métiers = colonnes sauf la première (NOM)
function getJobs(header) {
return header.slice(1).filter(Boolean);
}
// !qui <metier>
// !liste <metier> (alias)
if (command === 'qui' || command === 'liste') {
const query = rest.join(' ').trim();
try {
const sheet = await fetchSheet(false);
const header = sheet.header;
const rows = sheet.rows;
// si "!liste" sans argument -> liste des métiers dispos
if (command === 'liste' && !query) {
const jobs = getJobs(header);
const preview = jobs.slice(0, 25).join(', ');
const extra = jobs.length > 25 ? ` … +${jobs.length - 25} autres` : '';
const title = `${emojiFor(header[col])} ${header[col]} — ${matches.length} joueur(s)`;
//const embed = makeEmbed(title, `${top}${extra}`);
const embed = makeEmbed(
`🧰 Métiers disponibles (${jobs.length})`,
jobs.slice(0, 40).map(j => `${emojiFor(j)} ${j}`).join('\n') + (jobs.length > 40 ? `\n… +${jobs.length - 40} autres` : '')
);
await msg.channel.send({ embeds: [embed] });
return;
}
if (!query) {
await msg.channel.send(`Usage: \`${PREFIX}${command} <metier>\` (ex: \`${PREFIX}${command} mining\`)`);
return;
}
const col = bestColumnMatch(header, query);
if (col === -1 || col === 0) {
await msg.channel.send(`Je ne trouve pas le métier **${query}**. Essaie \`${PREFIX}liste\` pour voir les colonnes.`);
return;
}
const nameCol = 0; // colonne A = NOM
const matches = [];
for (const r of rows) {
const player = r[nameCol] || '';
const value = (r[col] || '').trim();
if (player && value) matches.push({ player, value });
}
if (!matches.length) {
await msg.channel.send(`Personne trouvé pour **${header[col]}**.`);
return;
}
const top = matches
.sort((a, b) => a.player.localeCompare(b.player))
.slice(0, 25)
.map(x => `• ${emojiFor(header[col])} **${x.player}** — ${x.value}`)
.join('\n');
const extra = matches.length > 25 ? `\n… +${matches.length - 25} autres` : '';
await msg.channel.send(`📜 **${header[col]}**:\n${top}${extra}`);
return;
} catch (err) {
console.error(err);
await msg.channel.send(`💥 Problème lecture sheet. Vérifie le lien CSV et réessaie.`);
return;
}
}
// !metiers <Nom>
if (command === 'metiers') {
const who = rest.join(' ').trim();
if (!who) {
await msg.channel.send(`Usage: \`${PREFIX}metiers <Nom>\` (ex: \`${PREFIX}metiers Skurcey\`)`);
return;
}
try {
const sheet = await fetchSheet(false);
const header = sheet.header;
const rows = sheet.rows;
const nameCol = 0;
const target = normalize(who);
const row = rows.find(r => normalize(r[nameCol] || '') === target);
if (!row) {
const embed = makeEmbed(
`🧑🏭 Métiers de ${row[nameCol]}`,
`${shown}${extra}`
);
await msg.channel.send({ embeds: [embed] });
return;
}
// Construire liste des métiers non vides
const items = [];
for (let i = 1; i < header.length; i++) {
const job = header[i];
const val = (row[i] || '').trim();
if (job && val) items.push(`• ${emojiFor(job)} **${job}** — ${val}`);
}
if (!items.length) {
await msg.channel.send(`**${who}** n’a aucun métier rempli (ou ligne vide).`);
return;
}
const shown = items.slice(0, 40).join('\n');
const extra = items.length > 40 ? `\n… +${items.length - 40} autres` : '';
await msg.channel.send(`🧑🏭 **Métiers de ${row[nameCol]}**:\n${shown}${extra}`);
return;
} catch (err) {
console.error(err);
await msg.channel.send(`💥 Problème lecture sheet. Vérifie le lien CSV et réessaie.`);
return;
}
}
// !refresh
if (command === 'refresh') {
try {
await fetchSheet(true);
await msg.channel.send(`✅ Cache rafraîchi.`);
} catch (err) {
console.error(err);
await msg.channel.send(`💥 Impossible de rafraîchir. Vérifie le lien CSV.`);
}
}
// !top <metier> [n]
if (command === 'top') {
const query = rest.join(' ').trim();
if (!query) {
await msg.channel.send(`Usage: \`${PREFIX}top <metier> [nombre]\``);
return;
}
// séparer le métier et le nombre (ex: "mining 10")
const parts = query.split(/\s+/);
const maybeNum = Number(parts[parts.length - 1]);
const limit = Number.isInteger(maybeNum) ? Math.min(Math.max(maybeNum, 1), 25) : 10;
const jobQuery = Number.isInteger(maybeNum) ? parts.slice(0, -1).join(' ') : parts.join(' ');
try {
const sheet = await fetchSheet(false);
const header = sheet.header;
const rows = sheet.rows;
const col = bestColumnMatch(header, jobQuery);
if (col === -1 || col === 0) {
await msg.channel.send(`Métier **${jobQuery}** introuvable. Essaie \`${PREFIX}liste\`.`);
return;
}
const nameCol = 0;
const ranked = [];
for (const r of rows) {
const player = r[nameCol] || '';
const val = (r[col] || '').trim();
const score = levelScore(val);
if (player && score > 0) {
ranked.push({ player, val, score });
}
}
if (!ranked.length) {
await msg.channel.send(`Aucun résultat pour **${header[col]}**.`);
return;
}
ranked.sort((a, b) => b.score - a.score || a.player.localeCompare(b.player));
const lines = ranked.slice(0, limit).map((x, i) =>
`**${i + 1}.** ${emojiFor(header[col])} **${x.player}** — ${x.val}`
).join('\n');
const embed = makeEmbed(
`🏆 Top ${header[col]} (Top ${Math.min(limit, ranked.length)})`,
lines
);
await msg.channel.send({ embeds: [embed] });
} catch (err) {
console.error(err);
await msg.channel.send(`💥 Impossible de calculer le top.`);
}
}
});
client.login(TOKEN);