import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client'; import { COSTS, REWARDS } from '../utils/economy'; const prisma = new PrismaClient(); export const VILLAGE_WIDTH = 5; export const VILLAGE_HEIGHT = 7; const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours export const PRODUCING_BUILDINGS: string[] = [ 'FIELD', 'LUMBERJACK', 'QUARRY', ]; // Helper to get the start of a given date for daily EXP checks const getStartOfDay = (date: Date) => { const d = new Date(date); d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency return d; }; /** * Generates the initial village for a new user atomically. */ export async function generateVillageForUser(user: User) { const existingVillage = await prisma.village.findUnique({ where: { userId: user.id }, }); if (existingVillage) { return; } const tilesToCreate: Omit[] = []; const central_x_start = 1; const central_x_end = 4; const central_y_start = 2; const central_y_end = 5; for (let y = 0; y < VILLAGE_HEIGHT; y++) { for (let x = 0; x < VILLAGE_WIDTH; x++) { const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end; const terrainType = isCentral ? 'EMPTY' : Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE'; tilesToCreate.push({ x, y, terrainType, terrainState: 'IDLE', }); } } // FIX: Wrap village generation in a single transaction for atomicity. await prisma.$transaction(async (tx) => { const village = await tx.village.create({ data: { userId: user.id, }, }); await tx.user.update({ where: { id: user.id }, data: { coins: 10, exp: 0, }, }); await tx.villageTile.createMany({ data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })), }); }); } type FullVillage = Prisma.VillageGetPayload<{ include: { user: true; tiles: { include: { object: true } }; objects: { include: { tile: true } }; }; }>; /** * Gets the full, updated state of a user's village, calculating all time-based progression. */ export async function getVillageState(userId: number): Promise { const now = new Date(); const { applyStreakMultiplier } = await import('../utils/streak'); const today = getStartOfDay(now); // --- Step 1: Initial Snapshot --- let villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } }, }, }); if (!villageSnapshot) { throw createError({ statusCode: 404, statusMessage: 'Village not found' }); } const userForStreak = villageSnapshot.user; // --- Step 2: Terrain Cleaning Completion --- const finishedClearingTiles = villageSnapshot.tiles.filter( t => t.terrainState === 'CLEARING' && t.clearingStartedAt && getStartOfDay(t.clearingStartedAt) < today ); if (finishedClearingTiles.length > 0) { // Apply streak multiplier to clearing rewards const baseClearingReward = REWARDS.VILLAGE.CLEARING; const finalClearingReward = applyStreakMultiplier(baseClearingReward, userForStreak.dailyStreak); const totalCoins = finishedClearingTiles.length * finalClearingReward.coins; const totalExp = finishedClearingTiles.length * finalClearingReward.exp; await prisma.$transaction(async (tx) => { // 1. Update user totals await tx.user.update({ where: { id: userId }, data: { coins: { increment: totalCoins }, exp: { increment: totalExp }, }, }); // 2. Update all the tiles await tx.villageTile.updateMany({ where: { id: { in: finishedClearingTiles.map(t => t.id) } }, data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null }, }); // 3. Create an event for each completed tile with the final reward const multiplier = userForStreak.dailyStreak; let streakBonusText = ''; if (multiplier === 2) { streakBonusText = ' Ваша серия визитов (x2) удвоила награду!'; } else if (multiplier >= 3) { streakBonusText = ' Ваша серия визитов (x3) утроила награду!'; } for (const tile of finishedClearingTiles) { const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень'; const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун'; await tx.villageEvent.create({ data: { villageId: villageSnapshot.id, type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE', message: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`, tileX: tile.x, tileY: tile.y, coins: finalClearingReward.coins, exp: finalClearingReward.exp, } }); } }); } // --- Step 3: Refetch for next logic step --- villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; const fieldsForExp = villageSnapshot.objects.filter( obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today) ); if (fieldsForExp.length > 0) { const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`)); let totalExpFromFields = 0; const eventsToCreate = []; for (const field of fieldsForExp) { // First, calculate base EXP with existing game logic (well bonus) let baseFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE; if (wellPositions.has(`${field.tile.x},${field.tile.y - 1}`) || wellPositions.has(`${field.tile.x},${field.tile.y + 1}`) || wellPositions.has(`${field.tile.x - 1},${field.tile.y}`) || wellPositions.has(`${field.tile.x + 1},${field.tile.y}`)) { baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; } // Now, apply the daily streak multiplier const finalFieldExp = applyStreakMultiplier({ coins: 0, exp: baseFieldExp }, userForStreak.dailyStreak).exp; totalExpFromFields += finalFieldExp; const multiplier = userForStreak.dailyStreak; let streakBonusText = ''; if (multiplier === 2) { streakBonusText = ' Ваша серия визитов (x2) удвоила урожай опыта!'; } else if (multiplier >= 3) { streakBonusText = ' Ваша серия визитов (x3) утроила урожай опыта!'; } eventsToCreate.push({ villageId: villageSnapshot.id, type: 'FIELD_EXP', message: `Поле (${field.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`, tileX: field.tile.x, tileY: field.tile.y, coins: 0, exp: finalFieldExp, }); } await prisma.$transaction([ prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }), prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }), prisma.villageEvent.createMany({ data: eventsToCreate }), ]); } // --- Step 5: Refetch for next logic step --- villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; // --- Step 6: Auto-start Terrain Cleaning --- const lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length; const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length; const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length; const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length; const freeLumberjacks = lumberjackCount - clearingTreesCount; const freeQuarries = quarryCount - clearingStonesCount; const tileIdsToClear = new Set(); if (freeLumberjacks > 0) { const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE'); if (idleTrees.length > 0) { // For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here. idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id)); } } if (freeQuarries > 0) { const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'); if (idleStones.length > 0) { // For simplicity, just take the first N available stones. idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id)); } } if (tileIdsToClear.size > 0) { await prisma.villageTile.updateMany({ where: { id: { in: Array.from(tileIdsToClear) } }, data: { terrainState: 'CLEARING', clearingStartedAt: getStartOfDay(now) }, }); // Refetch state after starting new clearings villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; } // --- Step 7: Final Fetch & Action Enrichment --- const finalVillageState = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] }, objects: { include: { tile: true } }, }, }); if (!finalVillageState) { throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' }); } // --- Step 8: Enrich tiles with available actions --- const { user } = finalVillageState; const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK'); const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY'); const housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length; const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; const freeWorkers = housesCount - producingCount; const tilesWithActions = finalVillageState.tiles.map(tile => { const availableActions: any[] = []; // Action: BUILD if (tile.terrainType === 'EMPTY' && !tile.object) { const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL']; const buildActions = buildableObjectTypes.map(buildingType => { const cost = COSTS.BUILD[buildingType]; const isProducing = PRODUCING_BUILDINGS.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); } if (tile.object) { // MOVE and REMOVE actions have been removed as per the refactor request. } return { ...tile, availableActions }; }); return { ...finalVillageState, tiles: tilesWithActions } as any; } // --- Action Service Functions --- export async function buildOnTile(userId: number, tileId: number, buildingType: string) { const { VillageObjectType } = await import('@prisma/client'); const validBuildingTypes = Object.keys(VillageObjectType); if (!validBuildingTypes.includes(buildingType)) { throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` }); } return prisma.$transaction(async (tx) => { // 1. Fetch all necessary data const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } }); // Ownership check if (tile.village.userId !== userId) { throw createError({ statusCode: 403, statusMessage: "You don't own this tile" }); } // Business logic validation 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)) { const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } }); const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length; const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; if (producingCount >= housesCount) { throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' }); } } // 2. Perform mutations await tx.user.update({ where: { id: userId }, data: { coins: { decrement: cost } }, }); await tx.villageObject.create({ data: { type: buildingType as keyof typeof VillageObjectType, villageId: tile.villageId, tileId: tileId, }, }); await tx.villageEvent.create({ data: { villageId: tile.villageId, type: `BUILD_${buildingType}`, message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`, tileX: tile.x, tileY: tile.y, coins: -cost, exp: 0, }, }); }); }