From da2d69960dd19a3b097ea3f957fdb109ee5690d3 Mon Sep 17 00:00:00 2001 From: Alexander Andreev Date: Mon, 5 Jan 2026 15:43:29 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BD=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=83.=20=D0=A1?= =?UTF-8?q?=D0=B5=D0=B9=D1=87=D0=B0=D1=81=20=D0=B2=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=87=D0=BC=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=D1=81=D1=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/composables/useAuth.ts | 1 + app/composables/useVisitTracker.ts | 12 + app/pages/index.vue | 389 ++++-------------- app/pages/village.vue | 292 +++++++++++-- middleware/auth.global.ts | 24 +- pages/index.vue | 12 - pages/login.vue | 62 --- pages/village.vue | 197 --------- .../migration.sql | 23 ++ prisma/schema.prisma | 1 + server/api/auth/me.get.ts | 1 + server/api/habits/[id]/complete.post.ts | 63 ++- server/api/quests/daily-visit.post.ts | 109 ----- server/api/user/visit.post.ts | 31 ++ server/api/village/action.post.ts | 17 +- server/services/villageService.ts | 129 +++--- server/utils/streak.ts | 108 +++++ server/utils/village.ts | 7 - 18 files changed, 615 insertions(+), 863 deletions(-) create mode 100644 app/composables/useVisitTracker.ts delete mode 100644 pages/index.vue delete mode 100644 pages/login.vue delete mode 100644 pages/village.vue create mode 100644 prisma/migrations/20260105114635_add_user_streak/migration.sql delete mode 100644 server/api/quests/daily-visit.post.ts create mode 100644 server/api/user/visit.post.ts create mode 100644 server/utils/streak.ts diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index fc19857..9052fcd 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -7,6 +7,7 @@ interface User { avatar: string | null; coins: number; exp: number; + dailyStreak: number; soundOn: boolean; confettiOn: boolean; createdAt: string; diff --git a/app/composables/useVisitTracker.ts b/app/composables/useVisitTracker.ts new file mode 100644 index 0000000..63f75e8 --- /dev/null +++ b/app/composables/useVisitTracker.ts @@ -0,0 +1,12 @@ +// app/composables/useVisitTracker.ts +import { ref } from 'vue'; + +// This is a simple, client-side, non-persisted state to ensure the +// daily visit API call is only made once per application lifecycle. +const visitCalled = ref(false); + +export function useVisitTracker() { + return { + visitCalled, + }; +} diff --git a/app/pages/index.vue b/app/pages/index.vue index 3cd1c9a..ee553e5 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,9 +1,25 @@ \ No newline at end of file diff --git a/middleware/auth.global.ts b/middleware/auth.global.ts index dd73011..6e035a9 100644 --- a/middleware/auth.global.ts +++ b/middleware/auth.global.ts @@ -1,11 +1,27 @@ -// /middleware/auth.ts -export default defineNuxtRouteMiddleware((to) => { - const { isAuthenticated, initialized } = useAuth(); - +// /middleware/auth.global.ts +export default defineNuxtRouteMiddleware(async (to) => { + const { isAuthenticated, initialized, updateUser } = useAuth(); + const { visitCalled } = useVisitTracker(); + const api = useApi(); + // Do not run middleware until auth state is initialized on client-side if (!initialized.value) { return; } + + // --- Daily Visit Registration --- + // This logic runs once per application load on the client-side for authenticated users. + if (process.client && isAuthenticated.value && !visitCalled.value) { + visitCalled.value = true; // Set flag immediately to prevent race conditions + try { + const updatedUser = await api('/api/user/visit', { method: 'POST' }); + if (updatedUser) { + updateUser(updatedUser); + } + } catch (e) { + console.error("Failed to register daily visit from middleware:", e); + } + } // if the user is authenticated and tries to access /login, redirect to home if (isAuthenticated.value && to.path === '/login') { diff --git a/pages/index.vue b/pages/index.vue deleted file mode 100644 index 484e61c..0000000 --- a/pages/index.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/pages/login.vue b/pages/login.vue deleted file mode 100644 index 7f25d6f..0000000 --- a/pages/login.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - - diff --git a/pages/village.vue b/pages/village.vue deleted file mode 100644 index 4d07d4d..0000000 --- a/pages/village.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - diff --git a/prisma/migrations/20260105114635_add_user_streak/migration.sql b/prisma/migrations/20260105114635_add_user_streak/migration.sql new file mode 100644 index 0000000..00dd163 --- /dev/null +++ b/prisma/migrations/20260105114635_add_user_streak/migration.sql @@ -0,0 +1,23 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_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, + "dailyStreak" 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 +); +INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5d16a5..d24292a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { coins Int @default(0) exp Int @default(0) + dailyStreak Int @default(0) // User settings soundOn Boolean @default(true) diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index c96b208..6d949f5 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => { avatar: user.avatar, coins: user.coins, exp: user.exp, + dailyStreak: user.dailyStreak, soundOn: user.soundOn, confettiOn: user.confettiOn, createdAt: user.createdAt, diff --git a/server/api/habits/[id]/complete.post.ts b/server/api/habits/[id]/complete.post.ts index 9ecb2a5..717a9dc 100644 --- a/server/api/habits/[id]/complete.post.ts +++ b/server/api/habits/[id]/complete.post.ts @@ -1,27 +1,23 @@ import { getUserIdFromSession } from '../../../utils/auth'; import { REWARDS } from '../../../utils/economy'; -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); +import prisma from '../../../utils/prisma'; +import { applyStreakMultiplier } from '../../../utils/streak'; interface CompletionResponse { message: string; reward: { coins: number; - exp: number; // Added + exp: number; }; updatedCoins: number; - updatedExp: number; // Added + updatedExp: number; } -/** - * Creates a Date object for the start of a given day in UTC. - * This is duplicated here as per the instruction not to create new shared utilities. - */ +// Helper to get the start of the day in UTC function getStartOfDay(date: Date): Date { - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - return startOfDay; + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + return d; } export default defineEventHandler(async (event): Promise => { @@ -32,10 +28,15 @@ export default defineEventHandler(async (event): Promise => throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' }); } - const habit = await prisma.habit.findFirst({ - where: { id: habitId, userId }, - }); + // Fetch user and habit in parallel + const [user, habit] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + prisma.habit.findFirst({ where: { id: habitId, userId } }) + ]); + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'User not found.' }); + } if (!habit) { throw createError({ statusCode: 404, statusMessage: 'Habit not found.' }); } @@ -50,13 +51,12 @@ export default defineEventHandler(async (event): Promise => throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' }); } - // Normalize date to the beginning of the day for consistent checks - const startOfToday = getStartOfDay(new Date()); // Correctly get a Date object + const startOfToday = getStartOfDay(today); const existingCompletion = await prisma.habitCompletion.findFirst({ where: { habitId: habitId, - date: startOfToday, // Use precise equality check + date: startOfToday, }, }); @@ -64,25 +64,27 @@ export default defineEventHandler(async (event): Promise => throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' }); } - const rewardCoins = REWARDS.HABITS.COMPLETION.coins; - const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added + // Apply the streak multiplier to the base reward + const baseReward = REWARDS.HABITS.COMPLETION; + const finalReward = applyStreakMultiplier(baseReward, user.dailyStreak); + const village = await prisma.village.findUnique({ where: { userId } }); const [, updatedUser] = await prisma.$transaction([ prisma.habitCompletion.create({ data: { habitId: habitId, - date: startOfToday, // Save the normalized date + date: startOfToday, }, }), prisma.user.update({ where: { id: userId }, data: { coins: { - increment: rewardCoins, + increment: finalReward.coins, }, - exp: { // Added - increment: rewardExp, // Added + exp: { + increment: finalReward.exp, }, }, }), @@ -90,20 +92,17 @@ export default defineEventHandler(async (event): Promise => data: { villageId: village.id, type: 'HABIT_COMPLETION', - message: `Completed habit: "${habit.name}"`, - coins: rewardCoins, - exp: rewardExp, // Changed from 0 to rewardExp + message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) ${user.dailyStreak === 2 ? 'удвоила' : 'утроила'} награду!` : ''}`, + coins: finalReward.coins, + exp: finalReward.exp, } })] : []), ]); return { message: 'Habit completed successfully!', - reward: { - coins: rewardCoins, - exp: rewardExp, // Added - }, + reward: finalReward, updatedCoins: updatedUser.coins, - updatedExp: updatedUser.exp, // Added + updatedExp: updatedUser.exp, }; }); diff --git a/server/api/quests/daily-visit.post.ts b/server/api/quests/daily-visit.post.ts deleted file mode 100644 index cb26d38..0000000 --- a/server/api/quests/daily-visit.post.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { getUserIdFromSession } from '../../utils/auth'; -import { REWARDS } from '../../utils/economy'; -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); - -interface DailyVisitResponse { - message: string; - reward: { - coins: number; - streakBonus: boolean; - }; - updatedCoins: number; -} - -/** - * Creates a Date object for the start of a given day in UTC. - */ -function getStartOfDay(date: Date): Date { - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - return startOfDay; -} - -export default defineEventHandler(async (event): Promise => { - const userId = await getUserIdFromSession(event); - const today = getStartOfDay(new Date()); - - // 1. Check if the user has already claimed the reward today - const existingVisit = await prisma.dailyVisit.findUnique({ - where: { - userId_date: { - userId, - date: today, - }, - }, - }); - - if (existingVisit) { - throw createError({ - statusCode: 409, - statusMessage: 'Daily visit reward has already been claimed today.', - }); - } - - // 2. Check for a 5-day consecutive streak (i.e., visits on the 4 previous days) - const previousDates = Array.from({ length: 4 }, (_, i) => { - const d = new Date(today); - d.setUTCDate(d.getUTCDate() - (i + 1)); - return d; - }); - - const priorVisitsCount = await prisma.dailyVisit.count({ - where: { - userId, - date: { - in: previousDates, - }, - }, - }); - - const hasStreak = priorVisitsCount === 4; - - // 3. Calculate rewards and update the database in a transaction - let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins; - let message = 'Daily visit claimed!'; - if (hasStreak) { - totalReward += REWARDS.QUESTS.DAILY_VISIT.STREAK_BONUS.coins; - message = 'Daily visit and streak bonus claimed!'; - } - - const village = await prisma.village.findUnique({ where: { userId } }); - - const [, updatedUser] = await prisma.$transaction([ - prisma.dailyVisit.create({ - data: { - userId, - date: today, - }, - }), - prisma.user.update({ - where: { id: userId }, - data: { - coins: { - increment: totalReward, - }, - }, - }), - ...(village ? [prisma.villageEvent.create({ - data: { - villageId: village.id, - type: 'QUEST_DAILY_VISIT', - message, - coins: totalReward, - exp: 0, - } - })] : []), - ]); - - // 4. Return the response - return { - message, - reward: { - coins: totalReward, - streakBonus: hasStreak, - }, - updatedCoins: updatedUser.coins, - }; -}); diff --git a/server/api/user/visit.post.ts b/server/api/user/visit.post.ts new file mode 100644 index 0000000..7d592dc --- /dev/null +++ b/server/api/user/visit.post.ts @@ -0,0 +1,31 @@ +import { getUserIdFromSession } from '../../utils/auth'; +import { calculateDailyStreak } from '../../utils/streak'; +import prisma from '../../utils/prisma'; + +/** + * Registers a user's daily visit and calculates their new streak. + * This endpoint is idempotent. Calling it multiple times on the same day + * will not increment the streak further. + */ +export default defineEventHandler(async (event) => { + const userId = await getUserIdFromSession(event); + + // Calculate the streak and create today's visit record + const updatedUser = await calculateDailyStreak(prisma, userId); + + // The consumer of this endpoint needs the most up-to-date user info, + // including the newly calculated streak. + return { + id: updatedUser.id, + email: updatedUser.email, + nickname: updatedUser.nickname, + avatar: updatedUser.avatar, + coins: updatedUser.coins, + exp: updatedUser.exp, + dailyStreak: updatedUser.dailyStreak, + soundOn: updatedUser.soundOn, + confettiOn: updatedUser.confettiOn, + createdAt: updatedUser.createdAt, + updatedAt: updatedUser.updatedAt, + } +}); \ No newline at end of file diff --git a/server/api/village/action.post.ts b/server/api/village/action.post.ts index 17516ab..9889452 100644 --- a/server/api/village/action.post.ts +++ b/server/api/village/action.post.ts @@ -1,6 +1,6 @@ // server/api/village/action.post.ts import { getUserIdFromSession } from '../../utils/auth'; -import { buildOnTile, clearTile, moveObject, removeObject } from '../../services/villageService'; +import { buildOnTile } from '../../services/villageService'; import { getVillageState } from '../../services/villageService'; export default defineEventHandler(async (event) => { @@ -20,21 +20,6 @@ export default defineEventHandler(async (event) => { } await buildOnTile(userId, tileId, payload.buildingType); break; - - case 'CLEAR': - await clearTile(userId, tileId); - break; - - case 'MOVE': - if (!payload?.toTileId) { - throw createError({ statusCode: 400, statusMessage: 'Missing toTileId for MOVE action' }); - } - await moveObject(userId, tileId, payload.toTileId); - break; - - case 'REMOVE': - await removeObject(userId, tileId); - break; default: throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' }); diff --git a/server/services/villageService.ts b/server/services/villageService.ts index 9908510..4a7406e 100644 --- a/server/services/villageService.ts +++ b/server/services/villageService.ts @@ -89,6 +89,7 @@ type FullVillage = Prisma.VillageGetPayload<{ */ export async function getVillageState(userId: number): Promise { const now = new Date(); + const { applyStreakMultiplier } = await import('../utils/streak'); // --- Step 1: Initial Snapshot --- let villageSnapshot = await prisma.village.findUnique({ @@ -104,14 +105,20 @@ export async function getVillageState(userId: number): Promise { throw createError({ statusCode: 404, statusMessage: 'Village not found' }); } + const userForStreak = villageSnapshot.user; + // --- 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) { - const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins; - const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp; + // Apply streak multiplier to clearing rewards + const baseClearingReward = REWARDS.VILLAGE.CLEARING; + const finalClearingReward = applyStreakMultiplier(baseClearingReward, userForStreak.dailyStreak); + + const totalCoins = finishedClearingTiles.length * finalClearingReward.coins; + const totalExp = finishedClearingTiles.length * finalClearingReward.exp; await prisma.$transaction(async (tx) => { // 1. Update user totals @@ -129,17 +136,28 @@ export async function getVillageState(userId: number): Promise { data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null }, }); - // 3. Create an event for each completed tile + // 3. Create an event for each completed tile with the final reward + const multiplier = userForStreak.dailyStreak; + let streakBonusText = ''; + if (multiplier === 2) { + streakBonusText = ' Ваша серия визитов (x2) удвоила награду!'; + } else if (multiplier >= 3) { + streakBonusText = ' Ваша серия визитов (x3) утроила награду!'; + } + for (const tile of finishedClearingTiles) { + const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень'; + const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун'; + await tx.villageEvent.create({ data: { villageId: villageSnapshot.id, type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE', - message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`, + message: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`, tileX: tile.x, tileY: tile.y, - coins: REWARDS.VILLAGE.CLEARING.coins, - exp: REWARDS.VILLAGE.CLEARING.exp, + coins: finalClearingReward.coins, + exp: finalClearingReward.exp, } }); } @@ -161,19 +179,33 @@ export async function getVillageState(userId: number): Promise { const eventsToCreate = []; for (const field of fieldsForExp) { - let fieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE; + // First, calculate base EXP with existing game logic (well bonus) + let baseFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE; 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 *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; + baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; } - totalExpFromFields += fieldExp; + + // Now, apply the daily streak multiplier + const finalFieldExp = applyStreakMultiplier({ coins: 0, exp: baseFieldExp }, userForStreak.dailyStreak).exp; + + totalExpFromFields += finalFieldExp; + + const multiplier = userForStreak.dailyStreak; + let streakBonusText = ''; + if (multiplier === 2) { + streakBonusText = ' Ваша серия визитов (x2) удвоила урожай опыта!'; + } else if (multiplier >= 3) { + streakBonusText = ' Ваша серия визитов (x3) утроила урожай опыта!'; + } + eventsToCreate.push({ villageId: villageSnapshot.id, type: 'FIELD_EXP', - message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`, + message: `Поле (${field.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`, tileX: field.tile.x, tileY: field.tile.y, coins: 0, - exp: fieldExp, + exp: finalFieldExp, }); } @@ -251,17 +283,6 @@ export async function getVillageState(userId: number): Promise { 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']; @@ -288,19 +309,7 @@ export async function getVillageState(userId: number): Promise { } 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, - }); + // MOVE and REMOVE actions have been removed as per the refactor request. } return { ...tile, availableActions }; @@ -372,53 +381,7 @@ export async function buildOnTile(userId: number, tileId: number, buildingType: exp: 0, }, }); - }); -} - -export async function clearTile(userId: number, tileId: number) { - return prisma.$transaction(async (tx) => { - const tile = await tx.villageTile.findUniqueOrThrow({ - where: { id: tileId }, - include: { village: { include: { objects: true } } }, - }); - - if (tile.village.userId !== userId) { - throw createError({ statusCode: 403, statusMessage: "You don't own this tile" }); - } - - if (tile.terrainState !== 'IDLE') { - throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' }); - } - - if (tile.terrainType === 'BLOCKED_TREE') { - const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK'); - if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' }); - } else if (tile.terrainType === 'BLOCKED_STONE') { - const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY'); - if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' }); - } else { - throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' }); - } - - await tx.villageTile.update({ - where: { id: tileId }, - data: { - terrainState: 'CLEARING', - clearingStartedAt: new Date(), - }, - }); - }); -} - -export async function removeObject(userId: number, tileId: number) { - // As requested, this is a stub for now. - throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' }); -} - -export async function moveObject(userId: number, fromTileId: number, toTileId: number) { - // As requested, this is a stub for now. - throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' }); -} - + }); + } \ No newline at end of file diff --git a/server/utils/streak.ts b/server/utils/streak.ts new file mode 100644 index 0000000..2bfb1d8 --- /dev/null +++ b/server/utils/streak.ts @@ -0,0 +1,108 @@ +import { PrismaClient, User } from '@prisma/client'; + +/** + * Creates a Date object for the start of a given day in UTC. + */ +function getStartOfDay(date: Date): Date { + const startOfDay = new Date(date); + startOfDay.setUTCHours(0, 0, 0, 0); + return startOfDay; +} + +/** + * Calculates the user's daily visit streak. + * It checks for consecutive daily visits and updates the user's streak count. + * This function is idempotent and creates a visit record for the current day. + * + * @param prisma The Prisma client instance. + * @param userId The ID of the user. + * @returns The updated User object with the new streak count. + */ +export async function calculateDailyStreak(prisma: PrismaClient, userId: number): Promise { + const today = getStartOfDay(new Date()); + const yesterday = getStartOfDay(new Date()); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + + // 1. Find the user and their most recent visit + const [user, lastVisit] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + prisma.dailyVisit.findFirst({ + where: { userId }, + orderBy: { date: 'desc' }, + }), + ]); + + if (!user) { + throw new Error('User not found'); + } + + let newStreak = user.dailyStreak; + + // 2. Determine the new streak count + if (lastVisit) { + const lastVisitDate = getStartOfDay(new Date(lastVisit.date)); + + if (lastVisitDate.getTime() === today.getTime()) { + // Already visited today, streak doesn't change. + newStreak = user.dailyStreak; + } else if (lastVisitDate.getTime() === yesterday.getTime()) { + // Visited yesterday, so increment the streak (capped at 3). + newStreak = Math.min(user.dailyStreak + 1, 3); + } else { + // Missed a day, reset streak to 1. + newStreak = 1; + } + } else { + // No previous visits, so this is the first day of the streak. + newStreak = 1; + } + + if (newStreak === 0) { + newStreak = 1; + } + + // 3. Use upsert to create today's visit record and update the user's streak in a transaction + const [, updatedUser] = await prisma.$transaction([ + prisma.dailyVisit.upsert({ + where: { userId_date: { userId, date: today } }, + update: {}, + create: { userId, date: today }, + }), + prisma.user.update({ + where: { id: userId }, + data: { dailyStreak: newStreak }, + }), + ]); + + return updatedUser; +} + +interface Reward { + coins: number; + exp: number; +} + +/** + * Applies a streak-based multiplier to a given reward. + * The multiplier is the streak count, capped at 3x. + * + * @param reward The base reward object { coins, exp }. + * @param streak The user's current daily streak. + * @returns The new reward object with the multiplier applied. + */ +export function applyStreakMultiplier(reward: Reward, streak: number | null | undefined): Reward { + const effectiveStreak = streak || 0; + const multiplier = Math.max(1, Math.min(effectiveStreak, 3)); + + if (multiplier === 0) { + return { + coins: reward.coins * 1, + exp: reward.exp * 1, + }; + } + + return { + coins: reward.coins * multiplier, + exp: reward.exp * multiplier, + }; +} diff --git a/server/utils/village.ts b/server/utils/village.ts index 9ceb981..ae58d07 100644 --- a/server/utils/village.ts +++ b/server/utils/village.ts @@ -29,7 +29,6 @@ export const OBSTACLE_CLEAR_COST: Record = { }; 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 = { @@ -37,12 +36,6 @@ export const CROP_GROWTH_TIME: Record = { CORN: 4 * 60 * 60 * 1000, // 4 hours }; -// --- Rewards --- -export const CROP_HARVEST_REWARD: Record = { - 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.