feat: complete backend MVP (auth, habits, village, leaderboard)
This commit is contained in:
parent
e399805d14
commit
19b7f5d93d
|
|
@ -1,9 +1,6 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { verifyPassword } from '../utils/password';
|
||||
import { verifyPassword } from '../../utils/password';
|
||||
import { useSession } from 'h3';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { email, password } = body;
|
||||
|
|
@ -16,19 +13,22 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// 2. Find the user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401, // Unauthorized
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Verify the password
|
||||
// WARNING: This verifyPassword is a mock. Replace with a secure library like bcrypt before production.
|
||||
const isPasswordValid = await verifyPassword(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
throw createError({
|
||||
|
|
@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
// 4. Create and update the session
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password', // Should be in .env
|
||||
password: process.env.SESSION_PASSWORD, // Relies on fail-fast check in auth.ts
|
||||
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||
});
|
||||
|
||||
|
|
@ -50,7 +50,19 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 5. Return user data
|
||||
const { password: _password, ...userWithoutPassword } = user;
|
||||
return { user: userWithoutPassword };
|
||||
// 5. Return user data DTO
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
coins: user.coins,
|
||||
exp: user.exp,
|
||||
soundOn: user.soundOn,
|
||||
confettiOn: user.confettiOn,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useSession } from 'h3';
|
|||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password',
|
||||
password: process.env.SESSION_PASSWORD,
|
||||
});
|
||||
|
||||
await session.clear();
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { useSession } from 'h3';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Get the session
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password',
|
||||
});
|
||||
// 1. Get user ID from session; this helper handles the 401 check.
|
||||
const userId = await getUserIdFromSession(event);
|
||||
|
||||
// 2. Check if user is in session
|
||||
if (!session.data?.user?.id) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Fetch the full user from the database
|
||||
// 2. Fetch the full user from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.data.user.id },
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// This case might happen if the user was deleted but the session still exists.
|
||||
// Clear the invalid session.
|
||||
// The helper can't handle this, so we clear the session here.
|
||||
const session = await useSession(event, { password: process.env.SESSION_PASSWORD });
|
||||
await session.clear();
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
statusMessage: 'Unauthorized: User not found.',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Return user data
|
||||
const { password: _password, ...userWithoutPassword } = user;
|
||||
return { user: userWithoutPassword };
|
||||
// 3. Return user data DTO
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
coins: user.coins,
|
||||
exp: user.exp,
|
||||
soundOn: user.soundOn,
|
||||
confettiOn: user.confettiOn,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword } from '../utils/password';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { hashPassword } from '../../utils/password';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
|
@ -21,10 +18,11 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase(); // Normalize email
|
||||
|
||||
// 2. Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
|
|
@ -35,16 +33,25 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
// 3. Hash password and create user
|
||||
// WARNING: This hashPassword is a mock. Replace with a secure library like bcrypt before production.
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
nickname: nickname || 'New Smurf',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Return the new user, excluding the password
|
||||
const { password: _password, ...userWithoutPassword } = user;
|
||||
return { user: userWithoutPassword };
|
||||
// NOTE: Registration does not automatically log in the user.
|
||||
// The user needs to explicitly call the login endpoint after registration.
|
||||
|
||||
// 4. Return the new user, excluding sensitive fields and shortening DTO
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSession } from 'h3';
|
||||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
|
||||
interface CompletionResponse {
|
||||
message: string;
|
||||
|
|
@ -8,20 +8,6 @@ interface CompletionResponse {
|
|||
updatedCoins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
*/
|
||||
async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD,
|
||||
});
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useSession } from 'h3';
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { Habit } from '@prisma/client';
|
||||
|
||||
// DTO to shape the output
|
||||
interface HabitCompletionDto {
|
||||
|
|
@ -12,21 +13,6 @@ interface HabitDto {
|
|||
completions: HabitCompletionDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
*/
|
||||
async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD,
|
||||
});
|
||||
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSession } from 'h3';
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
|
||||
interface HabitDto {
|
||||
id: number;
|
||||
|
|
@ -6,21 +6,6 @@ interface HabitDto {
|
|||
daysOfWeek: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
*/
|
||||
async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD,
|
||||
});
|
||||
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const { name, daysOfWeek } = await readBody(event);
|
||||
|
|
|
|||
10
server/api/health.get.ts
Normal file
10
server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import prisma from '../utils/prisma'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const users = await prisma.user.findMany()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
usersCount: users.length,
|
||||
}
|
||||
})
|
||||
44
server/api/leaderboard.get.ts
Normal file
44
server/api/leaderboard.get.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// server/api/leaderboard.get.ts
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
// --- MVP Compromise: Monthly EXP ---
|
||||
// The current schema does not support tracking monthly EXP.
|
||||
// For MVP, we will use the total cumulative `User.exp` as a stand-in
|
||||
// for "current month's EXP". This should be revisited if true monthly
|
||||
// tracking becomes a requirement.
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
nickname: true,
|
||||
avatar: true,
|
||||
exp: true,
|
||||
},
|
||||
orderBy: {
|
||||
exp: 'desc',
|
||||
},
|
||||
take: 50, // Limit to top 50 users
|
||||
});
|
||||
|
||||
// --- Rank Calculation ---
|
||||
let currentRank = 0;
|
||||
let previousExp = -1; // Ensure any valid EXP is greater than this
|
||||
|
||||
// To handle shared ranks, we iterate through the sorted users.
|
||||
// If the current user's EXP is different from the previous user's EXP,
|
||||
// their rank is their position in the 1-based sorted list.
|
||||
// If their EXP is the same, they share the rank of the previous user.
|
||||
const leaderboard = users.map((user, index) => {
|
||||
if (user.exp !== previousExp) {
|
||||
currentRank = index + 1; // Ranks are 1-based
|
||||
}
|
||||
previousExp = user.exp; // Update previous EXP for the next iteration
|
||||
|
||||
return {
|
||||
rank: currentRank,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
exp: user.exp,
|
||||
};
|
||||
});
|
||||
|
||||
return { leaderboard };
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSession } from 'h3';
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
|
||||
interface DailyVisitResponse {
|
||||
message: string;
|
||||
|
|
@ -9,20 +9,6 @@ interface DailyVisitResponse {
|
|||
updatedCoins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
*/
|
||||
async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD,
|
||||
});
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Date object for the start of a given day in UTC.
|
||||
*/
|
||||
|
|
|
|||
72
server/api/village/harvest.post.ts
Normal file
72
server/api/village/harvest.post.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { CROP_HARVEST_REWARD, isCropGrown } from '../../utils/village';
|
||||
import { CropType } from '@prisma/client';
|
||||
|
||||
// --- Handler ---
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const { fieldId } = await readBody(event);
|
||||
|
||||
// 1. --- Validation ---
|
||||
if (typeof fieldId !== 'number') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" is required.' });
|
||||
}
|
||||
|
||||
// 2. --- Find the target field and validate its state ---
|
||||
const field = await prisma.villageObject.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
type: 'FIELD',
|
||||
village: { userId: userId }, // Ensures ownership
|
||||
},
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' });
|
||||
}
|
||||
|
||||
if (!field.cropType || !field.plantedAt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Nothing is planted in this field.' });
|
||||
}
|
||||
|
||||
if (!isCropGrown(field.plantedAt, field.cropType)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Crop is not yet fully grown.' });
|
||||
}
|
||||
|
||||
// 3. --- Grant rewards and clear field in a transaction ---
|
||||
const reward = CROP_HARVEST_REWARD[field.cropType];
|
||||
|
||||
const [, , updatedField] = await prisma.$transaction([
|
||||
// Grant EXP and Coins
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
exp: { increment: reward.exp },
|
||||
coins: { increment: reward.coins },
|
||||
},
|
||||
}),
|
||||
// Clear the crop from the field
|
||||
prisma.villageObject.update({
|
||||
where: { id: fieldId },
|
||||
data: {
|
||||
cropType: null,
|
||||
plantedAt: null,
|
||||
},
|
||||
}),
|
||||
// Re-fetch the field to return its cleared state
|
||||
prisma.villageObject.findUniqueOrThrow({ where: { id: fieldId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: `${field.cropType} harvested successfully!`,
|
||||
reward: reward,
|
||||
updatedField: {
|
||||
id: updatedField.id,
|
||||
type: updatedField.type,
|
||||
x: updatedField.x,
|
||||
y: updatedField.y,
|
||||
cropType: updatedField.cropType,
|
||||
isGrown: false,
|
||||
}
|
||||
};
|
||||
});
|
||||
50
server/api/village/index.get.ts
Normal file
50
server/api/village/index.get.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { isCropGrown } from '../../utils/village';
|
||||
import { CropType, VillageObjectType } from '@prisma/client';
|
||||
|
||||
// --- DTOs ---
|
||||
interface VillageObjectDto {
|
||||
id: number;
|
||||
type: VillageObjectType;
|
||||
x: number;
|
||||
y: number;
|
||||
cropType: CropType | null;
|
||||
isGrown: boolean | null;
|
||||
}
|
||||
|
||||
interface VillageDto {
|
||||
objects: VillageObjectDto[];
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
export default defineEventHandler(async (event): Promise<VillageDto> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
|
||||
let village = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: { objects: true },
|
||||
});
|
||||
|
||||
// If the user has no village yet, create one automatically.
|
||||
if (!village) {
|
||||
village = await prisma.village.create({
|
||||
data: { userId },
|
||||
include: { objects: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Map Prisma objects to clean DTOs, computing `isGrown`.
|
||||
const objectDtos: VillageObjectDto[] = village.objects.map(obj => ({
|
||||
id: obj.id,
|
||||
type: obj.type,
|
||||
x: obj.x,
|
||||
y: obj.y,
|
||||
cropType: obj.cropType,
|
||||
isGrown: obj.type === 'FIELD' ? isCropGrown(obj.plantedAt, obj.cropType) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
objects: objectDtos,
|
||||
};
|
||||
});
|
||||
78
server/api/village/objects.post.ts
Normal file
78
server/api/village/objects.post.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { VILLAGE_GRID_SIZE, ITEM_COSTS } from '../../utils/village';
|
||||
import { VillageObjectType } from '@prisma/client';
|
||||
|
||||
// --- Handler ---
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const { type, x, y } = await readBody(event);
|
||||
|
||||
// 1. --- Validation ---
|
||||
if (!type || typeof x !== 'number' || typeof y !== 'number') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "type", "x", and "y" are required.' });
|
||||
}
|
||||
if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Object placed outside of village bounds.' });
|
||||
}
|
||||
const cost = ITEM_COSTS[type as VillageObjectType];
|
||||
if (cost === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot place objects of this type.' });
|
||||
}
|
||||
|
||||
// 2. --- Fetch current state and enforce rules ---
|
||||
const village = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: { objects: true },
|
||||
});
|
||||
|
||||
if (!village) {
|
||||
// This should not happen if GET /village is called first, but as a safeguard:
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
|
||||
}
|
||||
|
||||
// Rule: Cell must be empty
|
||||
if (village.objects.some(obj => obj.x === x && obj.y === y)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A building already exists on this cell.' });
|
||||
}
|
||||
|
||||
// Rule: Fields require available workers
|
||||
if (type === 'FIELD') {
|
||||
const houseCount = village.objects.filter(obj => obj.type === 'HOUSE').length;
|
||||
const fieldCount = village.objects.filter(obj => obj.type === 'FIELD').length;
|
||||
if (fieldCount >= houseCount) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Not enough available workers to build a new field. Build more houses first.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. --- Perform atomic transaction ---
|
||||
try {
|
||||
const [, newObject] = await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId, coins: { gte: cost } },
|
||||
data: { coins: { decrement: cost } },
|
||||
}),
|
||||
prisma.villageObject.create({
|
||||
data: {
|
||||
villageId: village.id,
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
setResponseStatus(event, 201);
|
||||
return {
|
||||
id: newObject.id,
|
||||
type: newObject.type,
|
||||
x: newObject.x,
|
||||
y: newObject.y,
|
||||
cropType: null,
|
||||
isGrown: null,
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
// Catches failed transactions, likely from the user.update 'where' clause (insufficient funds)
|
||||
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to build.' });
|
||||
}
|
||||
});
|
||||
74
server/api/village/objects/[id].delete.ts
Normal file
74
server/api/village/objects/[id].delete.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { OBSTACLE_CLEAR_COST } from '../../../utils/village';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Get objectId from params
|
||||
const objectId = parseInt(event.context.params?.id || '', 10);
|
||||
if (isNaN(objectId)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' });
|
||||
}
|
||||
|
||||
const userId = await getUserIdFromSession(event);
|
||||
|
||||
// 2. Find village + objects
|
||||
const village = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: { objects: true }
|
||||
});
|
||||
|
||||
if (!village) {
|
||||
// This case is unlikely if user has ever fetched their village, but is a good safeguard.
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
|
||||
}
|
||||
|
||||
// 3. Find the object
|
||||
const objectToDelete = village.objects.find(o => o.id === objectId);
|
||||
|
||||
// 4. If not found -> 404
|
||||
if (!objectToDelete) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Object not found.' });
|
||||
}
|
||||
|
||||
// 5. If OBSTACLE
|
||||
if (objectToDelete.type === 'OBSTACLE') {
|
||||
const cost = OBSTACLE_CLEAR_COST[objectToDelete.obstacleMetadata || 'DEFAULT'] ?? OBSTACLE_CLEAR_COST.DEFAULT;
|
||||
|
||||
try {
|
||||
// Atomically check coins, deduct, and delete
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId, coins: { gte: cost } },
|
||||
data: { coins: { decrement: cost } },
|
||||
}),
|
||||
prisma.villageObject.delete({ where: { id: objectId } }),
|
||||
]);
|
||||
} catch (e) {
|
||||
// The transaction fails if the user update fails due to insufficient coins
|
||||
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to clear obstacle.' });
|
||||
}
|
||||
|
||||
// 6. If HOUSE
|
||||
} else if (objectToDelete.type === 'HOUSE') {
|
||||
const houseCount = village.objects.filter(o => o.type === 'HOUSE').length;
|
||||
const fieldCount = village.objects.filter(o => o.type === 'FIELD').length;
|
||||
|
||||
// Check if removing this house violates the worker rule
|
||||
if (fieldCount > (houseCount - 1)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Cannot remove house. You have ${fieldCount} fields and need at least ${fieldCount} workers.`
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the house (no cost)
|
||||
await prisma.villageObject.delete({ where: { id: objectId } });
|
||||
|
||||
// 7. Otherwise (FIELD, ROAD, FENCE)
|
||||
} else {
|
||||
// Delete the object (no cost)
|
||||
await prisma.villageObject.delete({ where: { id: objectId } });
|
||||
}
|
||||
|
||||
// 8. Return success message
|
||||
return { message: "Object removed successfully" };
|
||||
});
|
||||
73
server/api/village/objects/[id].patch.ts
Normal file
73
server/api/village/objects/[id].patch.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { VILLAGE_GRID_SIZE, MOVE_COST, isCropGrown } from '../../../utils/village';
|
||||
|
||||
// --- Handler ---
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const objectId = parseInt(event.context.params?.id || '', 10);
|
||||
const { x, y } = await readBody(event);
|
||||
|
||||
// 1. --- Validation ---
|
||||
if (isNaN(objectId)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' });
|
||||
}
|
||||
if (typeof x !== 'number' || typeof y !== 'number') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "x" and "y" are required.' });
|
||||
}
|
||||
if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot move object outside of village bounds.' });
|
||||
}
|
||||
|
||||
// 2. --- Fetch state and enforce rules ---
|
||||
const village = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: { objects: true },
|
||||
});
|
||||
|
||||
if (!village) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
|
||||
}
|
||||
|
||||
const objectToMove = village.objects.find(obj => obj.id === objectId);
|
||||
|
||||
// Rule: Object must exist and belong to the user (implicit via village)
|
||||
if (!objectToMove) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Object not found.' });
|
||||
}
|
||||
|
||||
// Rule: Cannot move obstacles
|
||||
if (objectToMove.type === 'OBSTACLE') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot move obstacles. They must be cleared.' });
|
||||
}
|
||||
|
||||
// Rule: Target cell must be empty (and not the same cell)
|
||||
if (village.objects.some(obj => obj.x === x && obj.y === y)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Target cell is already occupied.' });
|
||||
}
|
||||
|
||||
// 3. --- Perform atomic transaction ---
|
||||
try {
|
||||
const [, updatedObject] = await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId, coins: { gte: MOVE_COST } },
|
||||
data: { coins: { decrement: MOVE_COST } },
|
||||
}),
|
||||
prisma.villageObject.update({
|
||||
where: { id: objectId },
|
||||
data: { x, y },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: updatedObject.id,
|
||||
type: updatedObject.type,
|
||||
x: updatedObject.x,
|
||||
y: updatedObject.y,
|
||||
cropType: updatedObject.cropType,
|
||||
isGrown: isCropGrown(updatedObject.plantedAt, updatedObject.cropType),
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to move object.' });
|
||||
}
|
||||
});
|
||||
63
server/api/village/plant.post.ts
Normal file
63
server/api/village/plant.post.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { PLANTING_COST, isCropGrown } from '../../utils/village';
|
||||
import { CropType } from '@prisma/client';
|
||||
|
||||
// --- Handler ---
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const { fieldId, cropType } = await readBody(event);
|
||||
|
||||
// 1. --- Validation ---
|
||||
if (typeof fieldId !== 'number' || !cropType) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" and "cropType" are required.' });
|
||||
}
|
||||
if (!Object.values(CropType).includes(cropType)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid crop type.' });
|
||||
}
|
||||
|
||||
// 2. --- Find the target field and validate its state ---
|
||||
const field = await prisma.villageObject.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
type: 'FIELD',
|
||||
village: { userId: userId }, // Ensures ownership
|
||||
},
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' });
|
||||
}
|
||||
|
||||
if (field.cropType !== null) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A crop is already planted in this field.' });
|
||||
}
|
||||
|
||||
// 3. --- Perform atomic transaction ---
|
||||
try {
|
||||
const [, updatedField] = await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId, coins: { gte: PLANTING_COST } },
|
||||
data: { coins: { decrement: PLANTING_COST } },
|
||||
}),
|
||||
prisma.villageObject.update({
|
||||
where: { id: fieldId },
|
||||
data: {
|
||||
cropType: cropType,
|
||||
plantedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: updatedField.id,
|
||||
type: updatedField.type,
|
||||
x: updatedField.x,
|
||||
y: updatedField.y,
|
||||
cropType: updatedField.cropType,
|
||||
isGrown: isCropGrown(updatedField.plantedAt, updatedField.cropType), // will be false
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to plant seeds.' });
|
||||
}
|
||||
});
|
||||
22
server/utils/auth.ts
Normal file
22
server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// server/utils/auth.ts
|
||||
import { useSession } from 'h3';
|
||||
|
||||
if (!process.env.SESSION_PASSWORD) {
|
||||
// Fail-fast if the session password is not configured
|
||||
throw new Error('FATAL ERROR: SESSION_PASSWORD environment variable is not set. Session management will not work securely.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
* Throws a 401 Unauthorized error if the user is not authenticated.
|
||||
*/
|
||||
export async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check
|
||||
});
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
5
server/utils/prisma.ts
Normal file
5
server/utils/prisma.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export default prisma
|
||||
52
server/utils/village.ts
Normal file
52
server/utils/village.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// server/utils/village.ts
|
||||
import { CropType, VillageObjectType } from '@prisma/client';
|
||||
|
||||
// --- Game Economy & Rules ---
|
||||
export const VILLAGE_GRID_SIZE = { width: 15, height: 15 };
|
||||
|
||||
export const ITEM_COSTS: Partial<Record<VillageObjectType, number>> = {
|
||||
HOUSE: 50,
|
||||
FIELD: 15,
|
||||
ROAD: 5,
|
||||
FENCE: 10,
|
||||
};
|
||||
|
||||
export const OBSTACLE_CLEAR_COST: Record<string, number> = {
|
||||
ROCK: 20,
|
||||
BUSH: 5,
|
||||
MUSHROOM: 10,
|
||||
DEFAULT: 15, // Fallback cost for untyped obstacles
|
||||
};
|
||||
|
||||
export const PLANTING_COST = 2; // A small, flat cost for seeds
|
||||
export const MOVE_COST = 1; // Cost to move any player-built item
|
||||
|
||||
// --- Crop Timings (in milliseconds) ---
|
||||
export const CROP_GROWTH_TIME: Record<CropType, number> = {
|
||||
BLUEBERRIES: 60 * 60 * 1000, // 1 hour
|
||||
CORN: 4 * 60 * 60 * 1000, // 4 hours
|
||||
};
|
||||
|
||||
// --- Rewards ---
|
||||
export const CROP_HARVEST_REWARD: Record<CropType, { exp: number, coins: number }> = {
|
||||
BLUEBERRIES: { exp: 5, coins: 0 },
|
||||
CORN: { exp: 10, coins: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a crop is grown based on when it was planted.
|
||||
* @param plantedAt The ISO string or Date object when the crop was planted.
|
||||
* @param cropType The type of crop.
|
||||
* @returns True if the crop has finished growing.
|
||||
*/
|
||||
export function isCropGrown(plantedAt: Date | null, cropType: CropType | null): boolean {
|
||||
if (!plantedAt || !cropType) {
|
||||
return false;
|
||||
}
|
||||
const growthTime = CROP_GROWTH_TIME[cropType];
|
||||
if (growthTime === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (Date.now() - new Date(plantedAt).getTime()) > growthTime;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user