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 @@ - - - + + + + Loading session... + + + + - \ 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 @@ - - - - SmurfCoins: {{ user.coins }} - EXP: {{ user.exp }} - - - + + + {{ user.nickname }} + 💰 {{ user.coins }} + ✨ {{ user.exp }} + Logout - + - - \ 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 @@ - - - My Habits for {{ user.nickname }} + + + Welcome, {{ user.nickname }}! + This is your dashboard. Let's get those habits done! - - Loading habits... - - - - - - - - {{ habit.name }} - {{ formatDaysOfWeek(habit.daysOfWeek) }} - - - {{ completing === habit.id ? '...' : 'Complete' }} - - ✅ Done! - - - - {{ formatDate(day) }} + + My Habits + Loading habits... + Could not load habits. + + + {{ habit.name }} + + + {{ day.getDate() }} + + {{ isCompleted(habit, today) ? 'Completed Today' : 'Complete for Today' }} + + + You have no habits yet. Go to the My Habits page to create one. + - - You haven't created any habits yet. - Manage Habits + + Manage Habits + My Village + Leaderboard - - - Loading session... + + Добро пожаловать в SmurfHabits! + Отслеживайте свои привычки и развивайте свою деревню. + + Войти + Зарегистрироваться + - + +.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 @@ - - Smurf Habits - - {{ error }} - - Email - + + + Login + + + Email + + + + Password + + + {{ error }} + + {{ loading ? 'Logging in...' : 'Login' }} + + + + + Don't have an account? + Register here + - - Password - - - {{ loading ? 'Logging in...' : 'Login' }} - - - No account? Register - +.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 @@ - - Create Account - - {{ error }} - - - Email - + + + Register + + + Nickname + + + + Email + + + + Password (min 8 characters) + + + {{ error }} + {{ successMessage }} + + {{ loading ? 'Registering...' : 'Register' }} + + + + + Already have an account? + Login here + - - - Nickname (optional) - - - - - Password - - - - - {{ loading ? 'Registering...' : 'Register' }} - - - - - Already have an account? Log In - +.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 @@ - - My Village - - - - + + My Village + + Loading your village... + + + Please log in to view your village. + An error occurred while fetching your village data. Please try again. - - Build Mode + + + + + {{ getTileEmoji(tile) }} + + + + + Tile ({{ selectedTile.x }}, {{ selectedTile.y }}) + + + + {{ getActionLabel(action) }} + + {{ action.disabledReason }} + + + Close + - + \ 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 @@ + + + My Village + + Loading your village... + + + Please log in to view your village. + An error occurred while fetching your village data. Please try again. + + + + + + {{ getTileEmoji(tile) }} + + + + + Tile ({{ selectedTile.x }}, {{ selectedTile.y }}) + + + + {{ getActionLabel(action) }} + + {{ action.disabledReason }} + + + Close + + + + + + + + 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
Loading session...
This is your dashboard. Let's get those habits done!
Loading habits...
You have no habits yet. Go to the My Habits page to create one.
You haven't created any habits yet.
Отслеживайте свои привычки и развивайте свою деревню.
+ Don't have an account? + Register here +
No account? Register
+ Already have an account? + Login here +
Already have an account? Log In
Please log in to view your village.
An error occurred while fetching your village data. Please try again.