feat: Phase 3 Lore & Contenu — L'Odyssée d'un têtard

Lore Bible (canon narratif complet) + Engine Design (séparation moteur/univers).

4 nouvelles zones (Ruisseau Miroir, Marais des Murmures, Torrent Brisé, Source du Courant)
dans la chaîne d'unlock après desert (niv 16-25+).

Module NPC complet (entity, service, controller) — 8 PNJ avec dialogues évolutifs
par palier de niveau : Gorn (niv 1-15), Pierre-Mémoire (niv 16+), Mira, Vell,
La Batracienne, Le Forgeron, Le Marchand.

20 monstres lore-friendly, 12 matériaux, 15 items (dont Bâton de Gorn légendaire).

17 quêtes narratives (4 arcs ch.9-12) avec textes acceptText/completeText
qui racontent l'Odyssée. Nouveau type story_event pour les moments narratifs purs.
3 quêtes répétables optionnelles.

Seed runner : npm run seed:odyssee

Tout est additif — zéro impact sur le contenu existant niv 1-15.
This commit is contained in:
2026-03-25 00:43:26 +01:00
parent 2c94e4f3aa
commit 4beb1b2ed9
16 changed files with 1520 additions and 10 deletions

View File

@@ -19,6 +19,7 @@ import { HallOfFameModule } from './halloffame/halloffame.module';
import { ProfileModule } from './profile/profile.module';
import { QuestModule } from './quest/quest.module';
import { ShopModule } from './shop/shop.module';
import { NpcModule } from './npc/npc.module';
import { HealthController } from './common/health.controller';
@Module({
@@ -61,6 +62,7 @@ import { HealthController } from './common/health.controller';
ProfileModule,
QuestModule,
ShopModule,
NpcModule,
],
controllers: [HealthController],
})

View File

