feat: architecture DDD hexagonale + tooling Vite/TypeScript

Migration complète du monolithe vers une architecture en couches :
- Domain : entités, value objects, services purs, ports
- Application : CQRS avec CommandBus/QueryBus, 15+ commandes, 9 requêtes
- Tooling : Vite + TypeScript strict + Vitest + path aliases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
POL Mickaël 2026-04-06 05:42:53 +02:00
parent 0afc53fc1a
commit c640fbd416
57 changed files with 4419 additions and 3155 deletions

2185
package-lock.json generated Normal file → Executable file

File diff suppressed because it is too large Load Diff

44
package.json Normal file → Executable file
View File

@ -1,24 +1,32 @@
{ {
"name": "minuteur-dragodinde", "name": "minuteur-dragodinde",
"version": "1.1.5", "version": "1.1.6",
"description": "Minuteur elevage Dragodinde Dofus 3", "description": "Minuteur elevage Dragodinde Dofus 3",
"main": "main.js", "main": "dist-electron/main.js",
"author": "Mickael", "author": "Mickael",
"scripts": { "scripts": {
"start": "electron .", "dev": "vite",
"build": "electron-builder --win --x64" "build": "vite build && electron-builder --win --x64",
"start": "npm run dev",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "npx playwright test"
}, },
"build": { "build": {
"appId": "fr.mickael-pol.minuteur-dragodinde", "appId": "fr.mickael-pol.minuteur-dragodinde",
"publish": {
"provider": "generic",
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer/releases/download/latest"
},
"productName": "Minuteur Dragodinde", "productName": "Minuteur Dragodinde",
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
"files": [ "files": [
"main.js", "dist-vite/**/*",
"preload.js", "dist-electron/**/*",
"src/**/*", "icon.ico"
"icon.png"
], ],
"win": { "win": {
"target": [ "target": [
@ -31,7 +39,7 @@
], ],
"sign": null, "sign": null,
"signingHashAlgorithms": [], "signingHashAlgorithms": [],
"icon": "icon.png", "icon": "icon.ico",
"requestedExecutionLevel": "asInvoker" "requestedExecutionLevel": "asInvoker"
}, },
"nsis": { "nsis": {
@ -46,12 +54,24 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.2",
"electron": "32.2.7", "electron": "32.2.7",
"electron-builder": "24.13.3" "electron-builder": "24.13.3",
"esbuild": "^0.27.4",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.1.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer.git" "url": "https://gitea.mickael-pol.fr/mickael/dd-timer.git"
}, },
"productName": "Minuteur Dragodinde" "productName": "Minuteur Dragodinde",
} "dependencies": {
"electron-updater": "^6.8.3"
}
}

View File

@ -0,0 +1,13 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { addDragodinde } from '@domain/entities/Enclos';
export interface AddDragodindeCommand { type: 'add-dragodinde'; enclosId: number; }
export function createAddDragodindeHandler(state: AppState, repo: StateRepository) {
return (cmd: AddDragodindeCommand): void => {
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
if (idx < 0) return;
state.enclos[idx] = addDragodinde(state.enclos[idx]!);
repo.save(state);
};
}

View File

@ -0,0 +1,52 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { EventBus } from '@domain/events/EventBus';
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
import { computeGaugeState } from '@domain/services/GaugeCalculator';
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
export interface CompleteTimerCommand { type: 'complete-timer'; enclosId: number; }
export function createCompleteTimerHandler(state: AppState, repo: StateRepository, events: EventBus) {
return (cmd: CompleteTimerCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || !enc.timer.running || enc.alerted['__done__']) return;
const now = Date.now();
enc.timer.running = false;
enc.timer.pausedAt = now;
enc.alerted['__done__'] = true;
// Persister les stats finales de chaque DD dans dd.stats
// pour que la session suivante parte des bonnes valeurs.
const el = (now - enc.timer.startTime! - enc.timer.pausedMs) / 1000;
for (const dd of enc.dragodindes) {
for (const gid of enc.activeGauges) {
const def = GAUGE_DEFS[gid];
const startGl = enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid];
const startSt = (enc.timer.snapStats[dd.id]?.[def.stat] ?? (dd.stats as Record<string, number>)[def.stat]) as number;
const recharges = enc.timer.gaugeRecharges[gid] ?? [];
let pts: number;
if (def.isXp) {
pts = Math.max(0, xpForLevel(200) - xpForLevel(startSt));
} else {
const sd = STAT_DEFS[def.stat];
pts = def.dir > 0 ? Math.max(0, sd.max - startSt) : Math.max(0, startSt - sd.min);
}
const { gained } = computeGaugeState(startGl, recharges, pts, el);
if (def.isXp) {
dd.stats.xp = Math.min(200, Math.max(1, levelFromXp(xpForLevel(startSt) + gained)));
} else {
const sd = STAT_DEFS[def.stat];
const raw = startSt + def.dir * gained;
(dd.stats as Record<string, number>)[def.stat] = Math.min(sd.max, Math.max(sd.min, Math.round(raw)));
}
}
}
repo.save(state);
events.emit({ type: 'timer-completed', enclosName: enc.name });
};
}

View File

@ -0,0 +1,24 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createEnclos, addDragodinde, MAX_ENCLOS } from '@domain/entities/Enclos';
export interface CreateEnclosCommand { type: 'create-enclos'; }
export function createCreateEnclosHandler(state: AppState, repo: StateRepository) {
return (_cmd: CreateEnclosCommand): void => {
if (state.enclos.length >= MAX_ENCLOS) return;
// Gap-filling : premier numéro de slot (1..MAX_ENCLOS) absent des noms existants "Enclos N"
const usedSlots = new Set<number>();
for (const e of state.enclos) {
const m = e.name.match(/^Enclos (\d+)$/);
if (m) usedSlots.add(Number(m[1]));
}
let slot = 1;
while (usedSlots.has(slot)) slot++;
let enc = createEnclos(state.nextEnclosId, `Enclos ${slot}`);
enc = addDragodinde(enc); // Always start with 1 DD
state.enclos.push(enc);
state.nextEnclosId++;
state.activeId = enc.id;
repo.save(state);
};
}

View File

@ -0,0 +1,17 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { EventBus } from '@domain/events/EventBus';
export interface DeleteEnclosCommand { type: 'delete-enclos'; enclosId: number; }
export function createDeleteEnclosHandler(state: AppState, repo: StateRepository, events: EventBus) {
return (cmd: DeleteEnclosCommand): void => {
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
if (idx < 0) return;
state.enclos.splice(idx, 1);
if (state.activeId === cmd.enclosId) {
state.activeId = state.enclos.length > 0 ? state.enclos[0]!.id : 'dashboard';
}
events.emit({ type: 'enclos-deleted', enclosId: cmd.enclosId });
repo.save(state);
};
}

View File

@ -0,0 +1,13 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
export interface DeleteWorkflowCommand {
type: 'delete-workflow';
workflowId: number;
}
export function createDeleteWorkflowHandler(state: AppState, repo: StateRepository) {
return (cmd: DeleteWorkflowCommand): void => {
state.workflows = (state.workflows as { id: number }[]).filter(w => w.id !== cmd.workflowId);
repo.save(state);
};
}

View File

@ -0,0 +1,65 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { StatType } from '@domain/value-objects/GaugeType';
export interface RenameDragodindeCommand { type: 'rename-dragodinde'; enclosId: number; ddId: number; name: string; }
export interface UpdateDdStatCommand { type: 'update-dd-stat'; enclosId: number; ddId: number; stat: StatType; value: number; }
export interface UpdateDdSerenTargetCmd { type: 'update-dd-seren-target'; enclosId: number; ddId: number; target: number | null; }
export interface UpdateDdLevelTargetCmd { type: 'update-dd-level-target'; enclosId: number; ddId: number; target: number | null; }
export interface ReorderDragodindeCommand { type: 'reorder-dragodinde'; enclosId: number; fromDdId: number; toDdId: number; }
export function createRenameDragodindeHandler(state: AppState, repo: StateRepository) {
return (cmd: RenameDragodindeCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
if (!dd || !cmd.name.trim()) return;
dd.name = cmd.name.trim();
repo.save(state);
};
}
export function createUpdateDdStatHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateDdStatCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
if (!dd) return;
dd.stats[cmd.stat] = cmd.value;
repo.save(state);
};
}
export function createUpdateDdSerenTargetHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateDdSerenTargetCmd): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
if (!dd) return;
dd.sereniteTarget = cmd.target;
repo.save(state);
};
}
export function createUpdateDdLevelTargetHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateDdLevelTargetCmd): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
if (!dd) return;
dd.levelTarget = cmd.target;
repo.save(state);
};
}
export function createReorderDragodindeHandler(state: AppState, repo: StateRepository) {
return (cmd: ReorderDragodindeCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
const fromIdx = enc.dragodindes.findIndex(d => d.id === cmd.fromDdId);
const toIdx = enc.dragodindes.findIndex(d => d.id === cmd.toDdId);
if (fromIdx < 0 || toIdx < 0) return;
const [item] = enc.dragodindes.splice(fromIdx, 1);
enc.dragodindes.splice(toIdx, 0, item!);
repo.save(state);
};
}

View File

@ -0,0 +1,56 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createDragodinde } from '@domain/entities/Dragodinde';
export interface ClearEnclosCommand { type: 'clear-enclos'; enclosId: number; }
export interface RenameEnclosCommand { type: 'rename-enclos'; enclosId: number; name: string; }
export interface ResetTimerCommand { type: 'reset-timer'; enclosId: number; }
export interface NouvelleFourneeCommand { type: 'nouvelle-fournee'; enclosId: number; }
export function createClearEnclosHandler(state: AppState, repo: StateRepository) {
return (cmd: ClearEnclosCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
enc.dragodindes = [];
enc.nextDdId = 1;
enc.activeGauges = [];
enc.gaugeLevels = { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 };
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
enc.alerted = {};
repo.save(state);
};
}
export function createRenameEnclosHandler(state: AppState, repo: StateRepository) {
return (cmd: RenameEnclosCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || !cmd.name.trim()) return;
enc.name = cmd.name.trim();
repo.save(state);
};
}
export function createResetTimerHandler(state: AppState, repo: StateRepository) {
return (cmd: ResetTimerCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
enc.alerted = {};
repo.save(state);
};
}
export function createNouvelleFourneeHandler(state: AppState, repo: StateRepository) {
return (cmd: NouvelleFourneeCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
// Reset timer et état de session
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
enc.alerted = {};
// Remet les niveaux de jauges à 0
enc.gaugeLevels = { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 };
// Vide toutes les DDs et en ajoute une nouvelle avec les stats de base
enc.dragodindes = [createDragodinde(1)];
enc.nextDdId = 2;
repo.save(state);
};
}

View File

