habits.andr33v.ru/server/services/villageService.ts

548 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { calculateDailyStreak } from '../utils/streak';
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
========================= */
/**
* Processes the village's daily "tick" if necessary and returns the
* complete, up-to-date village state.
* This is the single source of truth for all time-based progression.
*/
export async function processVillageTick(userId: number): Promise<FullVillage> {
try {
const today = getTodayDay();
let villageSnapshot = await fetchVillage(userId);
if (!villageSnapshot) {
// This should not happen for a logged-in user with a village.
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
// Even if tick is done, we should ensure the streak is updated for the day.
// The calculateDailyStreak function is idempotent.
if (villageSnapshot.lastTickDay === today) {
villageSnapshot.user = await calculateDailyStreak(prisma, userId, today);
return villageSnapshot;
}
// The tick for today has not run. Execute all daily logic in a transaction.
await prisma.$transaction(async (tx) => {
// 1. UPDATE STREAK FIRST. This is critical for all reward calculations.
const updatedUser = await calculateDailyStreak(tx, userId, today);
villageSnapshot.user = updatedUser; // Update snapshot with fresh user data.
// 2. Process other daily logic using the updated snapshot
const finishedTiles = await processFinishedClearing(tx, villageSnapshot, today);
await processFieldExp(tx, villageSnapshot, today);
await autoStartClearing(tx, villageSnapshot, today, finishedTiles);
// 3. Update the last tick day to prevent re-processing
await tx.village.update({
where: { id: villageSnapshot.id },
data: { lastTickDay: today },
});
});
// After the transaction, the original villageSnapshot is stale.
// Re-fetch to get the latest state with all changes.
const updatedVillage = await fetchVillage(userId);
if (!updatedVillage) {
// This would be a critical error, as the village existed moments ago.
throw createError({ statusCode: 500, statusMessage: 'Village disappeared post-transaction' });
}
return updatedVillage;
} catch (error) {
// Log the error and re-throw it to be handled by the calling API endpoint.
console.error(`Error in processVillageTick for user ${userId}:`, error);
if ((error as any).statusCode) throw error; // Re-throw h3 errors
throw createError({ statusCode: 500, statusMessage: 'Failed to process village tick.' });
}
}
/**
* Main entry point for the frontend to get the village state.
* It ensures the daily tick is processed, then enriches the state with UI-specific data.
*/
export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
try {
// This function will now run the tick (if needed) AND return the up-to-date village state.
const villageSnapshot = await processVillageTick(userId);
// --- Enrich tiles with available actions ---
const user = villageSnapshot.user;
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);
// Let the API endpoint handle the final error response.
if ((error as any).statusCode) throw error; // Re-throw h3 errors
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,
},
});
// If a clearing building was built, immediately try to start a new clearing job.
// This makes new buildings feel responsive and start working right away.
if (buildingType === 'LUMBERJACK' || buildingType === 'QUARRY') {
const today = getTodayDay();
// We need a fresh, full snapshot of the village *within the transaction*
// to correctly calculate clearing capacity.
const villageSnapshot = await tx.village.findUnique({
where: { id: tile.villageId },
include: {
user: true,
tiles: { include: { object: true } },
objects: { include: { tile: true } },
},
});
if (villageSnapshot) {
await autoStartClearing(tx, villageSnapshot, today);
}
}
});
}
/* =========================
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
): Promise<VillageTile[]> {
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,
};
// Ensure dailyStreak is at least 1 for multiplier calculation if it's 0 or null
const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1;
const finalReward = applyStreakMultiplier(totalBaseReward, currentDailyStreak);
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 && village.user.dailyStreak > 1 ? village.user.dailyStreak : 0;
let streakBonusText = '';
if (streakMultiplier > 1) {
streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`;
}
const events = finishedTiles.map(t => {
// Apply streak multiplier with a default of 1 if streak is not active
const tileReward = applyStreakMultiplier(baseReward, currentDailyStreak);
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,
});
return finishedTiles;
}
async function processFieldExp(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
): Promise<number> {
const fieldsNeedingUpdate = village.objects.filter(
(o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today)
);
if (!fieldsNeedingUpdate.length) return 0;
const wells = village.objects.filter(o => o.type === 'WELL');
let totalBaseExpGained = 0;
const eventsToCreate: any[] = [];
const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1;
let streakBonusText = '';
if (currentDailyStreak > 1) {
streakBonusText = ` Ваша серия визитов (${currentDailyStreak}) увеличила награду.`;
}
for (const field of fieldsNeedingUpdate) {
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
let expGainedForField = 0;
let wellBonusText = '';
for (let i = 0; i < daysMissed; i++) {
let dailyFieldExp = 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)
);
if (isNearWell) {
dailyFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
wellBonusText = ' Рядом с колодцем урожай удвоился!';
}
expGainedForField += dailyFieldExp;
// Create an event for each day the field gained experience
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: applyStreakMultiplier({ coins: 0, exp: dailyFieldExp }, currentDailyStreak).exp,
});
}
totalBaseExpGained += expGainedForField; // This is base exp without final streak multiplier for total
}
const finalTotalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, currentDailyStreak);
if (finalTotalExp.exp > 0) { // Check final total exp after multiplier
await tx.user.update({
where: { id: village.user.id },
data: { exp: { increment: finalTotalExp.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,
});
}
return finalTotalExp.exp; // Return the final experience gained
}
async function autoStartClearing(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string,
justFinishedTiles: VillageTile[] = []
): Promise<number> {
// Count total capacity for clearing
const lumberjackCapacity = village.objects.filter(o => o.type === 'LUMBERJACK').length;
const quarryCapacity = village.objects.filter(o => o.type === 'QUARRY').length;
// We must account for the tiles that were *just* finished in this transaction.
const finishedTreeTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_TREE').length;
const finishedStoneTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_STONE').length;
const busyTreesInSnapshot = village.tiles.filter(
t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING'
).length;
const busyStonesInSnapshot = village.tiles.filter(
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
).length;
// Correctly calculate busy workers by subtracting those that just finished
const currentBusyTrees = Math.max(0, busyTreesInSnapshot - finishedTreeTiles);
const currentBusyStones = Math.max(0, busyStonesInSnapshot - finishedStoneTiles);
const freeLumberjacks = Math.max(0, lumberjackCapacity - currentBusyTrees);
const freeQuarries = Math.max(0, quarryCapacity - currentBusyStones);
// Find idle tiles that are not among those just finished (though they should be EMPTY now anyway)
const idleTrees = village.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
const idleStones = village.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
const treesToStart = idleTrees.slice(0, freeLumberjacks);
const stonesToStart = idleStones.slice(0, freeQuarries);
const tilesToStart = [...treesToStart, ...stonesToStart];
if (!tilesToStart.length) {
return 0;
}
await tx.villageTile.updateMany({
where: { id: { in: tilesToStart.map(t => t.id) } },
data: {
terrainState: 'CLEARING',
clearingStartedDay: today,
},
});
return tilesToStart.length;
}