@@ -3,10 +3,14 @@ import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
import { QuestArc } from '../quest/quest-arc.entity';
// Zone unlock chain: each zone requires completing the previous zone's arc
// marais → always open
// egouts → requires "Les Marais du Têtard" arc completed
// desert → requires the egouts arc completed
const ZONE_ORDER = ['marais', 'egouts', 'desert'];
// marais → always open (L'Étang — niv 1-5)
// egouts → requires "Les Marais du Têtard" arc (L'Étang profond — niv 6-10)
// desert → requires egouts arc (L'Étang Brisé — niv 11-15)
// ruisseau_miroir → requires desert arc (Ruisseau Miroir — niv 16-18)
// marais_murmures → requires ruisseau_miroir arc (Marais des Murmures — niv 19-21)
// torrent_brise → requires marais_murmures arc (Torrent Brisé — niv 22-24)
// source_courant → requires torrent_brise arc (Source du Courant — niv 25+)
const ZONE_ORDER = ['marais', 'egouts', 'desert', 'ruisseau_miroir', 'marais_murmures', 'torrent_brise', 'source_courant'];
export async function getUnlockedZones(
characterId: string,

View File

@@ -24,6 +24,10 @@ import { Quest } from '../quest/quest.entity';
import { QuestArc } from '../quest/quest-arc.entity';
import { PlayerQuest } from '../quest/player-quest.entity';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
import { Spell } from '../combat/turn/spell.entity';
import { PlayerSpell } from '../combat/turn/player-spell.entity';
import { PlayerDaoPath } from '../combat/turn/player-dao-path.entity';
import { Npc } from '../npc/npc.entity';
// DataSource pour le CLI TypeORM (migrations manuelles)
export const AppDataSource = new DataSource({
@@ -54,6 +58,10 @@ export const AppDataSource = new DataSource({
QuestArc,
PlayerQuest,
PlayerQuestArc,
Spell,
PlayerSpell,
PlayerDaoPath,
Npc,
],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,

View File

@@ -0,0 +1,420 @@
import { DataSource } from 'typeorm';
import { QuestArc } from '../quest/quest-arc.entity';
import { Quest } from '../quest/quest.entity';
/**
* Seed Phase 3 — L'Odyssée d'un têtard
*
* Chaque quête raconte une scène de l'histoire.
* Les story_event sont des moments narratifs purs (auto-complete).
* Les combats sont légers et ont du sens (1 boss, pas 10 mobs).
* Les textes (acceptText / completeText) sont les dialogues du PNJ.
*/
export async function seedOdysseeQuests(dataSource: DataSource) {
const arcRepo = dataSource.getRepository(QuestArc);
const questRepo = dataSource.getRepository(Quest);
const monsters = await dataSource.query('SELECT id, name FROM monsters');
const m = new Map<string, string>(monsters.map((r: any) => [r.name, r.id]));
const materials = await dataSource.query('SELECT id, name FROM materials');
const mat = new Map<string, string>(materials.map((r: any) => [r.name, r.id]));
let questsAdded = 0;
// ═══════════════════════════════════════════════════
// ARC 4 — LE RUISSEAU MIROIR (ch.9)
// "L'eau claire comme du verre montre ce qu'on ne veut pas voir."
// ═══════════════════════════════════════════════════
let arc4 = await arcRepo.findOne({ where: { name: 'Le Ruisseau Miroir' } });
if (!arc4) {
arc4 = await arcRepo.save(arcRepo.create({
name: 'Le Ruisseau Miroir',
description: 'Gorn en parlait : un ruisseau qui montre ce qu\'on ne veut pas voir. Mira, Vell et toi devez l\'affronter ensemble.',
zone: 'ruisseau_miroir',
sortOrder: 4,
minLevel: 15,
}));
}
const arc4Quests = [
{
name: 'Le Serment des Trois',
description: 'Après la destruction de l\'étang, Mira et Vell vous rejoignent. Ensemble, vous faites un serment.',
acceptText: 'Mira tend sa nageoire : « Que le courant, même blessé, nous porte ensemble. »\nVell la touche, puis vous. Une onde frémit autour de vous, comme une promesse.\nSous une pierre fissurée, un objet luit : un fragment de coquille laissé par Gorn. Dessus, un mot : « Continue. »',
completeText: 'Le Serment des Trois est scellé. Votre chemin est tracé. L\'Odyssée commence vraiment.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 400,
rewardGold: 200,
rewardTitle: null as string | null,
arcId: arc4.id,
arcOrder: 1,
zone: null as string | null,
minLevel: 15,
repeatable: false,
},
{
name: 'L\'Eau qui ne Ment Pas',
description: 'Le Ruisseau Miroir projette des doubles sombres de vous-même. Ils murmurent vos peurs.',
acceptText: 'Mira fronce les sourcils : « Le Ruisseau Miroir. Gorn en parlait. Il montre ce que l\'on ne veut pas voir. »\nVous plongez dans une eau si transparente que le ciel s\'y reflète sans déformation. Puis les reflets changent. Votre double vous fixe, le regard dur : « Tu crois comprendre le courant ? Tu n\'es qu\'un songeur. »',
completeText: 'Le Reflet s\'efface dans un éclat de cristal. Ses derniers mots résonnent encore... mais ils n\'ont plus de pouvoir sur vous.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Reflet Sombre') ?? null,
objectiveCount: 1,
rewardXp: 500,
rewardGold: 200,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 2,
zone: 'ruisseau_miroir',
minLevel: 15,
repeatable: false,
},
{
name: 'L\'Épreuve de Vell',
description: 'Vell affronte son propre reflet. Il doit accepter que la force brute ne suffit pas.',
acceptText: 'Le double de Vell rit, moqueur : « Tu n\'as rien protégé. Gorn est parti, l\'étang est mort. À quoi sers-tu, si tu ne peux frapper assez fort ? »\nVell hurle, frappe l\'eau. Mais le doute s\'insinue dans ses membres. Il se fige.\nVous devez l\'aider — vaincre les Échos de Doute qui l\'emprisonnent.',
completeText: 'Mira chante une note pure. La torpeur cède. Vell redresse la tête, tremblant mais libre : « Ensemble, nous sommes plus que nos reflets. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Écho de Doute') ?? null,
objectiveCount: 2,
rewardXp: 700,
rewardGold: 300,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 3,
zone: 'ruisseau_miroir',
minLevel: 16,
repeatable: false,
},
{
name: 'Le Chant de Mira',
description: 'Mira fait face à son reflet. Il lui pose la question qu\'elle évite depuis toujours.',
acceptText: 'Le reflet de Mira chante doucement. Une mélodie pure. Puis il cesse, la fixant :\n« Le chant est en toi. Pourquoi le caches-tu ? Peur de briser l\'harmonie ? Ou peur de ce que tu deviendrais ? »\nMira ferme les yeux. Quand elle les rouvre, elle chante. Fort. L\'eau vibre autour d\'elle.',
completeText: 'Le Ruisseau Miroir frémit sous le chant de Mira. Les reflets tremblent, se fissurent, et disparaissent.\nMira sourit, grave : « Le chant guérit. Mais il faut l\'entendre. Et l\'accepter. »',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 600,
rewardGold: 250,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 4,
zone: null,
minLevel: 16,
repeatable: false,
},
{
name: 'Le Gardien du Passage',
description: 'Au cœur du Ruisseau, un dernier gardien bloque le chemin. Il ne vous laissera passer que si vous vous êtes acceptés.',
acceptText: 'Une forme massive se dresse dans l\'eau cristalline. Le Gardien du Reflet. Il est fait de tous les doutes que vous avez laissés derrière vous, condensés en une seule entité.\nIl ne parle pas. Il attend.',
completeText: 'Le Gardien s\'effrite comme du verre au soleil. Un éclat tombe dans l\'eau — un Éclat de Miroir qui chante doucement.\nVous l\'avez. Un Fragment du Chant Perdu.\nL\'eau s\'opacifie, l\'air se rafraîchit. Le Miroir est passé. Le courant vous appelle plus loin.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Gardien du Reflet') ?? null,
objectiveCount: 1,
rewardXp: 1200,
rewardGold: 500,
rewardTitle: 'Vainqueur du Miroir',
arcId: arc4.id,
arcOrder: 5,
zone: 'ruisseau_miroir',
minLevel: 17,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 5 — LE MARAIS DES MURMURES (ch.10)
// "L'eau retient les souvenirs et les murmure."
// ═══════════════════════════════════════════════════
let arc5 = await arcRepo.findOne({ where: { name: 'Le Marais des Murmures' } });
if (!arc5) {
arc5 = await arcRepo.save(arcRepo.create({
name: 'Le Marais des Murmures',
description: 'Un marais oppressant où l\'eau chuchote des vérités anciennes. Une sage vous y attend.',
zone: 'marais_murmures',
sortOrder: 5,
minLevel: 18,
}));
}
const arc5Quests = [
{
name: 'Les Voix dans la Brume',
description: 'Le marais murmure. Des voix émergent du courant lui-même, portant des bribes de mots et de souvenirs.',
acceptText: 'Mira s\'arrête, l\'oreille tendue : « Entendez-vous… ces voix ? »\nVell fronce les sourcils : « C\'est le vent. Des sifflements, rien de plus. »\nMais vous, vous n\'écoutez pas avec vos oreilles. Vous percevez quelque chose de plus profond.\nLes spectres du marais errent entre les arbres morts. Dissipez-les pour avancer.',
completeText: 'Les spectres se dissipent en murmures. Leurs dernières paroles flottent dans l\'air comme un écho de quelque chose de très ancien.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Spectre de Brume') ?? null,
objectiveCount: 3,
rewardXp: 600,
rewardGold: 250,
rewardTitle: null as string | null,
arcId: arc5.id,
arcOrder: 1,
zone: 'marais_murmures',
minLevel: 18,
repeatable: false,
},
{
name: 'La Batracienne',
description: 'Dans une clairière aquatique, une forme ancienne vous attend. Elle savait que vous viendriez.',
acceptText: 'Dans une clairière, vous voyez une forme. Une batracienne ancienne, vêtue de lianes et d\'algues, aux yeux voilés mais brillants d\'une lumière verte.\n« Bienvenue, voyageurs. Le Marais vous attendait. »\nVous sentez une vibration dans l\'eau, comme une reconnaissance.\n« Tu portes le chant en germe, petit têtard. Mais avant la source, il faut comprendre le courant. Veux-tu entendre ? »',
completeText: 'Vous acquiescez. La Batracienne pose une patte sur votre front, et l\'eau se trouble autour de vous...',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 500,
rewardGold: 200,
rewardTitle: null,
arcId: arc5.id,
arcOrder: 2,
zone: null as string | null,
minLevel: 19,
repeatable: false,
},
{
name: 'La Vision',
description: 'La Batracienne vous montre le passé. Vous voyez l\'Hydre... telle qu\'elle était avant.',
acceptText: 'Vision.\nDes étangs anciens, des batraciens chantant sous une lune violette. L\'Hydre, paisible autrefois, gardienne des eaux profondes.\nPuis la rupture : un chant dévoyé, une note brisée, la douleur de l\'Hydre devenue chaos.\nVous rouvrez les yeux, bouleversé.\n« L\'Hydre était la clef… Elle est la mémoire déformée. Nous devons la guérir, pas la fuir. »',
completeText: 'La Batracienne hoche la tête : « Tu entends. Le chant te choisira, si tu choisis de l\'écouter. »\nDans l\'air, une note pure monte. Un fragment du chant, fragile mais réel. Vous l\'accueillez, le gravez en vous.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 800,
rewardGold: 350,
rewardTitle: null,
arcId: arc5.id,
arcOrder: 3,
zone: null,
minLevel: 19,
repeatable: false,
},
{
name: 'La Mémoire de l\'Hydre',
description: 'Un écho de l\'Hydre hante le marais. Ce n\'est pas l\'Hydre elle-même — c\'est sa douleur, incarnée.',
acceptText: 'Vell murmure : « Ce que tu as vu, Tetardtek… qu\'est-ce que cela signifie pour nous ? »\n« L\'Hydre n\'est pas qu\'un monstre. Elle était la gardienne d\'un équilibre que le chant maintenait. Quand le chant a été brisé, elle a sombré. »\nMira : « Gorn aurait dit que toute chose brisée peut être transformée. »\nUne ombre se dresse devant vous — la Mémoire de l\'Hydre. Sa douleur faite chair.',
completeText: 'La Mémoire se dissipe en un soupir. Une fiole de brume reste — la Brume Condensée, dernier écho du chant de l\'Hydre dans ce marais.\nVell sourit légèrement : « Gorn aurait ajouté : "l\'eau qui dort peut devenir torrent." »\nVous riez doucement, unis par le souvenir.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Mémoire de l\'Hydre') ?? null,
objectiveCount: 1,
rewardXp: 1500,
rewardGold: 600,
rewardTitle: 'Porteur de Vérité',
arcId: arc5.id,
arcOrder: 4,
zone: 'marais_murmures',
minLevel: 20,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 6 — LE TORRENT BRISÉ (ch.11)
// "La force brute ne passe pas."
// ═══════════════════════════════════════════════════
let arc6 = await arcRepo.findOne({ where: { name: 'Le Torrent Brisé' } });
if (!arc6) {
arc6 = await arcRepo.save(arcRepo.create({
name: 'Le Torrent Brisé',
description: 'Des eaux violentes, des rochers acérés. Vell doit apprendre sa leçon la plus dure.',
zone: 'torrent_brise',
sortOrder: 6,
minLevel: 21,
}));
}
const arc6Quests = [
{
name: 'Le Courant Furieux',
description: 'Le torrent gronde. Des élémentaux de remous protègent le passage.',
acceptText: 'Le paysage change, devenant plus escarpé, plus sauvage. Des rochers acérés fendent l\'onde, et l\'eau gronde avec violence.\nVell observe le flot tumultueux, son regard s\'allumant d\'un feu ancien : « Laissez-moi ouvrir la voie. »\nIl s\'élance, défiant les remous... mais le torrent ne se laisse pas dompter.',
completeText: 'Les élémentaux reculent, mais Vell halète. L\'eau résiste à chaque poussée. La force brute ne suffira pas ici.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Élémental de Remous') ?? null,
objectiveCount: 2,
rewardXp: 800,
rewardGold: 350,
rewardTitle: null as string | null,
arcId: arc6.id,
arcOrder: 1,
zone: 'torrent_brise',
minLevel: 21,
repeatable: false,
},
{
name: 'La Chute de Vell',
description: 'Vell s\'obstine et le torrent le projette contre un rocher. Dans l\'eau noire, il entend une voix.',
acceptText: 'Vell rugit, frappe l\'eau : « Je dois réussir ! Je dois être assez fort ! Sinon… sinon Gorn est parti pour rien. »\nMais le torrent, indifférent, le projette contre un rocher. Étourdi, il sombre.\nLà, dans la noirceur du courant, une voix : « Crois... ou coule. »\nVous devez le repérer et le tirer vers la rive.',
completeText: 'Mira tend la nageoire, chantant une note pure. La torpeur cède.\nVell haletait, brisé, non de blessures, mais d\'orgueil.\n« J\'ai échoué... encore. J\'ai laissé Gorn partir... sans rien apprendre. »\nMira s\'approche : « Tu as trop voulu porter seul. Le courant ne se force pas, Vell. Il se suit, en confiance. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Roc Vivant') ?? null,
objectiveCount: 1,
rewardXp: 1000,
rewardGold: 400,
rewardTitle: null,
arcId: arc6.id,
arcOrder: 2,
zone: 'torrent_brise',
minLevel: 22,
repeatable: false,
},
{
name: 'La Voie Étroite',
description: 'Mira perçoit un chemin invisible. Ensemble, vous pouvez traverser.',
acceptText: 'Mira lève les yeux. Elle perçoit une veine plus douce, une trajectoire étroite, où l\'eau glisse sans heurt.\n« Là. Nous passerons ensemble. »\nGuidés par elle, vous vous laissez porter. Tetardtek fredonne le fragment du chant. Mira lit les remous. Vell nage entre vous, apaisé.\nLe torrent, sévère mais pas cruel, vous accueille.',
completeText: 'Quand vous atteignez l\'autre rive, Vell regarde en arrière. Il comprend.\n« La vraie force... c\'est de ne pas lutter seul. C\'est de croire. Nous sommes un flot, pas des gouttes isolées. »\nUn fragment de confiance naît en lui, pur et solide.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 600,
rewardGold: 300,
rewardTitle: null,
arcId: arc6.id,
arcOrder: 3,
zone: null as string | null,
minLevel: 22,
repeatable: false,
},
{
name: 'La Cascade Éveillée',
description: 'Une cascade souterraine bloque le passage. La force brute est inutile. Seule la résonance peut l\'ouvrir.',
acceptText: 'Vell s\'assit dans l\'eau, à l\'écoute. Il perçoit le rythme, la pulsation, la faille.\nUn souvenir de Gorn résonne : « Le roc le plus solide est celui qui a dansé avec l\'eau. »\nVell se laisse aller. En vibrant avec l\'onde, il amplifie le courant.\nMais la Cascade ne se laissera pas ouvrir sans combat.',
completeText: 'Les roches cèdent, l\'eau s\'ouvre. Vell a trouvé sa force : maîtrise, patience, résonance.\nIl n\'est plus une force brute. Il est un écho, une réponse.\nVous émergez transformés, épuisés mais éveillés. L\'eau vibre autour de vous, vous reconnaissant comme héritiers du chant.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Cascade Éveillée') ?? null,
objectiveCount: 1,
rewardXp: 2000,
rewardGold: 800,
rewardTitle: 'Maître du Torrent',
arcId: arc6.id,
arcOrder: 4,
zone: 'torrent_brise',
minLevel: 23,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 7 — LA SOURCE DU COURANT (ch.12)
// "Le lieu légendaire où le Chant est né."
// ═══════════════════════════════════════════════════
let arc7 = await arcRepo.findOne({ where: { name: 'La Source du Courant' } });
if (!arc7) {
arc7 = await arcRepo.save(arcRepo.create({
name: 'La Source du Courant',
description: 'Le courant vous guide vers des terres inconnues où la lumière devient plus claire, presque éthérée.',
zone: 'source_courant',
sortOrder: 7,
minLevel: 24,
}));
}
const arc7Quests = [
{
name: 'Les Gardiens Sacrés',
description: 'La Source est protégée. Des gardiens anciens testent ceux qui s\'approchent.',
acceptText: 'Le sol change, les pierres deviennent translucides, le courant chante. Vous approchez d\'un lieu légendaire.\nDevant vous, des formes lumineuses se matérialisent — les Gardiens de la Vasque. Ils ne sont pas hostiles. Ils évaluent.',
completeText: 'Les Gardiens s\'écartent. Vous avez été jugés dignes. La vasque de pierre apparaît, creusée par le temps, où l\'eau jaillit en fils de lumière.\nMira recule, les yeux écarquillés : « C\'est... magnifique. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Gardien de la Vasque') ?? null,
objectiveCount: 2,
rewardXp: 1200,
rewardGold: 500,
rewardTitle: null as string | null,
arcId: arc7.id,
arcOrder: 1,
zone: 'source_courant',
minLevel: 24,
repeatable: false,
},
{
name: 'Les Trois Visions',
description: 'La Source vous enveloppe. Chacun de vous reçoit une vision.',
acceptText: 'La Source vibre. Trois vagues s\'en détachent, vous enveloppant.\nVous voyez l\'étang renaître, inondé de chant. Gorn est là, devenu grenouille à la peau d\'argent, le regard paisible. Il murmure : « Le courant ne se possède pas. Il se transmet. Porte-le, et fais-le vivre. »\nMira flotte dans une eau sans fin, son chant se mêlant à celui de milliers de voix.\nVell se voit face à lui-même, plus grand, plus calme. Une force tranquille.',
completeText: 'Quand les vagues se dissipent, vous vous regardez. Transformés.\nDans la vasque, un filament de lumière violette s\'élève, spiralant dans l\'air, se divisant en trois. Il entre en vous, scellant votre lien au chant.\nVous comprenez, sans mots, que vous êtes désormais capables de cultiver le Dao du Courant.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 1500,
rewardGold: 600,
rewardTitle: null,
arcId: arc7.id,
arcOrder: 2,
zone: null as string | null,
minLevel: 25,
repeatable: false,
},
{
name: 'L\'Esprit du Chant',
description: 'Un dernier esprit erre près de la vasque. Il porte le fragment final — celui que Gorn n\'a jamais pu atteindre.',
acceptText: 'Tetardtek tend une nageoire : l\'eau y répond, traçant un cercle lumineux.\nMira chante doucement : les remous s\'harmonisent autour d\'elle.\nVell ferme les yeux, et une onde de force pulse dans l\'eau.\nMais quelque chose résiste. L\'Esprit du Chant, dernier écho de la mélodie perdue, ne vous laissera partir qu\'avec sa bénédiction.',
completeText: 'L\'Esprit se dissipe en une note pure qui résonne longtemps dans la vasque. Le Fragment du Chant se dépose entre vos nageoires.\nVous fermez les yeux. Vous ressentez tout : la Source, l\'étang, l\'Hydre. Tout est relié.\n« Nous sommes prêts. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Esprit du Chant') ?? null,
objectiveCount: 1,
rewardXp: 2000,
rewardGold: 800,
rewardTitle: null,
arcId: arc7.id,
arcOrder: 3,
zone: 'source_courant',
minLevel: 25,
repeatable: false,
},
{
name: 'L\'Héritage du Courant',
description: 'Le Dao du Courant coule en vous. Le chant est presque complet. L\'étang vous attend.',
acceptText: 'L\'eau vibre, et le courant vous pousse doucement vers l\'aval.\nMira : « Ce n\'est pas fini. L\'Hydre souffre encore. Et maintenant, nous avons le pouvoir de la guérir. »\nVell, le regard posé vers l\'horizon : « Si le courant nous appelle, nous devons répondre. »\nLe retour commence. L\'Hydre attend. Et le Chant attend d\'être chanté.',
completeText: 'Vous portez le Dao du Courant. Les fragments du Chant vibrent en vous.\nL\'heure du retour a sonné. L\'étang, brisé, attend sa guérison.\nEt l\'Hydre aussi.\n\n— L\'Odyssée continue...',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 2500,
rewardGold: 1000,
rewardTitle: 'Héritier du Chant',
arcId: arc7.id,
arcOrder: 4,
zone: null,
minLevel: 25,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// SEED
// ═══════════════════════════════════════════════════
const allQuests = [...arc4Quests, ...arc5Quests, ...arc6Quests, ...arc7Quests];
for (const q of allQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create(q));
questsAdded++;
}
}
// Quêtes répétables (grind léger entre les arcs — optionnel, pas obligatoire pour l'histoire)
const dailyQuests = [
{ name: 'Éclats quotidiens', description: 'Récoltez des Éclats de Miroir dans le Ruisseau.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 300, rewardGold: 150, zone: 'ruisseau_miroir', minLevel: 16 },
{ name: 'Brumes du jour', description: 'Récoltez de la Mousse Murmurante dans le Marais.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Mousse Murmurante'), objectiveCount: 2, rewardXp: 400, rewardGold: 200, zone: 'marais_murmures', minLevel: 19 },
{ name: 'Pierres du Torrent', description: 'Récoltez des Pierres de Torrent.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Pierre de Torrent'), objectiveCount: 2, rewardXp: 500, rewardGold: 250, zone: 'torrent_brise', minLevel: 22 },
];
for (const q of dailyQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create({ ...q, rewardTitle: null, arcId: null, arcOrder: 0, repeatable: true, acceptText: null, completeText: null }));
questsAdded++;
}
}
console.log(`✅ Odyssée: ${questsAdded} quêtes (4 arcs narratifs + ${dailyQuests.length} répétables)`);
}