@ -0,0 +1,25 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { WorkflowItem } from '@application/queries/GetWorkflows';
export interface ImportWorkflowsCommand {
type: 'import-workflows';
workflows: WorkflowItem[];
}
export function createImportWorkflowsHandler(state: AppState, repo: StateRepository) {
return (cmd: ImportWorkflowsCommand): void => {
const existing = state.workflows as WorkflowItem[];
const existingIds = new Set(existing.map(w => w.id));
for (const wf of cmd.workflows) {
if (existingIds.has(wf.id)) {
// Réattribuer un nouvel id pour éviter les doublons
wf.id = Date.now() + Math.floor(Math.random() * 1000);
}
existing.push(wf);
existingIds.add(wf.id);
}
repo.save(state);
};
}

View File

@ -0,0 +1,35 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { elapsed } from '@domain/services/GaugeCalculator';
export interface RechargeGaugeCommand {
type: 'recharge-gauge';
enclosId: number;
gaugeId: GaugeType;
level: number;
}
export function createRechargeGaugeHandler(state: AppState, repo: StateRepository) {
return (cmd: RechargeGaugeCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || !enc.timer.startTime) return;
const atSec = elapsed(enc.timer);
if (!enc.timer.gaugeRecharges[cmd.gaugeId]) enc.timer.gaugeRecharges[cmd.gaugeId] = [];
// Consolider : si la dernière recharge est à moins de 5s, la remplacer
// (évite de polluer le tableau lors de la saisie en temps réel)
const arr = enc.timer.gaugeRecharges[cmd.gaugeId];
const last = arr.length > 0 ? arr[arr.length - 1] : null;
if (last && Math.abs(atSec - last.atSec) < 2) {
last.atSec = atSec;
last.level = cmd.level;
} else {
arr.push({ atSec, level: cmd.level });
}
// Mettre à jour gaugeLevels pour l'affichage de l'input
enc.gaugeLevels[cmd.gaugeId] = cmd.level;
repo.save(state);
};
}

View File

@ -0,0 +1,23 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createAccouplement } from '@domain/entities/Accouplement';
import type { EventBus } from '@domain/events/EventBus';
import { RACE_GEN } from '@domain/value-objects/Race';
export interface RegisterAccouplementCommand {
type: 'register-accouplement';
parentA: string;
parentB: string;
baby: string;
couples: number;
babiesObtained: number;
}
export function createRegisterAccouplementHandler(state: AppState, repo: StateRepository, events: EventBus) {
return (cmd: RegisterAccouplementCommand): void => {
const gen = RACE_GEN[cmd.baby] ?? 0;
const acc = createAccouplement(cmd.parentA, cmd.parentB, cmd.baby, gen, cmd.couples, cmd.babiesObtained);
state.accouplements.push(acc);
events.emit({ type: 'accouplement-registered', accouplement: acc });
repo.save(state);
};
}

View File

@ -0,0 +1,13 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { removeDragodinde } from '@domain/entities/Enclos';
export interface RemoveDragodindeCommand { type: 'remove-dragodinde'; enclosId: number; ddId: number; }
export function createRemoveDragodindeHandler(state: AppState, repo: StateRepository) {
return (cmd: RemoveDragodindeCommand): void => {
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
if (idx < 0) return;
state.enclos[idx] = removeDragodinde(state.enclos[idx]!, cmd.ddId);
repo.save(state);
};
}

View File

@ -0,0 +1,17 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
export interface ReorderEnclosCommand {
type: 'reorder-enclos';
fromIndex: number;
toIndex: number;
}
export function createReorderEnclosHandler(state: AppState, repo: StateRepository) {
return (cmd: ReorderEnclosCommand): void => {
if (cmd.fromIndex < 0 || cmd.toIndex < 0) return;
if (cmd.fromIndex >= state.enclos.length || cmd.toIndex >= state.enclos.length) return;
const [moved] = state.enclos.splice(cmd.fromIndex, 1);
if (moved) state.enclos.splice(cmd.toIndex, 0, moved);
repo.save(state);
};
}

View File

@ -0,0 +1,11 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
export interface ResetStatsCommand { type: 'reset-stats'; }
export function createResetStatsHandler(state: AppState, repo: StateRepository) {
return (_cmd: ResetStatsCommand): void => {
state.archivedStats = [];
state.accouplements = [];
repo.save(state);
};
}

View File

@ -0,0 +1,56 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { WorkflowItem } from '@application/queries/GetWorkflows';
export interface SaveWorkflowCommand {
type: 'save-workflow';
target: string;
qty: number;
materials: { race: string; m: number; f: number }[];
steps: { baby: string; parentA: string; parentB: string; couples: number; gen: number }[];
repro: Record<string, number>;
}
export function createSaveWorkflowHandler(state: AppState, repo: StateRepository) {
return (cmd: SaveWorkflowCommand): void => {
const workflows = state.workflows as WorkflowItem[];
// Group steps by gen (already sorted gen2 → genN after calcAppro reverse)
const genMap = new Map<number, typeof cmd.steps>();
for (const step of cmd.steps) {
if (!genMap.has(step.gen)) genMap.set(step.gen, []);
genMap.get(step.gen)!.push(step);
}
const wfSteps = Array.from(genMap.entries())
.sort(([a], [b]) => a - b)
.map(([gen, steps]) => ({
gen,
crossings: steps.map(s => ({
race: s.baby,
needed: s.couples,
parentA: s.parentA,
parentB: s.parentB,
couples: s.couples,
repro: cmd.repro[s.baby] ?? 0,
done: 0,
})),
}));
const wf: WorkflowItem = {
id: Date.now(),
name: `${cmd.target} ×${cmd.qty}`,
target: cmd.target,
qty: cmd.qty,
createdAt: Date.now(),
materials: cmd.materials.map(m => ({
name: m.race,
needed: m.m + m.f,
done: 0,
})),
steps: wfSteps,
};
workflows.push(wf);
repo.save(state);
};
}

View File

@ -0,0 +1,59 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
import { xpForLevel } from '@domain/value-objects/XpTable';
export interface StartTimerCommand { type: 'start-timer'; enclosId: number; }
export function createStartTimerHandler(state: AppState, repo: StateRepository) {
return (cmd: StartTimerCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || enc.timer.running) return;
if (!enc.activeGauges.length || !enc.dragodindes.length) return;
// Ne pas démarrer si toutes les jauges actives sont à 0 (aucun point ne sera gagné)
const allGaugesEmpty = enc.activeGauges.every(gid => (enc.gaugeLevels[gid] ?? 0) <= 0);
if (allGaugesEmpty) return;
const now = Date.now();
if (enc.timer.startTime !== null && enc.timer.pausedAt !== null && !enc.alerted['__done__']) {
// Reprise depuis une pause manuelle : accumuler le temps pausé
enc.timer.pausedMs += now - enc.timer.pausedAt;
enc.timer.pausedAt = null;
enc.timer.running = true;
} else {
// Démarrage initial — vérifier qu'au moins une cible n'est pas déjà atteinte
const allTargetsAlreadyMet = enc.dragodindes.every(dd => {
return enc.activeGauges.every(gid => {
const def = GAUGE_DEFS[gid];
const stat = (dd.stats as Record<string, number>)[def.stat] as number;
if (def.isXp) {
const target = dd.levelTarget ?? 200;
return stat >= target;
}
const sd = STAT_DEFS[def.stat];
if (def.stat === 'serenite' && dd.sereniteTarget !== null && dd.sereniteTarget !== undefined) {
return def.dir > 0 ? stat >= dd.sereniteTarget : stat <= dd.sereniteTarget;
}
return def.dir > 0 ? stat >= sd.max : stat <= sd.min;
});
});
if (allTargetsAlreadyMet) return; // Ne pas démarrer si tout est déjà atteint
enc.timer.running = true;
enc.timer.startTime = now;
enc.timer.pausedAt = null;
enc.timer.pausedMs = 0;
enc.timer.snapGauges = { ...enc.gaugeLevels };
enc.timer.gaugeRecharges = {};
const snapStats: Record<string, Record<string, number>> = {};
for (const dd of enc.dragodindes) {
snapStats[dd.id] = { ...dd.stats };
}
enc.timer.snapStats = snapStats;
enc.alerted = {};
}
repo.save(state);
};
}

View File

@ -0,0 +1,13 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
export interface StopTimerCommand { type: 'stop-timer'; enclosId: number; }
export function createStopTimerHandler(state: AppState, repo: StateRepository) {
return (cmd: StopTimerCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || !enc.timer.running) return;
enc.timer.running = false;
enc.timer.pausedAt = Date.now();
repo.save(state);
};
}

View File

@ -0,0 +1,42 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { MAX_GAUGES } from '@domain/entities/Enclos';
export interface ToggleGaugeCommand { type: 'toggle-gauge'; enclosId: number; gaugeId: GaugeType; }
export interface UpdateGaugeLevelCommand { type: 'update-gauge-level'; enclosId: number; gaugeId: GaugeType; level: number; }
export function createToggleGaugeHandler(state: AppState, repo: StateRepository) {
return (cmd: ToggleGaugeCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || enc.timer.running) return;
const i = enc.activeGauges.indexOf(cmd.gaugeId);
if (i >= 0) {
enc.activeGauges.splice(i, 1);
} else {
// Exclusion mutuelle baffeur/caresseur : même stat, directions opposées
const SEREN_PAIR: Record<string, GaugeType> = { baffeur: 'caresseur', caresseur: 'baffeur' };
const opposite = SEREN_PAIR[cmd.gaugeId];
if (opposite) {
const oi = enc.activeGauges.indexOf(opposite);
if (oi >= 0) enc.activeGauges.splice(oi, 1);
}
if (enc.activeGauges.length >= MAX_GAUGES) enc.activeGauges.shift();
enc.activeGauges.push(cmd.gaugeId);
}
// Reset timer if gauge changed after a completed session
if (enc.timer.startTime && !enc.timer.running) {
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} };
enc.alerted = {};
}
repo.save(state);
};
}
export function createUpdateGaugeLevelHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateGaugeLevelCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc) return;
enc.gaugeLevels[cmd.gaugeId] = Math.max(0, Math.min(100000, cmd.level));
repo.save(state);
};
}

View File

@ -0,0 +1,19 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
export interface UpdateSettingsCommand {
type: 'update-settings';
alarmSound?: string;
notifsEnabled?: boolean;
ntfyTopic?: string;
inventaire?: Record<string, { m: number; f: number }>;
}
export function createUpdateSettingsHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateSettingsCommand): void => {
if (cmd.alarmSound !== undefined) state.alarmSound = cmd.alarmSound;
if (cmd.notifsEnabled !== undefined) state.notifsEnabled = cmd.notifsEnabled;
if (cmd.ntfyTopic !== undefined) state.ntfyTopic = cmd.ntfyTopic;
if (cmd.inventaire !== undefined) state.inventaire = cmd.inventaire;
repo.save(state);
};
}

View File

