From d1609efaae30d6de04d437a0bd2b703c20a14cb3 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 17:57:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20zone=20locking=20=E2=80=94=20progressio?= =?UTF-8?q?n=20par=20arcs=20narratifs=20+=20arcs=20=C3=89gouts/D=C3=A9sert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zones verrouillées: marais toujours ouvert, égouts après arc Marais, désert après arc Égouts. Filtrage backend sur monstres ET boutique. Arc "Les Égouts de la Cité" (4 quêtes, lv4-7, boss Roi des Rats) Arc "Les Sables Brûlants" (3 quêtes, lv8-12, boss Sphinx) GET /api/monsters/zones — retourne les zones avec statut unlocked. Combat page: monstres groupés par zone, zones lockées avec icône cadenas. Boutique: items filtrés par zones débloquées (potions toujours visibles). --- frontend/src/api/endpoints.ts | 1 + frontend/src/pages/CombatPage.tsx | 80 ++++++++++++++++++++++--------- src/common/zone-access.ts | 44 +++++++++++++++++ src/database/quests-seed.ts | 54 +++++++++++++++++++++ src/monster/monster.controller.ts | 50 +++++++++++++++++-- src/monster/monster.module.ts | 8 +++- src/shop/shop.module.ts | 4 +- src/shop/shop.service.ts | 10 ++++ 8 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 src/common/zone-access.ts diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3d807cd..2d27e08 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -24,6 +24,7 @@ export const characterApi = { // Combat export const combatApi = { + zones: () => api.get('/monsters/zones'), monsters: () => api.get('/monsters'), start: (monsterId: string, attackType: string) => api.post('/combat/start', { monsterId, attackType }), history: () => api.get('/combat/history'), diff --git a/frontend/src/pages/CombatPage.tsx b/frontend/src/pages/CombatPage.tsx index 729f1c1..d0d8b2a 100644 --- a/frontend/src/pages/CombatPage.tsx +++ b/frontend/src/pages/CombatPage.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { combatApi, characterApi } from '../api/endpoints'; import type { Monster, CombatResult, CombatLog } from '../api/types'; -import { Swords, Trophy, Skull, Clock, Zap, Heart } from 'lucide-react'; +import { Swords, Trophy, Skull, Clock, Zap, Heart, Lock } from 'lucide-react'; const COMBAT_COST = 5; const REST_COST = 10; @@ -127,6 +127,11 @@ export function CombatPage() { queryFn: combatApi.monsters, }); + const { data: zones } = useQuery({ + queryKey: ['zones'], + queryFn: combatApi.zones, + }); + const { data: history } = useQuery({ queryKey: ['combatHistory'], queryFn: combatApi.history, @@ -144,35 +149,64 @@ export function CombatPage() { if (isLoading) return
Chargement des monstres…
; - // Trier : monstres appropriés en haut, trop forts en bas - const sorted = [...(monsters ?? [])].sort((a, b) => { - const aOk = a.minLevel <= playerLevel + 2 ? 0 : 1; - const bOk = b.minLevel <= playerLevel + 2 ? 0 : 1; - if (aOk !== bOk) return aOk - bOk; - return a.minLevel - b.minLevel; - }); + // Group monsters by zone + const monstersByZone = new Map(); + for (const m of (monsters ?? [])) { + const zone = (m as any).zone ?? 'marais'; + const list = monstersByZone.get(zone) ?? []; + list.push(m); + monstersByZone.set(zone, list); + } + + const ZONE_LABELS: Record = { + marais: { name: 'Les Marais', emoji: '🌿' }, + egouts: { name: 'Les Égouts', emoji: '🕳️' }, + desert: { name: 'Le Désert', emoji: '🏜️' }, + }; + + // Locked zones (zones not in monsters response = locked) + const lockedZones = (zones ?? []).filter((z: any) => !z.unlocked); return (

⚔️ Combat

- {/* Choix monstre */} + {/* Choix monstre par zone */}
-

- Adversaire -

-
- {sorted.map(m => ( - setSelectedMonster(m)} - playerLevel={playerLevel} - /> - ))} -
+ {Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => { + const info = ZONE_LABELS[zone] ?? { name: zone, emoji: '📍' }; + return ( +
+

+ {info.emoji} {info.name} +

+
+ {zoneMonsters + .sort((a, b) => a.minLevel - b.minLevel) + .map(m => ( + setSelectedMonster(m)} + playerLevel={playerLevel} + /> + ))} +
+
+ ); + })} + + {/* Zones verrouillées */} + {lockedZones.map((z: any) => ( +
+ + + {z.emoji} {z.name} — Complétez l'arc précédent pour débloquer + +
+ ))}
{/* Panneau droite */} diff --git a/src/common/zone-access.ts b/src/common/zone-access.ts new file mode 100644 index 0000000..77005e6 --- /dev/null +++ b/src/common/zone-access.ts @@ -0,0 +1,44 @@ +import { Repository } from 'typeorm'; +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']; + +export async function getUnlockedZones( + characterId: string, + arcRepo: Repository, + playerArcRepo: Repository, +): Promise { + const unlocked: string[] = ['marais']; // always accessible + + // Get all completed arcs for this character + const completedArcs = await playerArcRepo.find({ + where: { characterId, completed: true }, + relations: ['questArc'], + }); + const completedArcZones = new Set(completedArcs.map(pa => pa.questArc?.zone).filter(Boolean)); + + // Check zone chain: each zone unlocks the next + for (let i = 0; i < ZONE_ORDER.length - 1; i++) { + const currentZone = ZONE_ORDER[i]; + const nextZone = ZONE_ORDER[i + 1]; + + // Find arc for current zone + const arc = await arcRepo.findOne({ where: { zone: currentZone } }); + if (!arc) continue; + + // If this zone's arc is completed, unlock the next zone + const isCompleted = completedArcs.some(pa => pa.questArcId === arc.id); + if (isCompleted) { + unlocked.push(nextZone); + } else { + break; // Can't skip zones + } + } + + return unlocked; +} diff --git a/src/database/quests-seed.ts b/src/database/quests-seed.ts index 4a46293..58823ae 100644 --- a/src/database/quests-seed.ts +++ b/src/database/quests-seed.ts @@ -156,6 +156,60 @@ export async function seedQuests(dataSource: DataSource) { } console.log(`✅ 1 arc + ${QUESTS.length + STANDALONE.length} quêtes + ${QUEST_ACHIEVEMENTS.length} achievements seedés`); + + // --- Arc 2: Les Égouts --- + let arcEgouts = await arcRepo.findOne({ where: { name: 'Les Égouts de la Cité' } }); + if (!arcEgouts) { + arcEgouts = await arcRepo.save(arcRepo.create({ + name: 'Les Égouts de la Cité', + description: 'Les égouts grouillent de créatures répugnantes. Nettoyez ces tunnels oubliés.', + zone: 'egouts', + sortOrder: 2, + minLevel: 4, + })); + + const ratId = monsterMap.get("Rat d'Égout") ?? null as string | null; + const crocoId = monsterMap.get('Crocodile') ?? null as string | null; + const roiId = monsterMap.get('Roi des Rats') ?? null as string | null; + + const EGOUTS_QUESTS = [ + { name: 'Dératisation', description: 'Les rats pullulent. Éliminez-en 5.', objectiveType: 'kill_monster', objectiveTargetId: ratId, objectiveCount: 5, rewardXp: 200, rewardGold: 80, rewardTitle: null, arcId: arcEgouts.id, arcOrder: 1, minLevel: 4, repeatable: false }, + { name: 'Chasseur des profondeurs', description: 'Remportez 15 combats dans les égouts.', objectiveType: 'kill_any', objectiveTargetId: null as string | null, objectiveCount: 15, rewardXp: 300, rewardGold: 120, rewardTitle: null, arcId: arcEgouts.id, arcOrder: 2, minLevel: 5, repeatable: false }, + { name: 'Le reptile', description: 'Terrassez 3 Crocodiles qui rôdent dans les canaux.', objectiveType: 'kill_monster', objectiveTargetId: crocoId, objectiveCount: 3, rewardXp: 400, rewardGold: 150, rewardTitle: null, arcId: arcEgouts.id, arcOrder: 3, minLevel: 6, repeatable: false }, + { name: 'Le Roi des Rats', description: 'Mettez fin au règne du Roi des Rats.', objectiveType: 'kill_monster', objectiveTargetId: roiId, objectiveCount: 1, rewardXp: 800, rewardGold: 400, rewardTitle: 'Nettoyeur des Égouts', arcId: arcEgouts.id, arcOrder: 4, minLevel: 7, repeatable: false }, + ]; + + for (const q of EGOUTS_QUESTS) { + await questRepo.save(questRepo.create(q)); + } + console.log(`✅ Arc Égouts + ${EGOUTS_QUESTS.length} quêtes seedés`); + } + + // --- Arc 3: Le Désert --- + let arcDesert = await arcRepo.findOne({ where: { name: 'Les Sables Brûlants' } }); + if (!arcDesert) { + arcDesert = await arcRepo.save(arcRepo.create({ + name: 'Les Sables Brûlants', + description: 'Le désert cache des trésors anciens et des créatures redoutables.', + zone: 'desert', + sortOrder: 3, + minLevel: 8, + })); + + const scorpId = monsterMap.get('Scorpion') ?? null as string | null; + const sphinxId = monsterMap.get('Sphinx') ?? null as string | null; + + const DESERT_QUESTS = [ + { name: 'Piqûres mortelles', description: 'Éliminez 5 Scorpions.', objectiveType: 'kill_monster', objectiveTargetId: scorpId, objectiveCount: 5, rewardXp: 400, rewardGold: 150, rewardTitle: null, arcId: arcDesert.id, arcOrder: 1, minLevel: 8, repeatable: false }, + { name: 'Survivant du désert', description: 'Remportez 20 combats dans le désert.', objectiveType: 'kill_any', objectiveTargetId: null as string | null, objectiveCount: 20, rewardXp: 600, rewardGold: 250, rewardTitle: null, arcId: arcDesert.id, arcOrder: 2, minLevel: 9, repeatable: false }, + { name: 'L\'énigme du Sphinx', description: 'Terrassez le Sphinx — gardien des sables.', objectiveType: 'kill_monster', objectiveTargetId: sphinxId, objectiveCount: 1, rewardXp: 1500, rewardGold: 800, rewardTitle: 'Conquérant du Désert', arcId: arcDesert.id, arcOrder: 3, minLevel: 12, repeatable: false }, + ]; + + for (const q of DESERT_QUESTS) { + await questRepo.save(questRepo.create(q)); + } + console.log(`✅ Arc Désert + ${DESERT_QUESTS.length} quêtes seedés`); + } } // Update monster XP rewards (nerf for quest-driven progression) diff --git a/src/monster/monster.controller.ts b/src/monster/monster.controller.ts index 8d1c794..9b12e70 100644 --- a/src/monster/monster.controller.ts +++ b/src/monster/monster.controller.ts @@ -1,14 +1,56 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, UseGuards, Req, BadRequestException } from '@nestjs/common'; import { MonsterService } from './monster.service'; import { AuthGuard } from '../auth/guards/auth.guard'; +import { Request } from 'express'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { PlayerQuestArc } from '../quest/player-quest-arc.entity'; +import { getUnlockedZones } from '../common/zone-access'; @Controller('monsters') @UseGuards(AuthGuard) export class MonsterController { - constructor(private readonly monsterService: MonsterService) {} + constructor( + private readonly monsterService: MonsterService, + @InjectRepository(Character) + private readonly characterRepo: Repository, + @InjectRepository(QuestArc) + private readonly arcRepo: Repository, + @InjectRepository(PlayerQuestArc) + private readonly playerArcRepo: Repository, + ) {} + + @Get('zones') + async getZones(@Req() req: Request) { + const user = (req as any).user; + const char = await this.characterRepo.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage'); + + const unlockedZones = await getUnlockedZones(char.id, this.arcRepo, this.playerArcRepo); + + const ALL_ZONES = [ + { id: 'marais', name: 'Les Marais', emoji: '🌿', minLevel: 1 }, + { id: 'egouts', name: 'Les Égouts', emoji: '🕳️', minLevel: 4 }, + { id: 'desert', name: 'Le Désert', emoji: '🏜️', minLevel: 8 }, + ]; + + return ALL_ZONES.map(z => ({ + ...z, + unlocked: unlockedZones.includes(z.id), + })); + } @Get() - findAll() { - return this.monsterService.findAll(); + async findAll(@Req() req: Request) { + const user = (req as any).user; + const char = await this.characterRepo.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage'); + + const unlockedZones = await getUnlockedZones(char.id, this.arcRepo, this.playerArcRepo); + const allMonsters = await this.monsterService.findAll(); + + return allMonsters.filter(m => unlockedZones.includes(m.zone)); } } diff --git a/src/monster/monster.module.ts b/src/monster/monster.module.ts index 9ef40b1..6e0fc93 100644 --- a/src/monster/monster.module.ts +++ b/src/monster/monster.module.ts @@ -4,9 +4,15 @@ import { Monster } from './monster.entity'; import { MonsterService } from './monster.service'; import { MonsterController } from './monster.controller'; import { AuthModule } from '../auth/auth.module'; +import { Character } from '../character/entities/character.entity'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { PlayerQuestArc } from '../quest/player-quest-arc.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Monster]), AuthModule], + imports: [ + TypeOrmModule.forFeature([Monster, Character, QuestArc, PlayerQuestArc]), + AuthModule, + ], controllers: [MonsterController], providers: [MonsterService], exports: [MonsterService, TypeOrmModule], diff --git a/src/shop/shop.module.ts b/src/shop/shop.module.ts index 3c108b7..81be161 100644 --- a/src/shop/shop.module.ts +++ b/src/shop/shop.module.ts @@ -5,11 +5,13 @@ import { ShopController } from './shop.controller'; import { Item } from '../item/item.entity'; import { CharacterItem } from '../item/character-item.entity'; import { Character } from '../character/entities/character.entity'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { PlayerQuestArc } from '../quest/player-quest-arc.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Item, CharacterItem, Character]), + TypeOrmModule.forFeature([Item, CharacterItem, Character, QuestArc, PlayerQuestArc]), AuthModule, ], controllers: [ShopController], diff --git a/src/shop/shop.service.ts b/src/shop/shop.service.ts index af40c6b..8a74c8c 100644 --- a/src/shop/shop.service.ts +++ b/src/shop/shop.service.ts @@ -4,6 +4,9 @@ import { DataSource, Repository, LessThanOrEqual } from 'typeorm'; import { Item } from '../item/item.entity'; import { CharacterItem } from '../item/character-item.entity'; import { Character } from '../character/entities/character.entity'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { PlayerQuestArc } from '../quest/player-quest-arc.entity'; +import { getUnlockedZones } from '../common/zone-access'; const SELL_RATIO = 0.4; // 40% du prix d'achat const POTION_HEAL_RATIO = 0.5; // 50% HP max @@ -17,6 +20,10 @@ export class ShopService { private readonly charItemRepo: Repository, @InjectRepository(Character) private readonly characterRepo: Repository, + @InjectRepository(QuestArc) + private readonly arcRepo: Repository, + @InjectRepository(PlayerQuestArc) + private readonly playerArcRepo: Repository, private readonly dataSource: DataSource, ) {} @@ -24,6 +31,8 @@ export class ShopService { const char = await this.characterRepo.findOne({ where: { id: characterId } }); if (!char) throw new BadRequestException('Aucun personnage'); + const unlockedZones = await getUnlockedZones(char.id, this.arcRepo, this.playerArcRepo); + const items = await this.itemRepo.find({ where: { buyPrice: LessThanOrEqual(999999) }, order: { type: 'ASC', minLevel: 'ASC', buyPrice: 'ASC' }, @@ -31,6 +40,7 @@ export class ShopService { return items .filter(i => i.buyPrice > 0) + .filter(i => !i.zone || unlockedZones.includes(i.zone)) // zone null = toujours visible (potions) .map(i => ({ ...i, affordable: char.gold >= i.buyPrice,