View File

@@ -0,0 +1,298 @@
import { DataSource } from 'typeorm';
/**
* Seed Phase 3 — L'Odyssée d'un têtard
* Zones 4-7 : Ruisseau Miroir, Marais des Murmures, Torrent Brisé, Source du Courant
* Monstres, items, matériaux, NPCs
*/
// ── MONSTRES ──
const MONSTERS = [
// Ruisseau Miroir (niv 13-15) — ennemis = reflets, illusions, créatures de cristal
// Commence pile après le Sphinx (niv 12-15) — le joueur est ~niv 13
{ name: 'Reflet Sombre', zone: 'ruisseau_miroir', minLevel: 12, maxLevel: 14, hp: 250, attack: 28, defense: 12, attackType: 'magic', xpReward: 95, goldMin: 55, goldMax: 130, dropMaterialId: null },
{ name: 'Gerris de Cristal', zone: 'ruisseau_miroir', minLevel: 13, maxLevel: 15, hp: 200, attack: 32, defense: 8, attackType: 'ranged', xpReward: 105, goldMin: 60, goldMax: 140, dropMaterialId: null },
{ name: 'Miroir Brisé', zone: 'ruisseau_miroir', minLevel: 13, maxLevel: 15, hp: 280, attack: 26, defense: 16, attackType: 'magic', xpReward: 115, goldMin: 65, goldMax: 150, dropMaterialId: null },
{ name: 'Écho de Doute', zone: 'ruisseau_miroir', minLevel: 14, maxLevel: 16, hp: 320, attack: 30, defense: 14, attackType: 'magic', xpReward: 130, goldMin: 70, goldMax: 165, dropMaterialId: null },
{ name: 'Gardien du Reflet', zone: 'ruisseau_miroir', minLevel: 14, maxLevel: 17, hp: 450, attack: 36, defense: 18, attackType: 'magic', xpReward: 180, goldMin: 90, goldMax: 220, dropMaterialId: null },
// Marais des Murmures (niv 16-18) — spectres, brume, mémoire
{ name: 'Spectre de Brume', zone: 'marais_murmures', minLevel: 15, maxLevel: 17, hp: 350, attack: 34, defense: 10, attackType: 'magic', xpReward: 140, goldMin: 75, goldMax: 180, dropMaterialId: null },
{ name: 'Crapaud Ancien', zone: 'marais_murmures', minLevel: 16, maxLevel: 18, hp: 400, attack: 30, defense: 18, attackType: 'melee', xpReward: 155, goldMin: 80, goldMax: 195, dropMaterialId: null },
{ name: 'Murmure Incarné', zone: 'marais_murmures', minLevel: 16, maxLevel: 18, hp: 380, attack: 38, defense: 12, attackType: 'magic', xpReward: 165, goldMin: 85, goldMax: 210, dropMaterialId: null },
{ name: 'Liane Étrangleuse', zone: 'marais_murmures', minLevel: 17, maxLevel: 19, hp: 440, attack: 32, defense: 20, attackType: 'melee', xpReward: 175, goldMin: 90, goldMax: 225, dropMaterialId: null },
{ name: 'Mémoire de l\'Hydre', zone: 'marais_murmures', minLevel: 17, maxLevel: 20, hp: 580, attack: 42, defense: 22, attackType: 'magic', xpReward: 240, goldMin: 120, goldMax: 300, dropMaterialId: null },
// Torrent Brisé (niv 19-21) — élémentaux d'eau, force brute, courant violent
{ name: 'Élémental de Remous', zone: 'torrent_brise', minLevel: 18, maxLevel: 20, hp: 480, attack: 40, defense: 16, attackType: 'melee', xpReward: 195, goldMin: 100, goldMax: 250, dropMaterialId: null },
{ name: 'Rapide d\'Écume', zone: 'torrent_brise', minLevel: 19, maxLevel: 21, hp: 380, attack: 46, defense: 10, attackType: 'ranged', xpReward: 210, goldMin: 105, goldMax: 265, dropMaterialId: null },
{ name: 'Roc Vivant', zone: 'torrent_brise', minLevel: 19, maxLevel: 21, hp: 600, attack: 36, defense: 28, attackType: 'melee', xpReward: 225, goldMin: 110, goldMax: 280, dropMaterialId: null },
{ name: 'Tourbillon Furieux', zone: 'torrent_brise', minLevel: 20, maxLevel: 22, hp: 520, attack: 44, defense: 18, attackType: 'magic', xpReward: 240, goldMin: 115, goldMax: 300, dropMaterialId: null },
{ name: 'Cascade Éveillée', zone: 'torrent_brise', minLevel: 20, maxLevel: 23, hp: 720, attack: 50, defense: 24, attackType: 'melee', xpReward: 320, goldMin: 150, goldMax: 400, dropMaterialId: null },
// Source du Courant (niv 22-25) — gardiens sacrés, lumière, épreuves
{ name: 'Gardien de la Vasque', zone: 'source_courant', minLevel: 21, maxLevel: 23, hp: 650, attack: 48, defense: 22, attackType: 'magic', xpReward: 280, goldMin: 140, goldMax: 350, dropMaterialId: null },
{ name: 'Onde Sentinelle', zone: 'source_courant', minLevel: 22, maxLevel: 24, hp: 550, attack: 52, defense: 18, attackType: 'ranged', xpReward: 300, goldMin: 150, goldMax: 380, dropMaterialId: null },
{ name: 'Esprit du Chant', zone: 'source_courant', minLevel: 22, maxLevel: 25, hp: 700, attack: 46, defense: 26, attackType: 'magic', xpReward: 330, goldMin: 160, goldMax: 420, dropMaterialId: null },
{ name: 'Prisme Ancien', zone: 'source_courant', minLevel: 23, maxLevel: 25, hp: 800, attack: 54, defense: 24, attackType: 'magic', xpReward: 360, goldMin: 180, goldMax: 460, dropMaterialId: null },
{ name: 'Avatar du Courant', zone: 'source_courant', minLevel: 23, maxLevel: 25, hp: 1000, attack: 60, defense: 30, attackType: 'magic', xpReward: 500, goldMin: 250, goldMax: 600, dropMaterialId: null },
];
// ── MATÉRIAUX ──
const MATERIALS = [
// Ruisseau Miroir
{ name: 'Éclat de Miroir', rarity: 'rare', description: 'Fragment de verre cristallin tombé du Ruisseau. Reflète une lumière étrange.' },
{ name: 'Larme de Cristal', rarity: 'rare', description: 'Goutte d\'eau solidifiée. On dit qu\'elle contient un souvenir.' },
{ name: 'Essence de Reflet', rarity: 'epic', description: 'L\'âme d\'un reflet vaincu. Vibre doucement entre les doigts.' },
// Marais des Murmures
{ name: 'Mousse Murmurante', rarity: 'rare', description: 'Mousse qui chuchote quand on l\'approche de l\'oreille.' },
{ name: 'Racine de Mémoire', rarity: 'rare', description: 'Racine noueuse imprégnée des souvenirs du marais.' },
{ name: 'Brume Condensée', rarity: 'epic', description: 'Fiole de brume pure. Les murmures y sont encore audibles.' },
// Torrent Brisé
{ name: 'Pierre de Torrent', rarity: 'rare', description: 'Roche polie par des siècles de courant furieux.' },
{ name: 'Écume Solidifiée', rarity: 'rare', description: 'Écume blanche durcie par la force du torrent.' },
{ name: 'Cœur de Cascade', rarity: 'epic', description: 'Noyau cristallin arraché au cœur de la cascade éveillée.' },
// Source du Courant
{ name: 'Eau de la Source', rarity: 'epic', description: 'Eau pure de la Source. Brille d\'une lumière douce.' },
{ name: 'Fragment du Chant', rarity: 'legendary', description: 'Morceau de la mélodie perdue. Vibre d\'une harmonie ancienne.' },
{ name: 'Filament Violet', rarity: 'legendary', description: 'Fil de lumière violette — le lien du Dao du Courant.' },
];
// ── ITEMS ──
const ITEMS = [
// Ruisseau Miroir (niv 16-18)
{ name: 'Lame du Reflet', type: 'weapon', rarity: 'epic', attackBonus: 30, defenseBonus: 0, buyPrice: 2000, minLevel: 13, zone: 'ruisseau_miroir', description: 'Une épée qui montre la vérité à celui qu\'elle frappe.' },
{ name: 'Bouclier Miroir', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 22, buyPrice: 1800, minLevel: 13, zone: 'ruisseau_miroir', description: 'Renvoie un reflet déformé des attaques.' },
{ name: 'Dague de Cristal', type: 'weapon', rarity: 'rare', attackBonus: 26, defenseBonus: 0, buyPrice: 1500, minLevel: 12, zone: 'ruisseau_miroir', description: 'Transparente et tranchante comme un reproche.' },
// Marais des Murmures (niv 19-21)
{ name: 'Bâton des Murmures', type: 'weapon', rarity: 'epic', attackBonus: 34, defenseBonus: 0, intelligenceBonus: 5, buyPrice: 2800, minLevel: 16, zone: 'marais_murmures', description: 'Les murmures du marais guident chaque coup.' },
{ name: 'Robe de Brume', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 26, buyPrice: 2500, minLevel: 16, zone: 'marais_murmures', description: 'Tissée de brume vivante. Dissimule et protège.' },
{ name: 'Amulette de la Batracienne', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 20, intelligenceBonus: 8, vitaliteBonus: 3, buyPrice: 3200, minLevel: 17, zone: 'marais_murmures', description: 'Don de la sage du marais. Pulse de lumière verte.' },
// Torrent Brisé (niv 22-24)
{ name: 'Masse du Torrent', type: 'weapon', rarity: 'epic', attackBonus: 40, defenseBonus: 0, forceBonus: 5, buyPrice: 3800, minLevel: 19, zone: 'torrent_brise', description: 'Lourde comme le courant. Chaque coup résonne.' },
{ name: 'Armure d\'Écume', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 30, buyPrice: 3500, minLevel: 19, zone: 'torrent_brise', description: 'L\'écume durcie absorbe les chocs comme l\'eau absorbe la pierre.' },
{ name: 'Gantelet de Résonance', type: 'armor', rarity: 'epic', attackBonus: 5, defenseBonus: 24, forceBonus: 6, buyPrice: 4000, minLevel: 20, zone: 'torrent_brise', description: 'Amplifie la force par l\'harmonie. Forgé dans le torrent.' },
// Source du Courant (niv 25+)
{ name: 'Lame de la Source', type: 'weapon', rarity: 'legendary', attackBonus: 50, defenseBonus: 0, intelligenceBonus: 8, chanceBonus: 5, buyPrice: 6000, minLevel: 22, zone: 'source_courant', description: 'Forgée dans l\'eau de la Source. Le Chant résonne à chaque frappe.' },
{ name: 'Armure du Dao', type: 'armor', rarity: 'legendary', attackBonus: 0, defenseBonus: 38, vitaliteBonus: 10, buyPrice: 5500, minLevel: 22, zone: 'source_courant', description: 'Imprégnée du Courant lui-même. Protège le corps et l\'esprit.' },
{ name: 'Bâton de Gorn', type: 'weapon', rarity: 'legendary', attackBonus: 45, defenseBonus: 5, intelligenceBonus: 12, buyPrice: 7000, minLevel: 22, zone: 'source_courant', description: 'L\'héritage de l\'ancien. Brille d\'une lumière dorée quand le Chant résonne.' },
// Potions avancées
{ name: 'Élixir du Courant', type: 'consumable', rarity: 'epic', attackBonus: 0, defenseBonus: 0, forceBonus: 100, buyPrice: 80, minLevel: 13, zone: null, description: 'Restaure 100 points d\'endurance. L\'eau pure revitalise.' },
{ name: 'Potion de soin majeure', type: 'consumable', rarity: 'epic', attackBonus: 0, defenseBonus: 0, forceBonus: 0, buyPrice: 60, minLevel: 13, zone: null, description: 'Restaure 80% des PV.' },
];
// ── NPCs ──
const NPCS = [
// Village — PNJ permanents
{
name: 'Gorn',
role: 'mentor',
location: 'village_quests',
description: 'L\'ancien de l\'étang. Sage, grave, bienveillant. Porte le poids du savoir.',
lore: 'On dit que Gorn a effleuré la Pierre-Mémoire et n\'a jamais été le même depuis.',
spriteKey: 'npc_gorn',
minLevel: 1,
maxLevel: 15, // Disparaît après le sacrifice (ch.7)
dialogues: [
{ trigger: 'default', text: 'Bienvenue, jeune têtard. Le courant te porte ici pour une raison.', action: 'open_quests' },
{ trigger: 'level_3', text: 'Tu as grandi. Le courant murmure ton nom, l\'entends-tu ?', action: 'open_quests' },
{ trigger: 'level_5', text: 'Le Dao du Courant... peu en parlent, peu en savent la voie. Mais toi, tu commences à comprendre.', action: 'open_quests' },
{ trigger: 'level_10', text: 'Viens. Il est temps que je te montre la Pierre-Mémoire. Ce que peu ont vu.', action: 'open_quests' },
{ trigger: 'arc_completed:egouts', text: 'Tu descends de plus en plus profond. Bientôt, tu verras ce que moi-même j\'ai tenté d\'oublier.', action: 'open_quests' },
],
},
{
name: 'Pierre-Mémoire',
role: 'quest_giver',
location: 'village_quests',
description: 'Pierre ancienne gravée de symboles. Les échos de Gorn y résonnent encore.',
lore: 'La Pierre-Mémoire existe depuis avant l\'étang. Elle porte les souvenirs de ceux qui l\'ont touchée.',
spriteKey: 'npc_pierre_memoire',
minLevel: 16, // Apparaît quand Gorn disparaît
maxLevel: null,
dialogues: [
{ trigger: 'default', text: '« Le courant t\'attend. Va. » — les mots de Gorn résonnent dans la pierre.', action: 'open_quests' },
{ trigger: 'level_20', text: 'La pierre pulse. Des visions anciennes dansent sur sa surface — des batraciens chantant sous une lune violette.', action: 'open_quests' },
{ trigger: 'level_25', text: 'La Pierre-Mémoire brille intensément. Le Chant complet est presque là. « Continue. »', action: 'open_quests' },
],
},
{
name: 'Mira',
role: 'companion',
location: 'village_plaza',
description: 'Douce, intuitive, silencieusement puissante. Connectée au Courant sans le savoir.',
lore: 'Mira fredonne des mélodies aquatiques. L\'eau semble la porter, la guider, la compléter.',
spriteKey: 'npc_mira',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Salut ! Tu as vu les nénuphars aujourd\'hui ? Ils brillent autrement...', action: 'heal' },
{ trigger: 'level_5', text: 'Je ne sais pas pourquoi, mais quand je nage, l\'eau me parle. Tu crois que c\'est normal ?', action: 'heal' },
{ trigger: 'level_10', text: 'Le chant guérit. Mais il faut l\'entendre. Et l\'accepter.', action: 'heal' },
{ trigger: 'arc_completed:desert', text: 'L\'étang souffre. Je le sens dans chaque remous. On doit faire quelque chose.', action: 'heal' },
{ trigger: 'level_16', text: 'Le Ruisseau Miroir m\'effraie. Il montre ce qu\'on ne veut pas voir... mais je viendrai avec toi.', action: 'heal' },
{ trigger: 'level_20', text: 'J\'ai accepté mon chant, Tetardtek. Et ensemble, nous sommes plus que nos reflets.', action: 'heal' },
{ trigger: 'level_25', text: 'La Source nous attend. Je sens le Chant, complet, qui vibre dans l\'eau. Allons-y.', action: 'heal' },
],
},
{
name: 'Vell',
role: 'rival',
location: 'village_arena',
description: 'Impétueux, fier, loyal. Confond d\'abord force et valeur.',
lore: 'Vell ne pense qu\'à défier ses compagnons en vitesse. Sa force physique est indéniable.',
spriteKey: 'npc_vell',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Eh ! On fait la course ? Je te laisse même de l\'avance.', action: 'challenge' },
{ trigger: 'level_5', text: 'Tu t\'es laissé porter par quelque chose à la course. Ce n\'était pas de la vitesse... c\'était autre chose.', action: 'challenge' },
{ trigger: 'level_10', text: 'J\'étais fort, mais je n\'ai rien pu faire quand ça a compté. Je dois comprendre la vraie force.', action: 'challenge' },
{ trigger: 'arc_completed:desert', text: 'L\'étang est brisé. Si ma force ne peut pas protéger, à quoi sert-elle ? ...Je viens avec toi.', action: 'challenge' },
{ trigger: 'level_20', text: 'Le Torrent m\'a montré. La vraie force, c\'est de ne pas lutter seul.', action: 'challenge' },
{ trigger: 'level_25', text: 'Nous sommes un flot, pas des gouttes isolées. Allons restaurer le Chant.', action: 'challenge' },
],
},
// PNJ de zone
{
name: 'La Batracienne',
role: 'sage',
location: 'marais_murmures',
description: 'Sage ancienne vêtue de lianes et d\'algues. Ses yeux voilés voient plus loin que les nôtres.',
lore: 'Elle vit dans le Marais depuis des générations. On dit qu\'elle a vu le Chant originel.',
spriteKey: 'npc_batracienne',
minLevel: 19,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Bienvenue, voyageurs. Le Marais vous attendait.', action: 'open_quests' },
{ trigger: 'level_20', text: 'Tu portes le chant en germe, petit têtard. Mais avant la source, il faut comprendre le courant.', action: 'open_quests' },
{ trigger: 'level_22', text: 'L\'Hydre était la clef... Elle est la mémoire déformée. Vous devez la guérir, pas la fuir.', action: 'open_quests' },
],
},
// Marchands existants (formalisés comme PNJ)
{
name: 'Le Forgeron',
role: 'merchant',
location: 'village_forge',
description: 'Un vieux crapaud aux bras musclés. Son enclume résonne jour et nuit.',
lore: 'Il forge des armes depuis plus longtemps que quiconque se souvient.',
spriteKey: 'npc_forgeron',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Apporte-moi des matériaux et je te forgerai quelque chose de solide.', action: 'open_forge' },
{ trigger: 'level_10', text: 'Tes armes grandissent avec toi. Bientôt tu auras besoin d\'acier des profondeurs.', action: 'open_forge' },
{ trigger: 'level_20', text: 'J\'ai entendu parler de matériaux dans le Torrent... si tu en rapportes, je pourrai forger du légendaire.', action: 'open_forge' },
],
},
{
name: 'Le Marchand',
role: 'merchant',
location: 'village_shop',
description: 'Un triton jovial qui a toujours ce qu\'il faut.',
lore: 'Personne ne sait d\'où il vient. Ses marchandises changent avec les zones que vous explorez.',
spriteKey: 'npc_marchand',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Bienvenue ! J\'ai de belles trouvailles aujourd\'hui.', action: 'open_shop' },
{ trigger: 'level_15', text: 'Les temps sont durs depuis l\'Hydre. Mais j\'ai de l\'équipement qui pourrait t\'aider au-delà de l\'étang.', action: 'open_shop' },
{ trigger: 'level_25', text: 'La Source... tu y vas vraiment ? Tiens, prends ça. Tu en auras besoin.', action: 'open_shop' },
],
},
];
// ── SEED FUNCTION ──
export async function seedOdyssee(dataSource: DataSource) {
let monstersAdded = 0;
let itemsAdded = 0;
let materialsAdded = 0;
let npcsAdded = 0;
// --- Monstres ---
for (const m of MONSTERS) {
const existing = await dataSource.query('SELECT id FROM monsters WHERE name = ?', [m.name]);
if (existing.length === 0) {
await dataSource.query(
`INSERT INTO monsters (id, name, zone, min_level, max_level, hp, attack, defense, attack_type, xp_reward, gold_min, gold_max, drop_material_id)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.name, m.zone, m.minLevel, m.maxLevel, m.hp, m.attack, m.defense, m.attackType, m.xpReward, m.goldMin, m.goldMax, m.dropMaterialId],
);
monstersAdded++;
}
}
// --- Matériaux ---
const materialRepo = dataSource.getRepository('Material');
const materialIdMap: Record<string, string> = {};
for (const mat of MATERIALS) {
let existing = await materialRepo.findOne({ where: { name: mat.name } });
if (!existing) {
existing = await materialRepo.save(materialRepo.create(mat));
materialsAdded++;
}
materialIdMap[mat.name] = existing.id;
}
// Lier les drops aux monstres
const dropLinks: Record<string, string> = {
'Reflet Sombre': 'Éclat de Miroir',
'Miroir Brisé': 'Larme de Cristal',
'Gardien du Reflet': 'Essence de Reflet',
'Spectre de Brume': 'Mousse Murmurante',
'Murmure Incarné': 'Racine de Mémoire',
'Mémoire de l\'Hydre': 'Brume Condensée',
'Élémental de Remous': 'Pierre de Torrent',
'Roc Vivant': 'Écume Solidifiée',
'Cascade Éveillée': 'Cœur de Cascade',
'Gardien de la Vasque': 'Eau de la Source',
'Esprit du Chant': 'Fragment du Chant',
'Avatar du Courant': 'Filament Violet',
};
for (const [monsterName, materialName] of Object.entries(dropLinks)) {
const matId = materialIdMap[materialName];
if (matId) {
await dataSource.query(
'UPDATE monsters SET drop_material_id = ? WHERE name = ?',
[matId, monsterName],
);
}
}
// --- Items ---
const itemRepo = dataSource.getRepository('Item');
for (const item of ITEMS) {
const existing = await itemRepo.findOne({ where: { name: item.name } });
if (!existing) {
await itemRepo.save(itemRepo.create(item));
itemsAdded++;
}
}
// --- NPCs ---
const npcRepo = dataSource.getRepository('Npc');
for (const npc of NPCS) {
const existing = await npcRepo.findOne({ where: { name: npc.name } });
if (!existing) {
await npcRepo.save(npcRepo.create(npc));
npcsAdded++;
}
}
console.log(`✅ Odyssée seed: ${monstersAdded} monstres, ${materialsAdded} matériaux, ${itemsAdded} items, ${npcsAdded} NPCs`);
}

View File

@@ -0,0 +1,20 @@
import 'reflect-metadata';
import { AppDataSource } from './data-source';
import { seedOdyssee } from './odyssee-seed';
import { seedOdysseeQuests } from './odyssee-quests-seed';
async function seed() {
await AppDataSource.initialize();
console.log('DB connectée (MySQL)');
await seedOdyssee(AppDataSource);
await seedOdysseeQuests(AppDataSource);
await AppDataSource.destroy();
console.log('✅ Odyssée seed terminé');
}
seed().catch((err) => {
console.error('Seed échoué :', err);
process.exit(1);
});

23
src/npc/npc.controller.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { NpcService } from './npc.service';
import { AuthGuard } from '../auth/guards/auth.guard';
@Controller('api/npcs')
@UseGuards(AuthGuard)
export class NpcController {
constructor(private readonly npcService: NpcService) {}
/** GET /api/npcs — tous les PNJ visibles pour le joueur */
@Get()
async getAll(@Req() req: any) {
const { characterId, level } = req.character;
return this.npcService.getVisibleNpcs(characterId, level);
}
/** GET /api/npcs?location=village_plaza — PNJ d'un emplacement */
@Get('location')
async getByLocation(@Req() req: any, @Query('location') location: string) {
const { characterId, level } = req.character;
return this.npcService.getNpcsByLocation(characterId, level, location);
}
}

51
src/npc/npc.entity.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export type NpcRole = 'mentor' | 'companion' | 'merchant' | 'quest_giver' | 'sage' | 'rival';
@Entity('npcs')
export class Npc {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'varchar', length: 20 })
role: NpcRole;
/** Emplacement dans le village ou zone du monde */
@Column({ type: 'varchar', length: 50 })
location: string; // 'village_plaza' | 'village_arena' | 'village_quests' | 'village_forge' | 'village_shop' | zone name
@Column({ type: 'text', nullable: true })
description: string | null;
/** Lore courte affichée dans le hub */
@Column({ type: 'text', nullable: true })
lore: string | null;
/** Niveau minimum du joueur pour voir ce PNJ */
@Column({ name: 'min_level', default: 1 })
minLevel: number;
/** Niveau max de visibilité (null = toujours visible après minLevel) */
@Column({ name: 'max_level', type: 'int', nullable: true })
maxLevel: number | null;
/** Clé sprite pour le frontend */
@Column({ name: 'sprite_key', type: 'varchar', length: 50, nullable: true })
spriteKey: string | null;
/** Dialogues évolutifs — JSON array trié par priorité */
@Column({ type: 'json', nullable: true })
dialogues: NpcDialogue[] | null;
}
export interface NpcDialogue {
/** Condition de déclenchement */
trigger: string; // 'default' | 'level_5' | 'level_15' | 'arc_completed:desert' | 'story:gorn_sacrifice' | etc.
/** Texte affiché */
text: string;
/** Action proposée (optionnel) */
action?: string; // 'open_quests' | 'open_shop' | 'open_forge' | 'heal' | 'challenge'
}

14
src/npc/npc.module.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Npc } from './npc.entity';
import { NpcController } from './npc.controller';
import { NpcService } from './npc.service';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
@Module({
imports: [TypeOrmModule.forFeature([Npc, PlayerQuestArc])],
controllers: [NpcController],
providers: [NpcService],
exports: [NpcService],
})
export class NpcModule {}

117
src/npc/npc.service.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { Npc, NpcDialogue } from './npc.entity';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
export interface NpcView {
id: string;
name: string;
role: string;
location: string;
description: string | null;
lore: string | null;
spriteKey: string | null;
dialogue: string;
action?: string;
}
@Injectable()
export class NpcService {
constructor(
@InjectRepository(Npc)
private readonly npcRepo: Repository<Npc>,
@InjectRepository(PlayerQuestArc)
private readonly playerArcRepo: Repository<PlayerQuestArc>,
) {}
/**
* Retourne les PNJ visibles pour un joueur donné, avec le bon dialogue résolu.
*/
async getVisibleNpcs(characterId: string, playerLevel: number): Promise<NpcView[]> {
// Tous les PNJ dont le joueur est dans la fourchette de niveaux
const npcs = await this.npcRepo.find({
where: {
minLevel: LessThanOrEqual(playerLevel),
},
order: { location: 'ASC', name: 'ASC' },
});
// Filtrer par maxLevel (null = pas de limite)
const visible = npcs.filter(
(npc) => npc.maxLevel === null || playerLevel <= npc.maxLevel,
);
// Arcs complétés par ce joueur (pour résoudre les dialogues conditionnels)
const completedArcs = await this.playerArcRepo.find({
where: { characterId, completed: true },
relations: ['questArc'],
});
const completedArcZones = new Set(
completedArcs.map((pa) => pa.questArc?.zone).filter((z): z is string => z != null),
);
return visible.map((npc) => {
const resolved = this.resolveDialogue(npc.dialogues, playerLevel, completedArcZones);
return {
id: npc.id,
name: npc.name,
role: npc.role,
location: npc.location,
description: npc.description,
lore: npc.lore,
spriteKey: npc.spriteKey,
dialogue: resolved.text,
action: resolved.action,
};
});
}
/**
* Retourne les PNJ d'un emplacement spécifique (ex: 'village_plaza')
*/
async getNpcsByLocation(characterId: string, playerLevel: number, location: string): Promise<NpcView[]> {
const all = await this.getVisibleNpcs(characterId, playerLevel);
return all.filter((npc) => npc.location === location);
}
/** Résout le dialogue le plus pertinent selon l'état du joueur */
private resolveDialogue(
dialogues: NpcDialogue[] | null,
playerLevel: number,
completedArcZones: Set<string>,
): { text: string; action?: string } {
if (!dialogues || dialogues.length === 0) {
return { text: '...' };
}
// Parcours par priorité (dernière condition valide gagne)
let best: NpcDialogue = dialogues[0];
for (const d of dialogues) {
if (d.trigger === 'default') {
// Default = fallback, déjà capturé
continue;
}
// Trigger par niveau : "level_15" → joueur >= 15
const levelMatch = d.trigger.match(/^level_(\d+)$/);
if (levelMatch && playerLevel >= parseInt(levelMatch[1], 10)) {
best = d;
continue;
}
// Trigger par arc complété : "arc_completed:desert"
const arcMatch = d.trigger.match(/^arc_completed:(.+)$/);
if (arcMatch && completedArcZones.has(arcMatch[1])) {
best = d;
continue;
}
// Trigger story (future — quand on aura un story tracker)
// Pour l'instant, les triggers story sont ignorés
}
return { text: best.text, action: best.action };
}
}

View File

@@ -19,9 +19,17 @@ export class Quest {
@Column('text')
description: string;
/** Texte narratif affiché quand le joueur accepte la quête (le PNJ parle) */
@Column({ name: 'accept_text', type: 'text', nullable: true })
acceptText: string | null;
/** Texte narratif affiché quand la quête est complétée (conclusion de la scène) */
@Column({ name: 'complete_text', type: 'text', nullable: true })
completeText: string | null;
// Objectif
@Column({ name: 'objective_type', length: 30 })
objectiveType: string; // 'kill_monster' | 'kill_any' | 'gather_material' | 'craft_item' | 'forge_item'
objectiveType: string; // 'kill_monster' | 'kill_any' | 'gather_material' | 'craft_item' | 'forge_item' | 'story_event'
@Column({ name: 'objective_target_id', type: 'varchar', length: 255, nullable: true })
objectiveTargetId: string | null; // monster ID or material ID (null for kill_any)

View File

@@ -128,11 +128,15 @@ export class QuestService {
return this.playerQuestRepo.save(existing);
}
// story_event quests complete immediately — they're narrative moments, not grinds
const isStoryEvent = quest.objectiveType === 'story_event';
const pq = this.playerQuestRepo.create({
characterId,
questId,
progress: 0,
status: 'active',
progress: isStoryEvent ? 1 : 0,
status: isStoryEvent ? 'completed' : 'active',
completedAt: isStoryEvent ? new Date() : null,
});
return this.playerQuestRepo.save(pq);
}