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

299 lines
10 KiB
TypeScript

// server/services/villageService.ts
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
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 BUILDING_COSTS: Record<string, number> = {
HOUSE: 50,
FIELD: 15,
LUMBERJACK: 30,
QUARRY: 30,
WELL: 20,
};
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<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
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<FullVillage> {
const now = new Date();
// --- 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' });
}
// --- Step 2: Terrain Cleaning Completion ---
const finishedClearingTiles = villageSnapshot.tiles.filter(
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
);
if (finishedClearingTiles.length > 0) {
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
coins: { increment: finishedClearingTiles.length },
exp: { increment: finishedClearingTiles.length },
},
}),
...finishedClearingTiles.map(t =>
prisma.villageTile.update({
where: { id: t.id },
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
})
),
]);
}
// --- 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 } } } })!;
// --- Step 4: Field EXP Processing ---
const today = getStartOfDay(now);
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;
for (const field of fieldsForExp) {
let fieldExp = 1;
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}`)) {
fieldExp *= 2;
}
totalExpFromFields += fieldExp;
}
await prisma.$transaction([
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
...fieldsForExp.map(f => prisma.villageObject.update({ where: { id: f.id }, data: { lastExpAt: today } })),
]);
}
// --- 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 housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
const freeWorkers = housesCount - producingCount;
if (producingCount <= housesCount) {
const manhattanDistance = (p1: {x: number, y: number}, p2: {x: number, y: number}) => Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
const getDirectionalPriority = (worker: VillageTile, target: VillageTile): number => {
const dy = target.y - worker.y;
const dx = target.x - worker.x;
if (dy < 0) return 1; // Up
if (dx < 0) return 2; // Left
if (dx > 0) return 3; // Right
if (dy > 0) return 4; // Down
return 5;
};
const assignTasks = (workers: (VillageObject & { tile: VillageTile })[], targets: VillageTile[], newlyTargeted: Set<number>) => {
workers.forEach(worker => {
const potentialTargets = targets
.filter(t => !newlyTargeted.has(t.id))
.map(target => ({ target, distance: manhattanDistance(worker.tile, target) }))
.sort((a, b) => a.distance - b.distance);
if (!potentialTargets.length) return;
const minDistance = potentialTargets[0].distance;
const tiedTargets = potentialTargets.filter(t => t.distance === minDistance).map(t => t.target);
tiedTargets.sort((a, b) => getDirectionalPriority(worker.tile, a) - getDirectionalPriority(worker.tile, b));
const bestTarget = tiedTargets[0];
if (bestTarget) newlyTargeted.add(bestTarget.id);
});
};
const lumberjacks = villageSnapshot.objects.filter(obj => obj.type === 'LUMBERJACK') as (VillageObject & { tile: VillageTile })[];
const quarries = villageSnapshot.objects.filter(obj => obj.type === 'QUARRY') as (VillageObject & { tile: VillageTile })[];
const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
const tileIdsToClear = new Set<number>();
assignTasks(lumberjacks, idleTrees, tileIdsToClear);
assignTasks(quarries, idleStones, tileIdsToClear);
if (tileIdsToClear.size > 0) {
await prisma.villageTile.updateMany({
where: { id: { in: Array.from(tileIdsToClear) } },
data: { terrainState: 'CLEARING', clearingStartedAt: now },
});
}
}
// --- 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 tilesWithActions = finalVillageState.tiles.map(tile => {
const availableActions: any[] = [];
// Action: CLEAR
if (tile.terrainState === 'IDLE' && (tile.terrainType === 'BLOCKED_STONE' || tile.terrainType === 'BLOCKED_TREE')) {
const canClearTree = tile.terrainType === 'BLOCKED_TREE' && hasLumberjack;
const canClearStone = tile.terrainType === 'BLOCKED_STONE' && hasQuarry;
availableActions.push({
type: 'CLEAR',
isEnabled: canClearTree || canClearStone,
disabledReason: !(canClearTree || canClearStone) ? `Requires ${tile.terrainType === 'BLOCKED_TREE' ? 'Lumberjack' : 'Quarry'}` : undefined,
});
}
// Action: BUILD
if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = BUILDING_COSTS[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) {
const isHouse = tile.object.type === 'HOUSE';
// Action: MOVE
availableActions.push({
type: 'MOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be moved' : undefined,
});
// Action: REMOVE
availableActions.push({
type: 'REMOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be removed' : undefined,
});
}
return { ...tile, availableActions };
});
return { ...finalVillageState, tiles: tilesWithActions } as any;
}