@ -0,0 +1,32 @@
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { WorkflowItem } from '@application/queries/GetWorkflows';
export interface UpdateWorkflowCommand {
type: 'update-workflow';
workflowId: number;
materialIdx?: number;
stepIdx?: number;
crossingIdx?: number;
done: number;
}
export function createUpdateWorkflowHandler(state: AppState, repo: StateRepository) {
return (cmd: UpdateWorkflowCommand): void => {
const workflows = state.workflows as WorkflowItem[];
const wf = workflows.find(w => w.id === cmd.workflowId);
if (!wf) return;
if (cmd.materialIdx !== undefined) {
const mat = wf.materials[cmd.materialIdx];
if (mat) mat.done = Math.max(0, cmd.done);
} else if (cmd.stepIdx !== undefined && cmd.crossingIdx !== undefined) {
const step = wf.steps[cmd.stepIdx];
if (step) {
const crossing = step.crossings[cmd.crossingIdx];
if (crossing) crossing.done = Math.max(0, cmd.done);
}
}
repo.save(state);
};
}

View File

@ -0,0 +1,24 @@
export interface Command {
readonly type: string;
readonly [key: string]: unknown;
}
type CommandHandler<T extends Command = Command> = (cmd: T) => void;
export class CommandBus {
private handlers = new Map<string, CommandHandler>();
register<T extends Command>(type: string, handler: CommandHandler<T>): void {
this.handlers.set(type, handler as CommandHandler);
}
execute(cmd: Command): void {
const handler = this.handlers.get(cmd.type);
if (!handler) throw new Error(`No handler for command: ${cmd.type}`);
handler(cmd);
}
has(type: string): boolean {
return this.handlers.has(type);
}
}

View File

@ -0,0 +1,24 @@
export interface Query {
readonly type: string;
readonly [key: string]: unknown;
}
type QueryHandler<T extends Query = Query, R = unknown> = (query: T) => R;
export class QueryBus {
private handlers = new Map<string, QueryHandler>();
register<T extends Query, R>(type: string, handler: QueryHandler<T, R>): void {
this.handlers.set(type, handler as QueryHandler);
}
execute<R>(query: Query): R {
const handler = this.handlers.get(query.type);
if (!handler) throw new Error(`No handler for query: ${query.type}`);
return handler(query) as R;
}
has(type: string): boolean {
return this.handlers.has(type);
}
}

View File

@ -0,0 +1,15 @@
import { BreedingService, type PartnerInfo } from '@domain/services/BreedingService';
export interface GetBreedingOptionsQuery { type: 'get-breeding-options'; race: string; }
export interface BreedingOptionsResult {
partners: readonly PartnerInfo[];
}
const breedingSvc = new BreedingService();
export function createGetBreedingOptionsHandler() {
return (query: GetBreedingOptionsQuery): BreedingOptionsResult => {
return { partners: breedingSvc.getCompatiblePartners(query.race) };
};
}

View File

@ -0,0 +1,57 @@
import type { AppState } from '@domain/ports/StateRepository';
import { elapsed } from '@domain/services/GaugeCalculator';
export interface DashboardQuery { type: 'get-dashboard'; }
export interface EnclosSummary {
id: number;
name: string;
ddCount: number;
running: boolean;
elapsedSec: number;
activeGauges: string[];
}
export interface DashboardResult {
enclosSummaries: EnclosSummary[];
totalCouples: number;
totalBabies: number;
raceBreakdown: Record<string, number>;
successRate: number;
}
export function createGetDashboardHandler(state: AppState) {
return (_query: DashboardQuery): DashboardResult => {
const summaries: EnclosSummary[] = state.enclos.map(enc => ({
id: enc.id,
name: enc.name,
ddCount: enc.dragodindes.length,
running: enc.timer.running,
elapsedSec: elapsed(enc.timer),
activeGauges: [...enc.activeGauges],
}));
let totalCouples = 0, totalBabies = 0;
const raceBreakdown: Record<string, number> = {};
// From accouplements
for (const acc of state.accouplements) {
totalCouples += acc.couples;
totalBabies += acc.babiesObtained;
raceBreakdown[acc.baby] = (raceBreakdown[acc.baby] ?? 0) + acc.babiesObtained;
}
// From archived stats (legacy migration)
for (const arch of state.archivedStats as Array<{ baby?: string; couples?: number; babiesObtained?: number }>) {
if (arch.baby) {
totalCouples += arch.couples ?? 0;
totalBabies += arch.babiesObtained ?? 0;
raceBreakdown[arch.baby] = (raceBreakdown[arch.baby] ?? 0) + (arch.babiesObtained ?? 0);
}
}
const successRate = totalCouples > 0 ? Math.round((totalBabies / totalCouples) * 100) : 0;
return { enclosSummaries: summaries, totalCouples, totalBabies, raceBreakdown, successRate };
};
}

View File

@ -0,0 +1,10 @@
import type { AppState } from '@domain/ports/StateRepository';
import type { Enclos } from '@domain/entities/Enclos';
export interface GetEnclosDetailQuery { type: 'get-enclos-detail'; enclosId: number; }
export function createGetEnclosDetailHandler(state: AppState) {
return (query: GetEnclosDetailQuery): Enclos | null => {
return state.enclos.find(e => e.id === query.enclosId) ?? null;
};
}

View File

@ -0,0 +1,9 @@
import type { AppState } from '@domain/ports/StateRepository';
export interface GetInventaireQuery { type: 'get-inventaire'; }
export function createGetInventaireHandler(state: AppState) {
return (_query: GetInventaireQuery): Record<string, { m: number; f: number }> => {
return state.inventaire;
};
}

View File

@ -0,0 +1,22 @@
import { ReapproCalculator, type ReapproResult } from '@domain/services/ReapproCalculator';
export interface GetReapproTreeQuery {
type: 'get-reappro-tree';
target: string;
qty: number;
repro: Record<string, number>;
inverted: Record<string, boolean>;
}
const calculator = new ReapproCalculator();
export function createGetReapproTreeHandler() {
return (query: GetReapproTreeQuery): ReapproResult => {
return calculator.compute({
target: query.target,
qty: query.qty,
repro: query.repro,
inverted: query.inverted,
});
};
}

View File

@ -0,0 +1,17 @@
import type { AppState } from '@domain/ports/StateRepository';
export interface GetSettingsQuery { type: 'get-settings'; }
export interface SettingsResult {
alarmSound: string;
notifsEnabled: boolean;
ntfyTopic: string;
}
export function createGetSettingsHandler(state: AppState) {
return (_query: GetSettingsQuery): SettingsResult => ({
alarmSound: state.alarmSound,
notifsEnabled: state.notifsEnabled,
ntfyTopic: state.ntfyTopic,
});
}

View File

