424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
|
|
import { COSTS, REWARDS } from '../utils/economy';
|
|
|
|
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 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) {
|
|
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
|
|
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
// 1. Update user totals
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
coins: { increment: totalCoins },
|
|
exp: { increment: totalExp },
|
|
},
|
|
});
|
|
|
|
// 2. Update all the tiles
|
|
await tx.villageTile.updateMany({
|
|
where: { id: { in: finishedClearingTiles.map(t => t.id) } },
|
|
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
|
|
});
|
|
|
|
// 3. Create an event for each completed tile
|
|
for (const tile of finishedClearingTiles) {
|
|
await tx.villageEvent.create({
|
|
data: {
|
|
villageId: villageSnapshot.id,
|
|
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
|
|
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
|
|
tileX: tile.x,
|
|
tileY: tile.y,
|
|
coins: REWARDS.VILLAGE.CLEARING.coins,
|
|
exp: REWARDS.VILLAGE.CLEARING.exp,
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- 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;
|
|
const eventsToCreate = [];
|
|
|
|
for (const field of fieldsForExp) {
|
|
let fieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
|
|
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 *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
|
|
}
|
|
totalExpFromFields += fieldExp;
|
|
eventsToCreate.push({
|
|
villageId: villageSnapshot.id,
|
|
type: 'FIELD_EXP',
|
|
message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
|
|
tileX: field.tile.x,
|
|
tileY: field.tile.y,
|
|
coins: 0,
|
|
exp: fieldExp,
|
|
});
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
|
|
prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
|
|
prisma.villageEvent.createMany({ data: eventsToCreate }),
|
|
]);
|
|
}
|
|
|
|
// --- 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 lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length;
|
|
const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length;
|
|
|
|
const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length;
|
|
const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length;
|
|
|
|
const freeLumberjacks = lumberjackCount - clearingTreesCount;
|
|
const freeQuarries = quarryCount - clearingStonesCount;
|
|
|
|
const tileIdsToClear = new Set<number>();
|
|
|
|
if (freeLumberjacks > 0) {
|
|
const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
|
|
if (idleTrees.length > 0) {
|
|
// For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here.
|
|
idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id));
|
|
}
|
|
}
|
|
|
|
if (freeQuarries > 0) {
|
|
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
|
|
if (idleStones.length > 0) {
|
|
// For simplicity, just take the first N available stones.
|
|
idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id));
|
|
}
|
|
}
|
|
|
|
if (tileIdsToClear.size > 0) {
|
|
await prisma.villageTile.updateMany({
|
|
where: { id: { in: Array.from(tileIdsToClear) } },
|
|
data: { terrainState: 'CLEARING', clearingStartedAt: now },
|
|
});
|
|
|
|
// Refetch state after starting new clearings
|
|
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
|
|
}
|
|
|
|
// --- 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 housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length;
|
|
const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
|
|
const freeWorkers = housesCount - producingCount;
|
|
|
|
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 = COSTS.BUILD[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;
|
|
}
|
|
|
|
// --- Action Service Functions ---
|
|
|
|
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
|
|
const { VillageObjectType } = await import('@prisma/client');
|
|
const validBuildingTypes = Object.keys(VillageObjectType);
|
|
if (!validBuildingTypes.includes(buildingType)) {
|
|
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` });
|
|
}
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
// 1. Fetch all necessary data
|
|
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
|
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
|
|
|
|
// Ownership check
|
|
if (tile.village.userId !== userId) {
|
|
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
|
}
|
|
|
|
// Business logic validation
|
|
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)) {
|
|
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } });
|
|
const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length;
|
|
const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
|
|
if (producingCount >= housesCount) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' });
|
|
}
|
|
}
|
|
|
|
// 2. Perform mutations
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: { coins: { decrement: cost } },
|
|
});
|
|
|
|
await tx.villageObject.create({
|
|
data: {
|
|
type: buildingType as keyof typeof VillageObjectType,
|
|
villageId: tile.villageId,
|
|
tileId: tileId,
|
|
},
|
|
});
|
|
|
|
await tx.villageEvent.create({
|
|
data: {
|
|
villageId: tile.villageId,
|
|
type: `BUILD_${buildingType}`,
|
|
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`,
|
|
tileX: tile.x,
|
|
tileY: tile.y,
|
|
coins: -cost,
|
|
exp: 0,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function clearTile(userId: number, tileId: number) {
|
|
return prisma.$transaction(async (tx) => {
|
|
const tile = await tx.villageTile.findUniqueOrThrow({
|
|
where: { id: tileId },
|
|
include: { village: { include: { objects: true } } },
|
|
});
|
|
|
|
if (tile.village.userId !== userId) {
|
|
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
|
}
|
|
|
|
if (tile.terrainState !== 'IDLE') {
|
|
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
|
|
}
|
|
|
|
if (tile.terrainType === 'BLOCKED_TREE') {
|
|
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
|
|
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
|
|
} else if (tile.terrainType === 'BLOCKED_STONE') {
|
|
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
|
|
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
|
|
} else {
|
|
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
|
|
}
|
|
|
|
await tx.villageTile.update({
|
|
where: { id: tileId },
|
|
data: {
|
|
terrainState: 'CLEARING',
|
|
clearingStartedAt: new Date(),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function removeObject(userId: number, tileId: number) {
|
|
// As requested, this is a stub for now.
|
|
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
|
|
}
|
|
|
|
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
|
|
// As requested, this is a stub for now.
|
|
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
|
|
}
|
|
|
|
|
|
|