From 72f69ad14d1eb044c2d83c333ce74d083c3e26ff Mon Sep 17 00:00:00 2001 From: Alexander Andreev Date: Fri, 9 Jan 2026 13:47:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D0=B2=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=BD=D0=B1=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B5,=20=D0=B8=20=D0=BD=D0=B0=D1=87=D0=B8=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D1=81=D1=82=D1=80=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GEMINI.md | 39 +- README.md | 24 +- app/components/HabitCard.vue | 72 ++-- app/components/OnboardingFunnel.vue | 22 +- app/composables/useAuth.ts | 35 ++ app/pages/habits.vue | 3 +- app/pages/index.vue | 9 +- middleware/auth.global.ts | 13 +- middleware/village-tick.global.ts | 45 ++ .../migration.sql | 15 + .../migration.sql | 25 ++ prisma/schema.prisma | 26 +- server/api/admin/village/trigger-tick.post.ts | 14 +- server/api/economy/constants.get.ts | 15 + server/api/habits/[id]/complete.post.ts | 33 +- server/api/leaderboard.get.ts | 6 + server/api/onboarding/initiate.post.ts | 41 +- server/api/user/visit.post.ts | 16 +- server/api/village/tick.post.ts | 39 ++ server/middleware/auth.ts | 28 +- server/services/OLDvillageService — копия.ts | 387 ------------------ server/services/villageService.ts | 216 +++++++--- server/utils/economy.ts | 10 +- server/utils/gameDay.ts | 26 ++ server/utils/streak.ts | 75 ++-- 25 files changed, 586 insertions(+), 648 deletions(-) create mode 100644 middleware/village-tick.global.ts create mode 100644 prisma/migrations/20260108105225_add_last_tick_day_to_village/migration.sql create mode 100644 prisma/migrations/20260108110445_change_date_to_string/migration.sql create mode 100644 server/api/economy/constants.get.ts create mode 100644 server/api/village/tick.post.ts delete mode 100644 server/services/OLDvillageService — копия.ts diff --git a/GEMINI.md b/GEMINI.md index 5e13096..5609ead 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -78,7 +78,44 @@ Adhering to these conventions is critical for maintaining project stability. - Friday: `4` - Saturday: `5` - Sunday: `6` +- **Daily Streak:** The `dailyStreak` field on the `User` model is an integer representing the user's consecutive daily visits. Its primary function is to act as a **multiplier for rewards** (coins and EXP) earned from completing habits and village activities. + - The multiplier is typically capped (e.g., at 3x). + - It is updated by the `calculateDailyStreak` function located in `server/utils/streak.ts`. This function determines if a visit extends the streak, maintains it, or resets it. -### AI / Gemini Usage Rules +## 4. Date and Time Handling (Game Day Concept) + +To ensure consistent and timezone-agnostic handling of daily game mechanics, the project employs a "Game Day" concept. This approach prioritizes the user's local calendar day for relevant actions and uses a standardized string format for persistence. + +### Key Principles: + +1. **"Game Day" Format:** All dates representing a calendar day (e.g., when a habit was completed, a daily visit occurred, or a village event was processed) are stored and primarily handled as a **`String` in "YYYY-MM-DD" format**. This includes fields like `DailyVisit.date`, `HabitCompletion.date`, `VillageObject.lastExpDay`, `VillageTile.clearingStartedDay`, and `Village.lastTickDay`. + +2. **Client as Source of Truth for User's Day:** + * For user-initiated actions (e.g., completing a habit, triggering a daily visit), the **frontend (client-side)** determines the current "Game Day" based on the user's local timezone. + * This "YYYY-MM-DD" string is then sent to the backend with the API request. This prevents timezone conflicts where the server's day might differ from the user's local perception of "today." + +3. **Server-Side Consistency with UTC:** + * On the backend, when parsing these "YYYY-MM-DD" strings into `Date` objects for calculations (e.g., to determine the day of the week or calculate `daysSince`), they are explicitly parsed as **UTC dates** (e.g., `new Date(\`${gameDay}T00:00:00Z\`)`). This ensures that date arithmetic (like finding the previous day) is consistent and avoids shifts due to the server's local timezone. + +4. **Day-of-Week Convention:** The application adheres to a specific convention for days of the week: + * **Monday: `0`** + * Tuesday: `1` + * Wednesday: `2` + * Thursday: `3` + * Friday: `4` + * Saturday: `5` + * Sunday: `6` + +### Example Usage: + +* **`DailyVisit.date`**: Stores the "Game Day" on which a user's visit was recorded. +* **`HabitCompletion.date`**: Stores the "Game Day" on which a habit was marked as complete. +* **`Village.lastTickDay`**: Stores the "Game Day" (server-side determined) when the village's background progression was last processed, preventing redundant calculations within the same server day. +* **`VillageObject.lastExpDay`**: Stores the "Game Day" when an object (e.g., a field) last generated experience. +* **`VillageTile.clearingStartedDay`**: Stores the "Game Day" when a tile's clearing process began. + +This consistent use of "Game Day" strings and client-provided dates (for user actions) combined with server-side UTC parsing (for calculations) provides a robust solution to managing time-based game mechanics across different timezones. + +## 5. AI / Gemini Usage Rules - **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure. - **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework. diff --git a/README.md b/README.md index 1915852..69b0cb6 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,15 @@ node -v --- -## 4. Prisma Setup (IMPORTANT) +## 4. Date and Time Handling + +To ensure consistent and timezone-agnostic management of daily game mechanics (like habit completion, streaks, and village progression), the project uses a "Game Day" concept. Dates are primarily stored as `String` in "YYYY-MM-DD" format. The client provides its local "Game Day" for user actions, and the server processes dates consistently using UTC. + +For a detailed explanation of the "Game Day" concept and its implementation, please refer to the `GEMINI.md` file. + +--- + +## 5. Prisma Setup (IMPORTANT) ### Prisma version @@ -112,7 +120,7 @@ Never forget migrations. --- -## 5. Prisma Client Usage +## 6. Prisma Client Usage Prisma client is initialized here: @@ -136,7 +144,7 @@ export default prisma --- -## 6. Development +## 7. Development Install dependencies: @@ -158,7 +166,7 @@ npm run dev --- -## 7. API Example +## 8. API Example Health check: @@ -177,7 +185,7 @@ Expected response: --- -## 8. Scheduled Cleanup Task +## 9. Scheduled Cleanup Task To manage anonymous user data, a cleanup task can be triggered via a protected API endpoint. This task deletes anonymous users who were created more than 24 hours ago and have not registered. @@ -212,7 +220,7 @@ In a production environment, this endpoint should be called by an **external sch --- -## 9. Deployment Notes +## 10. Deployment Notes - Use Node 20 on hosting - Run Prisma migrations during deployment @@ -221,7 +229,7 @@ In a production environment, this endpoint should be called by an **external sch --- -## 10. AI / Gemini Rules (IMPORTANT) +## 11. AI / Gemini Rules (IMPORTANT) When using Gemini / AI tools: @@ -238,7 +246,7 @@ When using Gemini / AI tools: --- -## 11. Why these constraints exist +## 12. Why these constraints exist This setup was intentionally chosen to: - avoid unstable Prisma 7 API diff --git a/app/components/HabitCard.vue b/app/components/HabitCard.vue index a7d16f9..583049d 100644 --- a/app/components/HabitCard.vue +++ b/app/components/HabitCard.vue @@ -57,27 +57,43 @@ const props = defineProps({ const emit = defineEmits(['complete']); -const today = new Date(); -const todayNormalized = new Date(); -todayNormalized.setHours(0, 0, 0, 0); +const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" + +const isCompleted = (habit, date) => { + if (!habit || !habit.completions) return false; + + // Ensure date is in "YYYY-MM-DD" string format for comparison + const comparisonDate = (typeof date === 'string') + ? date + : new Date(date).toISOString().slice(0, 10); + + return habit.completions.some(c => c.date === comparisonDate); +}; + +const getScheduleText = (habit) => { + if (habit.daysOfWeek.length === 7) { + return 'каждый день'; + } + const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' }; + return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', '); +}; const exploding = computed(() => props.explodingHabitId === props.habit.id); -// --- History Grid Logic (copied from index.vue) --- +// --- History Grid Logic --- const last14Days = computed(() => { const dates = []; - const today = new Date(); - const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc. + const todayDate = new Date(); + const todayDay = todayDate.getDay(); // 0 for Sunday, 1 for Monday, etc. // Adjust so that Monday is 0 and Sunday is 6 for application's convention const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1; // Calculate days to subtract to get to the Monday of LAST week - // (appDayOfWeek) gets us to this week's Monday. +7 gets us to last week's Monday. const daysToSubtract = appDayOfWeek + 7; const startDate = new Date(); - startDate.setDate(today.getDate() - daysToSubtract); + startDate.setDate(todayDate.getDate() - daysToSubtract); for (let i = 0; i < 14; i++) { const date = new Date(startDate); @@ -92,47 +108,31 @@ const formatDayLabel = (date) => { return formatted.replace(' г.', ''); }; -const isSameDay = (d1, d2) => { - d1 = new Date(d1); - d2 = new Date(d2); - return d1.getFullYear() === d2.getFullYear() && - d1.getMonth() === d2.getMonth() && - d1.getDate() === d2.getDate(); -}; - -const isCompleted = (habit, date) => { - if (!habit || !habit.completions) return false; - return habit.completions.some(c => isSameDay(c.date, date)); -}; - const getCellClasses = (habit, day) => { const classes = {}; - const dayNormalized = new Date(day); - dayNormalized.setHours(0, 0, 0, 0); + const dayString = new Date(day).toISOString().slice(0, 10); + const habitCreatedAt = new Date(habit.createdAt).toISOString().slice(0, 10); - const habitCreatedAt = new Date(habit.createdAt); - habitCreatedAt.setHours(0, 0, 0, 0); - - if (dayNormalized > todayNormalized) { + if (dayString > today) { classes['future-day'] = true; } - if (isSameDay(dayNormalized, todayNormalized)) { + if (dayString === today) { classes['today-highlight'] = true; } - const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6 + const dayOfWeek = (new Date(day).getDay() === 0) ? 6 : new Date(day).getDay() - 1; // Mon=0, Sun=6 const isScheduled = habit.daysOfWeek.includes(dayOfWeek); if (isScheduled) { classes['scheduled-day'] = true; } - if (isCompleted(habit, dayNormalized)) { + if (isCompleted(habit, dayString)) { classes['completed'] = true; return classes; } - if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) { + if (dayString < today && isScheduled && dayString >= habitCreatedAt) { classes['missed-day'] = true; } @@ -140,19 +140,11 @@ const getCellClasses = (habit, day) => { }; const isScheduledForToday = (habit) => { - const todayDay = today.getDay(); // Sunday is 0 + const todayDay = new Date().getDay(); // Sunday is 0 const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1; return habit.daysOfWeek.includes(appDayOfWeek); } -const getScheduleText = (habit) => { - if (habit.daysOfWeek.length === 7) { - return 'каждый день'; - } - const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' }; - return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', '); -}; - const emitComplete = () => { emit('complete', props.habit.id); }; diff --git a/app/components/OnboardingFunnel.vue b/app/components/OnboardingFunnel.vue index be75240..f1de634 100644 --- a/app/components/OnboardingFunnel.vue +++ b/app/components/OnboardingFunnel.vue @@ -64,7 +64,7 @@

