This commit is contained in:
s
2026-06-30 10:56:41 +02:00
parent d49189c6e3
commit 1a5afcfd6d
11 changed files with 7927 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# Electron builder cache
.cache/
out/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Claude Code
.claude/

116
README.md Normal file
View File

@@ -0,0 +1,116 @@
# Farm Tracker — application Linux
Une vraie application de bureau (Electron). Au premier lancement, elle te demande
ton dossier `.minecraft`, **une seule fois**. Tu arrives ensuite sur un écran
d'accueil avec une liste d'outils (pour l'instant : Farm Tracker). Ouvrir un outil
ne ferme pas les autres : ils continuent de tourner en arrière-plan, même quand
tu es revenu à l'accueil ou sur un autre outil.
Tout se sauvegarde **automatiquement** sur le disque : kills, items, cols, prix
de vente, et chances de drop — rien à exporter ni recharger toi-même au jour le
jour. Au prochain lancement, l'appli retrouve directement ton dossier et reprend
exactement où tu en étais.
## Où sont stockées les données (et pourquoi en deux fichiers)
Tout est dans `~/.config/farm-tracker/` :
- **`app-state.json`** — le dossier Minecraft choisi, et l'état de session de
chaque outil (kills, xp, items, prix...). Personnel, pas fait pour être
partagé.
- **`dropstats.json`** — uniquement les chances de drop cumulées. Volontairement
séparé du reste : c'est le fichier fait pour être **lu, partagé et fusionné**
avec celui d'autres joueurs (voir plus bas). Les noms d'items y sont stockés
en clair (`"name": "Peau de Sanglier"`) en plus du format coloré du jeu, pour
que tu puisses l'ouvrir et le comprendre sans l'appli.
## 1. Installer et lancer
```bash
node -v # si absent : sudo apt install nodejs npm (ou via nvm)
npm install
npm start
```
## 2. (Optionnel) Construire un AppImage
```bash
npm run dist
```
Voir la section "entrée de menu" plus bas si tu veux l'épingler à ton menu
d'applications.
## 3. Partager et fusionner les chances de drop entre joueurs
C'est le point important si plusieurs personnes farment le même mob : un seul
joueur a un échantillon limité (quelques dizaines de kills), mais en combinant
15 joueurs vous obtenez un échantillon de plusieurs centaines de kills — bien
plus fiable pour estimer les drops rares.
**Option A — depuis l'appli (le plus simple) :**
Dans le panneau "Chance de drop" de Farm Tracker :
- **📤 Exporter mes stats** → choisis où sauvegarder ton fichier (clé USB,
Discord, dossier partagé...).
- **📥 Importer / fusionner…** → sélectionne **plusieurs fichiers à la fois**
(ceux de tous les autres joueurs) : tout est additionné en une seule fois
dans tes propres stats.
**Option B — en ligne de commande (pratique si quelqu'un centralise les 15
fichiers de tout le monde) :**
```bash
node tools/merge-dropstats.js fusion.json joueur1.json joueur2.json joueur3.json ...
```
Ça fusionne tous les fichiers donnés en un seul `fusion.json`, avec un résumé
affiché (mobs suivis, kills cumulés). Ce script ne nécessite que Node — pas
besoin d'installer Electron juste pour fusionner des fichiers. Le fichier
résultat peut ensuite être réimporté par n'importe qui via l'option A.
## 4. Utilisation générale
- **Accueil** : clique sur une tuile pour ouvrir l'outil correspondant.
- **🏠 Accueil** (en haut) : revient à la liste d'outils sans rien arrêter.
- **Changer de dossier** (en haut, global) : repointe tous les outils vers un
autre dossier `.minecraft`. Remet à zéro la session de farm en cours, mais
**ne touche jamais** aux chances de drop déjà enregistrées.
- **Réinitialiser la session** (dans Farm Tracker) : remet à zéro kills/xp/items
de la session affichée, sans toucher aux chances de drop.
- **Réinitialiser** (dans le panneau Chance de drop) : efface l'historique
cumulé de drop — sépare exprès de la réinitialisation de session, exporte
avant si tu veux le garder.
## 5. Sauvegarder / migrer tes données manuellement
```bash
cp -r ~/.config/farm-tracker ~/backup-farm-tracker-$(date +%F)
```
Pour restaurer (l'appli doit être fermée) :
```bash
cp -r ~/backup-farm-tracker-2026-06-28/* ~/.config/farm-tracker/
```
## 6. Entrée de menu (AppImage)
Si tu as construit l'AppImage (`npm run dist`), utilise
`install-desktop-entry.sh` (fourni séparément) pour créer l'entrée de menu —
il détecte automatiquement le fichier `.AppImage` dans `dist/`.
## Structure du projet
```
farm-tracker-app/
├── package.json
├── main.js → process principal : fenêtre, fichiers, persistance
├── preload.js → pont sécurisé entre main.js et l'interface
├── lib/
│ └── dropstats.js → logique de fusion, partagée appli + script CLI
├── tools/
│ └── merge-dropstats.js → fusionne N fichiers dropstats.json en ligne de commande
└── renderer/
└── index.html → écran dossier -> accueil -> outils, toute la logique UI
```

37
install-desktop-entry.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Installe (ou met à jour) l'entrée de menu "Farm Tracker" pointant vers l'AppImage déjà construit.
set -e
APPDIR="/home/shamiiow/GitHub/farm-tracker-app/dist"
APPIMAGE=$(ls "$APPDIR"/*.AppImage 2>/dev/null | head -n1)
if [ -z "$APPIMAGE" ]; then
echo "Aucun fichier .AppImage trouvé dans : $APPDIR"
echo "Lance d'abord (depuis le dossier du projet) : npm run dist"
exit 1
fi
chmod +x "$APPIMAGE"
DESKTOP_DIR="$HOME/.local/share/applications"
mkdir -p "$DESKTOP_DIR"
DESKTOP_FILE="$DESKTOP_DIR/farm-tracker.desktop"
cat > "$DESKTOP_FILE" << INNEREOF
[Desktop Entry]
Type=Application
Name=Farm Tracker
Comment=Suivi de loot Minecraft en direct
Exec="$APPIMAGE" %U
Icon=applications-games
Terminal=false
Categories=Game;Utility;
StartupWMClass=farm-tracker
INNEREOF
chmod +x "$DESKTOP_FILE"
update-desktop-database "$DESKTOP_DIR" >/dev/null 2>&1 || true
echo "Entrée installée : $DESKTOP_FILE"
echo "AppImage détecté : $APPIMAGE"
echo "Cherche \"Farm Tracker\" dans ton menu d'applications (déconnexion/reconnexion parfois nécessaire selon le bureau)."

53
lib/dropstats.js Normal file
View File

@@ -0,0 +1,53 @@
// Logique de fusion des fichiers de stats de chance de drop.
// Utilisée à la fois par main.js (Electron) et par tools/merge-dropstats.js (CLI autonome).
// Format du fichier : voir emptyDropStats() ci-dessous.
function emptyDropStats() {
return {
type: 'farm-tracker-dropstats',
version: 1,
updatedAt: null,
mobs: {}
};
}
/**
* Fusionne `source.mobs` dans `target.mobs` (additionne les compteurs).
* Modifie `target` en place et retourne le nombre de kills ajoutés.
*/
function mergeDropStatsInto(target, source) {
let addedKills = 0;
const sourceMobs = (source && source.mobs) || {};
for (const [mobName, mobData] of Object.entries(sourceMobs)) {
if (!mobName) continue;
if (!target.mobs[mobName]) target.mobs[mobName] = { kills: 0, items: {} };
const t = target.mobs[mobName];
const kills = Number(mobData.kills) || 0;
t.kills += kills;
addedKills += kills;
const items = mobData.items || {};
for (const [key, item] of Object.entries(items)) {
if (!key) continue;
if (!t.items[key]) t.items[key] = { name: '', rawName: '', occurrences: 0, totalQty: 0 };
const ti = t.items[key];
ti.occurrences += Number(item.occurrences) || 0;
ti.totalQty += Number(item.totalQty) || 0;
if (item.rawName) ti.rawName = item.rawName;
if (item.name) ti.name = item.name;
}
}
return addedKills;
}
function summarize(dropStats) {
const mobs = Object.keys(dropStats.mobs || {});
const totalKills = mobs.reduce((sum, m) => sum + (dropStats.mobs[m].kills || 0), 0);
const totalCombos = mobs.reduce((sum, m) => sum + Object.keys(dropStats.mobs[m].items || {}).length, 0);
return { totalMobs: mobs.length, totalKills, totalCombos };
}
module.exports = { emptyDropStats, mergeDropStatsInto, summarize };

246
main.js Normal file
View File

@@ -0,0 +1,246 @@
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const fsp = fs.promises;
const { emptyDropStats, mergeDropStatsInto } = require('./lib/dropstats');
// Stocké automatiquement dans ~/.config/farm-tracker/ sur Linux (géré par Electron).
const userDataDir = app.getPath('userData');
const APP_STATE_FILE = path.join(userDataDir, 'app-state.json');
const DROPSTATS_FILE = path.join(userDataDir, 'dropstats.json');
const COMMUNITY_DROPSTATS_FILE = path.join(userDataDir, 'community-dropstats.json');
const SESSION_HISTORY_FILE = path.join(userDataDir, 'session-history.json');
// ---------- Lecture / écriture disque (JSON indenté = facilement lisible) ----------
function readJsonSafe(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
return fallback;
}
}
function writeJsonAtomic(filePath, obj) {
try {
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
const tmp = filePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
fs.renameSync(tmp, filePath);
return true;
} catch (e) {
console.error('Erreur de sauvegarde (' + filePath + '):', e);
return false;
}
}
function loadAppState() { return readJsonSafe(APP_STATE_FILE, null); }
function saveAppState(obj) { return writeJsonAtomic(APP_STATE_FILE, obj); }
function loadDropStats() { return readJsonSafe(DROPSTATS_FILE, emptyDropStats()); }
function saveDropStats(obj) { return writeJsonAtomic(DROPSTATS_FILE, obj); }
function loadCommunityDropStats() { return readJsonSafe(COMMUNITY_DROPSTATS_FILE, null); }
function saveCommunityDropStats(obj) { return writeJsonAtomic(COMMUNITY_DROPSTATS_FILE, obj); }
function loadSessionHistory() { return readJsonSafe(SESSION_HISTORY_FILE, { type: 'farm-tracker-session-history', version: 1, sessions: [] }); }
function saveSessionHistory(obj) { return writeJsonAtomic(SESSION_HISTORY_FILE, obj); }
function resolveLogPath(folderPath) {
const candidate1 = path.join(folderPath, 'logs', 'latest.log');
const candidate2 = path.join(folderPath, 'latest.log');
if (fs.existsSync(candidate1)) return candidate1;
if (fs.existsSync(candidate2)) return candidate2;
return null;
}
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1180,
height: 880,
minWidth: 760,
minHeight: 600,
backgroundColor: '#0B0F0C',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.setMenuBarVisibility(false);
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
// ---------- IPC : sélection et lecture du dossier Minecraft ----------
ipcMain.handle('pick-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
title: 'Choisis ton dossier .minecraft (ou directement le dossier logs)'
});
if (result.canceled || !result.filePaths.length) return { canceled: true };
const folder = result.filePaths[0];
const logPath = resolveLogPath(folder);
if (!logPath) {
return {
canceled: false,
error: 'Impossible de trouver "latest.log" dans ce dossier ni dans son sous-dossier "logs". Sélectionne ton dossier .minecraft, ou le dossier logs directement.'
};
}
return { canceled: false, logPath };
});
ipcMain.handle('check-log-path', async (event, logPath) => {
try { await fsp.access(logPath, fs.constants.R_OK); return { ok: true }; }
catch (e) { return { ok: false }; }
});
ipcMain.handle('get-file-size', async (event, logPath) => {
try { const st = await fsp.stat(logPath); return { size: st.size }; }
catch (e) { return { size: 0, error: e.message }; }
});
ipcMain.handle('read-chunk', async (event, logPath, start, end) => {
if (end <= start) return { text: '' };
let fd;
try {
fd = await fsp.open(logPath, 'r');
const length = end - start;
const buffer = Buffer.alloc(length);
await fd.read(buffer, 0, length, start);
return { text: buffer.toString('utf8') };
} catch (e) {
return { text: '', error: e.message };
} finally {
if (fd) await fd.close().catch(() => {});
}
});
// ---------- IPC : état général de l'appli ----------
ipcMain.handle('load-app-state', async () => loadAppState());
ipcMain.handle('save-app-state', async (event, obj) => saveAppState(obj));
// ---------- IPC : stats de chance de drop personnelles ----------
ipcMain.handle('load-drop-stats', async () => loadDropStats());
ipcMain.handle('save-drop-stats', async (event, obj) => saveDropStats(obj));
ipcMain.handle('export-drop-stats', async () => {
const current = loadDropStats();
const defaultName = 'farm-tracker-dropstats-' + new Date().toISOString().slice(0, 10) + '.json';
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Exporter mes stats de chance de drop',
defaultPath: defaultName,
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (result.canceled || !result.filePath) return { canceled: true };
fs.writeFileSync(result.filePath, JSON.stringify(current, null, 2));
return { canceled: false, filePath: result.filePath };
});
ipcMain.handle('import-merge-drop-stats', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Importer et fusionner des fichiers de stats de drop (plusieurs fichiers possibles)',
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (result.canceled || !result.filePaths.length) return { canceled: true };
const current = loadDropStats();
const details = [];
let totalAdded = 0;
for (const filePath of result.filePaths) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const data = JSON.parse(raw);
const added = mergeDropStatsInto(current, data);
totalAdded += added;
details.push({ file: path.basename(filePath), addedKills: added, ok: true });
} catch (e) {
details.push({ file: path.basename(filePath), ok: false, error: e.message });
}
}
current.updatedAt = new Date().toISOString();
saveDropStats(current);
return { canceled: false, totalAdded, details, dropStats: current };
});
// ---------- IPC : stats communautaires (reçues du serveur de sync) ----------
ipcMain.handle('load-community-drop-stats', async () => loadCommunityDropStats());
ipcMain.handle('save-community-drop-stats', async (event, obj) => saveCommunityDropStats(obj));
ipcMain.handle('get-file-paths', () => ({ dropStats: DROPSTATS_FILE, communityDropStats: COMMUNITY_DROPSTATS_FILE }));
// ---------- IPC : historique des sessions ----------
ipcMain.handle('load-session-history', async () => loadSessionHistory());
ipcMain.handle('save-session-history', async (event, obj) => saveSessionHistory(obj));
// ---------- IPC : sync avec le serveur VPS ----------
ipcMain.handle('fetch-community-stats', async (event, { serverUrl }) => {
return new Promise((resolve) => {
try {
const url = new URL('/stats', serverUrl);
const mod = url.protocol === 'https:' ? require('https') : require('http');
const req = mod.get({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: '/stats'
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try { resolve({ ok: true, ...JSON.parse(data) }); }
catch { resolve({ ok: false, error: 'Réponse invalide du serveur' }); }
});
});
req.setTimeout(15000, () => { req.destroy(); resolve({ ok: false, error: 'Timeout (15s)' }); });
req.on('error', (e) => resolve({ ok: false, error: e.message }));
} catch (e) {
resolve({ ok: false, error: e.message });
}
});
});
ipcMain.handle('sync-drop-stats', async (event, { serverUrl, clientId, dropStats }) => {
return new Promise((resolve) => {
try {
const url = new URL('/sync', serverUrl);
const body = JSON.stringify({ clientId, dropStats });
const mod = url.protocol === 'https:' ? require('https') : require('http');
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: '/sync',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
};
const req = mod.request(options, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try { resolve({ ok: true, ...JSON.parse(data) }); }
catch { resolve({ ok: false, error: 'Réponse invalide du serveur' }); }
});
});
req.setTimeout(15000, () => { req.destroy(); resolve({ ok: false, error: 'Timeout (15s)' }); });
req.on('error', (e) => resolve({ ok: false, error: e.message }));
req.write(body);
req.end();
} catch (e) {
resolve({ ok: false, error: e.message });
}
});
});

