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

462 lines
13 KiB
TypeScript
Raw 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';
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<FullVillage> {
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,
},
});
}