@ -0,0 +1,288 @@
import type { AppState } from '@domain/ports/StateRepository';
import { RACE_GEN } from '@domain/value-objects/Race';
// 66 races totales moins 3 Gen 1 (Rousse, Dorée, Amande) qui se capturent et ne se créent pas
export const TOTAL_RACES = 63;
export interface GetStatisticsQuery {
type: 'get-statistics';
days?: number; // 0 = tout l'historique
}
export interface DailyBirths {
date: string;
label: string;
count: number;
}
export interface RaceShare {
race: string;
count: number;
pct: number;
}
export interface KpiDelta {
value: number;
delta: number | null;
}
export interface RaceSuccessRate {
race: string;
couples: number;
babies: number;
rate: number;
}
export interface BestCouple {
parentA: string;
parentB: string;
baby: string;
couples: number;
babies: number;
rate: number;
}
export interface GenBreakdown {
gen: number;
babies: number;
couples: number;
races: number;
}
export interface MissingRace {
name: string;
gen: number;
}
export interface WeekdayActivity {
day: string;
count: number;
}
export interface StatisticsResult {
totalBabies: KpiDelta;
totalCouples: KpiDelta;
successRate: KpiDelta;
racesCount: KpiDelta;
dailyBirths: DailyBirths[];
raceShares: RaceShare[];
raceSuccessRates: RaceSuccessRate[];
bestCouples: BestCouple[];
genBreakdown: GenBreakdown[];
missingRaces: MissingRace[];
weekdayActivity: WeekdayActivity[];
days: number;
}
interface AccEntry {
parentA: string;
parentB: string;
baby: string;
gen: number;
couples: number;
babiesObtained: number;
date: string;
}
function toISO(d: Date): string {
return d.toISOString().slice(0, 10);
}
function aggregate(entries: AccEntry[]) {
let couples = 0, babies = 0;
const races = new Set<string>();
for (const e of entries) {
couples += e.couples;
babies += e.babiesObtained;
if (e.babiesObtained > 0) races.add(e.baby);
}
const rate = couples > 0 ? Math.round((babies / couples) * 100) : 0;
return { couples, babies, rate, racesCount: races.size };
}
const WEEKDAY_NAMES = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
export function createGetStatisticsHandler(state: AppState) {
return (query: GetStatisticsQuery): StatisticsResult => {
const days = query.days ?? 30;
// Collecter toutes les entrées normalisées (exclure Gen 1 : non créables, seulement capturables)
const all: AccEntry[] = [];
for (const acc of state.accouplements) {
if (acc.gen === 1 || (RACE_GEN[acc.baby] ?? 0) === 1) continue;
all.push({
parentA: acc.parentA, parentB: acc.parentB,
baby: acc.baby, gen: acc.gen,
couples: acc.couples, babiesObtained: acc.babiesObtained,
date: acc.date,
});
}
for (const arch of state.archivedStats as Array<{
parentA?: string; parentB?: string; baby?: string; gen?: number;
couples?: number; babiesObtained?: number; date?: string;
}>) {
if (arch.baby) {
const gen = arch.gen ?? (RACE_GEN[arch.baby] ?? 0);
if (gen === 1) continue;
all.push({
parentA: arch.parentA ?? '', parentB: arch.parentB ?? '',
baby: arch.baby, gen,
couples: arch.couples ?? 0,
babiesObtained: arch.babiesObtained ?? 0,
date: arch.date ?? '',
});
}
}
const now = new Date();
const todayISO = toISO(now);
let current: AccEntry[];
let previous: AccEntry[] | null;
if (days === 0) {
current = all;
previous = null;
} else {
const startCurrent = new Date(now);
startCurrent.setDate(startCurrent.getDate() - days);
const startCurrentISO = toISO(startCurrent);
const startPrevious = new Date(startCurrent);
startPrevious.setDate(startPrevious.getDate() - days);
const startPreviousISO = toISO(startPrevious);
current = all.filter(e => {
const d = e.date.slice(0, 10);
return d >= startCurrentISO && d <= todayISO;
});
previous = all.filter(e => {
const d = e.date.slice(0, 10);
return d >= startPreviousISO && d < startCurrentISO;
});
}
const cur = aggregate(current);
const prev = previous ? aggregate(previous) : null;
function delta(curVal: number, prevVal: number | null): KpiDelta {
if (prevVal === null) return { value: curVal, delta: null };
return { value: curVal, delta: curVal - prevVal };
}
// ── Naissances par jour ──────────────────────────────────────
const dailyMap: Record<string, number> = {};
for (const e of current) {
const day = e.date.slice(0, 10);
if (day) dailyMap[day] = (dailyMap[day] ?? 0) + e.babiesObtained;
}
const chartDays = days === 0 ? 30 : days;
const dailyBirths: DailyBirths[] = [];
for (let i = chartDays - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const iso = toISO(d);
const label = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
dailyBirths.push({ date: iso, label, count: dailyMap[iso] ?? 0 });
}
// ── Répartition des races ────────────────────────────────────
const raceBreakdown: Record<string, number> = {};
let totalBabiesForShares = 0;
for (const e of current) {
raceBreakdown[e.baby] = (raceBreakdown[e.baby] ?? 0) + e.babiesObtained;
totalBabiesForShares += e.babiesObtained;
}
const raceShares: RaceShare[] = Object.entries(raceBreakdown)
.sort((a, b) => b[1] - a[1])
.map(([race, count]) => ({
race, count,
pct: totalBabiesForShares > 0 ? Math.round((count / totalBabiesForShares) * 100) : 0,
}));
// ── Taux de réussite par race ────────────────────────────────
const raceAgg: Record<string, { couples: number; babies: number }> = {};
for (const e of current) {
if (!raceAgg[e.baby]) raceAgg[e.baby] = { couples: 0, babies: 0 };
raceAgg[e.baby].couples += e.couples;
raceAgg[e.baby].babies += e.babiesObtained;
}
const raceSuccessRates: RaceSuccessRate[] = Object.entries(raceAgg)
.map(([race, { couples, babies }]) => ({
race, couples, babies,
rate: couples > 0 ? Math.round((babies / couples) * 100) : 0,
}))
.sort((a, b) => b.rate - a.rate);
// ── Meilleurs couples ────────────────────────────────────────
const coupleAgg: Record<string, { parentA: string; parentB: string; baby: string; couples: number; babies: number }> = {};
for (const e of current) {
if (!e.parentA || !e.parentB) continue;
// Clé normalisée (ordre alphabétique) pour éviter les doublons A+B / B+A
const key = [e.parentA, e.parentB].sort().join('|');
if (!coupleAgg[key]) {
coupleAgg[key] = { parentA: e.parentA, parentB: e.parentB, baby: e.baby, couples: 0, babies: 0 };
}
coupleAgg[key].couples += e.couples;
coupleAgg[key].babies += e.babiesObtained;
}
const bestCouples: BestCouple[] = Object.values(coupleAgg)
.map(c => ({ ...c, rate: c.couples > 0 ? Math.round((c.babies / c.couples) * 100) : 0 }))
.sort((a, b) => b.rate - a.rate || b.babies - a.babies)
.slice(0, 10);
// ── Répartition par génération ───────────────────────────────
const genAgg: Record<number, { babies: number; couples: number; races: Set<string> }> = {};
for (const e of current) {
const g = e.gen || (RACE_GEN[e.baby] ?? 0);
if (!g) continue;
if (!genAgg[g]) genAgg[g] = { babies: 0, couples: 0, races: new Set() };
genAgg[g].babies += e.babiesObtained;
genAgg[g].couples += e.couples;
if (e.babiesObtained > 0) genAgg[g].races.add(e.baby);
}
const genBreakdown: GenBreakdown[] = Object.entries(genAgg)
.map(([g, v]) => ({ gen: Number(g), babies: v.babies, couples: v.couples, races: v.races.size }))
.sort((a, b) => a.gen - b.gen);
// ── Races manquantes ─────────────────────────────────────────
const obtainedAll = new Set<string>();
for (const e of all) {
if (e.babiesObtained > 0) obtainedAll.add(e.baby);
}
const missingRaces: MissingRace[] = Object.entries(RACE_GEN)
.filter(([name, gen]) => gen !== 1 && !obtainedAll.has(name))
.map(([name, gen]) => ({ name, gen }))
.sort((a, b) => a.gen - b.gen || a.name.localeCompare(b.name));
// ── Activité par jour de la semaine ──────────────────────────
const weekdayCounts = [0, 0, 0, 0, 0, 0, 0];
for (const e of current) {
const d = e.date.slice(0, 10);
if (!d) continue;
const dt = new Date(d + 'T12:00:00');
weekdayCounts[dt.getDay()] += e.babiesObtained;
}
// Réordonner : Lundi → Dimanche
const weekdayActivity: WeekdayActivity[] = [];
for (let i = 1; i <= 7; i++) {
const idx = i % 7;
weekdayActivity.push({ day: WEEKDAY_NAMES[idx], count: weekdayCounts[idx] });
}
return {
totalBabies: delta(cur.babies, prev?.babies ?? null),
totalCouples: delta(cur.couples, prev?.couples ?? null),
successRate: delta(cur.rate, prev?.rate ?? null),
racesCount: delta(cur.racesCount, prev?.racesCount ?? null),
dailyBirths,
raceShares,
raceSuccessRates,
bestCouples,
genBreakdown,
missingRaces,
weekdayActivity,
days,
};
};
}

View File

@ -0,0 +1,22 @@
import type { AppState } from '@domain/ports/StateRepository';
import { elapsed } from '@domain/services/GaugeCalculator';
export interface GetTimerStateQuery { type: 'get-timer-state'; enclosId: number; }
export interface TimerStateResult {
running: boolean;
elapsedSec: number;
startTime: number | null;
}
export function createGetTimerStateHandler(state: AppState) {
return (query: GetTimerStateQuery): TimerStateResult | null => {
const enc = state.enclos.find(e => e.id === query.enclosId);
if (!enc) return null;
return {
running: enc.timer.running,
elapsedSec: elapsed(enc.timer),
startTime: enc.timer.startTime,
};
};
}

View File

@ -0,0 +1,20 @@
import type { AppState } from '@domain/ports/StateRepository';
export interface GetWorkflowsQuery { type: 'get-workflows'; }
export interface WorkflowItem {
id: number;
name: string;
target: string;
qty: number;
createdAt: number;
materials: Array<{ name: string; needed: number; done: number }>;
steps: Array<{
gen: number;
crossings: Array<{ race: string; needed: number; parentA: string; parentB: string; couples: number; repro: number; done: number }>;
}>;
}
export function createGetWorkflowsHandler(state: AppState) {
return (_query: GetWorkflowsQuery): WorkflowItem[] => state.workflows as WorkflowItem[];
}

View File

@ -0,0 +1,16 @@
export interface Accouplement {
readonly parentA: string;
readonly parentB: string;
readonly baby: string;
readonly gen: number;
readonly couples: number;
readonly babiesObtained: number;
readonly date: string;
}
export function createAccouplement(
parentA: string, parentB: string, baby: string, gen: number,
couples: number, babiesObtained: number
): Accouplement {
return { parentA, parentB, baby, gen, couples, babiesObtained, date: new Date().toISOString() };
}

View File

@ -0,0 +1,37 @@
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType';
import type { Gender } from '@domain/value-objects/Gender';
export interface DragodindeStats {
serenite: number;
endurance: number;
maturite: number;
amour: number;
xp: number;
}
export interface Dragodinde {
readonly id: number;
name: string;
race: string;
gender: Gender;
stats: DragodindeStats;
targets: Record<GaugeType, number>;
sereniteTarget: number | null;
levelTarget: number | null;
reproducteur: number;
}
export function createDragodinde(id: number): Dragodinde {
return {
id,
name: `Dragodinde ${id}`,
race: '',
gender: 'n',
stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 },
targets: { ...DEFAULT_TARGETS },
sereniteTarget: null,
levelTarget: null,
reproducteur: 0,
};
}

View File

@ -0,0 +1,59 @@
import type { Dragodinde } from './Dragodinde';
import { createDragodinde } from './Dragodinde';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export const MAX_DD = 10;
export const MAX_ENCLOS = 6;
export const MAX_GAUGES = 2;
export interface GaugeRecharge {
atSec: number; // secondes écoulées au moment de la recharge
level: number; // nouveau niveau de jauge
}
export interface TimerData {
running: boolean;
startTime: number | null;
pausedAt: number | null;
pausedMs: number;
snapGauges: Record<string, number>;
snapStats: Record<string, Record<string, number>>;
gaugeRecharges: Record<string, GaugeRecharge[]>;
}
export interface Enclos {
readonly id: number;
name: string;
activeGauges: GaugeType[];
gaugeLevels: Record<GaugeType, number>;
dragodindes: Dragodinde[];
nextDdId: number;
timer: TimerData;
alerted: Record<string, boolean>;
}
export function createEnclos(id: number, name?: string): Enclos {
return {
id,
name: name ?? `Enclos ${id}`,
activeGauges: [],
gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
dragodindes: [],
nextDdId: 1,
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} },
alerted: {},
};
}
export function addDragodinde(enc: Enclos): Enclos {
if (enc.dragodindes.length >= MAX_DD) return enc;
const usedIds = new Set(enc.dragodindes.map(d => d.id));
let newId = 1;
while (usedIds.has(newId)) newId++;
const dd = createDragodinde(newId);
return { ...enc, dragodindes: [...enc.dragodindes, dd], nextDdId: newId + 1 };
}
export function removeDragodinde(enc: Enclos, ddId: number): Enclos {
return { ...enc, dragodindes: enc.dragodindes.filter(d => d.id !== ddId) };
}

View File

@ -0,0 +1,6 @@
export type DomainEventType = 'timer-completed' | 'gauge-threshold-reached' | 'accouplement-registered' | 'enclos-deleted';
export interface DomainEvent {
readonly type: DomainEventType;
readonly [key: string]: unknown;
}

View File

@ -0,0 +1,24 @@
import type { DomainEvent, DomainEventType } from './DomainEvent';
type Handler = (event: DomainEvent) => void;
export class EventBus {
private handlers = new Map<DomainEventType, Handler[]>();
on(type: DomainEventType, handler: Handler): void {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type)!.push(handler);
}
off(type: DomainEventType, handler: Handler): void {
const list = this.handlers.get(type);
if (!list) return;
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
}
emit(event: DomainEvent): void {
const handlers = this.handlers.get(event.type) ?? [];
for (const h of handlers) h(event);
}
}

View File

@ -0,0 +1,4 @@
export interface AlarmPort {
play(soundName: string): void;
stop(): void;
}

View File

@ -0,0 +1,4 @@
export interface NotificationPort {
showNotification(title: string, body: string): void;
sendMobileNotification(url: string, title: string, message: string): void;
}

View File

@ -0,0 +1,20 @@
import type { Enclos } from '@domain/entities/Enclos';
import type { Accouplement } from '@domain/entities/Accouplement';
export interface AppState {
enclos: Enclos[];
activeId: number | string | null;
nextEnclosId: number;
alarmSound: string;
notifsEnabled: boolean;
ntfyTopic: string;
archivedStats: unknown[];
inventaire: Record<string, { m: number; f: number }>;
workflows: unknown[];
accouplements: Accouplement[];
}
export interface StateRepository {
load(): Promise<AppState | null>;
save(state: AppState): void;
}

View File

@ -0,0 +1,11 @@
export interface UpdateInfo {
version: string;
downloadUrl: string;
assetName: string;
releaseNotes: string;
}
export interface UpdatePort {
checkForUpdates(): Promise<UpdateInfo | null>;
downloadAndInstall(info: UpdateInfo): void;
}

