Парвки на онбординге, и начислении стрика

This commit is contained in:
Alexander Andreev 2026-01-09 13:47:47 +03:00
parent ff27f664ef
commit 72f69ad14d
25 changed files with 586 additions and 648 deletions

View File

@ -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.

View File

@ -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

View File

@ -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);
};

View File

@ -64,7 +64,7 @@
<!-- Step 4: Build a House -->
<div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: 50 монет).</p>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: {{ houseCost }} монет).</p>
<VillageGrid
v-if="villageData"
@ -114,8 +114,14 @@ import { ref, reactive, watch, computed } from 'vue';
const api = useApi();
const { user, updateUser, register } = useAuth();
// --- Constants ---
const onboardingRewardAmount = 75;
// --- Economy Constants ---
const { data: economy, pending: economyPending } = useAsyncData('economy-constants', () =>
$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;

View File

@ -1,4 +1,6 @@
// /composables/useAuth.ts
import { computed, watch } from 'vue';
import { useVisitTracker } from './useVisitTracker';
interface User {
id: number;
@ -21,12 +23,44 @@ 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.
* It should be called once in app.vue.
@ -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('/');
}
};

View File

@ -170,7 +170,6 @@ const createHabit = async () => {
console.error('Failed to create habit:', err);
error.value = err.data?.message || 'Не удалось создать привычку.';
// Re-fetch on error to ensure consistency
await fetchHabits();
} finally {
loading.value.create = false;
}

View File

@ -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
});
}

View File

@ -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);
}
}

View File

@ -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);
}
}
});

View File

@ -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;

View File

@ -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;

View File

@ -85,7 +85,7 @@ model Habit {
// 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
date String // YYYY-MM-DD format
// Relations
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
@ -98,7 +98,7 @@ model HabitCompletion {
// quest and for calculating 5-day streaks.
model DailyVisit {
id Int @id @default(autoincrement())
date DateTime // Store only the date part
date String // YYYY-MM-DD format
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -111,12 +111,12 @@ model DailyVisit {
// village objects. Each user has exactly one village.
model Village {
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[]
objects VillageObject[]
events VillageEvent[]
}

View File

@ -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.`
};
});

View File

@ -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,
}
}
};
});

View File

@ -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<CompletionResponse> => {
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<CompletionResponse> =>
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<CompletionResponse> =>
}
}
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<CompletionResponse> =>
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<CompletionResponse> =>
prisma.habitCompletion.create({
data: {
habitId: habitId,
date: startOfToday,
date: gameDay,
},
}),
prisma.user.update({
@ -103,7 +99,7 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
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<CompletionResponse> =>
updatedExp: updatedUser.exp,
};
});

View File

@ -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,

View File

@ -22,18 +22,45 @@ export default defineEventHandler(async (event) => {
isAnonymous: true
},
select: {
id: true, // Also return ID
id: true,
anonymousSessionId: true,
village: { select: { id: true } } // Include village ID for deletion
}
});
// If a valid anonymous user is found for this session, reset their progress.
if (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 // Ensure this flag is returned
isAnonymous: true
}
});
// If a valid anonymous user is found for this session, return it.
if (user) {
return user;
return resetUser;
}
}

View File

@ -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.

View File

@ -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.',
});
}
});

View File

@ -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);
}
}

View File

@ -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<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
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<FullVillage> {
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<number>();
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,
},
});
});
}

View File

@ -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<FullVillage> {
export async function processVillageTick(userId: number): Promise<FullVillage> {
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<FullVillage> {
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<FullVillage> {
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<VillageTile[]> {
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,32 +412,38 @@ async function processFinishedClearing(
await tx.villageEvent.createMany({
data: events,
});
return finishedTiles;
}
async function processFieldExp(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
) {
): Promise<number> {
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;
let expGainedForField = 0;
let wellBonusText = '';
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 &&
@ -378,17 +451,13 @@ async function processFieldExp(
(well.tile.x !== field.tile.x || well.tile.y !== field.tile.y)
);
let wellBonusText = '';
if (isNearWell) {
expGainedForField *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
dailyFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
wellBonusText = ' Рядом с колодцем урожай удвоился!';
}
expGainedForField += dailyFieldExp;
totalBaseExpGained += expGainedForField;
const finalExpForField = applyStreakMultiplier({ coins: 0, exp: expGainedForField }, village.user.dailyStreak);
for (let i = 0; i < daysMissed; i++) {
// 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<number> {
// 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;
}

View File

@ -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 },
}
};

View File

@ -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];
}

View File

@ -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<User> {
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<User> {
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()}`);
const [, updatedUser] = await prisma.$transaction([
prisma.dailyVisit.upsert({
where: { userId_date: { userId, date: today } },
// 3. Upsert today's visit record.
await db.dailyVisit.upsert({
where: { userId_date: { userId, date: gameDay } },
update: {},
create: { userId, date: today },
}),
prisma.user.update({
create: { userId, date: gameDay },
});
// 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;
}
}
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,