init
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
116
README.md
Normal 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
37
install-desktop-entry.sh
Executable 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
53
lib/dropstats.js
Normal 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
246
main.js
Normal 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
5364
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
26
preload.js
Normal 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
1862
renderer/index.html
Normal file
File diff suppressed because it is too large
Load Diff
101
server.js
Normal file
101
server.js
Normal 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
44
tools/merge-dropstats.js
Normal 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)}`);
|
||||||
Reference in New Issue
Block a user