View File

@ -0,0 +1,28 @@
import { BREEDING_RECIPES, BREEDING_BY_PARENTS, COMPATIBLE_PARTNERS, RACE_GEN } from '@domain/value-objects/Race';
export interface PartnerInfo {
readonly partner: string;
readonly baby: string;
readonly gen: number;
}
export class BreedingService {
deduceBaby(parent1: string, parent2: string): string | null {
return BREEDING_BY_PARENTS[`${parent1}|${parent2}`]
?? BREEDING_BY_PARENTS[`${parent2}|${parent1}`]
?? null;
}
getCompatiblePartners(race: string): readonly PartnerInfo[] {
return (COMPATIBLE_PARTNERS[race] as PartnerInfo[] | undefined) ?? [];
}
getParents(babyRace: string): readonly [string, string] | null {
const recipe = BREEDING_RECIPES[babyRace];
return recipe ? [recipe[0], recipe[1]] : null;
}
getGeneration(race: string): number {
return RACE_GEN[race] ?? 0;
}
}

View File

@ -0,0 +1,151 @@
export interface TimerState {
readonly startTime: number | null;
readonly running: boolean;
readonly pausedAt: number | null;
readonly pausedMs: number;
}
/**
* Points gained when a gauge of given level runs for `sec` seconds.
* Gauge level decreases through tiers: 90k40, 70k30, 40k20, 010 pts per 10sec tick.
*/
export function gainedIn(lvl: number, sec: number): number {
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let g = Math.min(Math.max(lvl, 0), 100000);
let tl = Math.floor(sec / 10);
let out = 0;
for (const { lo, r } of T) {
if (g <= lo || tl <= 0) continue;
const a = g - lo;
const m = Math.floor(a / r);
const u = Math.min(m, tl);
out += u * r;
tl -= u;
g = lo;
}
return out;
}
/**
* Seconds needed to gain `pts` points starting from gauge level `lvl`.
* Returns Infinity if impossible (level too low).
*/
export function timeToGain(lvl: number, pts: number): number {
if (pts <= 0) return 0;
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let s = 0, rem = pts;
let g = Math.min(Math.max(lvl, 0), 100000);
for (const { lo, r } of T) {
if (g <= lo || rem <= 0) continue;
const a = g - lo;
const d = Math.min(rem, a);
s += Math.ceil(d / r) * 10;
rem -= d;
g = lo;
}
return rem > 0 ? Infinity : s;
}
/**
* Gauge level after `sec` seconds of depletion.
*/
export function gaugeAfter(lvl: number, sec: number): number {
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let g = Math.min(Math.max(lvl, 0), 100000);
let tl = Math.floor(sec / 10);
for (const { lo, r } of T) {
if (g <= lo || tl <= 0) continue;
const a = g - lo;
const m = Math.floor(a / r);
const u = Math.min(m, tl);
g -= u * r;
tl -= u;
}
return g;
}
export interface GaugeRecharge {
readonly atSec: number;
readonly level: number;
}
export interface GaugeState {
gained: number; // points accumulés depuis le snapshot
curGl: number; // niveau de jauge actuel (après dépletion et recharges)
effectiveEl: number; // elapsed effectif (gelé au cap si atteint)
}
/**
* Calcule les points accumulés et le niveau de jauge actuel en tenant compte
* des recharges intermédiaires et du cap absolu (ptsAllowed).
*
* Algorithme segment par segment :
* Pour chaque segment [prevEl recharge.atSec] :
* - gainedIn(prevGl, segDur) points gagnés
* - Si le cap est atteint dans ce segment freeze ici
* - Sinon, continuer avec le nouveau niveau après recharge
*/
export function computeGaugeState(
startGl: number,
recharges: readonly GaugeRecharge[],
ptsAllowed: number,
el: number,
): GaugeState {
let gained = 0;
let prevEl = 0;
let prevGl = startGl;
const sorted = recharges.filter(r => r.atSec < el).sort((a, b) => a.atSec - b.atSec);
for (const r of sorted) {
const segDur = r.atSec - prevEl;
const segGained = gainedIn(prevGl, segDur);
if (isFinite(ptsAllowed) && gained + segGained >= ptsAllowed) {
const ptsNeeded = ptsAllowed - gained;
const secInSeg = timeToGain(prevGl, ptsNeeded);
return { gained: ptsAllowed, curGl: Math.max(0, gaugeAfter(prevGl, secInSeg)), effectiveEl: prevEl + secInSeg };
}
gained += segGained;
prevEl = r.atSec;
prevGl = r.level;
}
// Dernier segment (de la dernière recharge jusqu'à el)
const lastDur = el - prevEl;
const lastGained = gainedIn(prevGl, lastDur);
if (isFinite(ptsAllowed) && gained + lastGained >= ptsAllowed) {
const ptsNeeded = ptsAllowed - gained;
const secInSeg = timeToGain(prevGl, ptsNeeded);
return { gained: ptsAllowed, curGl: Math.max(0, gaugeAfter(prevGl, secInSeg)), effectiveEl: prevEl + secInSeg };
}
return { gained: gained + lastGained, curGl: Math.max(0, gaugeAfter(prevGl, lastDur)), effectiveEl: el };
}
/**
* Elapsed seconds for a timer state.
*/
export function elapsed(timer: TimerState): number {
if (!timer.startTime) return 0;
if (timer.running) return (Date.now() - timer.startTime - timer.pausedMs) / 1000;
if (timer.pausedAt) return (timer.pausedAt - timer.startTime - timer.pausedMs) / 1000;
return 0;
}

View File

@ -0,0 +1,69 @@
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
export interface Stock { m: number; f: number; n: number; }
export interface CrossingDetail { aSex: string; bSex: string; }
export interface CrossingResult { name: string; qty: number; parents: readonly [string, string]; gen: number; details: CrossingDetail[]; }
export interface GenerationResult { gen: number; crossings: CrossingResult[]; }
export interface InventaireResult { generations: GenerationResult[]; remaining: Record<string, Stock>; }
export class InventaireCalculator {
compute(inventaire: Readonly<Record<string, { m: number; f: number }>>): InventaireResult {
const avail: Record<string, Stock> = {};
for (const [k, v] of Object.entries(inventaire)) {
if ((v.m + v.f) > 0) avail[k] = { m: v.m, f: v.f, n: 0 };
}
if (Object.keys(avail).length === 0) return { generations: [], remaining: {} };
const hasMale = (s: Stock) => s.m > 0 || s.n > 0;
const hasFemale = (s: Stock) => s.f > 0 || s.n > 0;
const takeMale = (s: Stock) => { if (s.m > 0) s.m--; else s.n--; };
const takeFemale = (s: Stock) => { if (s.f > 0) s.f--; else s.n--; };
const totalOf = (s: Stock) => s.m + s.f + s.n;
const generations: GenerationResult[] = [];
for (let gen = 2; gen <= 10; gen++) {
const crossingsAtGen = Object.entries(BREEDING_RECIPES)
.filter(([name]) => (RACE_GEN[name] ?? 0) === gen)
.map(([name, parents]) => ({ name, parents }));
const genResults: CrossingResult[] = [];
let more = true;
while (more) {
more = false;
for (const cr of crossingsAtGen) {
const [a, b] = cr.parents;
const sa = avail[a] ?? { m: 0, f: 0, n: 0 };
const sb = avail[b] ?? { m: 0, f: 0, n: 0 };
let ok = false, aSex = '', bSex = '';
if (a === b) {
if (totalOf(sa) >= 2 && hasMale(sa) && hasFemale(sa)) {
takeMale(sa); takeFemale(sa); aSex = '\u2642'; bSex = '\u2640'; ok = true;
}
} else if (hasMale(sa) && hasFemale(sb)) {
takeMale(sa); takeFemale(sb); aSex = '\u2642'; bSex = '\u2640'; ok = true;
} else if (hasFemale(sa) && hasMale(sb)) {
takeFemale(sa); takeMale(sb); aSex = '\u2640'; bSex = '\u2642'; ok = true;
}
if (ok) {
if (!avail[a]) avail[a] = sa;
if (!avail[b]) avail[b] = sb;
if (!avail[cr.name]) avail[cr.name] = { m: 0, f: 0, n: 0 };
avail[cr.name]!.n++;
let entry = genResults.find(r => r.name === cr.name);
if (!entry) { entry = { name: cr.name, qty: 0, parents: cr.parents as [string, string], gen, details: [] }; genResults.push(entry); }
entry.qty++;
entry.details.push({ aSex, bSex });
more = true;
}
}
}
if (genResults.length > 0) generations.push({ gen, crossings: genResults });
}
return { generations, remaining: avail };
}
}

View File

@ -0,0 +1,80 @@
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
export interface ReapproInput {
target: string;
qty: number;
repro: Readonly<Record<string, number>>;
inverted: Readonly<Record<string, boolean>>;
}
export interface ReapproStep {
race: string;
gen: number;
qty: number;
couples: number;
parentA: string;
parentB: string;
repro: number;
}
export interface Gen1Need {
name: string;
total: number;
m: number;
f: number;
}
export interface ReapproResult {
steps: readonly ReapproStep[];
gen1Needs: readonly Gen1Need[];
totalGen1: number;
}
export class ReapproCalculator {
compute(input: ReapproInput): ReapproResult {
const { target, qty, repro, inverted } = input;
if (!target || !BREEDING_RECIPES[target]) return { steps: [], gen1Needs: [], totalGen1: 0 };
const needs: Record<string, { total: number; m: number; f: number }> = {};
needs[target] = { total: qty, m: 0, f: 0 };
const steps: ReapproStep[] = [];
const processed = new Set<string>();
const targetGen = RACE_GEN[target] ?? 2;
for (let gen = targetGen; gen >= 2; gen--) {
const racesAtGen = Object.keys(needs).filter(
r => !processed.has(r) && (RACE_GEN[r] ?? 0) === gen && !!BREEDING_RECIPES[r]
);
for (const race of racesAtGen) {
const Q = needs[race]!.total;
if (Q <= 0) { processed.add(race); continue; }
const R = repro[race] ?? 0;
const couplesReal = (2 * R >= Q) ? Math.ceil(Q / 2) : (R > 0 ? Q - R : Q);
const isInv = inverted[race] ?? false;
const [rawA, rawB] = BREEDING_RECIPES[race]!;
const a = isInv ? rawB : rawA;
const b = isInv ? rawA : rawB;
if (!needs[a]) needs[a] = { total: 0, m: 0, f: 0 };
if (!needs[b]) needs[b] = { total: 0, m: 0, f: 0 };
needs[a]!.total += couplesReal;
needs[a]!.m += couplesReal;
needs[b]!.total += couplesReal;
needs[b]!.f += couplesReal;
steps.push({ race, gen, qty: Q, couples: couplesReal, parentA: a, parentB: b, repro: R });
processed.add(race);
}
}
const gen1Needs: Gen1Need[] = Object.entries(needs)
.filter(([n]) => !BREEDING_RECIPES[n] && needs[n]!.total > 0)
.sort((a, b) => b[1].total - a[1].total)
.map(([name, d]) => ({ name, total: d.total, m: d.m, f: d.f }));
const totalGen1 = gen1Needs.reduce((s, n) => s + n.total, 0);
return { steps, gen1Needs, totalGen1 };
}
}

