diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts new file mode 100644 index 0000000..b4ad697 --- /dev/null +++ b/src/game/minigames/service.ts @@ -0,0 +1,208 @@ +import { prisma } from '../../core/database/prisma' +import { addItemByKey, adjustCoins, findItemByKey, getInventoryEntry } from '../economy/service'; +import type { ItemProps, InventoryState } from '../economy/types'; +import type { LevelRequirements, RunMinigameOptions, RunResult, RewardsTable, MobsTable } from './types'; +import type { Prisma } from '@prisma/client'; + +function parseJSON(v: unknown): T | null { + if (!v || (typeof v !== 'object' && typeof v !== 'string')) return null; + return v as T; +} + +function pickWeighted(arr: T[]): T | null { + const total = arr.reduce((s, a) => s + Math.max(0, a.weight || 0), 0); + if (total <= 0) return null; + const r = Math.random() * total; + let acc = 0; + for (const a of arr) { + acc += Math.max(0, a.weight || 0); + if (r <= acc) return a; + } + return arr[arr.length - 1] ?? null; +} + +async function ensureAreaAndLevel(guildId: string, areaKey: string, level: number) { + const area = await prisma.gameArea.findFirst({ where: { key: areaKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); + if (!area) throw new Error('Área no encontrada'); + const lvl = await prisma.gameAreaLevel.findFirst({ where: { areaId: area.id, level } }); + if (!lvl) throw new Error('Nivel no encontrado'); + return { area, lvl } as const; +} + +function parseItemProps(json: unknown): ItemProps { + if (!json || typeof json !== 'object') return {}; + return json as ItemProps; +} + +function parseInvState(json: unknown): InventoryState { + if (!json || typeof json !== 'object') return {}; + return json as InventoryState; +} + +async function validateRequirements(userId: string, guildId: string, req?: LevelRequirements, toolKey?: string) { + if (!req) return { toolKeyUsed: undefined as string | undefined }; + const toolReq = req.tool; + if (!toolReq) return { toolKeyUsed: undefined as string | undefined }; + + // herramienta requerida + if (toolReq.required && !toolKey) throw new Error('Se requiere una herramienta'); + if (!toolKey) return { toolKeyUsed: undefined }; + + // verificar herramienta + const toolItem = await findItemByKey(guildId, toolKey); + if (!toolItem) throw new Error('Herramienta no encontrada'); + const { entry } = await getInventoryEntry(userId, guildId, toolKey); + if (!entry || (entry.quantity ?? 0) <= 0) throw new Error('No tienes la herramienta'); + + const props = parseItemProps(toolItem.props); + const tool = props.tool; + if (toolReq.toolType && tool?.type !== toolReq.toolType) throw new Error('Tipo de herramienta incorrecto'); + if (toolReq.minTier != null && (tool?.tier ?? 0) < toolReq.minTier) throw new Error('Tier de herramienta insuficiente'); + if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKey)) throw new Error('Esta herramienta no es válida para esta área'); + + return { toolKeyUsed: toolKey }; +} + +async function applyRewards(userId: string, guildId: string, rewards?: RewardsTable): Promise { + const results: RunResult['rewards'] = []; + if (!rewards || !Array.isArray(rewards.table) || rewards.table.length === 0) return results; + const draws = Math.max(1, rewards.draws ?? 1); + for (let i = 0; i < draws; i++) { + const pick = pickWeighted(rewards.table); + if (!pick) continue; + if (pick.type === 'coins') { + const amt = Math.max(0, pick.amount); + if (amt > 0) { + await adjustCoins(userId, guildId, amt); + results.push({ type: 'coins', amount: amt }); + } + } else if (pick.type === 'item') { + const qty = Math.max(1, pick.qty); + await addItemByKey(userId, guildId, pick.itemKey, qty); + results.push({ type: 'item', itemKey: pick.itemKey, qty }); + } + } + return results; +} + +async function sampleMobs(mobs?: MobsTable): Promise { + const out: string[] = []; + if (!mobs || !Array.isArray(mobs.table) || mobs.table.length === 0) return out; + const draws = Math.max(0, mobs.draws ?? 0); + for (let i = 0; i < draws; i++) { + const pick = pickWeighted(mobs.table); + if (pick) out.push(pick.mobKey); + } + return out; +} + +async function reduceToolDurability(userId: string, guildId: string, toolKey: string) { + const { item, entry } = await getInventoryEntry(userId, guildId, toolKey); + if (!entry) return { broken: false, delta: 0 } as const; + const props = parseItemProps(item.props); + const breakable = props.breakable; + const delta = Math.max(1, breakable?.durabilityPerUse ?? 1); + if (item.stackable) { + // Herramientas deberían ser no apilables; si lo son, solo decrementamos cantidad como fallback + const consumed = Math.min(1, entry.quantity); + if (consumed > 0) { + await prisma.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, data: { quantity: { decrement: consumed } } }); + } + return { broken: consumed > 0, delta } as const; + } + const state = parseInvState(entry.state); + state.instances ??= [{}]; + if (state.instances.length === 0) state.instances.push({}); + const inst = state.instances[0]; + const max = Math.max(1, breakable?.maxDurability ?? 1); + const current = Math.min(Math.max(0, inst.durability ?? max), max); + const next = current - delta; + let broken = false; + if (next <= 0) { + // romper: eliminar instancia + state.instances.shift(); + broken = true; + } else { + (inst as any).durability = next; + state.instances[0] = inst; + } + await prisma.inventoryEntry.update({ + where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, + data: { state: state as unknown as Prisma.InputJsonValue, quantity: state.instances.length }, + }); + return { broken, delta } as const; +} + +export { reduceToolDurability }; + +export async function runMinigame(userId: string, guildId: string, areaKey: string, level: number, opts?: RunMinigameOptions): Promise { + const { area, lvl } = await ensureAreaAndLevel(guildId, areaKey, level); + + // Cooldown por área + const areaConf = (area.config as any) ?? {}; + const cdSeconds = Math.max(0, Number(areaConf.cooldownSeconds ?? 0)); + const cdKey = `minigame:${area.key}`; + if (cdSeconds > 0) { + const existing = await prisma.actionCooldown.findUnique({ where: { userId_guildId_key: { userId, guildId, key: cdKey } } }); + if (existing && existing.until > new Date()) { + throw new Error('Cooldown activo para esta actividad'); + } + } + + // Leer configuración de nivel (requirements, rewards, mobs) + const requirements = parseJSON(lvl.requirements) ?? {}; + const rewards = parseJSON(lvl.rewards) ?? { table: [] }; + const mobs = parseJSON(lvl.mobs) ?? { table: [] }; + + // Validar herramienta si aplica + const reqRes = await validateRequirements(userId, guildId, requirements, opts?.toolKey); + + // Aplicar recompensas y samplear mobs + const delivered = await applyRewards(userId, guildId, rewards); + const mobsSpawned = await sampleMobs(mobs); + + // Reducir durabilidad de herramienta si se usó + let toolInfo: RunResult['tool'] | undefined; + if (reqRes.toolKeyUsed) { + const t = await reduceToolDurability(userId, guildId, reqRes.toolKeyUsed); + toolInfo = { key: reqRes.toolKeyUsed, durabilityDelta: t.delta, broken: t.broken }; + } + + // Registrar la ejecución + const resultJson: Prisma.InputJsonValue = { + rewards: delivered, + mobs: mobsSpawned, + tool: toolInfo, + notes: 'auto', + } as unknown as Prisma.InputJsonValue; + + await prisma.minigameRun.create({ + data: { + userId, + guildId, + areaId: area.id, + level, + toolItemId: null, // opcional si decides guardar id del item herramienta + success: true, + result: resultJson, + }, + }); + + // Progreso del jugador + await prisma.playerProgress.upsert({ + where: { userId_guildId_areaId: { userId, guildId, areaId: area.id } }, + create: { userId, guildId, areaId: area.id, highestLevel: Math.max(1, level) }, + update: { highestLevel: { set: level } }, + }); + + // Setear cooldown + if (cdSeconds > 0) { + await prisma.actionCooldown.upsert({ + where: { userId_guildId_key: { userId, guildId, key: cdKey } }, + update: { until: new Date(Date.now() + cdSeconds * 1000) }, + create: { userId, guildId, key: cdKey, until: new Date(Date.now() + cdSeconds * 1000) }, + }); + } + + return { success: true, rewards: delivered, mobs: mobsSpawned, tool: toolInfo }; +}