5364
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "farm-tracker",
"version": "1.0.0",
"description": "Suivi de loot Minecraft en direct, avec sauvegarde automatique des stats et des chances de drop.",
"main": "main.js",
"scripts": {
"start": "electron .",
"dist": "electron-builder --linux AppImage",
"dist:win": "electron-builder --win nsis"
},
"author": "",
"license": "ISC",
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"playwright-core": "^1.61.1"
},
"build": {
"appId": "com.shamiiow.farmtracker",
"productName": "Farm Tracker",
"files": [
"main.js",
"preload.js",
"lib/**/*",
"tools/**/*",
"renderer/**/*"
],
"linux": {
"target": [
"AppImage"
],
"category": "Game",
"executableName": "farm-tracker"
},
"win": {
"target": [
"nsis"
],
"executableName": "Farm Tracker"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
},
"dependencies": {
"dist": "^0.1.2",
"run": "^1.5.0"
}
}

26
preload.js Normal file
View File

@@ -0,0 +1,26 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
pickFolder: () => ipcRenderer.invoke('pick-folder'),
checkLogPath: (p) => ipcRenderer.invoke('check-log-path', p),
getFileSize: (p) => ipcRenderer.invoke('get-file-size', p),
readChunk: (p, start, end) => ipcRenderer.invoke('read-chunk', p, start, end),
loadAppState: () => ipcRenderer.invoke('load-app-state'),
saveAppState: (obj) => ipcRenderer.invoke('save-app-state', obj),
loadDropStats: () => ipcRenderer.invoke('load-drop-stats'),
saveDropStats: (obj) => ipcRenderer.invoke('save-drop-stats', obj),
exportDropStats: () => ipcRenderer.invoke('export-drop-stats'),
importMergeDropStats: () => ipcRenderer.invoke('import-merge-drop-stats'),
loadCommunityDropStats: () => ipcRenderer.invoke('load-community-drop-stats'),
saveCommunityDropStats: (obj) => ipcRenderer.invoke('save-community-drop-stats', obj),
syncDropStats: (serverUrl, clientId, dropStats) =>
ipcRenderer.invoke('sync-drop-stats', { serverUrl, clientId, dropStats }),
fetchCommunityStats: (serverUrl) => ipcRenderer.invoke('fetch-community-stats', { serverUrl }),
getFilePaths: () => ipcRenderer.invoke('get-file-paths'),
loadSessionHistory: () => ipcRenderer.invoke('load-session-history'),
saveSessionHistory: (obj) => ipcRenderer.invoke('save-session-history', obj)
});