View File

@ -0,0 +1,34 @@
import { timeToGain } from './GaugeCalculator';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export interface SerenityEtaInput {
currentSerenite: number;
target: number | null;
activeGauges: readonly string[];
gaugeLevels: Readonly<Record<string, number>>;
}
export interface EtaResult {
done: boolean;
seconds: number;
needsGauge?: GaugeType;
}
export class SerenityCalculator {
computeEta(input: SerenityEtaInput): EtaResult {
const { currentSerenite, target, activeGauges, gaugeLevels } = input;
if (target === null || target === undefined) return { done: false, seconds: 0 };
const diff = target - currentSerenite;
if (diff === 0) return { done: true, seconds: 0 };
const needUp = diff > 0;
const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';
if (!activeGauges.includes(gid)) return { done: false, seconds: Infinity, needsGauge: gid };
const gl = gaugeLevels[gid] ?? 0;
const pts = Math.abs(diff);
const sec = timeToGain(gl, pts);
return { done: false, seconds: sec };
}
}

View File

@ -0,0 +1,108 @@
import { RACES_DATA, BREEDING_RECIPES } from '@domain/value-objects/Race';
export interface SimulationCrossing {
parentA: string;
parentB: string;
baby: string;
gen: number;
count: number;
pAMale: number;
pAFemale: number;
pBMale: number;
pBFemale: number;
}
export interface SimulationResult {
crossings: SimulationCrossing[];
unusedStock: { race: string; m: number; f: number }[];
}
/**
* Simule tous les croisements possibles depuis un inventaire en stock.
*
* Algorithme d'allocation proportionnelle dynamique :
* Pour chaque croisement à une génération donnée, le stock de chaque parent
* est divisé équitablement par le nombre de croisements restants qui utilisent
* encore ce parent. Cela évite qu'un premier croisement épuise tout le stock
* et prive les suivants (ex. : 3 races Gen2 partagent les mêmes parents de base).
*
* Les deux configurations sont utilisées simultanément :
* c1 = min(A_alloué, B_alloué) A × B
* c2 = min(A_allouée, B_alloué) A × B
* bred = c1 + c2
*/
export function simulateStock(
inventaire: Readonly<Record<string, { m: number; f: number }>>,
): SimulationResult {
const stock: Record<string, { m: number; f: number }> = {};
for (const [race, entry] of Object.entries(inventaire)) {
if (entry.m > 0 || entry.f > 0) stock[race] = { m: entry.m, f: entry.f };
}
const crossings: SimulationCrossing[] = [];
for (let g = 2; g <= 10; g++) {
const racesAtGen = RACES_DATA[g];
if (!racesAtGen) continue;
// Crossings possibles à cette génération (les deux parents ont du stock)
const genCrossings: { baby: string; parentA: string; parentB: string }[] = [];
for (const raceData of racesAtGen) {
const recipe = BREEDING_RECIPES[raceData.name];
if (!recipe) continue;
const [parentA, parentB] = recipe;
const sA = stock[parentA];
const sB = stock[parentB];
if (!sA || !sB || sA.m + sA.f <= 0 || sB.m + sB.f <= 0) continue;
genCrossings.push({ baby: raceData.name, parentA, parentB });
}
for (let i = 0; i < genCrossings.length; i++) {
const { baby, parentA, parentB } = genCrossings[i]!;
const sA = stock[parentA];
const sB = stock[parentB];
if (!sA || !sB || sA.m + sA.f <= 0 || sB.m + sB.f <= 0) continue;
// Croisements restants dont les deux parents ont encore du stock
const remaining = genCrossings.slice(i).filter(c => {
const a = stock[c.parentA];
const b = stock[c.parentB];
return a && b && a.m + a.f > 0 && b.m + b.f > 0;
});
const countA = remaining.filter(c => c.parentA === parentA || c.parentB === parentA).length;
const countB = remaining.filter(c => c.parentA === parentB || c.parentB === parentB).length;
const allocAm = Math.floor(sA.m / countA);
const allocAf = Math.floor(sA.f / countA);
const allocBm = Math.floor(sB.m / countB);
const allocBf = Math.floor(sB.f / countB);
const c1 = Math.min(allocAm, allocBf); // ♂A × ♀B
const c2 = Math.min(allocAf, allocBm); // ♀A × ♂B
const bred = c1 + c2;
if (bred > 0) {
sA.m -= c1; sB.f -= c1;
sA.f -= c2; sB.m -= c2;
crossings.push({
parentA, parentB, baby, gen: g, count: bred,
pAMale: c1, pAFemale: c2,
pBMale: c2, pBFemale: c1,
});
if (!stock[baby]) stock[baby] = { m: 0, f: 0 };
const halfM = Math.ceil(bred / 2);
stock[baby].m += halfM;
stock[baby].f += bred - halfM;
}
}
}
const unusedStock = Object.entries(stock)
.filter(([, s]) => s.m > 0 || s.f > 0)
.map(([race, s]) => ({ race, m: s.m, f: s.f }));
return { crossings, unusedStock };
}

View File

@ -0,0 +1,32 @@
import { timeToGain } from '@domain/services/GaugeCalculator';
import { xpForLevel } from '@domain/value-objects/XpTable';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export interface XpEtaInput {
currentLevel: number;
target: number | null;
gaugeLevels: Readonly<Record<string, number>>;
activeGauges: readonly string[];
}
export interface XpEtaResult {
done: boolean;
seconds: number;
needsGauge?: GaugeType;
}
export class XpCalculator {
computeEta(input: XpEtaInput): XpEtaResult {
const { currentLevel, target, gaugeLevels, activeGauges } = input;
if (target === null || target === undefined) return { done: false, seconds: 0 };
if (currentLevel >= target) return { done: true, seconds: 0 };
if (!activeGauges.includes('mangeoire')) return { done: false, seconds: Infinity, needsGauge: 'mangeoire' };
const gl = gaugeLevels['mangeoire'] ?? 0;
const xpNeeded = Math.max(0, xpForLevel(target) - xpForLevel(currentLevel));
if (xpNeeded <= 0) return { done: true, seconds: 0 };
const sec = timeToGain(gl, xpNeeded);
return { done: false, seconds: sec };
}
}

View File

@ -0,0 +1,49 @@
export type GaugeType = 'baffeur' | 'caresseur' | 'foudroyeur' | 'abreuvoir' | 'dragofesse' | 'mangeoire';
export type StatType = 'serenite' | 'endurance' | 'maturite' | 'amour' | 'xp';
export interface GaugeDef {
readonly label: string;
readonly icon: string;
readonly cssVar: string;
readonly stat: StatType;
readonly dir: -1 | 1;
readonly isXp?: true;
}
export const GAUGE_DEFS: Readonly<Record<GaugeType, GaugeDef>> = {
baffeur: { label: 'Baffeur', icon: '', cssVar: '--ser', stat: 'serenite', dir: -1 },
caresseur: { label: 'Caresseur', icon: '', cssVar: '--ser', stat: 'serenite', dir: 1 },
foudroyeur: { label: 'Foudroyeur', icon: '⚡', cssVar: '--end', stat: 'endurance', dir: 1 },
abreuvoir: { label: 'Abreuvoir', icon: '💧', cssVar: '--mat', stat: 'maturite', dir: 1 },
dragofesse: { label: 'Dragofesse', icon: '❤', cssVar: '--amour', stat: 'amour', dir: 1 },
mangeoire: { label: 'Mangeoire', icon: '🍖', cssVar: '--xp', stat: 'xp', dir: 1, isXp: true },
};
export interface StatDef {
readonly label: string;
readonly min: number;
readonly max: number;
readonly cssVar: string;
readonly isLevel?: true;
}
export const STAT_DEFS: Readonly<Record<StatType, StatDef>> = {
serenite: { label: 'Sérénité', min: -5000, max: 5000, cssVar: '--ser' },
endurance: { label: 'Endurance', min: 0, max: 20000, cssVar: '--end' },
maturite: { label: 'Maturité', min: 0, max: 20000, cssVar: '--mat' },
amour: { label: 'Amour', min: 0, max: 20000, cssVar: '--amour' },
xp: { label: 'Niveau', min: 1, max: 200, cssVar: '--xp', isLevel: true },
};
export const DEFAULT_TARGETS: Readonly<Record<GaugeType, number>> = {
baffeur: -5000, caresseur: 40, foudroyeur: 20000, abreuvoir: 20000, dragofesse: 20000, mangeoire: 100,
};
export function targetRange(gid: GaugeType): { min: number; max: number } {
const def = GAUGE_DEFS[gid];
if (def.isXp) return { min: 1, max: 200 };
const sd = STAT_DEFS[def.stat];
if (def.dir < 0) return { min: sd.min, max: 0 };
if (def.dir > 0 && sd.min < 0) return { min: 0, max: sd.max };
return { min: sd.min, max: sd.max };
}

View File

@ -0,0 +1 @@
export type Gender = 'm' | 'f' | 'n';

View File

