548 lines
17 KiB
TypeScript
548 lines
17 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';
|
||
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;
|
||
}
|