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