1862
renderer/index.html Normal file

File diff suppressed because it is too large Load Diff

101
server.js Normal file
View File

@@ -0,0 +1,101 @@
'use strict';
// Serveur de sync communautaire Farm Tracker.
// Stocke les stats de chaque joueur (identifié par UUID) et renvoie l'agrégat.
// Lance avec : node server.js (ou PORT=3742 node server.js)
// Aucune dépendance externe.
const http = require('http');
const fs = require('fs');
const path = require('path');
const DATA_FILE = path.join(__dirname, 'sync-data.json');
const PORT = Number(process.env.PORT) || 3742;
const MAX_BODY = 10 * 1024 * 1024; // 10 Mo max par requête
function loadData() {
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
catch { return { clients: {} }; }
}
function saveData(data) {
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data));
fs.renameSync(tmp, DATA_FILE);
}
function mergeInto(target, source) {
for (const [mob, mobData] of Object.entries(source.mobs || {})) {
if (!mob) continue;
if (!target.mobs[mob]) target.mobs[mob] = { kills: 0, items: {} };
const t = target.mobs[mob];
t.kills += Number(mobData.kills) || 0;
for (const [key, item] of Object.entries(mobData.items || {})) {
if (!t.items[key]) t.items[key] = { rawName: '', name: '', occurrences: 0, totalQty: 0 };
t.items[key].occurrences += Number(item.occurrences) || 0;
t.items[key].totalQty += Number(item.totalQty) || 0;
if (item.rawName) t.items[key].rawName = item.rawName;
if (item.name) t.items[key].name = item.name;
}
}
}
// Recompute l'agrégat à partir des contributions individuelles de chaque client.
// Chaque client est compté exactement une fois (la dernière soumission).
function computeAggregate(clients) {
const agg = { type: 'farm-tracker-dropstats', version: 1, updatedAt: new Date().toISOString(), mobs: {} };
for (const stats of Object.values(clients)) mergeInto(agg, stats);
return agg;
}
http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (req.method === 'GET' && req.url === '/stats') {
const data = loadData();
const aggregate = computeAggregate(data.clients);
const clientCount = Object.keys(data.clients).length;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, dropStats: aggregate, clientCount }));
return;
}
if (req.method !== 'POST' || req.url !== '/sync') {
res.writeHead(404); res.end('Not found'); return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > MAX_BODY) req.destroy();
});
req.on('end', () => {
try {
const { clientId, dropStats } = JSON.parse(body);
if (!clientId || typeof clientId !== 'string' || clientId.length > 64)
throw new Error('clientId invalide');
if (!dropStats || dropStats.type !== 'farm-tracker-dropstats')
throw new Error('Format dropStats invalide');
const data = loadData();
data.clients[clientId] = dropStats;
const aggregate = computeAggregate(data.clients);
saveData(data);
const clientCount = Object.keys(data.clients).length;
console.log(`[${new Date().toISOString()}] sync client=${clientId.slice(0, 8)}… clients=${clientCount}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, dropStats: aggregate, clientCount }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
req.on('error', () => {});
}).listen(PORT, '0.0.0.0', () => {
console.log(`Farm Tracker sync server — port ${PORT} — data: ${DATA_FILE}`);
});

44
tools/merge-dropstats.js Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
// Fusionne plusieurs fichiers dropstats.json en un seul fichier combiné.
// Usage : node merge-dropstats.js sortie.json fichier1.json fichier2.json [...]
// ou : node merge-dropstats.js sortie.json dossier-contenant-les-jsons/*.json
const fs = require('fs');
const path = require('path');
const { emptyDropStats, mergeDropStatsInto, summarize } = require('../lib/dropstats');
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node merge-dropstats.js <sortie.json> <fichier1.json> [fichier2.json ...]');
console.error('Exemple: node merge-dropstats.js fusion.json alice.json bob.json charlie.json');
process.exit(1);
}
const [outputPath, ...inputPaths] = args;
const result = emptyDropStats();
let filesOk = 0;
console.log(`Fusion de ${inputPaths.length} fichier(s)...\n`);
for (const inputPath of inputPaths) {
try {
const raw = fs.readFileSync(inputPath, 'utf8');
const data = JSON.parse(raw);
const added = mergeDropStatsInto(result, data);
filesOk++;
console.log(`${path.basename(inputPath)} — +${added} kills`);
} catch (e) {
console.error(`${path.basename(inputPath)} — erreur : ${e.message}`);
}
}
result.updatedAt = new Date().toISOString();
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));
const stats = summarize(result);
console.log('');
console.log(`Fichiers fusionnés : ${filesOk}/${inputPaths.length}`);
console.log(`Mobs suivis : ${stats.totalMobs}`);
console.log(`Kills cumulés : ${stats.totalKills}`);
console.log(`Combinaisons : ${stats.totalCombos}`);
console.log(`Écrit dans : ${path.resolve(outputPath)}`);