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';
|
import { useSession } from 'h3';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { email, password } = body;
|
const { email, password } = body;
|
||||||
|
|
@ -16,19 +13,22 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
// 2. Find the user
|
// 2. Find the user
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email: normalizedEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401, // Unauthorized
|
statusCode: 401,
|
||||||
statusMessage: 'Invalid credentials',
|
statusMessage: 'Invalid credentials',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify the password
|
// 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);
|
const isPasswordValid = await verifyPassword(password, user.password);
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// 4. Create and update the session
|
// 4. Create and update the session
|
||||||
const session = await useSession(event, {
|
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
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -50,7 +50,19 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Return user data
|
// 5. Return user data DTO
|
||||||
const { password: _password, ...userWithoutPassword } = user;
|
return {
|
||||||
return { user: userWithoutPassword };
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await useSession(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();
|
await session.clear();
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
import { useSession } from 'h3';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// 1. Get the session
|
// 1. Get user ID from session; this helper handles the 401 check.
|
||||||
const session = await useSession(event, {
|
const userId = await getUserIdFromSession(event);
|
||||||
password: process.env.SESSION_PASSWORD || 'your-super-secret-32-character-password',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Check if user is in session
|
// 2. Fetch the full user from the database
|
||||||
if (!session.data?.user?.id) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Unauthorized',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch the full user from the database
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: session.data.user.id },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// This case might happen if the user was deleted but the session still exists.
|
// 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();
|
await session.clear();
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Unauthorized',
|
statusMessage: 'Unauthorized: User not found.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Return user data
|
// 3. Return user data DTO
|
||||||
const { password: _password, ...userWithoutPassword } = user;
|
return {
|
||||||
return { user: userWithoutPassword };
|
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';
|
||||||
import { hashPassword } from '../utils/password';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(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
|
// 2. Check if user already exists
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email: normalizedEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
|
|
@ -35,16 +33,25 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Hash password and create user
|
// 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 hashedPassword = await hashPassword(password);
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
nickname: nickname || 'New Smurf',
|
nickname: nickname || 'New Smurf',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Return the new user, excluding the password
|
// NOTE: Registration does not automatically log in the user.
|
||||||
const { password: _password, ...userWithoutPassword } = user;
|
// The user needs to explicitly call the login endpoint after registration.
|
||||||
return { user: userWithoutPassword };
|
|
||||||
|
// 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 {
|
interface CompletionResponse {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -8,20 +8,6 @@ interface CompletionResponse {
|
||||||
updatedCoins: number;
|
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> => {
|
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||||
const userId = await getUserIdFromSession(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
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
|
// DTO to shape the output
|
||||||
interface HabitCompletionDto {
|
interface HabitCompletionDto {
|
||||||
|
|
@ -12,21 +13,6 @@ interface HabitDto {
|
||||||
completions: HabitCompletionDto[];
|
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[]> => {
|
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
|
||||||
const userId = await getUserIdFromSession(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useSession } from 'h3';
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
|
|
||||||
interface HabitDto {
|
interface HabitDto {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -6,21 +6,6 @@ interface HabitDto {
|
||||||
daysOfWeek: number[];
|
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> => {
|
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
||||||
const userId = await getUserIdFromSession(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
const { name, daysOfWeek } = await readBody(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 {
|
interface DailyVisitResponse {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -9,20 +9,6 @@ interface DailyVisitResponse {
|
||||||
updatedCoins: number;
|
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.
|
* 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