From de89a419266ae6b0b136ed9e424c24fd22307810 Mon Sep 17 00:00:00 2001 From: Alexander Andreev Date: Sat, 3 Jan 2026 23:56:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81,=20=D0=BA=D0=B0=D1=81=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B5=D0=B9=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8=20=D0=94=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=BD=D0=B8.=20=D0=A1=D0=B5=D0=B9=D1=87=D0=B0?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app.vue | 40 +- app/layouts/default.vue | 137 +++--- app/layouts/login.vue | 13 +- app/pages/index.vue | 430 +++++++----------- app/pages/login.vue | 125 ++--- app/pages/register.vue | 163 +++---- app/pages/village.vue | 210 +++++++-- pages/village.vue | 197 ++++++++ .../20260102113639_init/migration.sql | 9 - .../migration.sql | 64 +-- prisma/schema.prisma | 48 +- scripts/fix-migration.sql | 2 + server/api/auth/register.post.ts | 6 +- server/api/village/harvest.post.ts | 72 --- server/api/village/index.get.ts | 68 +-- server/api/village/objects.post.ts | 78 ---- server/api/village/objects/[id].delete.ts | 74 --- server/api/village/objects/[id].patch.ts | 73 --- server/api/village/plant.post.ts | 63 --- server/middleware/auth.ts | 42 ++ server/services/villageService.ts | 299 ++++++++++++ server/utils/prisma.ts | 2 +- server/utils/village.ts | 20 +- 23 files changed, 1247 insertions(+), 988 deletions(-) create mode 100644 pages/village.vue delete mode 100644 prisma/migrations/20260102113639_init/migration.sql rename prisma/migrations/{20260102115918_init_revised_schema => 20260103205016_init}/migration.sql (73%) create mode 100644 scripts/fix-migration.sql delete mode 100644 server/api/village/harvest.post.ts delete mode 100644 server/api/village/objects.post.ts delete mode 100644 server/api/village/objects/[id].delete.ts delete mode 100644 server/api/village/objects/[id].patch.ts delete mode 100644 server/api/village/plant.post.ts create mode 100644 server/middleware/auth.ts create mode 100644 server/services/villageService.ts diff --git a/app/app.vue b/app/app.vue index 32b561b..4f09288 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,17 +1,37 @@ - \ No newline at end of file + + + diff --git a/app/layouts/default.vue b/app/layouts/default.vue index b4d3765..71b02c3 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,96 +1,115 @@ - \ No newline at end of file + +.nav-item.router-link-active { + color: #007bff; +} + diff --git a/app/layouts/login.vue b/app/layouts/login.vue index 8722bdd..5e36519 100644 --- a/app/layouts/login.vue +++ b/app/layouts/login.vue @@ -1,15 +1,18 @@ + + + \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index 6ea6ee4..9eb3ccb 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,323 +1,205 @@ - + +.welcome-content p { + font-size: 1.2em; + color: #555; + margin-bottom: 40px; +} + +.auth-buttons { + display: flex; + justify-content: center; + gap: 20px; +} + +.button { + display: inline-block; + padding: 12px 25px; + border-radius: 8px; + text-decoration: none; + font-weight: bold; + font-size: 1.1em; + transition: background-color 0.3s ease; + border: 1px solid transparent; +} + +.button.primary { + background-color: #007bff; + color: white; +} + +.button.primary:hover { + background-color: #0056b3; +} + +.button.secondary { + background-color: #6c757d; + color: white; +} + +.button.secondary:hover { + background-color: #5a6268; +} + +.links { + display: flex; + justify-content: center; + gap: 20px; + margin: 40px 0; +} +.links a.button { + background-color: #e9ecef; + color: #333; +} +.links a.button:hover { + background-color: #dee2e6; +} + \ No newline at end of file diff --git a/app/pages/login.vue b/app/pages/login.vue index a1cc828..a1b71c4 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -1,109 +1,116 @@ - +.switch-link { + margin-top: 20px; + text-align: center; +} + \ No newline at end of file diff --git a/app/pages/register.vue b/app/pages/register.vue index dcdca61..bf32f29 100644 --- a/app/pages/register.vue +++ b/app/pages/register.vue @@ -1,138 +1,139 @@ - +.success-message { + color: green; + margin-bottom: 16px; + text-align: center; +} +.switch-link { + margin-top: 20px; + text-align: center; +} + \ No newline at end of file diff --git a/app/pages/village.vue b/app/pages/village.vue index bba3af1..0ea7919 100644 --- a/app/pages/village.vue +++ b/app/pages/village.vue @@ -1,59 +1,197 @@ - + \ No newline at end of file diff --git a/pages/village.vue b/pages/village.vue new file mode 100644 index 0000000..4d07d4d --- /dev/null +++ b/pages/village.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/prisma/migrations/20260102113639_init/migration.sql b/prisma/migrations/20260102113639_init/migration.sql deleted file mode 100644 index ac0bba9..0000000 --- a/prisma/migrations/20260102113639_init/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "email" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/20260102115918_init_revised_schema/migration.sql b/prisma/migrations/20260103205016_init/migration.sql similarity index 73% rename from prisma/migrations/20260102115918_init_revised_schema/migration.sql rename to prisma/migrations/20260103205016_init/migration.sql index c32e192..b2de39f 100644 --- a/prisma/migrations/20260102115918_init_revised_schema/migration.sql +++ b/prisma/migrations/20260103205016_init/migration.sql @@ -1,10 +1,18 @@ -/* - Warnings: +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "nickname" TEXT, + "avatar" TEXT DEFAULT '/avatars/default.png', + "coins" INTEGER NOT NULL DEFAULT 0, + "exp" INTEGER NOT NULL DEFAULT 0, + "soundOn" BOOLEAN NOT NULL DEFAULT true, + "confettiOn" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); - - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty. - - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. - -*/ -- CreateTable CREATE TABLE "Habit" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, @@ -43,37 +51,30 @@ CREATE TABLE "Village" ( CREATE TABLE "VillageObject" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "type" TEXT NOT NULL, - "x" INTEGER NOT NULL, - "y" INTEGER NOT NULL, - "obstacleMetadata" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastExpAt" DATETIME, "cropType" TEXT, "plantedAt" DATETIME, "villageId" INTEGER NOT NULL, - CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE + "tileId" INTEGER NOT NULL, + CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "VillageObject_tileId_fkey" FOREIGN KEY ("tileId") REFERENCES "VillageTile" ("id") ON DELETE RESTRICT ON UPDATE CASCADE ); --- RedefineTables -PRAGMA defer_foreign_keys=ON; -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_User" ( +-- CreateTable +CREATE TABLE "VillageTile" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - "nickname" TEXT, - "avatar" TEXT DEFAULT '/avatars/default.png', - "coins" INTEGER NOT NULL DEFAULT 0, - "exp" INTEGER NOT NULL DEFAULT 0, - "soundOn" BOOLEAN NOT NULL DEFAULT true, - "confettiOn" BOOLEAN NOT NULL DEFAULT true, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL + "x" INTEGER NOT NULL, + "y" INTEGER NOT NULL, + "terrainType" TEXT NOT NULL, + "terrainState" TEXT NOT NULL DEFAULT 'IDLE', + "clearingStartedAt" DATETIME, + "villageId" INTEGER NOT NULL, + CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); -INSERT INTO "new_User" ("createdAt", "email", "id") SELECT "createdAt", "email", "id" FROM "User"; -DROP TABLE "User"; -ALTER TABLE "new_User" RENAME TO "User"; + +-- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); -PRAGMA foreign_keys=ON; -PRAGMA defer_foreign_keys=OFF; -- CreateIndex CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date"); @@ -85,4 +86,7 @@ CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId"); -- CreateIndex -CREATE UNIQUE INDEX "VillageObject_villageId_x_y_key" ON "VillageObject"("villageId", "x", "y"); +CREATE UNIQUE INDEX "VillageObject_tileId_key" ON "VillageObject"("tileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VillageTile_villageId_x_y_key" ON "VillageTile"("villageId", "x", "y"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 522f867..47b063a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,9 +14,17 @@ datasource db { enum VillageObjectType { HOUSE FIELD - ROAD - FENCE - OBSTACLE +} + +enum TerrainType { + EMPTY + BLOCKED_TREE + BLOCKED_STONE +} + +enum TerrainState { + IDLE + CLEARING } // CropType: Defines the types of crops that can be planted. @@ -101,25 +109,39 @@ model Village { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @unique // Each user has only one village objects VillageObject[] + tiles VillageTile[] } -// VillageObject: An object (e.g., house, field, obstacle) placed on the -// village grid. It stores the object's type, its coordinates, and optionally -// details if it's an obstacle or a planted crop. +// VillageObject: An object (e.g., house, field) placed on a village tile. model VillageObject { id Int @id @default(autoincrement()) type VillageObjectType - x Int - y Int - obstacleMetadata String? // Stores metadata for obstacles (e.g., "rock", "bush"). + createdAt DateTime @default(now()) + lastExpAt DateTime? // Crop details (only if type is FIELD) cropType CropType? plantedAt DateTime? // Relations - village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) - villageId Int - - @@unique([villageId, x, y]) // Ensure only one object per grid cell per village + village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) + villageId Int + tile VillageTile @relation(fields: [tileId], references: [id]) + tileId Int @unique +} + +model VillageTile { + id Int @id @default(autoincrement()) + x Int + y Int + terrainType TerrainType + terrainState TerrainState @default(IDLE) + clearingStartedAt DateTime? + + // Relations + village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) + villageId Int + object VillageObject? + + @@unique([villageId, x, y]) } diff --git a/scripts/fix-migration.sql b/scripts/fix-migration.sql new file mode 100644 index 0000000..6efb79e --- /dev/null +++ b/scripts/fix-migration.sql @@ -0,0 +1,2 @@ +DELETE FROM _prisma_migrations +WHERE migration_name = '20260103181802_refactor_village_schema'; diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index ea62952..50b7949 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -1,4 +1,5 @@ import { hashPassword } from '../../utils/password'; +import { generateVillageForUser } from '../../services/villageService'; export default defineEventHandler(async (event) => { const body = await readBody(event); @@ -43,10 +44,13 @@ export default defineEventHandler(async (event) => { }, }); + // 4. Generate the user's village + await generateVillageForUser(user); + // 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 + // 5. Return the new user, excluding sensitive fields and shortening DTO return { user: { id: user.id, diff --git a/server/api/village/harvest.post.ts b/server/api/village/harvest.post.ts deleted file mode 100644 index bffe178..0000000 --- a/server/api/village/harvest.post.ts +++ /dev/null @@ -1,72 +0,0 @@ -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, - } - }; -}); diff --git a/server/api/village/index.get.ts b/server/api/village/index.get.ts index a68bf98..5e50813 100644 --- a/server/api/village/index.get.ts +++ b/server/api/village/index.get.ts @@ -1,50 +1,28 @@ -import { getUserIdFromSession } from '../../utils/auth'; -import { isCropGrown } from '../../utils/village'; -import { CropType, VillageObjectType } from '@prisma/client'; +// server/api/village/index.get.ts +import { getVillageState, generateVillageForUser } from '../../services/villageService'; +import { defineEventHandler } from 'h3'; -// --- DTOs --- -interface VillageObjectDto { - id: number; - type: VillageObjectType; - x: number; - y: number; - cropType: CropType | null; - isGrown: boolean | null; -} +export default defineEventHandler(async (event) => { + const user = event.context.user; -interface VillageDto { - objects: VillageObjectDto[]; -} - -// --- Handler --- - -export default defineEventHandler(async (event): Promise => { - const userId = await getUserIdFromSession(event); - - let village = await prisma.village.findUnique({ - where: { userId }, - include: { objects: true }, + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: 'Unauthorized', }); + } - // If the user has no village yet, create one automatically. - if (!village) { - village = await prisma.village.create({ - data: { userId }, - include: { objects: true }, - }); - } + // Ensure the user has a village generated. This function is idempotent. + await generateVillageForUser(user); - // 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, - }; -}); + try { + const villageState = await getVillageState(user.id); + return villageState; + } catch (error: any) { + // Catch errors from the service and re-throw them as H3 errors + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'An error occurred while fetching village state.', + }); + } +}); \ No newline at end of file diff --git a/server/api/village/objects.post.ts b/server/api/village/objects.post.ts deleted file mode 100644 index d3a3b70..0000000 --- a/server/api/village/objects.post.ts +++ /dev/null @@ -1,78 +0,0 @@ -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.' }); - } -}); diff --git a/server/api/village/objects/[id].delete.ts b/server/api/village/objects/[id].delete.ts deleted file mode 100644 index 3c9287a..0000000 --- a/server/api/village/objects/[id].delete.ts +++ /dev/null @@ -1,74 +0,0 @@ -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" }; -}); \ No newline at end of file diff --git a/server/api/village/objects/[id].patch.ts b/server/api/village/objects/[id].patch.ts deleted file mode 100644 index b99642c..0000000 --- a/server/api/village/objects/[id].patch.ts +++ /dev/null @@ -1,73 +0,0 @@ -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.' }); - } -}); diff --git a/server/api/village/plant.post.ts b/server/api/village/plant.post.ts deleted file mode 100644 index e2c5e91..0000000 --- a/server/api/village/plant.post.ts +++ /dev/null @@ -1,63 +0,0 @@ -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.' }); - } -}); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts new file mode 100644 index 0000000..015a027 --- /dev/null +++ b/server/middleware/auth.ts @@ -0,0 +1,42 @@ +// server/middleware/auth.ts +import { defineEventHandler, useSession } from 'h3'; +import prisma from '../utils/prisma'; + +/** + * Global server middleware to populate `event.context.user` for every incoming request. + * + * It safely checks for a session and fetches the user from the database if a + * valid session ID is found. It does NOT block requests or throw errors if the + * user is not authenticated, as authorization is handled within API endpoints themselves. + */ +export default defineEventHandler(async (event) => { + // This middleware should not run on static assets or internal requests. + const path = event.path || ''; + if (path.startsWith('/_nuxt') || path.startsWith('/__nuxt_error')) { + return; + } + + // Safely get the session + const session = await useSession(event, { + password: process.env.SESSION_PASSWORD!, + }); + + const userId = session.data?.user?.id; + + // If a userId is found in the session, fetch the user and attach it to the context. + if (userId) { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (user) { + event.context.user = user; + } + } catch (error) { + // If there's an error fetching the user (e.g., DB connection issue), + // we log it but don't block the request. The user will be treated as unauthenticated. + console.error('Error fetching user in auth middleware:', error); + } + } +}); diff --git a/server/services/villageService.ts b/server/services/villageService.ts new file mode 100644 index 0000000..84b4b01 --- /dev/null +++ b/server/services/villageService.ts @@ -0,0 +1,299 @@ +// server/services/villageService.ts +import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export const VILLAGE_WIDTH = 5; +export const VILLAGE_HEIGHT = 7; +const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours + +export const BUILDING_COSTS: Record = { + HOUSE: 50, + FIELD: 15, + LUMBERJACK: 30, + QUARRY: 30, + WELL: 20, +}; + +export const PRODUCING_BUILDINGS: string[] = [ + 'FIELD', + 'LUMBERJACK', + 'QUARRY', +]; + +// Helper to get the start of a given date for daily EXP checks +const getStartOfDay = (date: Date) => { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency + return d; +}; + +/** + * Generates the initial village for a new user atomically. + */ +export async function generateVillageForUser(user: User) { + const existingVillage = await prisma.village.findUnique({ + where: { userId: user.id }, + }); + + if (existingVillage) { + return; + } + + const tilesToCreate: Omit[] = []; + const central_x_start = 1; + const central_x_end = 4; + const central_y_start = 2; + const central_y_end = 5; + + for (let y = 0; y < VILLAGE_HEIGHT; y++) { + for (let x = 0; x < VILLAGE_WIDTH; x++) { + const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end; + const terrainType = isCentral + ? 'EMPTY' + : Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE'; + + tilesToCreate.push({ + x, + y, + terrainType, + terrainState: 'IDLE', + }); + } + } + + // FIX: Wrap village generation in a single transaction for atomicity. + await prisma.$transaction(async (tx) => { + const village = await tx.village.create({ + data: { + userId: user.id, + }, + }); + + await tx.user.update({ + where: { id: user.id }, + data: { + coins: 10, + exp: 0, + }, + }); + + await tx.villageTile.createMany({ + data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })), + }); + }); +} + +type FullVillage = Prisma.VillageGetPayload<{ + include: { + user: true; + tiles: { include: { object: true } }; + objects: { include: { tile: true } }; + }; +}>; + +/** + * Gets the full, updated state of a user's village, calculating all time-based progression. + */ +export async function getVillageState(userId: number): Promise { + const now = new Date(); + + // --- Step 1: Initial Snapshot --- + let villageSnapshot = await prisma.village.findUnique({ + where: { userId }, + include: { + user: true, + tiles: { include: { object: true } }, + objects: { include: { tile: true } }, + }, + }); + + if (!villageSnapshot) { + throw createError({ statusCode: 404, statusMessage: 'Village not found' }); + } + + // --- Step 2: Terrain Cleaning Completion --- + const finishedClearingTiles = villageSnapshot.tiles.filter( + t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME + ); + + if (finishedClearingTiles.length > 0) { + await prisma.$transaction([ + prisma.user.update({ + where: { id: userId }, + data: { + coins: { increment: finishedClearingTiles.length }, + exp: { increment: finishedClearingTiles.length }, + }, + }), + ...finishedClearingTiles.map(t => + prisma.villageTile.update({ + where: { id: t.id }, + data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null }, + }) + ), + ]); + } + + // --- Step 3: Refetch for next logic step --- + villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; + + // --- Step 4: Field EXP Processing --- + const today = getStartOfDay(now); + const fieldsForExp = villageSnapshot.objects.filter( + obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today) + ); + + if (fieldsForExp.length > 0) { + const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`)); + let totalExpFromFields = 0; + + for (const field of fieldsForExp) { + let fieldExp = 1; + if (wellPositions.has(`${field.tile.x},${field.tile.y - 1}`) || wellPositions.has(`${field.tile.x},${field.tile.y + 1}`) || wellPositions.has(`${field.tile.x - 1},${field.tile.y}`) || wellPositions.has(`${field.tile.x + 1},${field.tile.y}`)) { + fieldExp *= 2; + } + totalExpFromFields += fieldExp; + } + + await prisma.$transaction([ + prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }), + ...fieldsForExp.map(f => prisma.villageObject.update({ where: { id: f.id }, data: { lastExpAt: today } })), + ]); + } + + // --- Step 5: Refetch for next logic step --- + villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; + + // --- Step 6: Auto-start Terrain Cleaning --- + const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length; + const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; + const freeWorkers = housesCount - producingCount; + + if (producingCount <= housesCount) { + const manhattanDistance = (p1: {x: number, y: number}, p2: {x: number, y: number}) => Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); + const getDirectionalPriority = (worker: VillageTile, target: VillageTile): number => { + const dy = target.y - worker.y; + const dx = target.x - worker.x; + if (dy < 0) return 1; // Up + if (dx < 0) return 2; // Left + if (dx > 0) return 3; // Right + if (dy > 0) return 4; // Down + return 5; + }; + + const assignTasks = (workers: (VillageObject & { tile: VillageTile })[], targets: VillageTile[], newlyTargeted: Set) => { + workers.forEach(worker => { + const potentialTargets = targets + .filter(t => !newlyTargeted.has(t.id)) + .map(target => ({ target, distance: manhattanDistance(worker.tile, target) })) + .sort((a, b) => a.distance - b.distance); + + if (!potentialTargets.length) return; + + const minDistance = potentialTargets[0].distance; + const tiedTargets = potentialTargets.filter(t => t.distance === minDistance).map(t => t.target); + + tiedTargets.sort((a, b) => getDirectionalPriority(worker.tile, a) - getDirectionalPriority(worker.tile, b)); + + const bestTarget = tiedTargets[0]; + if (bestTarget) newlyTargeted.add(bestTarget.id); + }); + }; + + const lumberjacks = villageSnapshot.objects.filter(obj => obj.type === 'LUMBERJACK') as (VillageObject & { tile: VillageTile })[]; + const quarries = villageSnapshot.objects.filter(obj => obj.type === 'QUARRY') as (VillageObject & { tile: VillageTile })[]; + const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE'); + const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'); + const tileIdsToClear = new Set(); + + assignTasks(lumberjacks, idleTrees, tileIdsToClear); + assignTasks(quarries, idleStones, tileIdsToClear); + + if (tileIdsToClear.size > 0) { + await prisma.villageTile.updateMany({ + where: { id: { in: Array.from(tileIdsToClear) } }, + data: { terrainState: 'CLEARING', clearingStartedAt: now }, + }); + } + } + + // --- Step 7: Final Fetch & Action Enrichment --- + const finalVillageState = await prisma.village.findUnique({ + where: { userId }, + include: { + user: true, + tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] }, + objects: { include: { tile: true } }, + }, + }); + + if (!finalVillageState) { + throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' }); + } + + // --- Step 8: Enrich tiles with available actions --- + const { user } = finalVillageState; + const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK'); + const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY'); + + const tilesWithActions = finalVillageState.tiles.map(tile => { + const availableActions: any[] = []; + + // Action: CLEAR + if (tile.terrainState === 'IDLE' && (tile.terrainType === 'BLOCKED_STONE' || tile.terrainType === 'BLOCKED_TREE')) { + const canClearTree = tile.terrainType === 'BLOCKED_TREE' && hasLumberjack; + const canClearStone = tile.terrainType === 'BLOCKED_STONE' && hasQuarry; + availableActions.push({ + type: 'CLEAR', + isEnabled: canClearTree || canClearStone, + disabledReason: !(canClearTree || canClearStone) ? `Requires ${tile.terrainType === 'BLOCKED_TREE' ? 'Lumberjack' : 'Quarry'}` : undefined, + }); + } + + // Action: BUILD + if (tile.terrainType === 'EMPTY' && !tile.object) { + const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL']; + const buildActions = buildableObjectTypes.map(buildingType => { + const cost = BUILDING_COSTS[buildingType]; + const isProducing = PRODUCING_BUILDINGS.includes(buildingType); + let isEnabled = user.coins >= cost; + let disabledReason = user.coins < cost ? 'Not enough coins' : undefined; + + if (isEnabled && isProducing && freeWorkers <= 0) { + isEnabled = false; + disabledReason = 'Not enough workers'; + } + + return { + type: 'BUILD', + buildingType, + cost, + isEnabled, + disabledReason, + }; + }); + availableActions.push(...buildActions); + } + + if (tile.object) { + const isHouse = tile.object.type === 'HOUSE'; + // Action: MOVE + availableActions.push({ + type: 'MOVE', + isEnabled: !isHouse, + disabledReason: isHouse ? 'House cannot be moved' : undefined, + }); + // Action: REMOVE + availableActions.push({ + type: 'REMOVE', + isEnabled: !isHouse, + disabledReason: isHouse ? 'House cannot be removed' : undefined, + }); + } + + return { ...tile, availableActions }; + }); + + return { ...finalVillageState, tiles: tilesWithActions } as any; +} \ No newline at end of file diff --git a/server/utils/prisma.ts b/server/utils/prisma.ts index 4590932..a5b7ccc 100644 --- a/server/utils/prisma.ts +++ b/server/utils/prisma.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient() diff --git a/server/utils/village.ts b/server/utils/village.ts index 151ef52..9ceb981 100644 --- a/server/utils/village.ts +++ b/server/utils/village.ts @@ -1,10 +1,20 @@ // server/utils/village.ts -import { CropType, VillageObjectType } from '@prisma/client'; + +export type VillageObjectKind = + | 'HOUSE' + | 'FIELD' + | 'LUMBERJACK' + | 'QUARRY' + | 'WELL' + | 'ROAD' + | 'FENCE'; + +export type CropKind = 'BLUEBERRIES' | 'CORN'; // --- Game Economy & Rules --- export const VILLAGE_GRID_SIZE = { width: 15, height: 15 }; -export const ITEM_COSTS: Partial> = { +export const ITEM_COSTS: Partial> = { HOUSE: 50, FIELD: 15, ROAD: 5, @@ -22,13 +32,13 @@ 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 = { +export const CROP_GROWTH_TIME: Record = { BLUEBERRIES: 60 * 60 * 1000, // 1 hour CORN: 4 * 60 * 60 * 1000, // 4 hours }; // --- Rewards --- -export const CROP_HARVEST_REWARD: Record = { +export const CROP_HARVEST_REWARD: Record = { BLUEBERRIES: { exp: 5, coins: 0 }, CORN: { exp: 10, coins: 1 }, }; @@ -39,7 +49,7 @@ export const CROP_HARVEST_REWARD: Record