Шаг 4: Постройте дом

-

Вы заработали {{ onboardingRewardAmount }} монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: 50 монет).

+

Вы заработали {{ onboardingRewardAmount }} монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: {{ houseCost }} монет).

+ $fetch('/api/economy/constants') +); + +const onboardingRewardAmount = computed(() => economy.value?.rewards?.onboardingCompletion ?? 50); +const houseCost = computed(() => economy.value?.costs?.build?.house ?? 15); + // --- State --- const currentStep = ref(1); @@ -141,7 +147,7 @@ const villagePending = ref(false); // --- Computed --- const isHouseBuilt = computed(() => villageData.value?.objects.some(o => o.type === 'HOUSE')); -const hasEnoughCoinsForHouse = computed(() => user.value && user.value.coins >= 50); +const hasEnoughCoinsForHouse = computed(() => user.value && user.value.coins >= houseCost.value); // --- Watchers --- watch(currentStep, async (newStep) => { @@ -219,14 +225,16 @@ const handleCompleteOnboardingHabit = async (habitId: number) => { isLoading.value = true; error.value = null; try { + const gameDay = new Date().toISOString().slice(0, 10); // Get client's gameDay const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST', + body: { gameDay } // Pass gameDay in the body }); // Manually update the user's coins from the response if (user.value && response.updatedCoins !== undefined) { user.value.coins = response.updatedCoins; } - createdHabit.value.completions.push({ date: new Date().toISOString() }); + createdHabit.value.completions.push({ date: gameDay }); // Use gameDay for optimistic update nextStep(); } catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; } finally { isLoading.value = false; } @@ -239,7 +247,7 @@ const handleTileClickToBuild = async (tile: any) => { return; } if (!hasEnoughCoinsForHouse.value) { - error.value = 'Не хватает монет для постройки дома (50 монет).'; + error.value = `Не хватает монет для постройки дома (${houseCost.value} монет).`; return; } @@ -402,7 +410,7 @@ const handleRegister = async () => { color: var(--text-color-light); font-weight: 600; font-size: 0.9rem; - padding: 0.6rem 1.2rem; + padding: 0.6rem 1rem; border-radius: 10px; border: 2px solid var(--border-color); cursor: pointer; diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 1befc69..9264118 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,4 +1,6 @@ // /composables/useAuth.ts +import { computed, watch } from 'vue'; +import { useVisitTracker } from './useVisitTracker'; interface User { id: number; @@ -21,11 +23,43 @@ export function useAuth() { const initialized = useState('auth_initialized', () => false); const api = useApi(); + const { visitCalled } = useVisitTracker(); // A user is fully authenticated only if they exist and are NOT anonymous. const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous); // A user is anonymous if they exist and have the isAnonymous flag. const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous); + + // --- This watcher is the new core logic for post-authentication tasks --- + watch(isAuthenticated, (newIsAuthenticated, oldIsAuthenticated) => { + // We only care about the transition from logged-out to logged-in + if (newIsAuthenticated && !oldIsAuthenticated) { + if (!visitCalled.value) { + visitCalled.value = true; + + const gameDay = new Date().toISOString().slice(0, 10); + + // --- 1. Trigger Daily Visit & Streak Calculation --- + api('/api/user/visit', { + method: 'POST', + body: { gameDay } + }).then(updatedUser => { + if (updatedUser) { + updateUser(updatedUser); + } + }).catch(e => { + console.error('Failed to register daily visit:', e); + visitCalled.value = false; // Allow retrying on next navigation if it failed + }); + + // --- 2. Trigger Village Tick --- + api('/api/village/tick', { method: 'POST' }) + .catch(e => { + console.error('Failed to trigger village tick:', e); + }); + } + } + }); /** * Initializes the authentication state for EXISTING users. @@ -91,6 +125,7 @@ export function useAuth() { await api('/auth/logout', { method: 'POST' }); } finally { user.value = null; + visitCalled.value = false; // Reset for the next session await navigateTo('/'); } }; diff --git a/app/pages/habits.vue b/app/pages/habits.vue index 1dc005e..bc83bf4 100644 --- a/app/pages/habits.vue +++ b/app/pages/habits.vue @@ -169,8 +169,7 @@ const createHabit = async () => { } catch (err: any) { console.error('Failed to create habit:', err); error.value = err.data?.message || 'Не удалось создать привычку.'; - // Re-fetch on error to ensure consistency - await fetchHabits(); + // Re-fetch on error to ensure consistency } finally { loading.value.create = false; } diff --git a/app/pages/index.vue b/app/pages/index.vue index 202c82c..5e3af2d 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -193,7 +193,12 @@ const completeHabit = async (habitId) => { // Removed event param since it's han isSubmittingHabit.value = true; try { - const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST' }); + const gameDay = new Date().toISOString().slice(0, 10); + const response = await api(`/api/habits/${habitId}/complete`, { + method: 'POST', + body: { gameDay } + }); + if (updateUser && response) { updateUser({ coins: response.updatedCoins, @@ -210,7 +215,7 @@ const completeHabit = async (habitId) => { // Removed event param since it's han habit.completions.push({ id: Math.random(), // Temporary ID for reactivity habitId: habitId, - date: new Date().toISOString(), + date: gameDay, // Use the same gameDay string }); } diff --git a/middleware/auth.global.ts b/middleware/auth.global.ts index bbe95fd..126f084 100644 --- a/middleware/auth.global.ts +++ b/middleware/auth.global.ts @@ -43,13 +43,18 @@ export default defineNuxtRouteMiddleware(async (to) => { if (process.client && isAuthenticated.value && !visitCalled.value) { visitCalled.value = true; // Set flag immediately to prevent race conditions try { - console.log('[Auth Middleware] User is authenticated, triggering daily visit registration.'); - const updatedUser = await api('/api/user/visit', { method: 'POST' }); + // Get the client's current date in "YYYY-MM-DD" format. + const gameDay = new Date().toISOString().slice(0, 10); + + const updatedUser = await api('/api/user/visit', { + method: 'POST', + body: { gameDay } + }); if (updatedUser) { updateUser(updatedUser); } - } catch (e) { - console.error("Failed to register daily visit from middleware:", e); + } catch (e: any) { + console.error("[Auth Middleware] Failed to register daily visit.", e); } } diff --git a/middleware/village-tick.global.ts b/middleware/village-tick.global.ts new file mode 100644 index 0000000..cb90a29 --- /dev/null +++ b/middleware/village-tick.global.ts @@ -0,0 +1,45 @@ +// middleware/village-tick.global.ts +export default defineNuxtRouteMiddleware(async (to) => { + // We only run this logic on the client-side after navigation. + if (process.server) { + return; + } + + const { isAuthenticated, initialized } = useAuth(); + + // Helper function to wait for auth initialization, with a timeout. + const waitForAuth = () => { + return new Promise((resolve) => { + if (initialized.value) { + return resolve(true); + } + const timeout = setTimeout(() => { + unwatch(); + resolve(true); // Resolve even if timeout, so middleware doesn't block + }, 5000); + const unwatch = watch(initialized, (newValue) => { + if (newValue) { + clearTimeout(timeout); + unwatch(); + resolve(true); + } + }); + }); + }; + + // Wait for auth to be initialized + await waitForAuth(); + + // If user is authenticated, trigger the village tick + if (isAuthenticated.value) { + const api = useApi(); + try { + // We must await the call to ensure it completes before a new navigation can cancel it. + await api('/api/village/tick', { method: 'POST' }); + } catch (e) { + // Even if we don't wait, a catch block is good practice in case the api call itself throws an error. + console.error('[Village Middleware] Failed to trigger village tick:', e); + } + } +}); + diff --git a/prisma/migrations/20260108105225_add_last_tick_day_to_village/migration.sql b/prisma/migrations/20260108105225_add_last_tick_day_to_village/migration.sql new file mode 100644 index 0000000..0019e33 --- /dev/null +++ b/prisma/migrations/20260108105225_add_last_tick_day_to_village/migration.sql @@ -0,0 +1,15 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Village" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" INTEGER NOT NULL, + "lastTickDay" TEXT, + CONSTRAINT "Village_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Village" ("id", "userId") SELECT "id", "userId" FROM "Village"; +DROP TABLE "Village"; +ALTER TABLE "new_Village" RENAME TO "Village"; +CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20260108110445_change_date_to_string/migration.sql b/prisma/migrations/20260108110445_change_date_to_string/migration.sql new file mode 100644 index 0000000..5043aae --- /dev/null +++ b/prisma/migrations/20260108110445_change_date_to_string/migration.sql @@ -0,0 +1,25 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_DailyVisit" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "date" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + CONSTRAINT "DailyVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_DailyVisit" ("date", "id", "userId") SELECT "date", "id", "userId" FROM "DailyVisit"; +DROP TABLE "DailyVisit"; +ALTER TABLE "new_DailyVisit" RENAME TO "DailyVisit"; +CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date"); +CREATE TABLE "new_HabitCompletion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "date" TEXT NOT NULL, + "habitId" INTEGER NOT NULL, + CONSTRAINT "HabitCompletion_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "Habit" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_HabitCompletion" ("date", "habitId", "id") SELECT "date", "habitId", "id" FROM "HabitCompletion"; +DROP TABLE "HabitCompletion"; +ALTER TABLE "new_HabitCompletion" RENAME TO "HabitCompletion"; +CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc5cbb5..7e2e488 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,11 +84,11 @@ model Habit { // HabitCompletion: Records a single completion of a habit on a specific date. // This creates a history of the user's progress for each habit. model HabitCompletion { - id Int @id @default(autoincrement()) - date DateTime // Store only the date part + id Int @id @default(autoincrement()) + date String // YYYY-MM-DD format // Relations - habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade) + habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade) habitId Int @@unique([habitId, date]) // A habit can only be completed once per day @@ -97,11 +97,11 @@ model HabitCompletion { // DailyVisit: Tracks the user's daily visit for the "I visited the site today" // quest and for calculating 5-day streaks. model DailyVisit { - id Int @id @default(autoincrement()) - date DateTime // Store only the date part + id Int @id @default(autoincrement()) + date String // YYYY-MM-DD format // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @@unique([userId, date]) // A user can only have one recorded visit per day @@ -110,14 +110,14 @@ model DailyVisit { // Village: The user's personal village, which acts as a container for all // village objects. Each user has exactly one village. model Village { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @unique + lastTickDay String? - // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int @unique // Each user has only one village - objects VillageObject[] - tiles VillageTile[] - events VillageEvent[] + tiles VillageTile[] + objects VillageObject[] + events VillageEvent[] } // VillageObject: An object (e.g., house, field) placed on a village tile. diff --git a/server/api/admin/village/trigger-tick.post.ts b/server/api/admin/village/trigger-tick.post.ts index f01f433..239bfae 100644 --- a/server/api/admin/village/trigger-tick.post.ts +++ b/server/api/admin/village/trigger-tick.post.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { const previousDay = getPreviousDay(); - const [fieldResult, tileResult] = await prisma.$transaction([ + const [fieldResult, tileResult, villageResult] = await prisma.$transaction([ // 1. Update lastExpDay for all FIELD objects for this user's village prisma.villageObject.updateMany({ where: { @@ -42,10 +42,20 @@ export default defineEventHandler(async (event) => { clearingStartedDay: previousDay, }, }), + + // 3. Update the village's lastTickDay + prisma.village.updateMany({ + where: { + userId: userId, + }, + data: { + lastTickDay: previousDay, + }, + }), ]); return { success: true, - message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}.` + message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}. Village lastTickDay updated.` }; }); diff --git a/server/api/economy/constants.get.ts b/server/api/economy/constants.get.ts new file mode 100644 index 0000000..518478d --- /dev/null +++ b/server/api/economy/constants.get.ts @@ -0,0 +1,15 @@ +// server/api/economy/constants.get.ts +import { COSTS, REWARDS } from '~/server/utils/economy'; + +export default defineEventHandler(() => { + return { + rewards: { + onboardingCompletion: REWARDS.HABITS.ONBOARDING_COMPLETION.coins, + }, + costs: { + build: { + house: COSTS.BUILD.HOUSE, + } + } + }; +}); diff --git a/server/api/habits/[id]/complete.post.ts b/server/api/habits/[id]/complete.post.ts index 8ae366e..5b8742d 100644 --- a/server/api/habits/[id]/complete.post.ts +++ b/server/api/habits/[id]/complete.post.ts @@ -2,6 +2,7 @@ import { getAuthenticatedUserId } from '../../../utils/auth'; import { REWARDS } from '../../../utils/economy'; import prisma from '../../../utils/prisma'; import { applyStreakMultiplier } from '../../../utils/streak'; +import { getDayOfWeekFromGameDay } from '~/server/utils/gameDay'; interface CompletionResponse { message: string; @@ -13,16 +14,15 @@ interface CompletionResponse { updatedExp: number; } -// Helper to get the start of the day in UTC -function getStartOfDay(date: Date): Date { - const d = new Date(date); - d.setUTCHours(0, 0, 0, 0); - return d; -} - export default defineEventHandler(async (event): Promise => { const userId = getAuthenticatedUserId(event); const habitId = parseInt(event.context.params?.id || '', 10); + const body = await readBody(event); + const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD" + + if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' }); + } if (isNaN(habitId)) { throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' }); @@ -41,11 +41,7 @@ export default defineEventHandler(async (event): Promise => throw createError({ statusCode: 404, statusMessage: 'Habit not found.' }); } - const today = new Date(); - const jsDayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday) - - // Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6) - const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1; + const appDayOfWeek = getDayOfWeekFromGameDay(gameDay); // For permanent users, ensure the habit is scheduled for today. // Anonymous users in the onboarding flow can complete it on any day. @@ -55,12 +51,10 @@ export default defineEventHandler(async (event): Promise => } } - const startOfToday = getStartOfDay(today); - const existingCompletion = await prisma.habitCompletion.findFirst({ where: { habitId: habitId, - date: startOfToday, + date: gameDay, }, }); @@ -75,8 +69,10 @@ export default defineEventHandler(async (event): Promise => finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION; } else { // Permanent users get rewards based on streak + // Streak defaults to 1 for multiplier if it's 0 or null + const currentDailyStreak = user.dailyStreak && user.dailyStreak > 0 ? user.dailyStreak : 1; const baseReward = REWARDS.HABITS.COMPLETION; - finalReward = applyStreakMultiplier(baseReward, user.dailyStreak); + finalReward = applyStreakMultiplier(baseReward, currentDailyStreak); } const village = await prisma.village.findUnique({ where: { userId } }); @@ -85,7 +81,7 @@ export default defineEventHandler(async (event): Promise => prisma.habitCompletion.create({ data: { habitId: habitId, - date: startOfToday, + date: gameDay, }, }), prisma.user.update({ @@ -103,7 +99,7 @@ export default defineEventHandler(async (event): Promise => data: { villageId: village.id, type: 'HABIT_COMPLETION', - message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) ${user.dailyStreak === 2 ? 'удвоила' : 'утроила'} награду!` : ''}`, + message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) увеличила награду!` : ''}`, coins: finalReward.coins, exp: finalReward.exp, } @@ -117,3 +113,4 @@ export default defineEventHandler(async (event): Promise => updatedExp: updatedUser.exp, }; }); + diff --git a/server/api/leaderboard.get.ts b/server/api/leaderboard.get.ts index 762a4d4..c42939d 100644 --- a/server/api/leaderboard.get.ts +++ b/server/api/leaderboard.get.ts @@ -7,6 +7,12 @@ export default defineEventHandler(async () => { // for "current month's EXP". This should be revisited if true monthly // tracking becomes a requirement. const users = await prisma.user.findMany({ + where: { + isAnonymous: false, + nickname: { + not: null, + }, + }, select: { nickname: true, avatar: true, diff --git a/server/api/onboarding/initiate.post.ts b/server/api/onboarding/initiate.post.ts index 108f041..fbf5f0e 100644 --- a/server/api/onboarding/initiate.post.ts +++ b/server/api/onboarding/initiate.post.ts @@ -22,18 +22,45 @@ export default defineEventHandler(async (event) => { isAnonymous: true }, select: { - id: true, // Also return ID + id: true, anonymousSessionId: true, - nickname: true, - coins: true, - exp: true, - isAnonymous: true // Ensure this flag is returned + village: { select: { id: true } } // Include village ID for deletion } }); - // If a valid anonymous user is found for this session, return it. + // If a valid anonymous user is found for this session, reset their progress. if (user) { - return user; + const villageId = user.village?.id; + + // Use a transaction to atomically delete all progress. + await prisma.$transaction([ + // 1. Delete all habits (HabitCompletion records will cascade) + prisma.habit.deleteMany({ where: { userId: user.id } }), + + // 2. Delete all village objects to break the dependency on tiles before deleting the village + ...(villageId ? [prisma.villageObject.deleteMany({ where: { villageId } })] : []), + + // 3. Delete the village if it exists (all related records will cascade) + ...(villageId ? [prisma.village.delete({ where: { id: villageId } })] : []), + ]); + + // Re-create the village from scratch. This service will also set initial coins. + await generateVillageForUser(user); + + // Fetch the final, reset state of the user to return + const resetUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + id: true, + anonymousSessionId: true, + nickname: true, + coins: true, + exp: true, + isAnonymous: true + } + }); + + return resetUser; } } diff --git a/server/api/user/visit.post.ts b/server/api/user/visit.post.ts index 6b07fa3..a716502 100644 --- a/server/api/user/visit.post.ts +++ b/server/api/user/visit.post.ts @@ -2,19 +2,17 @@ import { getAuthenticatedUserId } from '../../utils/auth'; import prisma from '../../utils/prisma'; import { calculateDailyStreak } from '../../utils/streak'; -// Helper to get the start of the day in UTC -function getStartOfDay(date: Date): Date { - const d = new Date(date); - d.setUTCHours(0, 0, 0, 0); - return d; -} - export default defineEventHandler(async (event) => { - console.log('[Visit API] Received request'); const userId = getAuthenticatedUserId(event); + const body = await readBody(event); + const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD" + + if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' }); + } // Calculate the streak and create today's visit record - const updatedUser = await calculateDailyStreak(prisma, userId); + const updatedUser = await calculateDailyStreak(prisma, userId, gameDay); // The consumer of this endpoint needs the most up-to-date user info, // including the newly calculated streak. diff --git a/server/api/village/tick.post.ts b/server/api/village/tick.post.ts new file mode 100644 index 0000000..61d0c93 --- /dev/null +++ b/server/api/village/tick.post.ts @@ -0,0 +1,39 @@ +// server/api/village/tick.post.ts +import { getAuthenticatedUserId } from '~/server/utils/auth'; +import { processVillageTick } from '~/server/services/villageService'; + +/** + * This endpoint is called on every route change for an authenticated user. + * It's responsible for triggering the "tick" of the village simulation, + * which can include things like clearing tiles, generating resources, etc. + */ +export default defineEventHandler(async (event) => { + try { + const userId = getAuthenticatedUserId(event); + + // Delegate the core logic to the villageService + const result = await processVillageTick(userId); + + // The response can be simple, or return a meaningful state if the client needs to react. + // For now, a success message is sufficient. + return { + success: true, + data: result, + }; + + } catch (e: any) { + // If getAuthenticatedUserId throws, it will be a 401 error, which should be propagated. + if (e.statusCode === 401) { + throw e; + } + + console.error('Error processing village tick:', e); + + // For other errors, return a generic 500. + throw createError({ + statusCode: 500, + statusMessage: 'Internal Server Error', + message: 'An unexpected error occurred while processing the village tick.', + }); + } +}); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 51e24ab..f97e2b8 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,8 +1,6 @@ // server/middleware/auth.ts import { defineEventHandler, useSession } from 'h3'; import prisma from '../utils/prisma'; -import { getTodayDay, isBeforeDay } from '../utils/gameDay'; -import { calculateDailyStreak } from '../utils/streak'; const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session'; @@ -31,33 +29,15 @@ export default defineEventHandler(async (event) => { if (userId) { try { const user = await prisma.user.findUnique({ - where: { id: userId }, // Find any user by their ID first + where: { id: userId }, }); - // A permanent user is identified by having an email address. - if (user && user.email) { + if (user) { event.context.user = user; - - // Track daily visit and streak - const today = getTodayDay(); - const lastVisitDay = session.data.lastVisitDay as string | undefined; - - if (isBeforeDay(lastVisitDay, today)) { - // It's a new day, calculate streak and record visit - const updatedUser = await calculateDailyStreak(prisma, user.id); - event.context.user = updatedUser; // Ensure context has the latest user data - - // Update the session to prevent re-running this today - await session.update({ - ...session.data, - lastVisitDay: today, - }); - } - - return; // Found a logged-in user, no need to check for anonymous session + return; // Found a user, no need to check for anonymous session } } catch (error) { - console.error('Error in auth middleware:', error); + console.error('Error fetching user in auth middleware:', error); } } diff --git a/server/services/OLDvillageService — копия.ts b/server/services/OLDvillageService — копия.ts deleted file mode 100644 index df4c25c..0000000 --- a/server/services/OLDvillageService — копия.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client'; -import { COSTS, REWARDS } from '../utils/economy'; - -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 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(); - const { applyStreakMultiplier } = await import('../utils/streak'); - const today = getStartOfDay(now); - - // --- 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' }); - } - - const userForStreak = villageSnapshot.user; - - // --- Step 2: Terrain Cleaning Completion --- - const finishedClearingTiles = villageSnapshot.tiles.filter( - t => t.terrainState === 'CLEARING' && t.clearingStartedAt && getStartOfDay(t.clearingStartedAt) < today - ); - - if (finishedClearingTiles.length > 0) { - // 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 - await tx.user.update({ - where: { id: userId }, - data: { - coins: { increment: totalCoins }, - exp: { increment: totalExp }, - }, - }); - - // 2. Update all the tiles - await tx.villageTile.updateMany({ - where: { id: { in: finishedClearingTiles.map(t => t.id) } }, - data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null }, - }); - - // 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: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`, - tileX: tile.x, - tileY: tile.y, - coins: finalClearingReward.coins, - exp: finalClearingReward.exp, - } - }); - } - }); - } - - // --- 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 } } } })!; - - - 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; - const eventsToCreate = []; - - for (const field of fieldsForExp) { - // 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}`)) { - baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; - } - - // 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.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`, - tileX: field.tile.x, - tileY: field.tile.y, - coins: 0, - exp: finalFieldExp, - }); - } - - await prisma.$transaction([ - prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }), - prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }), - prisma.villageEvent.createMany({ data: eventsToCreate }), - ]); - } - - // --- 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 lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length; - const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length; - - const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length; - const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length; - - const freeLumberjacks = lumberjackCount - clearingTreesCount; - const freeQuarries = quarryCount - clearingStonesCount; - - const tileIdsToClear = new Set(); - - if (freeLumberjacks > 0) { - const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE'); - if (idleTrees.length > 0) { - // For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here. - idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id)); - } - } - - if (freeQuarries > 0) { - const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'); - if (idleStones.length > 0) { - // For simplicity, just take the first N available stones. - idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id)); - } - } - - if (tileIdsToClear.size > 0) { - await prisma.villageTile.updateMany({ - where: { id: { in: Array.from(tileIdsToClear) } }, - data: { terrainState: 'CLEARING', clearingStartedAt: getStartOfDay(now) }, - }); - - // Refetch state after starting new clearings - villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; - } - - // --- 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 housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length; - const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; - const freeWorkers = housesCount - producingCount; - - const tilesWithActions = finalVillageState.tiles.map(tile => { - const availableActions: any[] = []; - - // Action: BUILD - if (tile.terrainType === 'EMPTY' && !tile.object) { - const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL']; - const buildActions = buildableObjectTypes.map(buildingType => { - const cost = COSTS.BUILD[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) { - // MOVE and REMOVE actions have been removed as per the refactor request. - } - - return { ...tile, availableActions }; - }); - - return { ...finalVillageState, tiles: tilesWithActions } as any; -} - -// --- Action Service Functions --- - -export async function buildOnTile(userId: number, tileId: number, buildingType: string) { - const { VillageObjectType } = await import('@prisma/client'); - const validBuildingTypes = Object.keys(VillageObjectType); - if (!validBuildingTypes.includes(buildingType)) { - throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` }); - } - - return prisma.$transaction(async (tx) => { - // 1. Fetch all necessary data - const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); - const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } }); - - // Ownership check - if (tile.village.userId !== userId) { - throw createError({ statusCode: 403, statusMessage: "You don't own this tile" }); - } - - // Business logic validation - if (tile.terrainType !== 'EMPTY' || tile.object) { - throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' }); - } - - const cost = COSTS.BUILD[buildingType]; - if (user.coins < cost) { - throw createError({ statusCode: 400, statusMessage: 'Not enough coins' }); - } - - if (PRODUCING_BUILDINGS.includes(buildingType)) { - const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } }); - const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length; - const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; - if (producingCount >= housesCount) { - throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' }); - } - } - - // 2. Perform mutations - await tx.user.update({ - where: { id: userId }, - data: { coins: { decrement: cost } }, - }); - - await tx.villageObject.create({ - data: { - type: buildingType as keyof typeof VillageObjectType, - villageId: tile.villageId, - tileId: tileId, - }, - }); - - await tx.villageEvent.create({ - data: { - villageId: tile.villageId, - type: `BUILD_${buildingType}`, - message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`, - tileX: tile.x, - tileY: tile.y, - coins: -cost, - exp: 0, - }, - }); - }); - } - - \ No newline at end of file diff --git a/server/services/villageService.ts b/server/services/villageService.ts index 76f36d3..aa4a425 100644 --- a/server/services/villageService.ts +++ b/server/services/villageService.ts @@ -8,6 +8,7 @@ import { import { COSTS, REWARDS } from '../utils/economy'; import { applyStreakMultiplier } from '../utils/streak'; import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay'; +import { calculateDailyStreak } from '../utils/streak'; const prisma = new PrismaClient(); @@ -41,33 +42,74 @@ type FullVillage = Prisma.VillageGetPayload<{ ========================= */ /** - * Главная точка входа. - * Синхронизирует day-based прогресс и возвращает актуальное состояние деревни. + * Processes the village's daily "tick" if necessary and returns the + * complete, up-to-date village state. + * This is the single source of truth for all time-based progression. */ -export async function syncAndGetVillage(userId: number): Promise { +export async function processVillageTick(userId: number): Promise { try { const today = getTodayDay(); - let villageSnapshot = await fetchVillage(userId); + if (!villageSnapshot) { + // This should not happen for a logged-in user with a village. throw createError({ statusCode: 404, statusMessage: 'Village not found' }); } - const user = villageSnapshot.user; - - await prisma.$transaction(async (tx) => { - await processFinishedClearing(tx, villageSnapshot, today); - await processFieldExp(tx, villageSnapshot, today); - await autoStartClearing(tx, villageSnapshot, today); - }); - - // Re-fetch the village state after the transaction to get the latest data including new objects, etc. - villageSnapshot = await fetchVillage(userId); - if (!villageSnapshot) { - throw createError({ statusCode: 404, statusMessage: 'Village not found post-transaction' }); + // Even if tick is done, we should ensure the streak is updated for the day. + // The calculateDailyStreak function is idempotent. + if (villageSnapshot.lastTickDay === today) { + villageSnapshot.user = await calculateDailyStreak(prisma, userId, today); + return villageSnapshot; } - // --- Enrich tiles with available actions (Step 8 from old getVillageState) --- + // The tick for today has not run. Execute all daily logic in a transaction. + await prisma.$transaction(async (tx) => { + // 1. UPDATE STREAK FIRST. This is critical for all reward calculations. + const updatedUser = await calculateDailyStreak(tx, userId, today); + villageSnapshot.user = updatedUser; // Update snapshot with fresh user data. + + // 2. Process other daily logic using the updated snapshot + const finishedTiles = await processFinishedClearing(tx, villageSnapshot, today); + await processFieldExp(tx, villageSnapshot, today); + await autoStartClearing(tx, villageSnapshot, today, finishedTiles); + + // 3. Update the last tick day to prevent re-processing + await tx.village.update({ + where: { id: villageSnapshot.id }, + data: { lastTickDay: today }, + }); + }); + + // After the transaction, the original villageSnapshot is stale. + // Re-fetch to get the latest state with all changes. + const updatedVillage = await fetchVillage(userId); + if (!updatedVillage) { + // This would be a critical error, as the village existed moments ago. + throw createError({ statusCode: 500, statusMessage: 'Village disappeared post-transaction' }); + } + + return updatedVillage; + + } catch (error) { + // Log the error and re-throw it to be handled by the calling API endpoint. + console.error(`Error in processVillageTick for user ${userId}:`, error); + if ((error as any).statusCode) throw error; // Re-throw h3 errors + throw createError({ statusCode: 500, statusMessage: 'Failed to process village tick.' }); + } +} + +/** + * Main entry point for the frontend to get the village state. + * It ensures the daily tick is processed, then enriches the state with UI-specific data. + */ +export async function syncAndGetVillage(userId: number): Promise { + try { + // This function will now run the tick (if needed) AND return the up-to-date village state. + const villageSnapshot = await processVillageTick(userId); + + // --- Enrich tiles with available actions --- + const user = villageSnapshot.user; const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length; const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length; const freeWorkers = housesCount - producingCount; @@ -106,6 +148,8 @@ export async function syncAndGetVillage(userId: number): Promise { return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage; } catch (error) { console.error('Error in syncAndGetVillage:', error); + // Let the API endpoint handle the final error response. + if ((error as any).statusCode) throw error; // Re-throw h3 errors throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' }); } } @@ -263,6 +307,26 @@ export async function buildOnTile( exp: 0, }, }); + + // If a clearing building was built, immediately try to start a new clearing job. + // This makes new buildings feel responsive and start working right away. + if (buildingType === 'LUMBERJACK' || buildingType === 'QUARRY') { + const today = getTodayDay(); + // We need a fresh, full snapshot of the village *within the transaction* + // to correctly calculate clearing capacity. + const villageSnapshot = await tx.village.findUnique({ + where: { id: tile.villageId }, + include: { + user: true, + tiles: { include: { object: true } }, + objects: { include: { tile: true } }, + }, + }); + + if (villageSnapshot) { + await autoStartClearing(tx, villageSnapshot, today); + } + } }); } @@ -289,14 +353,14 @@ async function processFinishedClearing( tx: Prisma.TransactionClient, village: FullVillage, today: string -) { +): Promise { const finishedTiles = village.tiles.filter( t => t.terrainState === 'CLEARING' && isBeforeDay(t.clearingStartedDay, today) ); - if (!finishedTiles.length) return; + if (!finishedTiles.length) return []; const baseReward = REWARDS.VILLAGE.CLEARING; const totalBaseReward = { @@ -304,7 +368,9 @@ async function processFinishedClearing( exp: baseReward.exp * finishedTiles.length, }; - const finalReward = applyStreakMultiplier(totalBaseReward, village.user.dailyStreak); + // Ensure dailyStreak is at least 1 for multiplier calculation if it's 0 or null + const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1; + const finalReward = applyStreakMultiplier(totalBaseReward, currentDailyStreak); await tx.user.update({ where: { id: village.user.id }, @@ -323,14 +389,15 @@ async function processFinishedClearing( }, }); - const streakMultiplier = village.user.dailyStreak > 1 ? village.user.dailyStreak : 0; + const streakMultiplier = village.user.dailyStreak && village.user.dailyStreak > 1 ? village.user.dailyStreak : 0; let streakBonusText = ''; if (streakMultiplier > 1) { streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`; } const events = finishedTiles.map(t => { - const tileReward = applyStreakMultiplier(baseReward, village.user.dailyStreak); + // Apply streak multiplier with a default of 1 if streak is not active + const tileReward = applyStreakMultiplier(baseReward, currentDailyStreak); return { villageId: village.id, type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE', @@ -345,50 +412,52 @@ async function processFinishedClearing( await tx.villageEvent.createMany({ data: events, }); + + return finishedTiles; } async function processFieldExp( tx: Prisma.TransactionClient, village: FullVillage, today: string -) { +): Promise { const fieldsNeedingUpdate = village.objects.filter( (o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today) ); - if (!fieldsNeedingUpdate.length) return; + if (!fieldsNeedingUpdate.length) return 0; const wells = village.objects.filter(o => o.type === 'WELL'); let totalBaseExpGained = 0; const eventsToCreate: any[] = []; - const streakMultiplierValue = village.user.dailyStreak > 1 ? village.user.dailyStreak : 1; + const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1; let streakBonusText = ''; - if (streakMultiplierValue > 1) { - streakBonusText = ` Ваша серия визитов (${streakMultiplierValue}) увеличила награду.`; + if (currentDailyStreak > 1) { + streakBonusText = ` Ваша серия визитов (${currentDailyStreak}) увеличила награду.`; } for (const field of fieldsNeedingUpdate) { const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1; - let expGainedForField = daysMissed * REWARDS.VILLAGE.FIELD_EXP.BASE; - - const isNearWell = wells.some(well => - Math.abs(well.tile.x - field.tile.x) <= 1 && - Math.abs(well.tile.y - field.tile.y) <= 1 && - (well.tile.x !== field.tile.x || well.tile.y !== field.tile.y) - ); - + let expGainedForField = 0; let wellBonusText = ''; - if (isNearWell) { - expGainedForField *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; - wellBonusText = ' Рядом с колодцем урожай удвоился!'; - } - - totalBaseExpGained += expGainedForField; - - const finalExpForField = applyStreakMultiplier({ coins: 0, exp: expGainedForField }, village.user.dailyStreak); for (let i = 0; i < daysMissed; i++) { + let dailyFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE; + + const isNearWell = wells.some(well => + Math.abs(well.tile.x - field.tile.x) <= 1 && + Math.abs(well.tile.y - field.tile.y) <= 1 && + (well.tile.x !== field.tile.x || well.tile.y !== field.tile.y) + ); + + if (isNearWell) { + dailyFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; + wellBonusText = ' Рядом с колодцем урожай удвоился!'; + } + expGainedForField += dailyFieldExp; + + // Create an event for each day the field gained experience eventsToCreate.push({ villageId: village.id, type: 'FIELD_EXP', @@ -396,17 +465,18 @@ async function processFieldExp( tileX: field.tile.x, tileY: field.tile.y, coins: 0, - exp: finalExpForField.exp / daysMissed, + exp: applyStreakMultiplier({ coins: 0, exp: dailyFieldExp }, currentDailyStreak).exp, }); } + totalBaseExpGained += expGainedForField; // This is base exp without final streak multiplier for total } - const finalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, village.user.dailyStreak); + const finalTotalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, currentDailyStreak); - if (totalBaseExpGained > 0) { + if (finalTotalExp.exp > 0) { // Check final total exp after multiplier await tx.user.update({ where: { id: village.user.id }, - data: { exp: { increment: finalExp.exp } }, + data: { exp: { increment: finalTotalExp.exp } }, }); } @@ -420,36 +490,50 @@ async function processFieldExp( data: eventsToCreate, }); } + + return finalTotalExp.exp; // Return the final experience gained } async function autoStartClearing( tx: Prisma.TransactionClient, village: FullVillage, - today: string -) { - const lumberjacks = village.objects.filter(o => o.type === 'LUMBERJACK').length; - const quarries = village.objects.filter(o => o.type === 'QUARRY').length; + today: string, + justFinishedTiles: VillageTile[] = [] +): Promise { + // Count total capacity for clearing + const lumberjackCapacity = village.objects.filter(o => o.type === 'LUMBERJACK').length; + const quarryCapacity = village.objects.filter(o => o.type === 'QUARRY').length; - const busyTrees = village.tiles.filter( + // We must account for the tiles that were *just* finished in this transaction. + const finishedTreeTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_TREE').length; + const finishedStoneTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_STONE').length; + + const busyTreesInSnapshot = village.tiles.filter( t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING' ).length; - - const busyStones = village.tiles.filter( + const busyStonesInSnapshot = village.tiles.filter( t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING' ).length; - const tilesToStart = [ - ...village.tiles - .filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE') - .slice(0, lumberjacks - busyTrees), - ...village.tiles - .filter( - t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE' - ) - .slice(0, quarries - busyStones), - ]; + // Correctly calculate busy workers by subtracting those that just finished + const currentBusyTrees = Math.max(0, busyTreesInSnapshot - finishedTreeTiles); + const currentBusyStones = Math.max(0, busyStonesInSnapshot - finishedStoneTiles); - if (!tilesToStart.length) return; + const freeLumberjacks = Math.max(0, lumberjackCapacity - currentBusyTrees); + const freeQuarries = Math.max(0, quarryCapacity - currentBusyStones); + + // Find idle tiles that are not among those just finished (though they should be EMPTY now anyway) + const idleTrees = village.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE'); + const idleStones = village.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'); + + const treesToStart = idleTrees.slice(0, freeLumberjacks); + const stonesToStart = idleStones.slice(0, freeQuarries); + + const tilesToStart = [...treesToStart, ...stonesToStart]; + + if (!tilesToStart.length) { + return 0; + } await tx.villageTile.updateMany({ where: { id: { in: tilesToStart.map(t => t.id) } }, @@ -458,4 +542,6 @@ async function autoStartClearing( clearingStartedDay: today, }, }); + + return tilesToStart.length; } diff --git a/server/utils/economy.ts b/server/utils/economy.ts index b305b82..01b6f04 100644 --- a/server/utils/economy.ts +++ b/server/utils/economy.ts @@ -5,7 +5,7 @@ */ export const COSTS = { BUILD: { - HOUSE: 30, + HOUSE: 15, FIELD: 15, LUMBERJACK: 20, QUARRY: 20, @@ -25,15 +25,9 @@ export const REWARDS = { WELL_MULTIPLIER: 2, }, }, - // Quest-related rewards - QUESTS: { - DAILY_VISIT: { - BASE: { coins: 1 }, - } - }, // Habit-related rewards HABITS: { - COMPLETION: { coins: 2, exp: 1 }, + COMPLETION: { coins: 10, exp: 1 }, ONBOARDING_COMPLETION: { coins: 50, exp: 0 }, } }; diff --git a/server/utils/gameDay.ts b/server/utils/gameDay.ts index fd71da7..2fd8dfa 100644 --- a/server/utils/gameDay.ts +++ b/server/utils/gameDay.ts @@ -36,3 +36,29 @@ export function daysSince(pastDay: string, futureDay: string): number { return diffDays > 0 ? diffDays : 0; } + +/** + * Converts a "YYYY-MM-DD" string into the application's day-of-week convention. + * @param gameDay A string in "YYYY-MM-DD" format. + * @returns A number where Monday is 0 and Sunday is 6. + */ +export function getDayOfWeekFromGameDay(gameDay: string): number { + // Create a date object from the string. Appending 'T00:00:00Z' ensures it's parsed as UTC, + // preventing the user's server timezone from shifting the date. + const date = new Date(`${gameDay}T00:00:00Z`); + const jsDayOfWeek = date.getUTCDay(); // 0 (Sunday) to 6 (Saturday) + + // Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6) + return (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1; +} + +/** + * Calculates the previous day from a given "YYYY-MM-DD" string. + * @param gameDay A string in "YYYY-MM-DD" format. + * @returns The previous day as a "YYYY-MM-DD" string. + */ +export function getPreviousGameDay(gameDay: string): string { + const date = new Date(`${gameDay}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() - 1); + return date.toISOString().split('T')[0]; +} diff --git a/server/utils/streak.ts b/server/utils/streak.ts index e681504..19821cf 100644 --- a/server/utils/streak.ts +++ b/server/utils/streak.ts @@ -1,32 +1,23 @@ import { PrismaClient, User } from '@prisma/client'; +import { getPreviousGameDay } from '~/server/utils/gameDay'; /** - * 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. + * Calculates the user's daily visit streak based on a client-provided "Game Day". * 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. + * This function is idempotent and creates a visit record for the provided day. * * @param prisma The Prisma client instance. * @param userId The ID of the user. + * @param gameDay The client's current day in "YYYY-MM-DD" format. * @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); +export async function calculateDailyStreak(db: PrismaClient | Prisma.TransactionClient, userId: number, gameDay: string): Promise { + const yesterdayGameDay = getPreviousGameDay(gameDay); // 1. Find the user and their most recent visit const [user, lastVisit] = await Promise.all([ - prisma.user.findUnique({ where: { id: userId } }), - prisma.dailyVisit.findFirst({ + db.user.findUnique({ where: { id: userId } }), + db.dailyVisit.findFirst({ where: { userId }, orderBy: { date: 'desc' }, }), @@ -40,12 +31,10 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number) // 2. Determine the new streak count if (lastVisit) { - const lastVisitDate = getStartOfDay(new Date(lastVisit.date)); - - if (lastVisitDate.getTime() === today.getTime()) { + if (lastVisit.date === gameDay) { // Already visited today, streak doesn't change. newStreak = user.dailyStreak; - } else if (lastVisitDate.getTime() === yesterday.getTime()) { + } else if (lastVisit.date === yesterdayGameDay) { // Visited yesterday, so increment the streak (capped at 3). newStreak = Math.min(user.dailyStreak + 1, 3); } else { @@ -61,29 +50,20 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number) newStreak = 1; } - // 3. Use upsert to create today's visit record and update the user's streak in a transaction - try { - console.log(`[Streak] Attempting to upsert DailyVisit for userId: ${userId}, date: ${today.toISOString()}`); + // 3. Upsert today's visit record. + await db.dailyVisit.upsert({ + where: { userId_date: { userId, date: gameDay } }, + update: {}, + create: { userId, date: gameDay }, + }); - 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 }, - }), - ]); + // 4. Update the user's streak. + const updatedUser = await db.user.update({ + where: { id: userId }, + data: { dailyStreak: newStreak }, + }); - console.log(`[Streak] Successfully updated streak for userId: ${userId} to ${newStreak}`); - return updatedUser; - } catch (error) { - console.error(`[Streak] Error during daily visit transaction for userId: ${userId}`, error); - // Re-throw the error or handle it as needed. For now, re-throwing. - throw error; - } + return updatedUser; } interface Reward { @@ -93,23 +73,16 @@ interface Reward { /** * Applies a streak-based multiplier to a given reward. - * The multiplier is the streak count, capped at 3x. + * The multiplier is the streak count, capped at 3x. A streak of 0 is treated as 1x. * * @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 effectiveStreak = streak || 1; // Treat a null/0 streak as 1x 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,