@ -0,0 +1,257 @@
// ══════════════════════════════════════════
// Race Value Object — extracted from index.html
// ══════════════════════════════════════════
export interface RaceData {
name: string;
stats: string[];
parents: string;
icon: string;
}
// Generation → color
export const GEN_COLORS: Record<number, string> = {
1: '#c8622a',
2: '#e8b820',
3: '#6040b0',
4: '#2a8acc',
5: '#c03050',
6: '#d040a0',
7: '#c8c0a0',
8: '#20a8b0',
9: '#28a058',
10: '#8050a0',
};
// Base race → primary color
export const RACE_BASE_COLORS: Record<string, string> = {
'Rousse': '#c8622a',
'Amande': '#d4b48a',
'Dorée': '#e8b820',
'Ebène': '#2a2a2a',
'Indigo': '#6040b0',
'Pourpre': '#c03050',
'Orchidée': '#d040a0',
'Ivoire': '#c8c0a0',
'Turquoise': '#20a8b0',
'Emeraude': '#28a058',
'Prune': '#8050a0',
};
// All race definitions by generation
export const RACES_DATA: Record<number, RaceData[]> = {
2: [
{ name: 'Amande et Rousse', stats: ['400 Vitalité', '60 Soins', '1200 Initiative'], parents: 'Amande + Rousse', icon: '🐦' },
{ name: 'Dorée et Rousse', stats: ['400 Vitalité', '1 Invocation', '45 Soins'], parents: 'Dorée + Rousse', icon: '🐦' },
{ name: 'Amande et Dorée', stats: ['400 Vitalité', '1 Invocation', '1200 Initiative'], parents: 'Amande + Dorée', icon: '🐦' },
],
3: [
{ name: 'Ebène', stats: ['400 Vitalité', '120 Agilité'], parents: 'Amande et Dorée + Dorée et Rousse', icon: '🐦' },
{ name: 'Indigo', stats: ['400 Vitalité', '120 Chance'], parents: 'Amande et Dorée + Amande et Rousse', icon: '🐦' },
],
4: [
{ name: 'Indigo et Rousse', stats: ['400 Vitalité', '90 Chance', '45 Soins'], parents: 'Indigo + Rousse', icon: '🐦' },
{ name: 'Ebène et Rousse', stats: ['400 Vitalité', '90 Agilité', '45 Soins'], parents: 'Ebène + Rousse', icon: '🐦' },
{ name: 'Amande et Indigo', stats: ['400 Vitalité', '90 Chance', '1200 Initiative'], parents: 'Amande + Indigo', icon: '🐦' },
{ name: 'Amande et Ebène', stats: ['400 Vitalité', '120 Agilité', '1200 Initiative'], parents: 'Amande + Ebène', icon: '🐦' },
{ name: 'Dorée et Indigo', stats: ['400 Vitalité', '90 Chance', '1 Invocation'], parents: 'Dorée + Indigo', icon: '🐦' },
{ name: 'Dorée et Ebène', stats: ['400 Vitalité', '90 Agilité', '1 Invocation'], parents: 'Dorée + Ebène', icon: '🐦' },
{ name: 'Ebène et Indigo', stats: ['400 Vitalité', '90 Chance', '90 Agilité'], parents: 'Ebène + Indigo', icon: '🐦' },
],
5: [
{ name: 'Pourpre', stats: ['400 Vitalité', '120 Force'], parents: 'Ebène et Indigo + Amande et Rousse', icon: '🐦' },
{ name: 'Orchidée', stats: ['400 Vitalité', '120 Intelligence'], parents: 'Ebène et Indigo + Dorée et Rousse', icon: '🐦' },
],
6: [
{ name: 'Pourpre et Rousse', stats: ['400 Vitalité', '90 Force', '45 Soins'], parents: 'Pourpre + Rousse', icon: '🐦' },
{ name: 'Orchidée et Rousse', stats: ['400 Vitalité', '90 Intelligence', '45 Soins'], parents: 'Orchidée + Rousse', icon: '🐦' },
{ name: 'Amande et Pourpre', stats: ['400 Vitalité', '90 Force', '1200 Initiative'], parents: 'Amande + Pourpre', icon: '🐦' },
{ name: 'Amande et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1200 Initiative'], parents: 'Amande + Orchidée', icon: '🐦' },
{ name: 'Dorée et Pourpre', stats: ['400 Vitalité', '90 Force', '1 Invocation'], parents: 'Dorée + Pourpre', icon: '🐦' },
{ name: 'Dorée et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1 Invocation'], parents: 'Dorée + Orchidée', icon: '🐦' },
{ name: 'Indigo et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Chance'], parents: 'Indigo + Pourpre', icon: '🐦' },
{ name: 'Indigo et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '90 Chance'], parents: 'Indigo + Orchidée', icon: '🐦' },
{ name: 'Ebène et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Agilité'], parents: 'Ebène + Pourpre', icon: '🐦' },
{ name: 'Ebène et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '90 Agilité'], parents: 'Ebène + Orchidée', icon: '🐦' },
{ name: 'Orchidée et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Intelligence'], parents: 'Orchidée + Pourpre', icon: '🐦' },
],
7: [
{ name: 'Ivoire', stats: ['400 Vitalité', '90 Puissance'], parents: 'Orchidée et Pourpre + Indigo et Pourpre', icon: '🐦' },
{ name: 'Turquoise', stats: ['400 Vitalité', '90 Prospection'], parents: 'Orchidée et Pourpre + Ebène et Orchidée', icon: '🐦' },
],
8: [
{ name: 'Ivoire et Rousse', stats: ['400 Vitalité', '70 Puissance', '45 Soins'], parents: 'Ivoire + Rousse', icon: '🐦' },
{ name: 'Turquoise et Rousse', stats: ['400 Vitalité', '45 Soins', '70 Prospection'], parents: 'Turquoise + Rousse', icon: '🐦' },
{ name: 'Amande et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1200 Initiative'], parents: 'Amande + Ivoire', icon: '🐦' },
{ name: 'Amande et Turquoise', stats: ['400 Vitalité', '70 Prospection', '1200 Initiative'], parents: 'Amande + Turquoise', icon: '🐦' },
{ name: 'Dorée et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1 Invocation'], parents: 'Dorée + Ivoire', icon: '🐦' },
{ name: 'Dorée et Turquoise', stats: ['400 Vitalité', '1 Invocation', '70 Prospection'], parents: 'Dorée + Turquoise', icon: '🐦' },
{ name: 'Indigo et Ivoire', stats: ['400 Vitalité', '90 Chance', '70 Puissance'], parents: 'Indigo + Ivoire', icon: '🐦' },
{ name: 'Indigo et Turquoise', stats: ['400 Vitalité', '90 Chance', '70 Prospection'], parents: 'Indigo + Turquoise', icon: '🐦' },
{ name: 'Ebène et Ivoire', stats: ['400 Vitalité', '90 Agilité', '70 Puissance'], parents: 'Ebène + Ivoire', icon: '🐦' },
{ name: 'Ebène et Turquoise', stats: ['400 Vitalité', '90 Agilité', '70 Prospection'], parents: 'Ebène + Turquoise', icon: '🐦' },
{ name: 'Ivoire et Pourpre', stats: ['400 Vitalité', '90 Force', '70 Puissance'], parents: 'Ivoire + Pourpre', icon: '🐦' },
{ name: 'Turquoise et Pourpre', stats: ['400 Vitalité', '90 Force', '70 Prospection'], parents: 'Turquoise + Pourpre', icon: '🐦' },
{ name: 'Ivoire et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '70 Puissance'], parents: 'Ivoire + Orchidée', icon: '🐦' },
{ name: 'Turquoise et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '70 Prospection'], parents: 'Turquoise + Orchidée', icon: '🐦' },
{ name: 'Ivoire et Turquoise', stats: ['400 Vitalité', '70 Puissance', '70 Prospection'], parents: 'Ivoire + Turquoise', icon: '🐦' },
],
9: [
{ name: 'Emeraude', stats: ['400 Vitalité', '14% Critique'], parents: 'Ivoire et Turquoise + Ivoire et Pourpre', icon: '🐦' },
{ name: 'Prune', stats: ['400 Vitalité', '2 Portée'], parents: 'Ivoire et Turquoise + Turquoise et Orchidée', icon: '🐦' },
],
10: [
{ name: 'Emeraude et Rousse', stats: ['400 Vitalité', '10% Critique', '45 Soins'], parents: 'Emeraude + Rousse', icon: '🐦' },
{ name: 'Prune et Rousse', stats: ['400 Vitalité', '1 Portée', '45 Soins'], parents: 'Prune + Rousse', icon: '🐦' },
{ name: 'Amande et Emeraude', stats: ['400 Vitalité', '10% Critique', '1200 Initiative'], parents: 'Amande + Emeraude', icon: '🐦' },
{ name: 'Prune et Amande', stats: ['400 Vitalité', '1 Portée', '1200 Initiative'], parents: 'Prune + Amande', icon: '🐦' },
{ name: 'Dorée et Emeraude', stats: ['400 Vitalité', '10% Critique', '1 Invocation'], parents: 'Dorée + Emeraude', icon: '🐦' },
{ name: 'Prune et Dorée', stats: ['400 Vitalité', '1 Portée', '1 Invocation'], parents: 'Prune + Dorée', icon: '🐦' },
{ name: 'Emeraude et Indigo', stats: ['400 Vitalité', '90 Chance', '10% Critique'], parents: 'Emeraude + Indigo', icon: '🐦' },
{ name: 'Prune et Indigo', stats: ['400 Vitalité', '90 Chance', '1 Portée'], parents: 'Prune + Indigo', icon: '🐦' },
{ name: 'Ebène et Emeraude', stats: ['400 Vitalité', '90 Agilité', '10% Critique'], parents: 'Ebène + Emeraude', icon: '🐦' },
{ name: 'Prune et Ebène', stats: ['400 Vitalité', '90 Agilité', '1 Portée'], parents: 'Prune + Ebène', icon: '🐦' },
{ name: 'Emeraude et Pourpre', stats: ['400 Vitalité', '90 Force', '10% Critique'], parents: 'Emeraude + Pourpre', icon: '🐦' },
{ name: 'Prune et Pourpre', stats: ['400 Vitalité', '90 Force', '1 Portée'], parents: 'Prune + Pourpre', icon: '🐦' },
{ name: 'Emeraude et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '10% Critique'], parents: 'Emeraude + Orchidée', icon: '🐦' },
{ name: 'Prune et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1 Portée'], parents: 'Prune + Orchidée', icon: '🐦' },
{ name: 'Emeraude et Ivoire', stats: ['400 Vitalité', '70 Puissance', '10% Critique'], parents: 'Emeraude + Ivoire', icon: '🐦' },
{ name: 'Prune et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1 Portée'], parents: 'Prune + Ivoire', icon: '🐦' },
{ name: 'Emeraude et Turquoise', stats: ['400 Vitalité', '10% Critique', '70 Prospection'], parents: 'Emeraude + Turquoise', icon: '🐦' },
{ name: 'Prune et Turquoise', stats: ['400 Vitalité', '1 Portée', '70 Prospection'], parents: 'Prune + Turquoise', icon: '🐦' },
{ name: 'Prune et Emeraude', stats: ['400 Vitalité', '10% Critique', '1 Portée'], parents: 'Prune + Emeraude', icon: '🐦' },
],
};
// Breeding recipes: baby race → [parentA, parentB]
export const BREEDING_RECIPES: Record<string, [string, string]> = {
'Dorée et Rousse': ['Rousse', 'Dorée'],
'Amande et Dorée': ['Amande', 'Dorée'],
'Amande et Rousse': ['Amande', 'Rousse'],
'Ebène': ['Amande et Dorée', 'Dorée et Rousse'],
'Indigo': ['Amande et Dorée', 'Amande et Rousse'],
'Indigo et Rousse': ['Indigo', 'Rousse'],
'Ebène et Rousse': ['Ebène', 'Rousse'],
'Amande et Indigo': ['Amande', 'Indigo'],
'Amande et Ebène': ['Amande', 'Ebène'],
'Dorée et Indigo': ['Dorée', 'Indigo'],
'Dorée et Ebène': ['Dorée', 'Ebène'],
'Ebène et Indigo': ['Ebène', 'Indigo'],
'Pourpre': ['Ebène et Indigo', 'Amande et Rousse'],
'Orchidée': ['Ebène et Indigo', 'Dorée et Rousse'],
'Pourpre et Rousse': ['Pourpre', 'Rousse'],
'Orchidée et Rousse': ['Orchidée', 'Rousse'],
'Amande et Pourpre': ['Amande', 'Pourpre'],
'Amande et Orchidée': ['Amande', 'Orchidée'],
'Dorée et Pourpre': ['Dorée', 'Pourpre'],
'Dorée et Orchidée': ['Dorée', 'Orchidée'],
'Indigo et Pourpre': ['Indigo', 'Pourpre'],
'Indigo et Orchidée': ['Indigo', 'Orchidée'],
'Ebène et Pourpre': ['Ebène', 'Pourpre'],
'Ebène et Orchidée': ['Ebène', 'Orchidée'],
'Orchidée et Pourpre': ['Orchidée', 'Pourpre'],
'Ivoire': ['Orchidée et Pourpre', 'Indigo et Pourpre'],
'Turquoise': ['Orchidée et Pourpre', 'Ebène et Orchidée'],
'Ivoire et Rousse': ['Ivoire', 'Rousse'],
'Turquoise et Rousse': ['Turquoise', 'Rousse'],
'Amande et Ivoire': ['Amande', 'Ivoire'],
'Amande et Turquoise': ['Amande', 'Turquoise'],
'Dorée et Ivoire': ['Dorée', 'Ivoire'],
'Dorée et Turquoise': ['Dorée', 'Turquoise'],
'Indigo et Ivoire': ['Indigo', 'Ivoire'],
'Indigo et Turquoise': ['Indigo', 'Turquoise'],
'Ebène et Ivoire': ['Ebène', 'Ivoire'],
'Ebène et Turquoise': ['Ebène', 'Turquoise'],
'Ivoire et Pourpre': ['Ivoire', 'Pourpre'],
'Turquoise et Pourpre': ['Turquoise', 'Pourpre'],
'Ivoire et Orchidée': ['Ivoire', 'Orchidée'],
'Turquoise et Orchidée': ['Turquoise', 'Orchidée'],
'Ivoire et Turquoise': ['Ivoire', 'Turquoise'],
'Emeraude': ['Ivoire et Turquoise', 'Ivoire et Pourpre'],
'Prune': ['Ivoire et Turquoise', 'Turquoise et Orchidée'],
'Emeraude et Rousse': ['Emeraude', 'Rousse'],
'Prune et Rousse': ['Prune', 'Rousse'],
'Amande et Emeraude': ['Amande', 'Emeraude'],
'Prune et Amande': ['Prune', 'Amande'],
'Dorée et Emeraude': ['Dorée', 'Emeraude'],
'Prune et Dorée': ['Prune', 'Dorée'],
'Emeraude et Indigo': ['Emeraude', 'Indigo'],
'Prune et Indigo': ['Prune', 'Indigo'],
'Ebène et Emeraude': ['Ebène', 'Emeraude'],
'Prune et Ebène': ['Prune', 'Ebène'],
'Emeraude et Pourpre': ['Emeraude', 'Pourpre'],
'Prune et Pourpre': ['Prune', 'Pourpre'],
'Emeraude et Orchidée': ['Emeraude', 'Orchidée'],
'Prune et Orchidée': ['Prune', 'Orchidée'],
'Emeraude et Ivoire': ['Emeraude', 'Ivoire'],
'Prune et Ivoire': ['Prune', 'Ivoire'],
'Emeraude et Turquoise': ['Emeraude', 'Turquoise'],
'Prune et Turquoise': ['Prune', 'Turquoise'],
'Prune et Emeraude': ['Prune', 'Emeraude'],
};
// Race name → generation number (computed)
export const RACE_GEN: Record<string, number> = {};
['Rousse', 'Dorée', 'Amande'].forEach((n) => (RACE_GEN[n] = 1));
Object.entries(RACES_DATA).forEach(([g, rs]) =>
rs.forEach((r) => (RACE_GEN[r.name] = parseInt(g))),
);
// Reverse lookup: "ParentA|ParentB" → baby race
export const BREEDING_BY_PARENTS: Record<string, string> = {};
Object.entries(BREEDING_RECIPES).forEach(([baby, [a, b]]) => {
BREEDING_BY_PARENTS[a + '|' + b] = baby;
if (a !== b) BREEDING_BY_PARENTS[b + '|' + a] = baby;
});
// For a given parent, which partners are possible?
export const COMPATIBLE_PARTNERS: Record<
string,
{ partner: string; baby: string; gen: number }[]
> = {};
Object.entries(BREEDING_RECIPES).forEach(([baby, [a, b]]) => {
if (!COMPATIBLE_PARTNERS[a]) COMPATIBLE_PARTNERS[a] = [];
COMPATIBLE_PARTNERS[a].push({ partner: b, baby, gen: RACE_GEN[baby] });
if (a !== b) {
if (!COMPATIBLE_PARTNERS[b]) COMPATIBLE_PARTNERS[b] = [];
COMPATIBLE_PARTNERS[b].push({ partner: a, baby, gen: RACE_GEN[baby] });
}
});
// ── Helper functions ──
const COLOR_ORDER = [
'Emeraude', 'Prune', 'Ivoire', 'Turquoise',
'Orchidée', 'Pourpre', 'Indigo', 'Ebène',
'Dorée', 'Amande', 'Rousse',
];
/** Returns the generation number for a race name. */
export function generationOf(name: string): number {
return RACE_GEN[name] ?? 0;
}
/** Returns true if the race is a base (gen 1) race. */
export function isBaseRace(name: string): boolean {
return RACE_GEN[name] === 1;
}
/** Returns the primary color for a race name. */
export function raceColor(name: string): string {
for (const k of COLOR_ORDER) {
if (name.includes(k)) return RACE_BASE_COLORS[k];
}
return '#888';
}
/** Returns the secondary color for gradient, or null if none. */
export function raceColor2(name: string): string | null {
let found = false;
for (const k of COLOR_ORDER) {
if (name.includes(k)) {
if (found) return RACE_BASE_COLORS[k];
found = true;
}
}
return null;
}

