feat: complete backend MVP (auth, habits, village, leaderboard)

This commit is contained in:
Alexander Andreev 2026-01-02 15:54:10 +03:00
parent e399805d14
commit 19b7f5d93d
19 changed files with 610 additions and 105 deletions

View File

@ -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,
}
};
});

View File

@ -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();

View File

@ -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,
}
};
});

View File

@ -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,
}
};
});

View File

@ -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);

View File

@ -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);

View File

@ -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
View 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,
}
})

View 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 };
});

View File

@ -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.
*/

View 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,
}
};
});

View 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,
};
});

View 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.' });
}
});

View 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" };
});

View 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.' });
}
});

View 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
View 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
View File

@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma

52
server/utils/village.ts Normal file
View 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;
}