feat: zone locking — progression par arcs narratifs + arcs Égouts/Désert
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

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).
This commit is contained in:
2026-03-24 17:57:23 +01:00
parent 8cb5fcd5ba
commit d1609efaae
8 changed files with 222 additions and 29 deletions

View File

@@ -24,6 +24,7 @@ export const characterApi = {
// Combat // Combat
export const combatApi = { export const combatApi = {
zones: () => api.get<any[]>('/monsters/zones'),
monsters: () => api.get<Monster[]>('/monsters'), monsters: () => api.get<Monster[]>('/monsters'),
start: (monsterId: string, attackType: string) => api.post<CombatResult>('/combat/start', { monsterId, attackType }), start: (monsterId: string, attackType: string) => api.post<CombatResult>('/combat/start', { monsterId, attackType }),
history: () => api.get<CombatLog[]>('/combat/history'), history: () => api.get<CombatLog[]>('/combat/history'),

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { combatApi, characterApi } from '../api/endpoints'; import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, CombatLog } from '../api/types'; 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 COMBAT_COST = 5;
const REST_COST = 10; const REST_COST = 10;
@@ -127,6 +127,11 @@ export function CombatPage() {
queryFn: combatApi.monsters, queryFn: combatApi.monsters,
}); });
const { data: zones } = useQuery({
queryKey: ['zones'],
queryFn: combatApi.zones,
});
const { data: history } = useQuery({ const { data: history } = useQuery({
queryKey: ['combatHistory'], queryKey: ['combatHistory'],
queryFn: combatApi.history, queryFn: combatApi.history,
@@ -144,26 +149,42 @@ export function CombatPage() {
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres…</div>; if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres…</div>;
// Trier : monstres appropriés en haut, trop forts en bas // Group monsters by zone
const sorted = [...(monsters ?? [])].sort((a, b) => { const monstersByZone = new Map<string, Monster[]>();
const aOk = a.minLevel <= playerLevel + 2 ? 0 : 1; for (const m of (monsters ?? [])) {
const bOk = b.minLevel <= playerLevel + 2 ? 0 : 1; const zone = (m as any).zone ?? 'marais';
if (aOk !== bOk) return aOk - bOk; const list = monstersByZone.get(zone) ?? [];
return a.minLevel - b.minLevel; list.push(m);
}); monstersByZone.set(zone, list);
}
const ZONE_LABELS: Record<string, { name: string; emoji: string }> = {
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 ( return (
<div> <div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>⚔️ Combat</h2> <h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>⚔️ Combat</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
{/* Choix monstre */} {/* Choix monstre par zone */}
<div> <div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> {Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
Adversaire const info = ZONE_LABELS[zone] ?? { name: zone, emoji: '📍' };
return (
<div key={zone} style={{ marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.name}
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{sorted.map(m => ( {zoneMonsters
.sort((a, b) => a.minLevel - b.minLevel)
.map(m => (
<MonsterCard <MonsterCard
key={m.id} key={m.id}
m={m} m={m}
@@ -174,6 +195,19 @@ export function CombatPage() {
))} ))}
</div> </div>
</div> </div>
);
})}
{/* Zones verrouillées */}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<Lock size={16} color="#6b7a99" style={{ display: 'inline', marginRight: 6 }} />
<span style={{ fontSize: 13, color: '#6b7a99' }}>
{z.emoji} {z.name} — Complétez l'arc précédent pour débloquer
</span>
</div>
))}
</div>
{/* Panneau droite */} {/* Panneau droite */}
<div> <div>

44
src/common/zone-access.ts Normal file
View File

@@ -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<QuestArc>,
playerArcRepo: Repository<PlayerQuestArc>,
): Promise<string[]> {
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;
}

View File

@@ -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`); 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) // Update monster XP rewards (nerf for quest-driven progression)

View File

@@ -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 { MonsterService } from './monster.service';
import { AuthGuard } from '../auth/guards/auth.guard'; 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') @Controller('monsters')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class MonsterController { export class MonsterController {
constructor(private readonly monsterService: MonsterService) {} constructor(
private readonly monsterService: MonsterService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
@InjectRepository(QuestArc)
private readonly arcRepo: Repository<QuestArc>,
@InjectRepository(PlayerQuestArc)
private readonly playerArcRepo: Repository<PlayerQuestArc>,
) {}
@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() @Get()
findAll() { async findAll(@Req() req: Request) {
return this.monsterService.findAll(); 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));
} }
} }

View File

@@ -4,9 +4,15 @@ import { Monster } from './monster.entity';
import { MonsterService } from './monster.service'; import { MonsterService } from './monster.service';
import { MonsterController } from './monster.controller'; import { MonsterController } from './monster.controller';
import { AuthModule } from '../auth/auth.module'; 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({ @Module({
imports: [TypeOrmModule.forFeature([Monster]), AuthModule], imports: [
TypeOrmModule.forFeature([Monster, Character, QuestArc, PlayerQuestArc]),
AuthModule,
],
controllers: [MonsterController], controllers: [MonsterController],
providers: [MonsterService], providers: [MonsterService],
exports: [MonsterService, TypeOrmModule], exports: [MonsterService, TypeOrmModule],

View File

@@ -5,11 +5,13 @@ import { ShopController } from './shop.controller';
import { Item } from '../item/item.entity'; import { Item } from '../item/item.entity';
import { CharacterItem } from '../item/character-item.entity'; import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.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'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Item, CharacterItem, Character]), TypeOrmModule.forFeature([Item, CharacterItem, Character, QuestArc, PlayerQuestArc]),
AuthModule, AuthModule,
], ],
controllers: [ShopController], controllers: [ShopController],

View File

@@ -4,6 +4,9 @@ import { DataSource, Repository, LessThanOrEqual } from 'typeorm';
import { Item } from '../item/item.entity'; import { Item } from '../item/item.entity';
import { CharacterItem } from '../item/character-item.entity'; import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.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 SELL_RATIO = 0.4; // 40% du prix d'achat
const POTION_HEAL_RATIO = 0.5; // 50% HP max const POTION_HEAL_RATIO = 0.5; // 50% HP max
@@ -17,6 +20,10 @@ export class ShopService {
private readonly charItemRepo: Repository<CharacterItem>, private readonly charItemRepo: Repository<CharacterItem>,
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepo: Repository<Character>, private readonly characterRepo: Repository<Character>,
@InjectRepository(QuestArc)
private readonly arcRepo: Repository<QuestArc>,
@InjectRepository(PlayerQuestArc)
private readonly playerArcRepo: Repository<PlayerQuestArc>,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
@@ -24,6 +31,8 @@ export class ShopService {
const char = await this.characterRepo.findOne({ where: { id: characterId } }); const char = await this.characterRepo.findOne({ where: { id: characterId } });
if (!char) throw new BadRequestException('Aucun personnage'); if (!char) throw new BadRequestException('Aucun personnage');
const unlockedZones = await getUnlockedZones(char.id, this.arcRepo, this.playerArcRepo);
const items = await this.itemRepo.find({ const items = await this.itemRepo.find({
where: { buyPrice: LessThanOrEqual(999999) }, where: { buyPrice: LessThanOrEqual(999999) },
order: { type: 'ASC', minLevel: 'ASC', buyPrice: 'ASC' }, order: { type: 'ASC', minLevel: 'ASC', buyPrice: 'ASC' },
@@ -31,6 +40,7 @@ export class ShopService {
return items return items
.filter(i => i.buyPrice > 0) .filter(i => i.buyPrice > 0)
.filter(i => !i.zone || unlockedZones.includes(i.zone)) // zone null = toujours visible (potions)
.map(i => ({ .map(i => ({
...i, ...i,
affordable: char.gold >= i.buyPrice, affordable: char.gold >= i.buyPrice,