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,