View File

@ -0,0 +1,14 @@
export const TIER_THRESHOLDS = [
{ lo: 90000, rate: 40, num: 4 },
{ lo: 70000, rate: 30, num: 3 },
{ lo: 40000, rate: 20, num: 2 },
{ lo: 0, rate: 10, num: 1 },
] as const;
export function tierRate(level: number): number {
return level > 90000 ? 40 : level > 70000 ? 30 : level > 40000 ? 20 : 10;
}
export function tierNum(level: number): number {
return level > 90000 ? 4 : level > 70000 ? 3 : level > 40000 ? 2 : 1;
}

View File

@ -0,0 +1,16 @@
// Copy of XP_RAW from src/index.html line 357
export const XP_RAW: Readonly<Record<number, number>> = {
1:0,2:19,3:49,4:96,5:161,6:246,7:353,8:481,9:633,10:809,11:1011,12:1238,13:1491,14:1772,15:2081,16:2419,17:2786,18:3182,19:3609,20:4067,21:4557,22:5078,23:5632,24:6219,25:6839,26:7493,27:8182,28:8905,29:9664,30:10457,31:11287,32:12154,33:13057,34:13997,35:14974,36:15990,37:17043,38:18135,39:19266,40:20437,41:21646,42:22896,43:24186,44:25516,45:26887,46:28299,47:29753,48:31248,49:32785,50:34365,51:35987,52:37652,53:39360,54:41111,55:42906,56:44745,57:46628,58:48555,59:50527,60:52544,61:54607,62:56714,63:58868,64:61067,65:63312,66:65604,67:67942,68:70327,69:72760,70:75239,71:77766,72:80341,73:82964,74:85635,75:88355,76:91123,77:93940,78:96806,79:99721,80:102685,81:105700,82:108764,83:111878,84:115042,85:118257,86:121523,87:124840,88:128207,89:131626,90:135096,91:138618,92:142191,93:145817,94:149495,95:153225,96:157008,97:160843,98:164732,99:168673,100:172668,101:176716,102:180818,103:184974,104:189183,105:193447,106:197765,107:202137,108:206565,109:211046,110:215583,111:220176,112:224823,113:229526,114:234284,115:239099,116:243969,117:248895,118:253878,119:258917,120:264013,121:269165,122:274375,123:279641,124:284965,125:290346,126:295784,127:301280,128:306834,129:312446,130:318116,131:323845,132:329631,133:335477,134:341381,135:347343,136:353365,137:359446,138:365587,139:371786,140:378045,141:384364,142:390743,143:397182,144:403681,145:410240,146:416859,147:423539,148:430280,149:437082,150:443944,151:450868,152:457852,153:464898,154:472006,155:479175,156:486406,157:493699,158:501054,159:508470,160:515950,161:523491,162:531095,163:538762,164:546491,165:554283,166:562139,167:570057,168:578039,169:586084,170:594193,171:602365,172:610601,173:618901,174:627265,175:635693,176:644185,177:652742,178:661363,179:670049,180:678799,181:687615,182:696495,183:705440,184:714451,185:723527,186:732668,187:741875,188:751148,189:760486,190:769890,191:779361,192:788897,193:798500,194:808169,195:817904,196:827706,197:837575,198:847510,199:857513,200:867582,
};
export function xpForLevel(lvl: number): number {
return XP_RAW[Math.min(200, Math.max(1, Math.round(lvl)))] ?? 0;
}
export function levelFromXp(xp: number): number {
if (xp >= XP_RAW[200]!) return 200;
for (let i = 199; i >= 1; i--) {
if (XP_RAW[i]! <= xp) return i;
}
return 1;
}

File diff suppressed because one or more lines are too long

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist-ts",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@domain/*": ["src/domain/*"],
"@application/*": ["src/application/*"],
"@infrastructure/*": ["src/infrastructure/*"],
"@presentation/*": ["src/presentation/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-ts", "tests"]
}

44
vite.config.ts Normal file
View File

@ -0,0 +1,44 @@
import { defineConfig } from 'vite';
import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import { resolve } from 'path';
export default defineConfig({
plugins: [
electron([
{
entry: resolve(__dirname, 'src/infrastructure/electron/main.ts'),
vite: {
build: {
outDir: resolve(__dirname, 'dist-electron'),
rollupOptions: { external: ['electron'] },
},
},
},
{
entry: resolve(__dirname, 'src/infrastructure/electron/preload.ts'),
onstart(args) { args.reload(); },
vite: {
build: {
outDir: resolve(__dirname, 'dist-electron'),
rollupOptions: { external: ['electron'] },
},
},
},
]),
renderer(),
],
resolve: {
alias: {
'@domain': resolve(__dirname, 'src/domain'),
'@application': resolve(__dirname, 'src/application'),
'@infrastructure': resolve(__dirname, 'src/infrastructure'),
'@presentation': resolve(__dirname, 'src/presentation'),
},
},
root: 'src',
build: {
outDir: resolve(__dirname, 'dist-vite'),
emptyOutDir: true,
},
});

28
vitest.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@domain': resolve(__dirname, 'src/domain'),
'@application': resolve(__dirname, 'src/application'),
'@infrastructure': resolve(__dirname, 'src/infrastructure'),
'@presentation': resolve(__dirname, 'src/presentation'),
},
},
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/domain/**', 'src/application/**'],
exclude: [
'src/domain/ports/**',
'src/domain/events/DomainEvent.ts',
'src/domain/value-objects/Gender.ts',
],
thresholds: { branches: 80, functions: 80, lines: 80 },
},
},
});