import { PrismaClient, User, Prisma, VillageObjectType, VillageTile, } from '@prisma/client'; import { COSTS, REWARDS } from '../utils/economy'; import { applyStreakMultiplier } from '../utils/streak'; import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay'; const prisma = new PrismaClient(); /* ========================= CONSTANTS ========================= */ export const VILLAGE_WIDTH = 5; export const VILLAGE_HEIGHT = 7; export const PRODUCING_BUILDINGS = [ 'FIELD', 'LUMBERJACK', 'QUARRY', ] as const; /* ========================= TYPES ========================= */ type FullVillage = Prisma.VillageGetPayload<{ include: { user: true; tiles: { include: { object: true } }; objects: { include: { tile: true } }; }; }>; /* ========================= PUBLIC API ========================= */ /** * Главная точка входа. * Синхронизирует day-based прогресс и возвращает актуальное состояние деревни. */ export async function syncAndGetVillage(userId: number): Promise { try { const today = getTodayDay(); let villageSnapshot = await fetchVillage(userId); if (!villageSnapshot) { throw createError({ statusCode: 404, statusMessage: 'Village not found' }); } const user = villageSnapshot.user; await prisma.$transaction(async (tx) => { await processFinishedClearing(tx, villageSnapshot, today); await processFieldExp(tx, villageSnapshot, today); await autoStartClearing(tx, villageSnapshot, today); }); // Re-fetch the village state after the transaction to get the latest data including new objects, etc. villageSnapshot = await fetchVillage(userId); if (!villageSnapshot) { throw createError({ statusCode: 404, statusMessage: 'Village not found post-transaction' }); } // --- Enrich tiles with available actions (Step 8 from old getVillageState) --- const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length; const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length; const freeWorkers = housesCount - producingCount; const tilesWithActions = villageSnapshot.tiles.map(tile => { const availableActions: any[] = []; // Action: BUILD if (tile.terrainType === 'EMPTY' && !tile.object) { const buildableObjectTypes: VillageObjectType[] = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL']; const buildActions = buildableObjectTypes.map(buildingType => { const cost = COSTS.BUILD[buildingType]; const isProducing = (PRODUCING_BUILDINGS as readonly string[]).includes(buildingType); let isEnabled = user.coins >= cost; let disabledReason = user.coins < cost ? 'Not enough coins' : undefined; if (isEnabled && isProducing && freeWorkers <= 0) { isEnabled = false; disabledReason = 'Not enough workers'; } return { type: 'BUILD', buildingType, cost, isEnabled, disabledReason, }; }); availableActions.push(...buildActions); } return { ...tile, availableActions }; }); return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage; } catch (error) { console.error('Error in syncAndGetVillage:', error); throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' }); } } /** * Генерация деревни для нового пользователя */ export async function generateVillageForUser(user: User) { // Enforce immutability of the village layout and ensure creation occurs only once. await prisma.$transaction(async (tx) => { // 1. Find or create Village let village = await tx.village.findUnique({ where: { userId: user.id }, }); let villageCreated = false; if (!village) { village = await tx.village.create({ data: { userId: user.id }, }); villageCreated = true; } // If village was just created, initialize user resources if (villageCreated) { await tx.user.update({ where: { id: user.id }, data: { coins: 10, exp: 0 }, }); } // 2. Count existing VillageTiles for this Village const tilesCount = await tx.villageTile.count({ where: { villageId: village!.id }, // village is guaranteed to exist here }); // If tiles already exist, layout is immutable. Do nothing. if (tilesCount > 0) { // Village layout is immutable once created. return; } // 3. Create tiles ONLY if tilesCount is 0 (broken state or first creation) // This logic ensures tiles are created exactly once. const tilesToCreate: Omit< VillageTile, 'id' | 'clearingStartedDay' | 'villageId' >[] = []; const centralXStart = 1; const centralXEnd = 4; const centralYStart = 2; const centralYEnd = 5; for (let y = 0; y < VILLAGE_HEIGHT; y++) { for (let x = 0; x < VILLAGE_WIDTH; x++) { const isCentral = x >= centralXStart && x < centralXEnd && y >= centralYStart && y < centralYEnd; tilesToCreate.push({ x, y, terrainType: isCentral ? 'EMPTY' : Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE', terrainState: 'IDLE', }); } } await tx.villageTile.createMany({ data: tilesToCreate.map((t) => ({ ...t, villageId: village!.id, // village is guaranteed to exist here })), }); }); } /** * BUILD command */ export async function buildOnTile( userId: number, tileId: number, buildingType: VillageObjectType ) { return prisma.$transaction(async (tx) => { const user = await tx.user.findUniqueOrThrow({ where: { id: userId }, }); const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true }, }); if (tile.village.userId !== userId) { throw createError({ statusCode: 403, statusMessage: 'Not your tile' }); } if (tile.terrainType !== 'EMPTY' || tile.object) { throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' }); } const cost = COSTS.BUILD[buildingType]; if (user.coins < cost) { throw createError({ statusCode: 400, statusMessage: 'Not enough coins' }); } if (PRODUCING_BUILDINGS.includes(buildingType as any)) { const objects = await tx.villageObject.findMany({ where: { villageId: tile.villageId }, }); const houses = objects.filter(o => o.type === 'HOUSE').length; const producing = objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any) ).length; if (producing >= houses) { throw createError({ statusCode: 400, statusMessage: 'Not enough workers', }); } } await tx.user.update({ where: { id: userId }, data: { coins: { decrement: cost } }, }); await tx.villageObject.create({ data: { type: buildingType, villageId: tile.villageId, tileId: tile.id, }, }); await tx.villageEvent.create({ data: { villageId: tile.villageId, type: `BUILD_${buildingType}`, message: `Построено ${buildingType} на (${tile.x}, ${tile.y})`, tileX: tile.x, tileY: tile.y, coins: -cost, exp: 0, }, }); }); } /* ========================= INTERNAL HELPERS ========================= */ function fetchVillage(userId: number) { return prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } }, }, }); } /* ========================= DAY-BASED LOGIC ========================= */ async function processFinishedClearing( tx: Prisma.TransactionClient, village: FullVillage, today: string ) { const finishedTiles = village.tiles.filter( t => t.terrainState === 'CLEARING' && isBeforeDay(t.clearingStartedDay, today) ); if (!finishedTiles.length) return; const baseReward = REWARDS.VILLAGE.CLEARING; const totalBaseReward = { coins: baseReward.coins * finishedTiles.length, exp: baseReward.exp * finishedTiles.length, }; const finalReward = applyStreakMultiplier(totalBaseReward, village.user.dailyStreak); await tx.user.update({ where: { id: village.user.id }, data: { coins: { increment: finalReward.coins }, exp: { increment: finalReward.exp }, }, }); await tx.villageTile.updateMany({ where: { id: { in: finishedTiles.map(t => t.id) } }, data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedDay: null, }, }); const streakMultiplier = village.user.dailyStreak > 1 ? village.user.dailyStreak : 0; let streakBonusText = ''; if (streakMultiplier > 1) { streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`; } const events = finishedTiles.map(t => { const tileReward = applyStreakMultiplier(baseReward, village.user.dailyStreak); return { villageId: village.id, type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE', message: `Участок (${t.x}, ${t.y}) расчищен.` + streakBonusText, tileX: t.x, tileY: t.y, coins: tileReward.coins, exp: tileReward.exp, }; }); await tx.villageEvent.createMany({ data: events, }); } async function processFieldExp( tx: Prisma.TransactionClient, village: FullVillage, today: string ) { const fieldsNeedingUpdate = village.objects.filter( (o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today) ); if (!fieldsNeedingUpdate.length) return; const wells = village.objects.filter(o => o.type === 'WELL'); let totalBaseExpGained = 0; const eventsToCreate: any[] = []; const streakMultiplierValue = village.user.dailyStreak > 1 ? village.user.dailyStreak : 1; let streakBonusText = ''; if (streakMultiplierValue > 1) { streakBonusText = ` Ваша серия визитов (${streakMultiplierValue}) увеличила награду.`; } for (const field of fieldsNeedingUpdate) { const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1; let expGainedForField = daysMissed * REWARDS.VILLAGE.FIELD_EXP.BASE; const isNearWell = wells.some(well => Math.abs(well.tile.x - field.tile.x) <= 1 && Math.abs(well.tile.y - field.tile.y) <= 1 && (well.tile.x !== field.tile.x || well.tile.y !== field.tile.y) ); let wellBonusText = ''; if (isNearWell) { expGainedForField *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; wellBonusText = ' Рядом с колодцем урожай удвоился!'; } totalBaseExpGained += expGainedForField; const finalExpForField = applyStreakMultiplier({ coins: 0, exp: expGainedForField }, village.user.dailyStreak); for (let i = 0; i < daysMissed; i++) { eventsToCreate.push({ villageId: village.id, type: 'FIELD_EXP', message: `Поле (${field.tile.x}, ${field.tile.y}) принесло опыт.` + wellBonusText + streakBonusText, tileX: field.tile.x, tileY: field.tile.y, coins: 0, exp: finalExpForField.exp / daysMissed, }); } } const finalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, village.user.dailyStreak); if (totalBaseExpGained > 0) { await tx.user.update({ where: { id: village.user.id }, data: { exp: { increment: finalExp.exp } }, }); } await tx.villageObject.updateMany({ where: { id: { in: fieldsNeedingUpdate.map((f) => f.id) } }, data: { lastExpDay: today }, }); if (eventsToCreate.length > 0) { await tx.villageEvent.createMany({ data: eventsToCreate, }); } } async function autoStartClearing( tx: Prisma.TransactionClient, village: FullVillage, today: string ) { const lumberjacks = village.objects.filter(o => o.type === 'LUMBERJACK').length; const quarries = village.objects.filter(o => o.type === 'QUARRY').length; const busyTrees = village.tiles.filter( t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING' ).length; const busyStones = village.tiles.filter( t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING' ).length; const tilesToStart = [ ...village.tiles .filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE') .slice(0, lumberjacks - busyTrees), ...village.tiles .filter( t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE' ) .slice(0, quarries - busyStones), ]; if (!tilesToStart.length) return; await tx.villageTile.updateMany({ where: { id: { in: tilesToStart.map(t => t.id) } }, data: { terrainState: 'CLEARING', clearingStartedDay: today, }, }); }