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:
parent
0afc53fc1a
commit
c640fbd416
2185
package-lock.json
generated
Normal file → Executable file
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
44
package.json
Normal file → Executable file
@ -1,24 +1,32 @@
|
||||
{
|
||||
"name": "minuteur-dragodinde",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"description": "Minuteur elevage Dragodinde Dofus 3",
|
||||
"main": "main.js",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "Mickael",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder --win --x64"
|
||||
"dev": "vite",
|
||||
"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": {
|
||||
"appId": "fr.mickael-pol.minuteur-dragodinde",
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer/releases/download/latest"
|
||||
},
|
||||
"productName": "Minuteur Dragodinde",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*",
|
||||
"icon.png"
|
||||
"dist-vite/**/*",
|
||||
"dist-electron/**/*",
|
||||
"icon.ico"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
@ -31,7 +39,7 @@
|
||||
],
|
||||
"sign": null,
|
||||
"signingHashAlgorithms": [],
|
||||
"icon": "icon.png",
|
||||
"icon": "icon.ico",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"nsis": {
|
||||
@ -46,12 +54,24 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"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": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer.git"
|
||||
},
|
||||
"productName": "Minuteur Dragodinde"
|
||||
}
|
||||
"productName": "Minuteur Dragodinde",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
13
src/application/commands/AddDragodinde.ts
Normal file
13
src/application/commands/AddDragodinde.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
52
src/application/commands/CompleteTimer.ts
Normal file
52
src/application/commands/CompleteTimer.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
24
src/application/commands/CreateEnclos.ts
Normal file
24
src/application/commands/CreateEnclos.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
17
src/application/commands/DeleteEnclos.ts
Normal file
17
src/application/commands/DeleteEnclos.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/DeleteWorkflow.ts
Normal file
13
src/application/commands/DeleteWorkflow.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
65
src/application/commands/DragodindeActions.ts
Normal file
65
src/application/commands/DragodindeActions.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
56
src/application/commands/EnclosActions.ts
Normal file
56
src/application/commands/EnclosActions.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
25
src/application/commands/ImportWorkflows.ts
Normal file
25
src/application/commands/ImportWorkflows.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
35
src/application/commands/RechargeGauge.ts
Normal file
35
src/application/commands/RechargeGauge.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
23
src/application/commands/RegisterAccouplement.ts
Normal file
23
src/application/commands/RegisterAccouplement.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/RemoveDragodinde.ts
Normal file
13
src/application/commands/RemoveDragodinde.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
17
src/application/commands/ReorderEnclos.ts
Normal file
17
src/application/commands/ReorderEnclos.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
11
src/application/commands/ResetStats.ts
Normal file
11
src/application/commands/ResetStats.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
56
src/application/commands/SaveWorkflow.ts
Normal file
56
src/application/commands/SaveWorkflow.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
59
src/application/commands/StartTimer.ts
Normal file
59
src/application/commands/StartTimer.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/StopTimer.ts
Normal file
13
src/application/commands/StopTimer.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
42
src/application/commands/UpdateGauge.ts
Normal file
42
src/application/commands/UpdateGauge.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
19
src/application/commands/UpdateSettings.ts
Normal file
19
src/application/commands/UpdateSettings.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
32
src/application/commands/UpdateWorkflow.ts
Normal file
32
src/application/commands/UpdateWorkflow.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
24
src/application/handlers/CommandBus.ts
Normal file
24
src/application/handlers/CommandBus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/application/handlers/QueryBus.ts
Normal file
24
src/application/handlers/QueryBus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/application/queries/GetBreedingOptions.ts
Normal file
15
src/application/queries/GetBreedingOptions.ts
Normal 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) };
|
||||
};
|
||||
}
|
||||
57
src/application/queries/GetDashboard.ts
Normal file
57
src/application/queries/GetDashboard.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
10
src/application/queries/GetEnclosDetail.ts
Normal file
10
src/application/queries/GetEnclosDetail.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
9
src/application/queries/GetInventaire.ts
Normal file
9
src/application/queries/GetInventaire.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
22
src/application/queries/GetReapproTree.ts
Normal file
22
src/application/queries/GetReapproTree.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
17
src/application/queries/GetSettings.ts
Normal file
17
src/application/queries/GetSettings.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
288
src/application/queries/GetStatistics.ts
Normal file
288
src/application/queries/GetStatistics.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
22
src/application/queries/GetTimerState.ts
Normal file
22
src/application/queries/GetTimerState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
20
src/application/queries/GetWorkflows.ts
Normal file
20
src/application/queries/GetWorkflows.ts
Normal 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[];
|
||||
}
|
||||
16
src/domain/entities/Accouplement.ts
Normal file
16
src/domain/entities/Accouplement.ts
Normal 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() };
|
||||
}
|
||||
37
src/domain/entities/Dragodinde.ts
Normal file
37
src/domain/entities/Dragodinde.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
59
src/domain/entities/Enclos.ts
Normal file
59
src/domain/entities/Enclos.ts
Normal 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) };
|
||||
}
|
||||
6
src/domain/events/DomainEvent.ts
Normal file
6
src/domain/events/DomainEvent.ts
Normal 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;
|
||||
}
|
||||
24
src/domain/events/EventBus.ts
Normal file
24
src/domain/events/EventBus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
src/domain/ports/AlarmPort.ts
Normal file
4
src/domain/ports/AlarmPort.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AlarmPort {
|
||||
play(soundName: string): void;
|
||||
stop(): void;
|
||||
}
|
||||
4
src/domain/ports/NotificationPort.ts
Normal file
4
src/domain/ports/NotificationPort.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface NotificationPort {
|
||||
showNotification(title: string, body: string): void;
|
||||
sendMobileNotification(url: string, title: string, message: string): void;
|
||||
}
|
||||
20
src/domain/ports/StateRepository.ts
Normal file
20
src/domain/ports/StateRepository.ts
Normal 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;
|
||||
}
|
||||
11
src/domain/ports/UpdatePort.ts
Normal file
11
src/domain/ports/UpdatePort.ts
Normal 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;
|
||||
}
|
||||
28
src/domain/services/BreedingService.ts
Normal file
28
src/domain/services/BreedingService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
151
src/domain/services/GaugeCalculator.ts
Normal file
151
src/domain/services/GaugeCalculator.ts
Normal 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: 90k→40, 70k→30, 40k→20, 0→10 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;
|
||||
}
|
||||
69
src/domain/services/InventaireCalculator.ts
Normal file
69
src/domain/services/InventaireCalculator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
80
src/domain/services/ReapproCalculator.ts
Normal file
80
src/domain/services/ReapproCalculator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
34
src/domain/services/SerenityCalculator.ts
Normal file
34
src/domain/services/SerenityCalculator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
108
src/domain/services/StockSimulator.ts
Normal file
108
src/domain/services/StockSimulator.ts
Normal 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 };
|
||||
}
|
||||
32
src/domain/services/XpCalculator.ts
Normal file
32
src/domain/services/XpCalculator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
49
src/domain/value-objects/GaugeType.ts
Normal file
49
src/domain/value-objects/GaugeType.ts
Normal 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 };
|
||||
}
|
||||
1
src/domain/value-objects/Gender.ts
Normal file
1
src/domain/value-objects/Gender.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Gender = 'm' | 'f' | 'n';
|
||||
257
src/domain/value-objects/Race.ts
Normal file
257
src/domain/value-objects/Race.ts
Normal 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;
|
||||
}
|
||||
14
src/domain/value-objects/Tier.ts
Normal file
14
src/domain/value-objects/Tier.ts
Normal 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;
|
||||
}
|
||||
16
src/domain/value-objects/XpTable.ts
Normal file
16
src/domain/value-objects/XpTable.ts
Normal 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;
|
||||
}
|
||||
3132
src/index.html
3132
src/index.html
File diff suppressed because one or more lines are too long
28
tsconfig.json
Normal file
28
tsconfig.json
Normal 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
44
vite.config.ts
Normal 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
28
vitest.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user