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