Парвки на онбординге, и начислении стрика
This commit is contained in:
parent
ff27f664ef
commit
72f69ad14d
39
GEMINI.md
39
GEMINI.md
|
|
@ -78,7 +78,44 @@ Adhering to these conventions is critical for maintaining project stability.
|
||||||
- Friday: `4`
|
- Friday: `4`
|
||||||
- Saturday: `5`
|
- Saturday: `5`
|
||||||
- Sunday: `6`
|
- 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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
24
README.md
24
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
|
### Prisma version
|
||||||
|
|
||||||
|
|
@ -112,7 +120,7 @@ Never forget migrations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Prisma Client Usage
|
## 6. Prisma Client Usage
|
||||||
|
|
||||||
Prisma client is initialized here:
|
Prisma client is initialized here:
|
||||||
|
|
||||||
|
|
@ -136,7 +144,7 @@ export default prisma
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Development
|
## 7. Development
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
|
|
@ -158,7 +166,7 @@ npm run dev
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. API Example
|
## 8. API Example
|
||||||
|
|
||||||
Health check:
|
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.
|
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
|
- Use Node 20 on hosting
|
||||||
- Run Prisma migrations during deployment
|
- 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:
|
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:
|
This setup was intentionally chosen to:
|
||||||
- avoid unstable Prisma 7 API
|
- avoid unstable Prisma 7 API
|
||||||
|
|
|
||||||
|
|
@ -57,27 +57,43 @@ const props = defineProps({
|
||||||
|
|
||||||
const emit = defineEmits(['complete']);
|
const emit = defineEmits(['complete']);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
|
||||||
const todayNormalized = new Date();
|
|
||||||
todayNormalized.setHours(0, 0, 0, 0);
|
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);
|
const exploding = computed(() => props.explodingHabitId === props.habit.id);
|
||||||
|
|
||||||
// --- History Grid Logic (copied from index.vue) ---
|
// --- History Grid Logic ---
|
||||||
const last14Days = computed(() => {
|
const last14Days = computed(() => {
|
||||||
const dates = [];
|
const dates = [];
|
||||||
const today = new Date();
|
const todayDate = new Date();
|
||||||
const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc.
|
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
|
// Adjust so that Monday is 0 and Sunday is 6 for application's convention
|
||||||
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
||||||
|
|
||||||
// Calculate days to subtract to get to the Monday of LAST week
|
// 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 daysToSubtract = appDayOfWeek + 7;
|
||||||
|
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(today.getDate() - daysToSubtract);
|
startDate.setDate(todayDate.getDate() - daysToSubtract);
|
||||||
|
|
||||||
for (let i = 0; i < 14; i++) {
|
for (let i = 0; i < 14; i++) {
|
||||||
const date = new Date(startDate);
|
const date = new Date(startDate);
|
||||||
|
|
@ -92,47 +108,31 @@ const formatDayLabel = (date) => {
|
||||||
return formatted.replace(' г.', '');
|
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 getCellClasses = (habit, day) => {
|
||||||
const classes = {};
|
const classes = {};
|
||||||
const dayNormalized = new Date(day);
|
const dayString = new Date(day).toISOString().slice(0, 10);
|
||||||
dayNormalized.setHours(0, 0, 0, 0);
|
const habitCreatedAt = new Date(habit.createdAt).toISOString().slice(0, 10);
|
||||||
|
|
||||||
const habitCreatedAt = new Date(habit.createdAt);
|
if (dayString > today) {
|
||||||
habitCreatedAt.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (dayNormalized > todayNormalized) {
|
|
||||||
classes['future-day'] = true;
|
classes['future-day'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSameDay(dayNormalized, todayNormalized)) {
|
if (dayString === today) {
|
||||||
classes['today-highlight'] = true;
|
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);
|
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
|
||||||
if (isScheduled) {
|
if (isScheduled) {
|
||||||
classes['scheduled-day'] = true;
|
classes['scheduled-day'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCompleted(habit, dayNormalized)) {
|
if (isCompleted(habit, dayString)) {
|
||||||
classes['completed'] = true;
|
classes['completed'] = true;
|
||||||
return classes;
|
return classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
|
if (dayString < today && isScheduled && dayString >= habitCreatedAt) {
|
||||||
classes['missed-day'] = true;
|
classes['missed-day'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,19 +140,11 @@ const getCellClasses = (habit, day) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isScheduledForToday = (habit) => {
|
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;
|
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
||||||
return habit.daysOfWeek.includes(appDayOfWeek);
|
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 = () => {
|
const emitComplete = () => {
|
||||||
emit('complete', props.habit.id);
|
emit('complete', props.habit.id);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
<!-- Step 4: Build a House -->
|
<!-- Step 4: Build a House -->
|
||||||
<div v-if="currentStep === 4">
|
<div v-if="currentStep === 4">
|
||||||
<h2>Шаг 4: Постройте дом</h2>
|
<h2>Шаг 4: Постройте дом</h2>
|
||||||
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: 50 монет).</p>
|
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: {{ houseCost }} монет).</p>
|
||||||
|
|
||||||
<VillageGrid
|
<VillageGrid
|
||||||
v-if="villageData"
|
v-if="villageData"
|
||||||
|
|
@ -114,8 +114,14 @@ import { ref, reactive, watch, computed } from 'vue';
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { user, updateUser, register } = useAuth();
|
const { user, updateUser, register } = useAuth();
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Economy Constants ---
|
||||||
const onboardingRewardAmount = 75;
|
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 ---
|
// --- State ---
|
||||||
const currentStep = ref(1);
|
const currentStep = ref(1);
|
||||||
|
|
@ -141,7 +147,7 @@ const villagePending = ref(false);
|
||||||
|
|
||||||
// --- Computed ---
|
// --- Computed ---
|
||||||
const isHouseBuilt = computed(() => villageData.value?.objects.some(o => o.type === 'HOUSE'));
|
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 ---
|
// --- Watchers ---
|
||||||
watch(currentStep, async (newStep) => {
|
watch(currentStep, async (newStep) => {
|
||||||
|
|
@ -219,14 +225,16 @@ const handleCompleteOnboardingHabit = async (habitId: number) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
|
const gameDay = new Date().toISOString().slice(0, 10); // Get client's gameDay
|
||||||
const response = await api(`/api/habits/${habitId}/complete`, {
|
const response = await api(`/api/habits/${habitId}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: { gameDay } // Pass gameDay in the body
|
||||||
});
|
});
|
||||||
// Manually update the user's coins from the response
|
// Manually update the user's coins from the response
|
||||||
if (user.value && response.updatedCoins !== undefined) {
|
if (user.value && response.updatedCoins !== undefined) {
|
||||||
user.value.coins = response.updatedCoins;
|
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();
|
nextStep();
|
||||||
} catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; }
|
} catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; }
|
||||||
finally { isLoading.value = false; }
|
finally { isLoading.value = false; }
|
||||||
|
|
@ -239,7 +247,7 @@ const handleTileClickToBuild = async (tile: any) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasEnoughCoinsForHouse.value) {
|
if (!hasEnoughCoinsForHouse.value) {
|
||||||
error.value = 'Не хватает монет для постройки дома (50 монет).';
|
error.value = `Не хватает монет для постройки дома (${houseCost.value} монет).`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,7 +410,7 @@ const handleRegister = async () => {
|
||||||
color: var(--text-color-light);
|
color: var(--text-color-light);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
// /composables/useAuth.ts
|
// /composables/useAuth.ts
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useVisitTracker } from './useVisitTracker';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,12 +23,44 @@ export function useAuth() {
|
||||||
const initialized = useState('auth_initialized', () => false);
|
const initialized = useState('auth_initialized', () => false);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const { visitCalled } = useVisitTracker();
|
||||||
|
|
||||||
// A user is fully authenticated only if they exist and are NOT anonymous.
|
// A user is fully authenticated only if they exist and are NOT anonymous.
|
||||||
const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous);
|
const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous);
|
||||||
// A user is anonymous if they exist and have the isAnonymous flag.
|
// A user is anonymous if they exist and have the isAnonymous flag.
|
||||||
const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous);
|
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.
|
* Initializes the authentication state for EXISTING users.
|
||||||
* It should be called once in app.vue.
|
* It should be called once in app.vue.
|
||||||
|
|
@ -91,6 +125,7 @@ export function useAuth() {
|
||||||
await api('/auth/logout', { method: 'POST' });
|
await api('/auth/logout', { method: 'POST' });
|
||||||
} finally {
|
} finally {
|
||||||
user.value = null;
|
user.value = null;
|
||||||
|
visitCalled.value = false; // Reset for the next session
|
||||||
await navigateTo('/');
|
await navigateTo('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,6 @@ const createHabit = async () => {
|
||||||
console.error('Failed to create habit:', err);
|
console.error('Failed to create habit:', err);
|
||||||
error.value = err.data?.message || 'Не удалось создать привычку.';
|
error.value = err.data?.message || 'Не удалось создать привычку.';
|
||||||
// Re-fetch on error to ensure consistency
|
// Re-fetch on error to ensure consistency
|
||||||
await fetchHabits();
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value.create = false;
|
loading.value.create = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,12 @@ const completeHabit = async (habitId) => { // Removed event param since it's han
|
||||||
isSubmittingHabit.value = true;
|
isSubmittingHabit.value = true;
|
||||||
|
|
||||||
try {
|
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) {
|
if (updateUser && response) {
|
||||||
updateUser({
|
updateUser({
|
||||||
coins: response.updatedCoins,
|
coins: response.updatedCoins,
|
||||||
|
|
@ -210,7 +215,7 @@ const completeHabit = async (habitId) => { // Removed event param since it's han
|
||||||
habit.completions.push({
|
habit.completions.push({
|
||||||
id: Math.random(), // Temporary ID for reactivity
|
id: Math.random(), // Temporary ID for reactivity
|
||||||
habitId: habitId,
|
habitId: habitId,
|
||||||
date: new Date().toISOString(),
|
date: gameDay, // Use the same gameDay string
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,18 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
if (process.client && isAuthenticated.value && !visitCalled.value) {
|
if (process.client && isAuthenticated.value && !visitCalled.value) {
|
||||||
visitCalled.value = true; // Set flag immediately to prevent race conditions
|
visitCalled.value = true; // Set flag immediately to prevent race conditions
|
||||||
try {
|
try {
|
||||||
console.log('[Auth Middleware] User is authenticated, triggering daily visit registration.');
|
// Get the client's current date in "YYYY-MM-DD" format.
|
||||||
const updatedUser = await api('/api/user/visit', { method: 'POST' });
|
const gameDay = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const updatedUser = await api('/api/user/visit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { gameDay }
|
||||||
|
});
|
||||||
if (updatedUser) {
|
if (updatedUser) {
|
||||||
updateUser(updatedUser);
|
updateUser(updatedUser);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error("Failed to register daily visit from middleware:", e);
|
console.error("[Auth Middleware] Failed to register daily visit.", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
45
middleware/village-tick.global.ts
Normal file
45
middleware/village-tick.global.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -84,11 +84,11 @@ model Habit {
|
||||||
// HabitCompletion: Records a single completion of a habit on a specific date.
|
// HabitCompletion: Records a single completion of a habit on a specific date.
|
||||||
// This creates a history of the user's progress for each habit.
|
// This creates a history of the user's progress for each habit.
|
||||||
model HabitCompletion {
|
model HabitCompletion {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date DateTime // Store only the date part
|
date String // YYYY-MM-DD format
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
||||||
habitId Int
|
habitId Int
|
||||||
|
|
||||||
@@unique([habitId, date]) // A habit can only be completed once per day
|
@@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"
|
// DailyVisit: Tracks the user's daily visit for the "I visited the site today"
|
||||||
// quest and for calculating 5-day streaks.
|
// quest and for calculating 5-day streaks.
|
||||||
model DailyVisit {
|
model DailyVisit {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date DateTime // Store only the date part
|
date String // YYYY-MM-DD format
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int
|
userId Int
|
||||||
|
|
||||||
@@unique([userId, date]) // A user can only have one recorded visit per day
|
@@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: The user's personal village, which acts as a container for all
|
||||||
// village objects. Each user has exactly one village.
|
// village objects. Each user has exactly one village.
|
||||||
model 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
|
tiles VillageTile[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
objects VillageObject[]
|
||||||
userId Int @unique // Each user has only one village
|
events VillageEvent[]
|
||||||
objects VillageObject[]
|
|
||||||
tiles VillageTile[]
|
|
||||||
events VillageEvent[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VillageObject: An object (e.g., house, field) placed on a village tile.
|
// VillageObject: An object (e.g., house, field) placed on a village tile.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const previousDay = getPreviousDay();
|
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
|
// 1. Update lastExpDay for all FIELD objects for this user's village
|
||||||
prisma.villageObject.updateMany({
|
prisma.villageObject.updateMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -42,10 +42,20 @@ export default defineEventHandler(async (event) => {
|
||||||
clearingStartedDay: previousDay,
|
clearingStartedDay: previousDay,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 3. Update the village's lastTickDay
|
||||||
|
prisma.village.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastTickDay: previousDay,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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.`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
15
server/api/economy/constants.get.ts
Normal file
15
server/api/economy/constants.get.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ import { getAuthenticatedUserId } from '../../../utils/auth';
|
||||||
import { REWARDS } from '../../../utils/economy';
|
import { REWARDS } from '../../../utils/economy';
|
||||||
import prisma from '../../../utils/prisma';
|
import prisma from '../../../utils/prisma';
|
||||||
import { applyStreakMultiplier } from '../../../utils/streak';
|
import { applyStreakMultiplier } from '../../../utils/streak';
|
||||||
|
import { getDayOfWeekFromGameDay } from '~/server/utils/gameDay';
|
||||||
|
|
||||||
interface CompletionResponse {
|
interface CompletionResponse {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -13,16 +14,15 @@ interface CompletionResponse {
|
||||||
updatedExp: number;
|
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> => {
|
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = getAuthenticatedUserId(event);
|
||||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
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)) {
|
if (isNaN(habitId)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
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.' });
|
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
const appDayOfWeek = getDayOfWeekFromGameDay(gameDay);
|
||||||
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;
|
|
||||||
|
|
||||||
// For permanent users, ensure the habit is scheduled for today.
|
// For permanent users, ensure the habit is scheduled for today.
|
||||||
// Anonymous users in the onboarding flow can complete it on any day.
|
// 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({
|
const existingCompletion = await prisma.habitCompletion.findFirst({
|
||||||
where: {
|
where: {
|
||||||
habitId: habitId,
|
habitId: habitId,
|
||||||
date: startOfToday,
|
date: gameDay,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,8 +69,10 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION;
|
finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION;
|
||||||
} else {
|
} else {
|
||||||
// Permanent users get rewards based on streak
|
// 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;
|
const baseReward = REWARDS.HABITS.COMPLETION;
|
||||||
finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
|
finalReward = applyStreakMultiplier(baseReward, currentDailyStreak);
|
||||||
}
|
}
|
||||||
|
|
||||||
const village = await prisma.village.findUnique({ where: { userId } });
|
const village = await prisma.village.findUnique({ where: { userId } });
|
||||||
|
|
@ -85,7 +81,7 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
prisma.habitCompletion.create({
|
prisma.habitCompletion.create({
|
||||||
data: {
|
data: {
|
||||||
habitId: habitId,
|
habitId: habitId,
|
||||||
date: startOfToday,
|
date: gameDay,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.user.update({
|
prisma.user.update({
|
||||||
|
|
@ -103,7 +99,7 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
data: {
|
data: {
|
||||||
villageId: village.id,
|
villageId: village.id,
|
||||||
type: 'HABIT_COMPLETION',
|
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,
|
coins: finalReward.coins,
|
||||||
exp: finalReward.exp,
|
exp: finalReward.exp,
|
||||||
}
|
}
|
||||||
|
|
@ -117,3 +113,4 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
updatedExp: updatedUser.exp,
|
updatedExp: updatedUser.exp,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ export default defineEventHandler(async () => {
|
||||||
// for "current month's EXP". This should be revisited if true monthly
|
// for "current month's EXP". This should be revisited if true monthly
|
||||||
// tracking becomes a requirement.
|
// tracking becomes a requirement.
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
isAnonymous: false,
|
||||||
|
nickname: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
nickname: true,
|
nickname: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,45 @@ export default defineEventHandler(async (event) => {
|
||||||
isAnonymous: true
|
isAnonymous: true
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true, // Also return ID
|
id: true,
|
||||||
anonymousSessionId: true,
|
anonymousSessionId: true,
|
||||||
nickname: true,
|
village: { select: { id: true } } // Include village ID for deletion
|
||||||
coins: true,
|
|
||||||
exp: true,
|
|
||||||
isAnonymous: true // Ensure this flag is returned
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,17 @@ import { getAuthenticatedUserId } from '../../utils/auth';
|
||||||
import prisma from '../../utils/prisma';
|
import prisma from '../../utils/prisma';
|
||||||
import { calculateDailyStreak } from '../../utils/streak';
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[Visit API] Received request');
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
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
|
// 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,
|
// The consumer of this endpoint needs the most up-to-date user info,
|
||||||
// including the newly calculated streak.
|
// including the newly calculated streak.
|
||||||
|
|
|
||||||
39
server/api/village/tick.post.ts
Normal file
39
server/api/village/tick.post.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// server/middleware/auth.ts
|
// server/middleware/auth.ts
|
||||||
import { defineEventHandler, useSession } from 'h3';
|
import { defineEventHandler, useSession } from 'h3';
|
||||||
import prisma from '../utils/prisma';
|
import prisma from '../utils/prisma';
|
||||||
import { getTodayDay, isBeforeDay } from '../utils/gameDay';
|
|
||||||
import { calculateDailyStreak } from '../utils/streak';
|
|
||||||
|
|
||||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
||||||
|
|
||||||
|
|
@ -31,33 +29,15 @@ export default defineEventHandler(async (event) => {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
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) {
|
||||||
if (user && user.email) {
|
|
||||||
event.context.user = user;
|
event.context.user = user;
|
||||||
|
return; // Found a user, no need to check for anonymous session
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in auth middleware:', error);
|
console.error('Error fetching user in auth middleware:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { COSTS, REWARDS } from '../utils/economy';
|
import { COSTS, REWARDS } from '../utils/economy';
|
||||||
import { applyStreakMultiplier } from '../utils/streak';
|
import { applyStreakMultiplier } from '../utils/streak';
|
||||||
import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay';
|
import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay';
|
||||||
|
import { calculateDailyStreak } from '../utils/streak';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -41,33 +42,74 @@ type FullVillage = Prisma.VillageGetPayload<{
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Главная точка входа.
|
* Processes the village's daily "tick" if necessary and returns the
|
||||||
* Синхронизирует day-based прогресс и возвращает актуальное состояние деревни.
|
* 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 {
|
try {
|
||||||
const today = getTodayDay();
|
const today = getTodayDay();
|
||||||
|
|
||||||
let villageSnapshot = await fetchVillage(userId);
|
let villageSnapshot = await fetchVillage(userId);
|
||||||
|
|
||||||
if (!villageSnapshot) {
|
if (!villageSnapshot) {
|
||||||
|
// This should not happen for a logged-in user with a village.
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = villageSnapshot.user;
|
// Even if tick is done, we should ensure the streak is updated for the day.
|
||||||
|
// The calculateDailyStreak function is idempotent.
|
||||||
await prisma.$transaction(async (tx) => {
|
if (villageSnapshot.lastTickDay === today) {
|
||||||
await processFinishedClearing(tx, villageSnapshot, today);
|
villageSnapshot.user = await calculateDailyStreak(prisma, userId, today);
|
||||||
await processFieldExp(tx, villageSnapshot, today);
|
return villageSnapshot;
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
|
||||||
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
|
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
|
||||||
const freeWorkers = housesCount - producingCount;
|
const freeWorkers = housesCount - producingCount;
|
||||||
|
|
@ -106,6 +148,8 @@ export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
|
||||||
return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
|
return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in syncAndGetVillage:', 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.' });
|
throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +307,26 @@ export async function buildOnTile(
|
||||||
exp: 0,
|
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,
|
tx: Prisma.TransactionClient,
|
||||||
village: FullVillage,
|
village: FullVillage,
|
||||||
today: string
|
today: string
|
||||||
) {
|
): Promise<VillageTile[]> {
|
||||||
const finishedTiles = village.tiles.filter(
|
const finishedTiles = village.tiles.filter(
|
||||||
t =>
|
t =>
|
||||||
t.terrainState === 'CLEARING' &&
|
t.terrainState === 'CLEARING' &&
|
||||||
isBeforeDay(t.clearingStartedDay, today)
|
isBeforeDay(t.clearingStartedDay, today)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!finishedTiles.length) return;
|
if (!finishedTiles.length) return [];
|
||||||
|
|
||||||
const baseReward = REWARDS.VILLAGE.CLEARING;
|
const baseReward = REWARDS.VILLAGE.CLEARING;
|
||||||
const totalBaseReward = {
|
const totalBaseReward = {
|
||||||
|
|
@ -304,7 +368,9 @@ async function processFinishedClearing(
|
||||||
exp: baseReward.exp * finishedTiles.length,
|
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({
|
await tx.user.update({
|
||||||
where: { id: village.user.id },
|
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 = '';
|
let streakBonusText = '';
|
||||||
if (streakMultiplier > 1) {
|
if (streakMultiplier > 1) {
|
||||||
streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`;
|
streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = finishedTiles.map(t => {
|
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 {
|
return {
|
||||||
villageId: village.id,
|
villageId: village.id,
|
||||||
type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
|
type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
|
||||||
|
|
@ -345,50 +412,52 @@ async function processFinishedClearing(
|
||||||
await tx.villageEvent.createMany({
|
await tx.villageEvent.createMany({
|
||||||
data: events,
|
data: events,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return finishedTiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processFieldExp(
|
async function processFieldExp(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
village: FullVillage,
|
village: FullVillage,
|
||||||
today: string
|
today: string
|
||||||
) {
|
): Promise<number> {
|
||||||
const fieldsNeedingUpdate = village.objects.filter(
|
const fieldsNeedingUpdate = village.objects.filter(
|
||||||
(o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today)
|
(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');
|
const wells = village.objects.filter(o => o.type === 'WELL');
|
||||||
let totalBaseExpGained = 0;
|
let totalBaseExpGained = 0;
|
||||||
const eventsToCreate: any[] = [];
|
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 = '';
|
let streakBonusText = '';
|
||||||
if (streakMultiplierValue > 1) {
|
if (currentDailyStreak > 1) {
|
||||||
streakBonusText = ` Ваша серия визитов (${streakMultiplierValue}) увеличила награду.`;
|
streakBonusText = ` Ваша серия визитов (${currentDailyStreak}) увеличила награду.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of fieldsNeedingUpdate) {
|
for (const field of fieldsNeedingUpdate) {
|
||||||
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
|
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
|
||||||
let expGainedForField = daysMissed * REWARDS.VILLAGE.FIELD_EXP.BASE;
|
let expGainedForField = 0;
|
||||||
|
|
||||||
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 wellBonusText = '';
|
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++) {
|
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({
|
eventsToCreate.push({
|
||||||
villageId: village.id,
|
villageId: village.id,
|
||||||
type: 'FIELD_EXP',
|
type: 'FIELD_EXP',
|
||||||
|
|
@ -396,17 +465,18 @@ async function processFieldExp(
|
||||||
tileX: field.tile.x,
|
tileX: field.tile.x,
|
||||||
tileY: field.tile.y,
|
tileY: field.tile.y,
|
||||||
coins: 0,
|
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({
|
await tx.user.update({
|
||||||
where: { id: village.user.id },
|
where: { id: village.user.id },
|
||||||
data: { exp: { increment: finalExp.exp } },
|
data: { exp: { increment: finalTotalExp.exp } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,36 +490,50 @@ async function processFieldExp(
|
||||||
data: eventsToCreate,
|
data: eventsToCreate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return finalTotalExp.exp; // Return the final experience gained
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoStartClearing(
|
async function autoStartClearing(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
village: FullVillage,
|
village: FullVillage,
|
||||||
today: string
|
today: string,
|
||||||
) {
|
justFinishedTiles: VillageTile[] = []
|
||||||
const lumberjacks = village.objects.filter(o => o.type === 'LUMBERJACK').length;
|
): Promise<number> {
|
||||||
const quarries = village.objects.filter(o => o.type === 'QUARRY').length;
|
// 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'
|
t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING'
|
||||||
).length;
|
).length;
|
||||||
|
const busyStonesInSnapshot = village.tiles.filter(
|
||||||
const busyStones = village.tiles.filter(
|
|
||||||
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
|
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const tilesToStart = [
|
// Correctly calculate busy workers by subtracting those that just finished
|
||||||
...village.tiles
|
const currentBusyTrees = Math.max(0, busyTreesInSnapshot - finishedTreeTiles);
|
||||||
.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE')
|
const currentBusyStones = Math.max(0, busyStonesInSnapshot - finishedStoneTiles);
|
||||||
.slice(0, lumberjacks - busyTrees),
|
|
||||||
...village.tiles
|
|
||||||
.filter(
|
|
||||||
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'
|
|
||||||
)
|
|
||||||
.slice(0, quarries - busyStones),
|
|
||||||
];
|
|
||||||
|
|
||||||
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({
|
await tx.villageTile.updateMany({
|
||||||
where: { id: { in: tilesToStart.map(t => t.id) } },
|
where: { id: { in: tilesToStart.map(t => t.id) } },
|
||||||
|
|
@ -458,4 +542,6 @@ async function autoStartClearing(
|
||||||
clearingStartedDay: today,
|
clearingStartedDay: today,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return tilesToStart.length;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
export const COSTS = {
|
export const COSTS = {
|
||||||
BUILD: {
|
BUILD: {
|
||||||
HOUSE: 30,
|
HOUSE: 15,
|
||||||
FIELD: 15,
|
FIELD: 15,
|
||||||
LUMBERJACK: 20,
|
LUMBERJACK: 20,
|
||||||
QUARRY: 20,
|
QUARRY: 20,
|
||||||
|
|
@ -25,15 +25,9 @@ export const REWARDS = {
|
||||||
WELL_MULTIPLIER: 2,
|
WELL_MULTIPLIER: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Quest-related rewards
|
|
||||||
QUESTS: {
|
|
||||||
DAILY_VISIT: {
|
|
||||||
BASE: { coins: 1 },
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Habit-related rewards
|
// Habit-related rewards
|
||||||
HABITS: {
|
HABITS: {
|
||||||
COMPLETION: { coins: 2, exp: 1 },
|
COMPLETION: { coins: 10, exp: 1 },
|
||||||
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
|
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,29 @@ export function daysSince(pastDay: string, futureDay: string): number {
|
||||||
|
|
||||||
return diffDays > 0 ? diffDays : 0;
|
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];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,23 @@
|
||||||
import { PrismaClient, User } from '@prisma/client';
|
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.
|
* Calculates the user's daily visit streak based on a client-provided "Game Day".
|
||||||
*/
|
|
||||||
function getStartOfDay(date: Date): Date {
|
|
||||||
const startOfDay = new Date(date);
|
|
||||||
startOfDay.setUTCHours(0, 0, 0, 0);
|
|
||||||
return startOfDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the user's daily visit streak.
|
|
||||||
* It checks for consecutive daily visits and updates the user's streak count.
|
* 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 prisma The Prisma client instance.
|
||||||
* @param userId The ID of the user.
|
* @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.
|
* @returns The updated User object with the new streak count.
|
||||||
*/
|
*/
|
||||||
export async function calculateDailyStreak(prisma: PrismaClient, userId: number): Promise<User> {
|
export async function calculateDailyStreak(db: PrismaClient | Prisma.TransactionClient, userId: number, gameDay: string): Promise<User> {
|
||||||
const today = getStartOfDay(new Date());
|
const yesterdayGameDay = getPreviousGameDay(gameDay);
|
||||||
const yesterday = getStartOfDay(new Date());
|
|
||||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
||||||
|
|
||||||
// 1. Find the user and their most recent visit
|
// 1. Find the user and their most recent visit
|
||||||
const [user, lastVisit] = await Promise.all([
|
const [user, lastVisit] = await Promise.all([
|
||||||
prisma.user.findUnique({ where: { id: userId } }),
|
db.user.findUnique({ where: { id: userId } }),
|
||||||
prisma.dailyVisit.findFirst({
|
db.dailyVisit.findFirst({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
}),
|
}),
|
||||||
|
|
@ -40,12 +31,10 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number)
|
||||||
|
|
||||||
// 2. Determine the new streak count
|
// 2. Determine the new streak count
|
||||||
if (lastVisit) {
|
if (lastVisit) {
|
||||||
const lastVisitDate = getStartOfDay(new Date(lastVisit.date));
|
if (lastVisit.date === gameDay) {
|
||||||
|
|
||||||
if (lastVisitDate.getTime() === today.getTime()) {
|
|
||||||
// Already visited today, streak doesn't change.
|
// Already visited today, streak doesn't change.
|
||||||
newStreak = user.dailyStreak;
|
newStreak = user.dailyStreak;
|
||||||
} else if (lastVisitDate.getTime() === yesterday.getTime()) {
|
} else if (lastVisit.date === yesterdayGameDay) {
|
||||||
// Visited yesterday, so increment the streak (capped at 3).
|
// Visited yesterday, so increment the streak (capped at 3).
|
||||||
newStreak = Math.min(user.dailyStreak + 1, 3);
|
newStreak = Math.min(user.dailyStreak + 1, 3);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -61,29 +50,20 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number)
|
||||||
newStreak = 1;
|
newStreak = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Use upsert to create today's visit record and update the user's streak in a transaction
|
// 3. Upsert today's visit record.
|
||||||
try {
|
await db.dailyVisit.upsert({
|
||||||
console.log(`[Streak] Attempting to upsert DailyVisit for userId: ${userId}, date: ${today.toISOString()}`);
|
where: { userId_date: { userId, date: gameDay } },
|
||||||
|
update: {},
|
||||||
|
create: { userId, date: gameDay },
|
||||||
|
});
|
||||||
|
|
||||||
const [, updatedUser] = await prisma.$transaction([
|
// 4. Update the user's streak.
|
||||||
prisma.dailyVisit.upsert({
|
const updatedUser = await db.user.update({
|
||||||
where: { userId_date: { userId, date: today } },
|
where: { id: userId },
|
||||||
update: {},
|
data: { dailyStreak: newStreak },
|
||||||
create: { userId, date: today },
|
});
|
||||||
}),
|
|
||||||
prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { dailyStreak: newStreak },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`[Streak] Successfully updated streak for userId: ${userId} to ${newStreak}`);
|
return updatedUser;
|
||||||
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 {
|
interface Reward {
|
||||||
|
|
@ -93,23 +73,16 @@ interface Reward {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a streak-based multiplier to a given 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 reward The base reward object { coins, exp }.
|
||||||
* @param streak The user's current daily streak.
|
* @param streak The user's current daily streak.
|
||||||
* @returns The new reward object with the multiplier applied.
|
* @returns The new reward object with the multiplier applied.
|
||||||
*/
|
*/
|
||||||
export function applyStreakMultiplier(reward: Reward, streak: number | null | undefined): Reward {
|
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));
|
const multiplier = Math.max(1, Math.min(effectiveStreak, 3));
|
||||||
|
|
||||||
if (multiplier === 0) {
|
|
||||||
return {
|
|
||||||
coins: reward.coins * 1,
|
|
||||||
exp: reward.exp * 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coins: reward.coins * multiplier,
|
coins: reward.coins * multiplier,
|
||||||
exp: reward.exp * multiplier,
|
exp: reward.exp * multiplier,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user