Довольно большой рефакторинг. Осталось поработать над стилями и проект готов

This commit is contained in:
Alexander Andreev 2026-01-07 14:00:55 +03:00
parent 5f8dc428be
commit 495c81e60e
47 changed files with 2444 additions and 830 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# Database URL for Prisma
DATABASE_URL="file:./dev.db"
SESSION_PASSWORD=some-long-random-secret-at-least-32-chars
# Secret key to authorize the cleanup task endpoint
# This should be a strong, unique secret in production
CLEANUP_SECRET="changeme"

View File

@ -177,7 +177,42 @@ Expected response:
--- ---
## 8. Deployment Notes ## 8. 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.
### Environment Variable
The cleanup endpoint is protected by a secret key. Ensure the following environment variable is set in your `.env` file (and in production environments):
```env
CLEANUP_SECRET="your_strong_secret_key_here"
```
**Replace `"your_strong_secret_key_here"` with a strong, unique secret.**
### Manual Trigger (for Development/Testing)
You can manually trigger the cleanup task using `curl` (or Postman, Insomnia, etc.):
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "x-cleanup-secret: your_strong_secret_key_here" \
http://localhost:3000/api/admin/cleanup
```
**Important:**
- Replace `http://localhost:3000` with your application's actual URL if not running locally.
- Replace `your_strong_secret_key_here` with the value set in your `CLEANUP_SECRET` environment variable.
### Production Setup
In a production environment, this endpoint should be called by an **external scheduler** (e.g., a cron job service provided by your hosting platform, GitHub Actions, etc.) on a regular basis (e.g., daily). This ensures reliable, automatic cleanup without impacting user experience.
---
## 9. Deployment Notes
- Use Node 20 on hosting - Use Node 20 on hosting
- Run Prisma migrations during deployment - Run Prisma migrations during deployment
@ -186,7 +221,7 @@ Expected response:
--- ---
## 9. AI / Gemini Rules (IMPORTANT) ## 10. AI / Gemini Rules (IMPORTANT)
When using Gemini / AI tools: When using Gemini / AI tools:
@ -203,7 +238,7 @@ When using Gemini / AI tools:
--- ---
## 10. Why these constraints exist ## 11. 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

View File

@ -10,12 +10,12 @@
</template> </template>
<script setup> <script setup>
const { initialized, fetchMe } = useAuth(); const { initialized, initAuth } = useAuth();
// Fetch the user state on initial client-side load. // Initialize the authentication state on client-side load.
// The middleware will wait for `initialized` to be true. // This will either fetch a logged-in user or create an anonymous session.
onMounted(() => { onMounted(() => {
fetchMe(); initAuth();
}); });
</script> </script>

View File

@ -0,0 +1,302 @@
<template>
<div class="habit-card">
<div class="habit-header">
<div class="habit-details">
<h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</div>
<div class="habit-action">
<div v-if="isScheduledForToday(habit) || forceShowAction">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="emitComplete" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить
</button>
</div>
</div>
</div>
<!-- Calendar / History Grid (only for full user dashboard, not onboarding) -->
<div v-if="showHistoryGrid" class="history-grid">
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
<span class="day-label">{{ formatDayLabel(day) }}</span>
</div>
</div>
<div v-if="exploding" class="confetti-container">
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
habit: {
type: Object,
required: true,
},
isSubmittingHabit: {
type: Boolean,
default: false,
},
explodingHabitId: {
type: Number,
default: null,
},
showHistoryGrid: {
type: Boolean,
default: true, // Default to true for dashboard, false for onboarding
},
forceShowAction: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['complete']);
const today = new Date();
const todayNormalized = new Date();
todayNormalized.setHours(0, 0, 0, 0);
const exploding = computed(() => props.explodingHabitId === props.habit.id);
// --- History Grid Logic (copied from index.vue) ---
const last14Days = computed(() => {
const dates = [];
const today = new Date();
const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc.
// Adjust so that Monday is 0 and Sunday is 6 for application's convention
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
// Calculate days to subtract to get to the Monday of LAST week
// (appDayOfWeek) gets us to this week's Monday. +7 gets us to last week's Monday.
const daysToSubtract = appDayOfWeek + 7;
const startDate = new Date();
startDate.setDate(today.getDate() - daysToSubtract);
for (let i = 0; i < 14; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
dates.push(date);
}
return dates;
});
const formatDayLabel = (date) => {
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
return formatted.replace(' г.', '');
};
const isSameDay = (d1, d2) => {
d1 = new Date(d1);
d2 = new Date(d2);
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
};
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
return habit.completions.some(c => isSameDay(c.date, date));
};
const getCellClasses = (habit, day) => {
const classes = {};
const dayNormalized = new Date(day);
dayNormalized.setHours(0, 0, 0, 0);
const habitCreatedAt = new Date(habit.createdAt);
habitCreatedAt.setHours(0, 0, 0, 0);
if (dayNormalized > todayNormalized) {
classes['future-day'] = true;
}
if (isSameDay(dayNormalized, todayNormalized)) {
classes['today-highlight'] = true;
}
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
if (isCompleted(habit, dayNormalized)) {
classes['completed'] = true;
return classes;
}
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = today.getDay(); // Sunday is 0
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
const getScheduleText = (habit) => {
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
};
const emitComplete = () => {
emit('complete', props.habit.id);
};
</script>
<style scoped>
.habit-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
margin: 24px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
}
.habit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.habit-details {
text-align: left;
}
.habit-details h3 {
margin: 0;
font-size: 1.3rem;
}
.habit-schedule {
margin: 5px 0 0 0;
font-size: 0.9em;
color: var(--text-color-light);
}
.completed-text {
font-weight: bold;
color: #16a34a; /* A nice green */
}
.history-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin: 20px 0;
}
.day-cell {
aspect-ratio: 1 / 1;
border: 1px solid var(--border-color);
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
position: relative;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #fee2e2;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: var(--primary-color);
}
.future-day .day-label {
color: #cbd5e1;
}
.day-cell.today-highlight::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-color);
}
.day-label {
font-size: 0.85em;
}
/* Confetti Animation */
.confetti-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 10;
}
.confetti-particle {
position: absolute;
left: 50%;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 50%;
animation: confetti-fall 1s ease-out forwards;
}
@keyframes confetti-fall {
from {
transform: translateY(0) translateX(0);
opacity: 1;
}
to {
transform: translateY(-200px) translateX(var(--x-end)) rotate(360deg);
opacity: 0;
}
}
/* Particle Colors & random-ish trajectories */
.confetti-particle:nth-child(1) { background-color: #d88e8e; --x-end: -150px; animation-delay: 0s; }
.confetti-particle:nth-child(2) { background-color: #a3be8c; --x-end: 150px; animation-delay: 0.1s; }
.confetti-particle:nth-child(3) { background-color: #ebcb8b; --x-end: 100px; animation-delay: 0.05s; }
.confetti-particle:nth-child(4) { background-color: #81a1c1; --x-end: -100px; animation-delay: 0.2s; }
.confetti-particle:nth-child(5) { background-color: #b48ead; --x-end: 50px; animation-delay: 0.15s; }
.confetti-particle:nth-child(6) { background-color: #d88e8e; --x-end: -50px; animation-delay: 0.3s; }
.confetti-particle:nth-child(7) { background-color: #a3be8c; --x-end: -80px; animation-delay: 0.25s; }
.confetti-particle:nth-child(8) { background-color: #ebcb8b; --x-end: 80px; animation-delay: 0.4s; }
.confetti-particle:nth-child(9) { background-color: #81a1c1; --x-end: 120px; animation-delay: 0.35s; }
.confetti-particle:nth-child(10) { background-color: #b48ead; --x-end: -120px; animation-delay: 0.45s; }
.confetti-particle:nth-child(11) { background-color: #d88e8e; --x-end: -180px; animation-delay: 0.08s; }
.confetto-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="onboarding-funnel">
<!-- Progress Bar -->
<div class="progress-bar">
<div v-for="n in 5" :key="n" class="step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
<div class="step-circle">{{ n }}</div>
<div class="step-label">{{ getStepLabel(n) }}</div>
</div>
</div>
<!-- Content Area -->
<div class="step-content">
<div class="step-container">
<!-- Step 1: Create Habit -->
<div v-if="currentStep === 1">
<h2>Шаг 1: Создайте свою первую привычку</h2>
<p>Привычки - это основа продуктивности. С чего начнем?</p>
<form @submit.prevent="handleCreateHabit">
<div class="form-group">
<label for="habit-name">Название привычки</label>
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required />
</div>
<div class="form-group">
<label>Дни выполнения</label>
<div class="days-selector">
<button v-for="day in daysOfWeek" :key="day.value" type="button" :class="{ 'selected': form.selectedDays.includes(day.value) }" @click="toggleDay(day.value)">{{ day.label }}</button>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button>
</form>
</div>
<!-- Step 2: Complete Habit -->
<div v-if="currentStep === 2">
<h2>Шаг 2: Завершите привычку</h2>
<p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p>
<div v-if="error" class="error-message">{{ error }}</div>
<HabitCard
v-if="createdHabit"
:habit="createdHabit"
:is-submitting-habit="isLoading"
:show-history-grid="false"
:force-show-action="true"
@complete="handleCompleteOnboardingHabit"
/>
</div>
<!-- NEW Step 3: Reward Screen -->
<div v-if="currentStep === 3" class="reward-step">
<div class="reward-icon">💰</div>
<h2>Вы получили {{ onboardingRewardAmount }} монет!</h2>
<p class="reward-caption">Деньги можно тратить на строительство вашей деревни.</p>
<button @click="nextStep">Продолжить</button>
</div>
<!-- Step 4: Build a House (was Step 3) -->
<div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> золота! Нажмите на пустой участок в деревне, чтобы построить Дом (Стоимость: 50 монет) и увеличить лимит рабочих.</p>
<VillageGrid
v-if="villageData"
:village-data="villageData"
@tile-click="handleTileClickToBuild"
/>
<div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="next-button">
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
</button>
</div>
<!-- Step 5: Register (was Step 4) -->
<div v-if="currentStep === 5">
<h2>Шаг 5: Сохраните свой прогресс!</h2>
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="nickname">Ваше имя</label>
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required />
</div>
<div class="form-group">
<label for="password">Пароль (мин. 8 символов)</label>
<input id="password" type="password" v-model="form.password" required />
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
// --- Composables ---
const api = useApi();
const { user, updateUser, register } = useAuth();
// --- Constants ---
const onboardingRewardAmount = 75;
// --- State ---
const currentStep = ref(1);
const isLoading = ref(false);
const error = ref<string | null>(null);
const form = reactive({
habitName: 'Читать 15 минут',
selectedDays: [0, 1, 2, 3, 4, 5, 6],
nickname: '',
email: '',
password: '',
});
const daysOfWeek = [
{ label: 'Пн', value: 0 }, { label: 'Вт', value: 1 }, { label: 'Ср', value: 2 },
{ label: 'Чт', value: 3 }, { label: 'Пт', value: 4 }, { label: 'Сб', value: 5 }, { label: 'Вс', value: 6 }
];
const createdHabit = ref<{ id: number; name: string; completions: any[] } | null>(null);
const villageData = ref(null);
const villagePending = ref(false);
// --- Computed ---
const isHouseBuilt = computed(() => villageData.value?.objects.some(o => o.type === 'HOUSE'));
const hasEnoughCoinsForHouse = computed(() => user.value && user.value.coins >= 50);
// --- Watchers ---
watch(currentStep, async (newStep) => {
if (newStep === 4 && !villageData.value) { // Was step 3, now 4
villagePending.value = true;
error.value = null;
try {
villageData.value = await api('/api/village');
} catch (e: any) { error.value = 'Не удалось загрузить данные деревни.'; }
finally { villagePending.value = false; }
}
});
// --- Methods ---
const getStepLabel = (step: number): string => {
switch (step) {
case 1: return 'Создать привычку';
case 2: return 'Завершить';
case 3: return 'Награда';
case 4: return 'Построить дом';
case 5: return 'Регистрация';
default: return '';
}
};
const nextStep = () => {
if (currentStep.value < 5) { // Max 5 steps now
error.value = null;
currentStep.value++;
}
};
const toggleDay = (day: number) => {
const index = form.selectedDays.indexOf(day);
if (index > -1) {
if (form.selectedDays.length > 1) form.selectedDays.splice(index, 1);
} else {
form.selectedDays.push(day);
}
};
// --- API Methods ---
const handleCreateHabit = async () => {
if (!form.habitName.trim() || form.selectedDays.length === 0) {
error.value = 'Пожалуйста, введите название и выберите хотя бы один день.';
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await api('/api/habits', {
method: 'POST',
body: { name: form.habitName, daysOfWeek: form.selectedDays },
});
createdHabit.value = { ...response, completions: [] };
nextStep();
} catch (e: any) { error.value = e.data?.message || 'Не удалось создать привычку.'; }
finally { isLoading.value = false; }
};
const handleCompleteOnboardingHabit = async (habitId: number) => {
if (!createdHabit.value) {
error.value = 'Ошибка: не найдена созданная привычка.';
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await api(`/api/habits/${habitId}/complete`, {
method: 'POST',
});
// Manually update the user's coins from the response
if (user.value && response.updatedCoins !== undefined) {
user.value.coins = response.updatedCoins;
}
createdHabit.value.completions.push({ date: new Date().toISOString() });
nextStep();
} catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; }
finally { isLoading.value = false; }
};
const handleTileClickToBuild = async (tile: any) => {
if (isLoading.value || isHouseBuilt.value) return;
if (tile.terrainType !== 'EMPTY' || tile.object) {
error.value = 'Выберите пустой участок для строительства.';
return;
}
if (!hasEnoughCoinsForHouse.value) {
error.value = 'Не хватает монет для постройки дома (50 монет).';
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await api('/api/village/action', {
method: 'POST',
body: {
tileId: tile.id,
actionType: 'BUILD',
payload: { buildingType: 'HOUSE' },
},
});
villageData.value = response;
if (updateUser && response.user) {
updateUser(response.user);
}
} catch (e: any) { error.value = e.data?.message || 'Не удалось построить дом.'; }
finally { isLoading.value = false; }
};
const handleRegister = async () => {
if (!form.email || !form.password || !form.nickname) {
error.value = "Пожалуйста, заполните все поля.";
return;
}
if (form.password.length < 8) {
error.value = "Пароль должен быть не менее 8 символов.";
return;
}
isLoading.value = true;
error.value = null;
try {
await register(form.email, form.password, form.nickname);
} catch (e: any) {
error.value = e.data?.message || 'Ошибка регистрации. Попробуйте другой email.';
} finally {
isLoading.value = false;
}
};
</script>
<style scoped>
/* --- Main Funnel & Progress Bar --- */
.onboarding-funnel { width: 100%; max-width: 800px; margin: 2rem auto; padding: 2rem; font-family: sans-serif; background-color: #f9f9f9; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.progress-bar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 3rem; position: relative; }
.progress-bar::before { content: ''; position: absolute; top: 18px; left: 10%; right: 10%; height: 4px; background-color: #e0e0e0; z-index: 1; }
.step { display: flex; flex-direction: column; align-items: center; text-align: center; flex: 1; position: relative; z-index: 2; }
.step-circle { width: 36px; height: 36px; border-radius: 50%; background-color: #e0e0e0; color: #888; display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: 1.2rem; border: 4px solid #e0e0e0; transition: all 0.3s ease; }
.step-label { margin-top: 0.5rem; font-size: 0.9rem; color: #888; }
.step.active .step-circle { background-color: #fff; border-color: #3b82f6; color: #3b82f6; }
.step.active .step-label { color: #3b82f6; font-weight: bold; }
.step.completed .step-circle { background-color: #16a34a; border-color: #16a34a; color: #fff; }
.step.completed .step-label { color: #333; }
.step.completed::after { content: ''; position: absolute; top: 18px; left: -50%; width: 100%; height: 4px; background-color: #16a34a; z-index: -1; }
.step:first-child.completed::after { width: 50%; left: 0; }
/* --- Content & Form Styling --- */
.step-content { margin-top: 2rem; }
.step-container { animation: fadeIn 0.5s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.step-container h2 { font-size: 1.8rem; margin-bottom: 1rem; color: #333; }
.step-container p { font-size: 1.1rem; color: #666; margin-bottom: 2rem; }
.loading-placeholder { background-color: #f0f0f0; border: 2px dashed #ccc; padding: 3rem; text-align: center; color: #999; border-radius: 8px; margin-bottom: 2rem; }
button { background-color: #3b82f6; color: white; padding: 1rem 2rem; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background-color 0.2s; width: 100%; }
button:hover:not(:disabled) { background-color: #2563eb; }
button:disabled { background-color: #9ca3af; cursor: not-allowed; }
.form-group { margin-bottom: 1.5rem; text-align: left; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: bold; color: #555; }
.form-group input { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.days-selector { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.days-selector button { background-color: #e0e0e0; color: #333; font-weight: normal; font-size: 0.9rem; padding: 0.5rem 1rem; border-radius: 20px; width: auto; }
.days-selector button.selected { background-color: #3b82f6; color: white; }
.error-message { color: #ef4444; margin-bottom: 1rem; text-align: center; }
/* Step 3 (Reward) specific styles */
.reward-step { text-align: center; }
.reward-icon { font-size: 5rem; line-height: 1; margin-bottom: 1rem; }
.reward-caption { color: #888; font-size: 1rem; }
/* Step 4 (Build) specific styles */
.next-button {
margin-top: 1rem;
background-color: #16a34a; /* Green for next step */
}
.next-button:hover:not(:disabled) {
background-color: #15803d;
}
</style>

View File

@ -0,0 +1,197 @@
<template>
<div v-if="villageData && villageData.tiles" class="village-container">
<div class="village-grid-wrapper" :style="gridWrapperStyle">
<!-- Empty corner for alignment -->
<div class="empty-corner"></div>
<!-- Column Labels (A, B, C...) -->
<div class="col-labels">
<div class="col-label" v-for="colLabel in colLabels" :key="colLabel">{{ colLabel }}</div>
</div>
<!-- Row Labels (7, 6, 5...) -->
<div class="row-labels">
<div class="row-label" v-for="rowLabel in rowLabels" :key="rowLabel">{{ rowLabel }}</div>
</div>
<!-- The actual grid -->
<div class="village-grid" :style="gridStyle">
<div
v-for="tile in villageData.tiles"
:key="tile.id"
class="tile"
:class="tileClasses(tile)"
:style="{ 'grid-column': tile.x + 1, 'grid-row': gridHeight - tile.y }"
@click="$emit('tile-click', tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div>
</div>
</div>
</div>
<div v-else class="loading">
Загрузка деревни...
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
// --- Props & Emits ---
const props = defineProps({
villageData: {
type: Object,
required: true,
},
});
defineEmits(['tile-click']);
// --- Grid Dimensions ---
const gridWidth = computed(() => {
if (!props.villageData?.tiles || props.villageData.tiles.length === 0) return 5; // Default
return Math.max(...props.villageData.tiles.map(t => t.x)) + 1;
});
const gridHeight = computed(() => {
if (!props.villageData?.tiles || props.villageData.tiles.length === 0) return 7; // Default
return Math.max(...props.villageData.tiles.map(t => t.y)) + 1;
});
// --- Dynamic Styles ---
const gridWrapperStyle = computed(() => ({
'grid-template-columns': `20px repeat(${gridWidth.value}, var(--tile-size))`,
'grid-template-rows': `repeat(${gridHeight.value}, var(--tile-size)) 20px`,
}));
const gridStyle = computed(() => ({
'grid-template-columns': `repeat(${gridWidth.value}, var(--tile-size))`,
'grid-template-rows': `repeat(${gridHeight.value}, var(--tile-size))`,
}));
// --- Labels ---
const colLabels = computed(() => {
return Array.from({ length: gridWidth.value }, (_, i) => String.fromCharCode(65 + i));
});
const rowLabels = computed(() => {
return Array.from({ length: gridHeight.value }, (_, i) => gridHeight.value - i);
});
// --- Tile Display Logic ---
const getTileEmoji = (tile) => {
if (tile.terrainState === 'CLEARING') return '⏳';
if (tile.object) {
switch (tile.object.type) {
case 'HOUSE': return '🏠';
case 'FIELD': return '🌱';
case 'LUMBERJACK': return '🪓';
case 'QUARRY': return '⛏️';
case 'WELL': return '💧';
default: return '❓';
}
}
switch (tile.terrainType) {
case 'BLOCKED_TREE': return '🌳';
case 'BLOCKED_STONE': return '🪨';
case 'EMPTY': return '';
default: return '❓';
}
};
const tileClasses = (tile) => {
return {
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
'tile-object': !!tile.object,
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
};
};
</script>
<style scoped>
.village-container {
display: flex;
justify-content: center;
width: 100%;
padding: 0 10px;
margin-top: 20px;
--tile-size: clamp(40px, 10vw, 55px);
}
.village-grid-wrapper {
display: grid;
gap: 4px;
padding: 4px;
width: fit-content;
margin: 0 auto;
}
.empty-corner {
grid-column: 1;
grid-row: 8;
}
.col-labels {
grid-column: 2 / -1;
grid-row: 8;
display: flex;
justify-content: space-around;
color: #999;
}
.row-labels {
grid-column: 1;
grid-row: 1 / 8;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
color: #999;
}
.col-label, .row-label {
display: flex;
justify-content: center;
align-items: center;
font-size: 0.8rem;
}
.village-grid {
grid-column: 2 / -1;
grid-row: 1 / 8;
display: grid;
gap: 4px;
border: 1px solid #e0e0e0;
}
.tile {
width: var(--tile-size);
height: var(--tile-size);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: #f9f9f9;
cursor: pointer;
transition: all 0.2s;
}
.tile.tile-blocked {
background-color: #f3f4f6;
}
.tile.tile-object {
background-color: #ecfdf5;
}
.tile.tile-empty:hover {
background-color: #fefce8;
border-color: #facc15;
}
.tile-content {
font-size: calc(var(--tile-size) * 0.4);
}
</style>

View File

@ -1,9 +1,9 @@
// /composables/useAuth.ts // /composables/useAuth.ts
interface User { interface User {
id: string; id: number;
email: string; email: string | null; // Can be null for anonymous users
nickname: string; nickname: string | null;
avatar: string | null; avatar: string | null;
coins: number; coins: number;
exp: number; exp: number;
@ -12,56 +12,86 @@ interface User {
confettiOn: boolean; confettiOn: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isAnonymous?: boolean; // Flag to distinguish anonymous users
anonymousSessionId?: string;
} }
export function useAuth() { export function useAuth() {
// All Nuxt composables that require instance access MUST be called inside the setup function.
// useState is how we create shared, SSR-safe state in Nuxt.
const user = useState<User | null>('user', () => null); const user = useState<User | null>('user', () => null);
const initialized = useState('auth_initialized', () => false); const initialized = useState('auth_initialized', () => false);
const loading = ref(false); // This is a local, non-shared loading ref for fetchMe's internal use
const api = useApi(); const api = useApi();
const isAuthenticated = computed(() => !!user.value);
const fetchMe = async () => { // A user is fully authenticated only if they exist and are NOT anonymous.
// This function can be called multiple times, but the logic inside const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous);
// will only run once thanks to the initialized flag. // A user is anonymous if they exist and have the isAnonymous flag.
const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous);
/**
* Initializes the authentication state for EXISTING users.
* It should be called once in app.vue.
* It will only try to fetch a logged-in user via /api/auth/me.
* If it fails, the user state remains null.
*/
const initAuth = async () => {
if (initialized.value) return; if (initialized.value) return;
loading.value = true;
try { try {
// The backend returns the user object nested under a 'user' key. const response = await api<{ user: User }>('/auth/me');
const response = await api<{ user: User }>('/auth/me', { method: 'GET' }); if (response.user) {
user.value = response.user; // Correctly assign the nested user object user.value = { ...response.user, isAnonymous: false };
} else {
user.value = null;
}
} catch (error) { } catch (error) {
user.value = null; // Silently set user to null on 401 // It's expected this will fail for non-logged-in users.
user.value = null;
} finally { } finally {
loading.value = false; initialized.value = true;
initialized.value = true; // Mark as initialized after the first attempt
} }
}; };
/**
* Starts the onboarding process by creating a new anonymous user.
*/
const startOnboarding = async () => {
try {
const anonymousUserData = await api<User>('/onboarding/initiate', { method: 'POST' });
// Explicitly set isAnonymous to true for robustness
user.value = { ...anonymousUserData, isAnonymous: true };
} catch (anonError) {
console.error('Could not initiate anonymous session:', anonError);
// Optionally, show an error message to the user
user.value = null;
}
};
const register = async (email, password, nickname) => {
await api('/auth/register', {
method: 'POST',
body: { email, password, nickname },
});
// After a successful registration, force a re-fetch of the new user state.
initialized.value = false;
await initAuth();
};
const login = async (email, password) => { const login = async (email, password) => {
// The calling component is responsible for its own loading state.
// This function just performs the action.
await api('/auth/login', { await api('/auth/login', {
method: 'POST', method: 'POST',
body: { email, password }, body: { email, password },
}); });
// After a successful login, allow a re-fetch of the user state. // After a successful login, force a re-fetch of the new user state.
initialized.value = false; initialized.value = false;
await fetchMe(); await initAuth();
}; };
const logout = async () => { const logout = async () => {
try { try {
await api('/auth/logout', { method: 'POST' }); await api('/auth/logout', { method: 'POST' });
} finally { } finally {
// Always clear state and redirect, regardless of API call success.
user.value = null; user.value = null;
initialized.value = false; await navigateTo('/');
await navigateTo('/login');
} }
}; };
@ -71,12 +101,14 @@ export function useAuth() {
} }
}; };
// Expose the state and methods.
return { return {
user, user,
isAuthenticated, isAuthenticated,
isAnonymous, // Expose this new state
initialized, initialized,
fetchMe, initAuth, // Called from app.vue
startOnboarding, // Called from index.vue
register, // Expose register function
login, login,
logout, logout,
updateUser, updateUser,

View File

@ -1,33 +1,56 @@
export const useVillageHelpers = () => { export const useVillageHelpers = () => {
/** /**
* Converts numeric coordinates to a chess-like format (e.g., 0,0 -> A7). * Converts data-coordinates (x, y) into a chess-like UI format (e.g. A7).
* The grid is 5 columns (A-E) and 7 rows (1-7). *
* Rows are numbered from bottom to top, so y=6 is row '1'. * DATA CONTRACT:
* @param x The column index (0-4). * - x: 0..4 (left right)
* @param y The row index (0-6). * - y: 0..6 (bottom top)
*
* UI CONTRACT:
* - rows are shown top bottom
* - row number = 7 - y
*/ */
const formatCoordinates = (x: number, y: number): string => { const formatCoordinates = (x: number, y: number): string => {
if (
typeof x !== 'number' ||
typeof y !== 'number' ||
x < 0 ||
y < 0
) {
return '';
}
const col = String.fromCharCode('A'.charCodeAt(0) + x); const col = String.fromCharCode('A'.charCodeAt(0) + x);
const row = 7 - y; const row = 7 - y;
return `${col}${row}`; return `${col}${row}`;
}; };
/** /**
* Finds and replaces all occurrences of numeric coordinates like (x, y) * Formats backend event messages that already contain
* in a string with the chess-like format. * raw data-coordinates in the form "(x, y)".
* @param message The message string. *
* IMPORTANT:
* - This function is PRESENTATION-ONLY
* - It assumes (x, y) are DATA coordinates
* - It does NOT change semantics, only visual output
*/ */
const formatMessageCoordinates = (message: string): string => { const formatMessageCoordinates = (message: string): string => {
if (!message) return ''; if (!message) return '';
// Regex to find coordinates like (1, 2)
return message.replace(/\((\d+), (\d+)\)/g, (match, xStr, yStr) => { return message.replace(
const x = parseInt(xStr, 10); /\((\d+),\s*(\d+)\)/g,
const y = parseInt(yStr, 10); (_match, xStr, yStr) => {
if (!isNaN(x) && !isNaN(y)) { const x = Number(xStr);
const y = Number(yStr);
if (Number.isNaN(x) || Number.isNaN(y)) {
return _match;
}
return formatCoordinates(x, y); return formatCoordinates(x, y);
} }
return match; // Return original if parsing fails );
});
}; };
return { return {

View File

@ -85,7 +85,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
// --- Type Definitions --- // --- Type Definitions ---
interface Habit { interface Habit {

View File

@ -1,5 +1,8 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- ================================= -->
<!-- Authenticated User Dashboard -->
<!-- ================================= -->
<div v-if="isAuthenticated && user" class="dashboard-content"> <div v-if="isAuthenticated && user" class="dashboard-content">
<h1>Ваши цели на сегодня</h1> <h1>Ваши цели на сегодня</h1>
@ -22,35 +25,17 @@
<div class="habits-section"> <div class="habits-section">
<h2>Привычки</h2> <h2>Привычки</h2>
<div v-if="habitsPending">Loading habits...</div> <div v-if="habitsPending">Загрузка привычек...</div>
<div v-else-if="habitsError">Could not load habits.</div> <div v-else-if="habitsError">Не удалось загрузить привычки.</div>
<div v-else-if="habits && habits.length > 0"> <div v-else-if="habits && habits.length > 0">
<div v-for="habit in habits" :key="habit.id" class="habit-card"> <HabitCard
<div class="habit-header"> v-for="habit in habits"
<div class="habit-details"> :key="habit.id"
<h3>{{ habit.name }}</h3> :habit="habit"
<p class="habit-schedule">{{ getScheduleText(habit) }}</p> :is-submitting-habit="isSubmittingHabit"
</div> :exploding-habit-id="explodingHabitId"
<div class="habit-action"> @complete="completeHabit"
<div v-if="isScheduledForToday(habit)"> />
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить
</button>
</div>
</div>
</div>
<div class="history-grid">
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
<span class="day-label">{{ formatDayLabel(day) }}</span>
</div>
</div>
<div v-if="explodingHabitId === habit.id" class="confetti-container">
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
</div>
</div>
</div> </div>
<div v-else> <div v-else>
<p>У вас еще нет привычек. Перейдите на страницу <NuxtLink to="/habits">Мои привычки</NuxtLink>, чтобы создать их.</p> <p>У вас еще нет привычек. Перейдите на страницу <NuxtLink to="/habits">Мои привычки</NuxtLink>, чтобы создать их.</p>
@ -58,12 +43,23 @@
</div> </div>
</div> </div>
<!-- ================================= -->
<!-- Anonymous User Onboarding Funnel -->
<!-- ================================= -->
<div v-else-if="isAnonymous" class="onboarding-container">
<OnboardingFunnel />
</div>
<!-- ================================= -->
<!-- New/Unidentified User Welcome -->
<!-- ================================= -->
<div v-else class="welcome-content"> <div v-else class="welcome-content">
<h1>Добро пожаловать в SmurfHabits!</h1> <h1>Добро пожаловать в SmurfHabits!</h1>
<p class="text-color-light">Отслеживайте свои привычки и развивайте свою деревню.</p> <p class="text-color-light">Отслеживайте свои привычки и развивайте свою деревню.</p>
<div class="auth-buttons"> <div class="auth-buttons">
<NuxtLink to="/login" class="btn btn-primary">Войти</NuxtLink> <button @click="startOnboarding" class="btn btn-primary">Начать онбординг</button>
<NuxtLink to="/register" class="btn btn-secondary">Зарегистрироваться</NuxtLink> <NuxtLink to="/login" class="btn btn-secondary">У меня уже есть аккаунт</NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@ -72,119 +68,21 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
const { user, isAuthenticated, updateUser } = useAuth(); // Use the refactored auth composable
const { user, isAuthenticated, isAnonymous, updateUser, startOnboarding } = useAuth();
const api = useApi(); const api = useApi();
// --- Habits Data --- // --- Habits Data (This part only runs for authenticated users but is fine to leave here) ---
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', { const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
lazy: true, lazy: true,
server: false, server: false,
}); });
// --- Date Logic & Helpers ---
const today = new Date();
const todayNormalized = new Date();
todayNormalized.setHours(0, 0, 0, 0);
const last14Days = computed(() => {
const dates = [];
const today = new Date();
const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc.
// Adjust so that Monday is 1 and Sunday is 7
const dayOfWeek = todayDay === 0 ? 7 : todayDay;
// Calculate days to subtract to get to the Monday of LAST week
// (dayOfWeek - 1) gets us to this week's Monday. +7 gets us to last week's Monday.
const daysToSubtract = (dayOfWeek - 1) + 7;
const startDate = new Date();
startDate.setDate(today.getDate() - daysToSubtract);
for (let i = 0; i < 14; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
dates.push(date);
}
return dates;
});
const formatDayLabel = (date) => {
// Use Intl for robust localization. 'янв' needs a specific format.
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
// The format might include a " г.", remove it.
return formatted.replace(' г.', '');
};
const isSameDay = (d1, d2) => {
d1 = new Date(d1);
d2 = new Date(d2);
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
};
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
return habit.completions.some(c => isSameDay(c.date, date));
};
const getCellClasses = (habit, day) => {
const classes = {};
const dayNormalized = new Date(day);
dayNormalized.setHours(0, 0, 0, 0);
const habitCreatedAt = new Date(habit.createdAt);
habitCreatedAt.setHours(0, 0, 0, 0);
if (dayNormalized > todayNormalized) {
classes['future-day'] = true;
}
if (isSameDay(dayNormalized, todayNormalized)) {
classes['today-highlight'] = true;
}
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
if (isCompleted(habit, dayNormalized)) {
classes['completed'] = true;
return classes;
}
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = today.getDay(); // Sunday is 0
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
const getScheduleText = (habit) => {
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
};
// --- Actions & UI State --- // --- Actions & UI State ---
const isSubmittingHabit = ref(false); const isSubmittingHabit = ref(false);
const explodingHabitId = ref(null); const explodingHabitId = ref(null);
const completeHabit = async (habitId, event) => { const completeHabit = async (habitId) => { // Removed event param since it's handled by HabitCard
if (event) {
event.preventDefault();
}
if (isSubmittingHabit.value) return; if (isSubmittingHabit.value) return;
isSubmittingHabit.value = true; isSubmittingHabit.value = true;
@ -199,8 +97,12 @@ const completeHabit = async (habitId, event) => {
const habit = habits.value.find(h => h.id === habitId); const habit = habits.value.find(h => h.id === habitId);
if (habit) { if (habit) {
// Optimistically update the completions. This assumes the API call is successful.
if (!habit.completions) {
habit.completions = [];
}
habit.completions.push({ habit.completions.push({
id: Math.random(), id: Math.random(), // Temporary ID for reactivity
habitId: habitId, habitId: habitId,
date: new Date().toISOString(), date: new Date().toISOString(),
}); });
@ -220,8 +122,7 @@ const completeHabit = async (habitId, event) => {
</script> </script>
<style scoped> <style scoped>
/* Scoped styles are kept for component-specific adjustments and animations */ .dashboard-content, .welcome-content, .onboarding-container {
.dashboard-content, .welcome-content {
text-align: center; text-align: center;
} }
@ -277,152 +178,10 @@ const completeHabit = async (habitId, event) => {
margin: 40px 0; margin: 40px 0;
} }
.habit-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
margin: 24px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
}
.habit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.habit-details {
text-align: left;
}
.habit-details h3 {
margin: 0;
font-size: 1.3rem;
}
.habit-schedule {
margin: 5px 0 0 0;
font-size: 0.9em;
color: var(--text-color-light);
}
.completed-text {
font-weight: bold;
color: #16a34a; /* A nice green */
}
.history-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin: 20px 0;
}
.day-cell {
aspect-ratio: 1 / 1;
border: 1px solid var(--border-color);
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
position: relative;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #fee2e2;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: var(--primary-color);
}
.future-day .day-label {
color: #cbd5e1;
}
.day-cell.today-highlight::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-color);
}
.day-label {
font-size: 0.85em;
}
.auth-buttons { .auth-buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
margin-top: 32px; margin-top: 32px;
} }
/* Confetti Animation */
.confetti-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 10;
}
.confetti-particle {
position: absolute;
left: 50%;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 50%;
animation: confetti-fall 1s ease-out forwards;
}
@keyframes confetti-fall {
from {
transform: translateY(0) translateX(0);
opacity: 1;
}
to {
transform: translateY(-200px) translateX(var(--x-end)) rotate(360deg);
opacity: 0;
}
}
/* Particle Colors & random-ish trajectories */
.confetti-particle:nth-child(1) { background-color: #d88e8e; --x-end: -150px; animation-delay: 0s; }
.confetti-particle:nth-child(2) { background-color: #a3be8c; --x-end: 150px; animation-delay: 0.1s; }
.confetti-particle:nth-child(3) { background-color: #ebcb8b; --x-end: 100px; animation-delay: 0.05s; }
.confetti-particle:nth-child(4) { background-color: #81a1c1; --x-end: -100px; animation-delay: 0.2s; }
.confetti-particle:nth-child(5) { background-color: #b48ead; --x-end: 50px; animation-delay: 0.15s; }
.confetti-particle:nth-child(6) { background-color: #d88e8e; --x-end: -50px; animation-delay: 0.3s; }
.confetti-particle:nth-child(7) { background-color: #a3be8c; --x-end: -80px; animation-delay: 0.25s; }
.confetti-particle:nth-child(8) { background-color: #ebcb8b; --x-end: 80px; animation-delay: 0.4s; }
.confetti-particle:nth-child(9) { background-color: #81a1c1; --x-end: 120px; animation-delay: 0.35s; }
.confetti-particle:nth-child(10) { background-color: #b48ead; --x-end: -120px; animation-delay: 0.45s; }
.confetti-particle:nth-child(11) { background-color: #d88e8e; --x-end: -180px; animation-delay: 0.08s; }
.confetto-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
</style> </style>

View File

@ -31,8 +31,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuth } from '~/composables/useAuth';
const { user: currentUser } = useAuth(); // Get current authenticated user const { user: currentUser } = useAuth(); // Get current authenticated user
const { data, pending, error } = await useFetch('/api/leaderboard', { const { data, pending, error } = await useFetch('/api/leaderboard', {

View File

@ -6,7 +6,10 @@
<div v-else-if="error" class="error-container"> <div v-else-if="error" class="error-container">
<p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p> <p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
<p v-else>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p> <div v-else>
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
<pre>{{ error }}</pre>
</div>
</div> </div>
<div v-else-if="villageData"> <div v-else-if="villageData">
@ -28,6 +31,7 @@
:key="tile.id" :key="tile.id"
class="tile" class="tile"
:class="[tileClasses(tile), { selected: selectedTile && selectedTile.id === tile.id }]" :class="[tileClasses(tile), { selected: selectedTile && selectedTile.id === tile.id }]"
:style="{ 'grid-column': tile.x + 1, 'grid-row': tile.y + 1 }"
@click="selectTile(tile)" @click="selectTile(tile)"
> >
<span class="tile-content">{{ getTileEmoji(tile) }}</span> <span class="tile-content">{{ getTileEmoji(tile) }}</span>
@ -119,8 +123,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useAuth } from '~/composables/useAuth'; // Import useAuth
import { useVillageHelpers } from '~/composables/useVillageHelpers';
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers(); const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser

BIN
assets/bugs/Untitled.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -4,8 +4,37 @@ export default defineNuxtRouteMiddleware(async (to) => {
const { visitCalled } = useVisitTracker(); const { visitCalled } = useVisitTracker();
const api = useApi(); const api = useApi();
// Do not run middleware until auth state is initialized on client-side // Helper function to wait for auth initialization, with a timeout.
if (!initialized.value) { const waitForAuth = () => {
return new Promise((resolve) => {
// If already initialized, resolve immediately.
if (initialized.value) {
return resolve(true);
}
// Set a timeout to prevent waiting indefinitely
const timeout = setTimeout(() => {
console.warn('[Auth Middleware] Waited 5 seconds for auth, but it did not initialize. Proceeding anyway.');
unwatch();
resolve(false);
}, 5000);
// Watch for the initialized value to change to true.
const unwatch = watch(initialized, (newValue) => {
if (newValue) {
clearTimeout(timeout);
unwatch();
resolve(true);
}
});
});
};
// Only run the waiting logic on the client-side
if (process.client) {
await waitForAuth();
} else if (!initialized.value) {
// On the server, if not initialized, we cannot wait.
return; return;
} }
@ -14,6 +43,7 @@ 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.');
const updatedUser = await api('/api/user/visit', { method: 'POST' }); const updatedUser = await api('/api/user/visit', { method: 'POST' });
if (updatedUser) { if (updatedUser) {
updateUser(updatedUser); updateUser(updatedUser);

View File

@ -1,4 +1,12 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import { resolve } from 'path';
export default defineNuxtConfig({ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
alias: {
'~': resolve(__dirname, './'), // Root directory
'~/': resolve(__dirname, './'), // Root directory
'@': resolve(__dirname, './app'), // Source directory (app)
'@/': resolve(__dirname, './app'), // Source directory (app)
}
}) })

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN DEFAULT true;

View File

@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anonymousSessionId" TEXT,
"email" TEXT,
"password" TEXT,
"nickname" TEXT,
"avatar" TEXT DEFAULT '/avatars/default.png',
"isAnonymous" BOOLEAN DEFAULT true,
"coins" INTEGER NOT NULL DEFAULT 0,
"exp" INTEGER NOT NULL DEFAULT 0,
"dailyStreak" INTEGER NOT NULL DEFAULT 0,
"soundOn" BOOLEAN NOT NULL DEFAULT true,
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_anonymousSessionId_key" ON "User"("anonymousSessionId");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "VillageObject" ADD COLUMN "lastExpDay" TEXT;
UPDATE "VillageObject" SET "lastExpDay" = strftime('%Y-%m-%d', "lastExpAt") WHERE "lastExpAt" IS NOT NULL;
-- AlterTable
ALTER TABLE "VillageTile" ADD COLUMN "clearingStartedDay" TEXT;
UPDATE "VillageTile" SET "clearingStartedDay" = strftime('%Y-%m-%d', "clearingStartedAt") WHERE "clearingStartedAt" IS NOT NULL;

View File

@ -0,0 +1,42 @@
/*
Warnings:
- You are about to drop the column `lastExpAt` on the `VillageObject` table. All the data in the column will be lost.
- You are about to drop the column `clearingStartedAt` on the `VillageTile` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_VillageObject" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"type" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastExpDay" TEXT,
"cropType" TEXT,
"plantedAt" DATETIME,
"villageId" INTEGER NOT NULL,
"tileId" INTEGER NOT NULL,
CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "VillageObject_tileId_fkey" FOREIGN KEY ("tileId") REFERENCES "VillageTile" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_VillageObject" ("createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId") SELECT "createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId" FROM "VillageObject";
DROP TABLE "VillageObject";
ALTER TABLE "new_VillageObject" RENAME TO "VillageObject";
CREATE UNIQUE INDEX "VillageObject_tileId_key" ON "VillageObject"("tileId");
CREATE TABLE "new_VillageTile" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"x" INTEGER NOT NULL,
"y" INTEGER NOT NULL,
"terrainType" TEXT NOT NULL,
"terrainState" TEXT NOT NULL DEFAULT 'IDLE',
"clearingStartedDay" TEXT,
"villageId" INTEGER NOT NULL,
CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_VillageTile" ("clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y") SELECT "clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y" FROM "VillageTile";
DROP TABLE "VillageTile";
ALTER TABLE "new_VillageTile" RENAME TO "VillageTile";
CREATE UNIQUE INDEX "VillageTile_villageId_x_y_key" ON "VillageTile"("villageId", "x", "y");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -40,10 +40,13 @@ enum CropType {
// settings, and in-game resources like coins and experience points. // settings, and in-game resources like coins and experience points.
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique anonymousSessionId String? @unique
password String
email String? @unique
password String?
nickname String? nickname String?
avatar String? @default("/avatars/default.png") avatar String? @default("/avatars/default.png")
isAnonymous Boolean? @default(true)
coins Int @default(0) coins Int @default(0)
exp Int @default(0) exp Int @default(0)
@ -122,7 +125,7 @@ model VillageObject {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type VillageObjectType type VillageObjectType
createdAt DateTime @default(now()) createdAt DateTime @default(now())
lastExpAt DateTime? lastExpDay String?
// Crop details (only if type is FIELD) // Crop details (only if type is FIELD)
cropType CropType? cropType CropType?
@ -141,7 +144,7 @@ model VillageTile {
y Int y Int
terrainType TerrainType terrainType TerrainType
terrainState TerrainState @default(IDLE) terrainState TerrainState @default(IDLE)
clearingStartedAt DateTime? clearingStartedDay String?
// Relations // Relations
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)

View File

@ -0,0 +1,34 @@
import { cleanupAnonymousUsers } from '~/server/tasks/cleanup';
/**
* API endpoint to trigger the cleanup of old anonymous users.
* This endpoint is protected by a secret key passed in the 'x-cleanup-secret' header.
*/
export default defineEventHandler(async (event) => {
// Read the secret from the request header.
const secret = getRequestHeader(event, 'x-cleanup-secret');
// Ensure the secret is present and matches the one in environment variables.
if (!process.env.CLEANUP_SECRET || secret !== process.env.CLEANUP_SECRET) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized. Invalid or missing cleanup secret.'
});
}
console.log('[API] Cleanup task triggered.');
try {
const result = await cleanupAnonymousUsers();
return {
success: true,
message: `Cleanup successful. Deleted ${result.count} anonymous users.`
};
} catch (error) {
console.error('[API] Error during cleanup task:', error);
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error. Failed to execute cleanup task.'
});
}
});

View File

@ -1,22 +1,24 @@
import { getUserIdFromSession } from '../../../utils/auth'; import { getAuthenticatedUserId } from '../../../utils/auth';
import prisma from "../../../utils/prisma";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
// Simple admin check // Simple admin check
if (userId !== 1) { if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
} }
const user = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
coins: { coins: {
increment: 1000, increment: 1000
}, }
}, }
}); });
return { success: true, message: `Added 1000 coins. New balance: ${user.coins}` }; return {
message: 'Added 1000 coins.',
newBalance: updatedUser.coins,
};
}); });

View File

@ -1,12 +1,12 @@
// server/api/admin/village/reset.post.ts // server/api/admin/village/reset.post.ts
import { getUserIdFromSession } from '../../../utils/auth'; import { getAuthenticatedUserId } from '../../../utils/auth';
import { generateVillageForUser } from '../../../services/villageService'; import { generateVillageForUser } from '../../../services/villageService';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
// Simple admin check // Simple admin check
if (userId !== 1) { if (userId !== 1) {

View File

@ -1,49 +1,51 @@
// server/api/admin/village/trigger-tick.post.ts // server/api/admin/village/trigger-tick.post.ts
import { getUserIdFromSession } from '../../../utils/auth'; import { getAuthenticatedUserId } from '../../../utils/auth';
import { getPreviousDay } from '../../../utils/gameDay';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// This is a simplified constant. In a real scenario, this might be shared from a single source. /**
const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours * Admin endpoint to manually trigger the game logic tick.
* This is useful for testing time-based mechanics without waiting.
* It returns the full, updated village state.
*/
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
// Simple admin check // Simple admin check
if (userId !== 1) { if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
} }
const previousDay = getPreviousDay();
const village = await prisma.village.findUniqueOrThrow({ where: { userId } }); const [fieldResult, tileResult] = await prisma.$transaction([
// 1. Update lastExpDay for all FIELD objects for this user's village
const now = Date.now();
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS - 1000); // 1 second safely in the past
await prisma.$transaction([
// 1. Fast-forward any tiles that are currently being cleared
prisma.villageTile.updateMany({
where: {
villageId: village.id,
terrainState: 'CLEARING',
},
data: {
clearingStartedAt: clearingFastForwardDate,
},
}),
// 2. Fast-forward any fields to be ready for EXP gain
prisma.villageObject.updateMany({ prisma.villageObject.updateMany({
where: { where: {
villageId: village.id, village: { userId: userId },
type: 'FIELD', type: 'FIELD',
}, },
data: { data: {
lastExpAt: yesterday, lastExpDay: previousDay,
},
}),
// 2. Update clearingStartedDay for all CLEANING VillageTile objects for this user's village
prisma.villageTile.updateMany({
where: {
village: { userId: userId },
terrainState: 'CLEARING',
},
data: {
clearingStartedDay: previousDay,
}, },
}), }),
]); ]);
return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' }; return {
success: true,
message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}.`
};
}); });

View File

@ -1,26 +1,16 @@
import { getUserIdFromSession } from '../../utils/auth';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// 1. Get user ID from session; this helper handles the 401 check. const user = event.context.user;
const userId = await getUserIdFromSession(event);
// 2. Fetch the full user from the database // The auth middleware has already populated event.context.user.
const user = await prisma.user.findUnique({ // We just need to verify it's a permanent user (has an email).
where: { id: userId }, if (!user || !user.email) {
});
if (!user) {
// This case might happen if the user was deleted but the session still exists.
// The helper can't handle this, so we clear the session here.
const session = await useSession(event, { password: process.env.SESSION_PASSWORD });
await session.clear();
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Unauthorized: User not found.', statusMessage: 'Unauthorized: No active session.',
}); });
} }
// 3. Return user data DTO // Return the user data DTO, which is already available on the context.
return { return {
user: { user: {
id: user.id, id: user.id,

View File

@ -1,56 +1,89 @@
import { hashPassword } from '../../utils/password'; import { hashPassword } from '~/server/utils/password';
import { generateVillageForUser } from '../../services/villageService'; import { generateVillageForUser } from '~/server/services/villageService';
import { useSession } from 'h3';
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
/**
* Handles user registration.
*
* This endpoint has two main flows:
* 1. Anonymous User Conversion: If an anonymous session cookie is present,
* it finds the anonymous user, updates their details, makes them permanent,
* and logs them in, preserving their progress.
* 2. Standard Registration: If no anonymous session is found, it creates a
* brand new user and a new village for them.
*
* In both cases, it automatically logs the user in upon successful registration.
*/
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event); const { email, password, nickname } = await readBody(event);
const { email, password, nickname } = body;
// 1. Validate input // --- 1. Input Validation ---
if (!email || !password) { if (!email || !password) {
throw createError({ throw createError({ statusCode: 400, statusMessage: 'Email and password are required' });
statusCode: 400,
statusMessage: 'Email and password are required',
});
} }
if (password.length < 8) { if (password.length < 8) {
throw createError({ throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' });
statusCode: 400,
statusMessage: 'Password must be at least 8 characters long',
});
} }
const normalizedEmail = email.toLowerCase(); // Normalize email const normalizedEmail = email.toLowerCase();
// 2. Check if user already exists // Check if email is already in use by a permanent account
const existingUser = await prisma.user.findUnique({ const existingPermanentUser = await prisma.user.findFirst({
where: { email: normalizedEmail }, where: { email: normalizedEmail, isAnonymous: false },
}); });
if (existingUser) { if (existingPermanentUser) {
throw createError({ throw createError({ statusCode: 409, statusMessage: 'Email already in use' });
statusCode: 409, // Conflict
statusMessage: 'Email already in use',
});
} }
// 3. Hash password and create user
// WARNING: This hashPassword is a mock. Replace with a secure library like bcrypt before production.
const hashedPassword = await hashPassword(password); const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({ let user;
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
},
});
// 4. Generate the user's village // --- 2. Identify User Flow (Anonymous Conversion vs. Standard) ---
await generateVillageForUser(user); const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
const anonymousUser = anonymousSessionId
? await prisma.user.findUnique({ where: { anonymousSessionId } })
: null;
// NOTE: Registration does not automatically log in the user. if (anonymousUser) {
// The user needs to explicitly call the login endpoint after registration. // --- Flow A: Convert Anonymous User ---
user = await prisma.user.update({
where: { id: anonymousUser.id },
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
isAnonymous: false, // Make the user permanent
anonymousSessionId: null, // Invalidate the anonymous session ID
},
});
// The village and progress are already associated with this user.
// 5. Return the new user, excluding sensitive fields and shortening DTO // Invalidate the anonymous cookie
setCookie(event, ANONYMOUS_COOKIE_NAME, '', { maxAge: -1 });
} else {
// --- Flow B: Create New User ---
user = await prisma.user.create({
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
isAnonymous: false,
},
});
// Generate a new village for the brand new user
await generateVillageForUser(user);
}
// --- 3. Automatically log the user in ---
const session = await useSession(event, { password: process.env.SESSION_PASSWORD! });
await session.update({ user: { id: user.id } });
// --- 4. Return DTO ---
return { return {
user: { user: {
id: user.id, id: user.id,
@ -58,4 +91,4 @@ export default defineEventHandler(async (event) => {
nickname: user.nickname, nickname: user.nickname,
} }
}; };
}); });

View File

@ -1,4 +1,4 @@
import { getUserIdFromSession } from '../../../utils/auth'; 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';
@ -21,7 +21,7 @@ function getStartOfDay(date: Date): Date {
} }
export default defineEventHandler(async (event): Promise<CompletionResponse> => { export default defineEventHandler(async (event): Promise<CompletionResponse> => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const habitId = parseInt(event.context.params?.id || '', 10); const habitId = parseInt(event.context.params?.id || '', 10);
if (isNaN(habitId)) { if (isNaN(habitId)) {
@ -47,8 +47,12 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6) // Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1; const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) { // For permanent users, ensure the habit is scheduled for today.
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' }); // Anonymous users in the onboarding flow can complete it on any day.
if (!user.isAnonymous) {
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
}
} }
const startOfToday = getStartOfDay(today); const startOfToday = getStartOfDay(today);
@ -64,9 +68,16 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' }); throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
} }
// Apply the streak multiplier to the base reward // Determine the reward based on user type
const baseReward = REWARDS.HABITS.COMPLETION; let finalReward: { coins: number, exp: number };
const finalReward = applyStreakMultiplier(baseReward, user.dailyStreak); if (user.isAnonymous) {
// Anonymous users in onboarding get a fixed reward from economy.ts
finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION;
} else {
// Permanent users get rewards based on streak
const baseReward = REWARDS.HABITS.COMPLETION;
finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
}
const village = await prisma.village.findUnique({ where: { userId } }); const village = await prisma.village.findUnique({ where: { userId } });

View File

@ -1,8 +1,8 @@
import { getUserIdFromSession } from '../../../utils/auth'; import { getAuthenticatedUserId } from '../../../utils/auth';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const habitId = Number(event.context.params?.id); const habitId = parseInt(event.context.params?.id || '', 10);
if (isNaN(habitId)) { if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' }); throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });

View File

@ -1,4 +1,4 @@
import { getUserIdFromSession } from '../../../utils/auth'; import { getAuthenticatedUserId } from '../../../utils/auth';
interface HabitDto { interface HabitDto {
id: number; id: number;
@ -7,7 +7,7 @@ interface HabitDto {
} }
export default defineEventHandler(async (event): Promise<HabitDto> => { export default defineEventHandler(async (event): Promise<HabitDto> => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const habitId = Number(event.context.params?.id); const habitId = Number(event.context.params?.id);
const { name, daysOfWeek } = await readBody(event); const { name, daysOfWeek } = await readBody(event);

View File

@ -1,4 +1,4 @@
import { getUserIdFromSession } from '../../utils/auth'; import { getAuthenticatedUserId } from '../../utils/auth';
import { Habit } from '@prisma/client'; import { Habit } from '@prisma/client';
// DTO to shape the output // DTO to shape the output
@ -15,7 +15,7 @@ interface HabitDto {
} }
export default defineEventHandler(async (event): Promise<HabitDto[]> => { export default defineEventHandler(async (event): Promise<HabitDto[]> => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const habits = await prisma.habit.findMany({ const habits = await prisma.habit.findMany({
where: { where: {

View File

@ -1,4 +1,4 @@
import { getUserIdFromSession } from '../../utils/auth'; import { getAuthenticatedUserId } from '../../utils/auth';
interface HabitDto { interface HabitDto {
id: number; id: number;
@ -7,7 +7,7 @@ interface HabitDto {
} }
export default defineEventHandler(async (event): Promise<HabitDto> => { export default defineEventHandler(async (event): Promise<HabitDto> => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const { name, daysOfWeek } = await readBody(event); const { name, daysOfWeek } = await readBody(event);
// --- Validation --- // --- Validation ---

View File

@ -0,0 +1,85 @@
import { getAuthenticatedUserId } from '~/server/utils/auth';
import prisma from '~/server/utils/prisma'; // Ensure prisma is imported
const ONBOARDING_REWARD_COINS = 75;
interface OnboardingCompletionResponse {
message: string;
updatedCoins: 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;
}
/**
* A special endpoint for the onboarding funnel to complete a habit.
* This provides a fixed reward and has simpler logic than the main completion endpoint.
* ONLY callable by anonymous users.
*/
export default defineEventHandler(async (event): Promise<OnboardingCompletionResponse> => {
const userId = getAuthenticatedUserId(event);
const { habitId } = await readBody(event);
// Fetch the full user to check their anonymous status
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isAnonymous: true }
});
// Ensure only anonymous users can use this endpoint
if (!user || !user.isAnonymous) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden: This endpoint is for anonymous onboarding users only.' });
}
if (!habitId || typeof habitId !== 'number') {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
const habit = await prisma.habit.findFirst({
where: { id: habitId, userId: userId }
});
if (!habit) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found for this user.' });
}
const startOfToday = getStartOfDay(new Date());
const existingCompletion = await prisma.habitCompletion.findFirst({
where: {
habitId: habitId,
date: startOfToday,
},
});
if (existingCompletion) {
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
}
// Use a transaction to ensure both operations succeed or fail together
const [, updatedUser] = await prisma.$transaction([
prisma.habitCompletion.create({
data: {
habitId: habitId,
date: startOfToday,
},
}),
prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: ONBOARDING_REWARD_COINS,
},
},
}),
]);
return {
message: 'Onboarding habit completed successfully!',
updatedCoins: updatedUser.coins,
};
});

View File

@ -0,0 +1,69 @@
import { randomUUID } from 'crypto';
import prisma from '~/server/utils/prisma';
import { generateVillageForUser } from '~/server/services/villageService';
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
/**
* Initiates an anonymous user session.
* 1. Checks for an existing session cookie.
* 2. If found, verifies it corresponds to an existing anonymous user.
* 3. If not found or invalid, creates a new anonymous user and a corresponding village.
* 4. Sets the session ID in a long-lived cookie.
* 5. Returns the session ID and basic user info.
*/
export default defineEventHandler(async (event) => {
const existingSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
if (existingSessionId) {
const user = await prisma.user.findUnique({
where: {
anonymousSessionId: existingSessionId,
isAnonymous: true
},
select: {
id: true, // Also return ID
anonymousSessionId: true,
nickname: true,
coins: true,
exp: true,
isAnonymous: true // Ensure this flag is returned
}
});
// If a valid anonymous user is found for this session, return it.
if (user) {
return user;
}
}
// No valid session found, create a new anonymous user.
const newSessionId = randomUUID();
const newUser = await prisma.user.create({
data: {
isAnonymous: true,
anonymousSessionId: newSessionId,
},
select: {
id: true, // Also return ID
anonymousSessionId: true,
nickname: true,
coins: true,
exp: true,
isAnonymous: true // Ensure this flag is returned
}
});
// Now, generate the village with default tiles for the new anonymous user
await generateVillageForUser(newUser);
// Set a long-lived cookie (e.g., 1 year)
setCookie(event, ANONYMOUS_COOKIE_NAME, newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60 // 1 year in seconds
});
return newUser;
});

View File

@ -1,14 +1,17 @@
import { getUserIdFromSession } from '../../utils/auth'; import { getAuthenticatedUserId } from '../../utils/auth';
import { calculateDailyStreak } from '../../utils/streak';
import prisma from '../../utils/prisma'; import prisma from '../../utils/prisma';
import { calculateDailyStreak } from '../../utils/streak';
// Helper to get the start of the day in UTC
function getStartOfDay(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}
/**
* Registers a user's daily visit and calculates their new streak.
* This endpoint is idempotent. Calling it multiple times on the same day
* will not increment the streak further.
*/
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); console.log('[Visit API] Received request');
const userId = getAuthenticatedUserId(event);
// 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);

View File

@ -1,30 +1,40 @@
// server/api/village/action.post.ts import { getAuthenticatedUserId } from '../../utils/auth';
import { getUserIdFromSession } from '../../utils/auth'; import {
import { buildOnTile } from '../../services/villageService'; buildOnTile,
import { getVillageState } from '../../services/villageService'; syncAndGetVillage,
} from '../../services/villageService';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const body = await readBody(event); const body = await readBody(event);
const { tileId, actionType, payload } = body; const { tileId, actionType, payload } = body;
if (!tileId || !actionType) { if (!tileId || !actionType) {
throw createError({ statusCode: 400, statusMessage: 'Missing tileId or actionType' }); throw createError({
statusCode: 400,
statusMessage: 'Missing tileId or actionType',
});
} }
switch (actionType) { switch (actionType) {
case 'BUILD': case 'BUILD':
if (!payload?.buildingType) { if (!payload?.buildingType) {
throw createError({ statusCode: 400, statusMessage: 'Missing buildingType for BUILD action' }); throw createError({
statusCode: 400,
statusMessage: 'Missing buildingType',
});
} }
await buildOnTile(userId, tileId, payload.buildingType); await buildOnTile(userId, tileId, payload.buildingType);
break; break;
default: default:
throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' }); throw createError({
statusCode: 400,
statusMessage: 'Invalid actionType',
});
} }
// Return the full updated village state return syncAndGetVillage(userId);
return getVillageState(userId);
}); });

View File

@ -1,11 +1,8 @@
// server/api/village/events.get.ts // server/api/village/events.get.ts
import { getUserIdFromSession } from '../../utils/auth'; import { getAuthenticatedUserId } from '../../utils/auth';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event); const userId = getAuthenticatedUserId(event);
const village = await prisma.village.findUnique({ const village = await prisma.village.findUnique({
where: { userId }, where: { userId },

View File

@ -1,5 +1,5 @@
// server/api/village/index.get.ts // server/api/village/index.get.ts
import { getVillageState, generateVillageForUser } from '../../services/villageService'; import { syncAndGetVillage, generateVillageForUser } from '../../services/villageService';
import { defineEventHandler } from 'h3'; import { defineEventHandler } from 'h3';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
await generateVillageForUser(user); await generateVillageForUser(user);
try { try {
const villageState = await getVillageState(user.id); const villageState = await syncAndGetVillage(user.id);
return villageState; return villageState;
} catch (error: any) { } catch (error: any) {
// Catch errors from the service and re-throw them as H3 errors // Catch errors from the service and re-throw them as H3 errors

View File

@ -1,13 +1,18 @@
// 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';
/** /**
* Global server middleware to populate `event.context.user` for every incoming request. * Global server middleware to populate `event.context.user` for every incoming request.
* *
* It safely checks for a session and fetches the user from the database if a * It first checks for a logged-in user session. If not found, it checks for an
* valid session ID is found. It does NOT block requests or throw errors if the * anonymous user session cookie. It attaches the corresponding user object to
* user is not authenticated, as authorization is handled within API endpoints themselves. * `event.context.user` if found. It does NOT block requests, allowing auth
* checks to be handled by individual endpoints.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// This middleware should not run on static assets or internal requests. // This middleware should not run on static assets or internal requests.
@ -16,27 +21,60 @@ export default defineEventHandler(async (event) => {
return; return;
} }
// Safely get the session // 1. Check for a logged-in user session
const session = await useSession(event, { const session = await useSession(event, {
password: process.env.SESSION_PASSWORD!, password: process.env.SESSION_PASSWORD!,
}); });
const userId = session.data?.user?.id; const userId = session.data?.user?.id;
// If a userId is found in the session, fetch the user and attach it to the context.
if (userId) { if (userId) {
try { try {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId }, // Find any user by their ID first
}); });
if (user) { // A permanent user is identified by having an email address.
if (user && user.email) {
event.context.user = user; event.context.user = user;
// Track daily visit and streak
const today = getTodayDay();
const lastVisitDay = session.data.lastVisitDay as string | undefined;
if (isBeforeDay(lastVisitDay, today)) {
// It's a new day, calculate streak and record visit
const updatedUser = await calculateDailyStreak(prisma, user.id);
event.context.user = updatedUser; // Ensure context has the latest user data
// Update the session to prevent re-running this today
await session.update({
...session.data,
lastVisitDay: today,
});
}
return; // Found a logged-in user, no need to check for anonymous session
} }
} catch (error) { } catch (error) {
// If there's an error fetching the user (e.g., DB connection issue), console.error('Error in auth middleware:', error);
// we log it but don't block the request. The user will be treated as unauthenticated. }
console.error('Error fetching user in auth middleware:', error); }
// 2. If no logged-in user, check for an anonymous session
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
if (anonymousSessionId) {
try {
const anonymousUser = await prisma.user.findUnique({
where: { anonymousSessionId: anonymousSessionId, isAnonymous: true },
});
if (anonymousUser) {
event.context.user = anonymousUser;
}
} catch (error) {
console.error('Error fetching anonymous user in auth middleware:', error);
} }
} }
}); });

View File

@ -0,0 +1,387 @@
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
import { COSTS, REWARDS } from '../utils/economy';
const prisma = new PrismaClient();
export const VILLAGE_WIDTH = 5;
export const VILLAGE_HEIGHT = 7;
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
export const PRODUCING_BUILDINGS: string[] = [
'FIELD',
'LUMBERJACK',
'QUARRY',
];
// Helper to get the start of a given date for daily EXP checks
const getStartOfDay = (date: Date) => {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency
return d;
};
/**
* Generates the initial village for a new user atomically.
*/
export async function generateVillageForUser(user: User) {
const existingVillage = await prisma.village.findUnique({
where: { userId: user.id },
});
if (existingVillage) {
return;
}
const tilesToCreate: Omit<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
const central_x_start = 1;
const central_x_end = 4;
const central_y_start = 2;
const central_y_end = 5;
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
for (let x = 0; x < VILLAGE_WIDTH; x++) {
const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end;
const terrainType = isCentral
? 'EMPTY'
: Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE';
tilesToCreate.push({
x,
y,
terrainType,
terrainState: 'IDLE',
});
}
}
// FIX: Wrap village generation in a single transaction for atomicity.
await prisma.$transaction(async (tx) => {
const village = await tx.village.create({
data: {
userId: user.id,
},
});
await tx.user.update({
where: { id: user.id },
data: {
coins: 10,
exp: 0,
},
});
await tx.villageTile.createMany({
data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })),
});
});
}
type FullVillage = Prisma.VillageGetPayload<{
include: {
user: true;
tiles: { include: { object: true } };
objects: { include: { tile: true } };
};
}>;
/**
* Gets the full, updated state of a user's village, calculating all time-based progression.
*/
export async function getVillageState(userId: number): Promise<FullVillage> {
const now = new Date();
const { applyStreakMultiplier } = await import('../utils/streak');
const today = getStartOfDay(now);
// --- Step 1: Initial Snapshot ---
let villageSnapshot = await prisma.village.findUnique({
where: { userId },
include: {
user: true,
tiles: { include: { object: true } },
objects: { include: { tile: true } },
},
});
if (!villageSnapshot) {
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
const userForStreak = villageSnapshot.user;
// --- Step 2: Terrain Cleaning Completion ---
const finishedClearingTiles = villageSnapshot.tiles.filter(
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && getStartOfDay(t.clearingStartedAt) < today
);
if (finishedClearingTiles.length > 0) {
// Apply streak multiplier to clearing rewards
const baseClearingReward = REWARDS.VILLAGE.CLEARING;
const finalClearingReward = applyStreakMultiplier(baseClearingReward, userForStreak.dailyStreak);
const totalCoins = finishedClearingTiles.length * finalClearingReward.coins;
const totalExp = finishedClearingTiles.length * finalClearingReward.exp;
await prisma.$transaction(async (tx) => {
// 1. Update user totals
await tx.user.update({
where: { id: userId },
data: {
coins: { increment: totalCoins },
exp: { increment: totalExp },
},
});
// 2. Update all the tiles
await tx.villageTile.updateMany({
where: { id: { in: finishedClearingTiles.map(t => t.id) } },
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
});
// 3. Create an event for each completed tile with the final reward
const multiplier = userForStreak.dailyStreak;
let streakBonusText = '';
if (multiplier === 2) {
streakBonusText = ' Ваша серия визитов (x2) удвоила награду!';
} else if (multiplier >= 3) {
streakBonusText = ' Ваша серия визитов (x3) утроила награду!';
}
for (const tile of finishedClearingTiles) {
const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень';
const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун';
await tx.villageEvent.create({
data: {
villageId: villageSnapshot.id,
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`,
tileX: tile.x,
tileY: tile.y,
coins: finalClearingReward.coins,
exp: finalClearingReward.exp,
}
});
}
});
}
// --- Step 3: Refetch for next logic step ---
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
const fieldsForExp = villageSnapshot.objects.filter(
obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today)
);
if (fieldsForExp.length > 0) {
const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`));
let totalExpFromFields = 0;
const eventsToCreate = [];
for (const field of fieldsForExp) {
// First, calculate base EXP with existing game logic (well bonus)
let baseFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
if (wellPositions.has(`${field.tile.x},${field.tile.y - 1}`) || wellPositions.has(`${field.tile.x},${field.tile.y + 1}`) || wellPositions.has(`${field.tile.x - 1},${field.tile.y}`) || wellPositions.has(`${field.tile.x + 1},${field.tile.y}`)) {
baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
}
// Now, apply the daily streak multiplier
const finalFieldExp = applyStreakMultiplier({ coins: 0, exp: baseFieldExp }, userForStreak.dailyStreak).exp;
totalExpFromFields += finalFieldExp;
const multiplier = userForStreak.dailyStreak;
let streakBonusText = '';
if (multiplier === 2) {
streakBonusText = ' Ваша серия визитов (x2) удвоила урожай опыта!';
} else if (multiplier >= 3) {
streakBonusText = ' Ваша серия визитов (x3) утроила урожай опыта!';
}
eventsToCreate.push({
villageId: villageSnapshot.id,
type: 'FIELD_EXP',
message: `Поле (${field.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`,
tileX: field.tile.x,
tileY: field.tile.y,
coins: 0,
exp: finalFieldExp,
});
}
await prisma.$transaction([
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
prisma.villageEvent.createMany({ data: eventsToCreate }),
]);
}
// --- Step 5: Refetch for next logic step ---
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
// --- Step 6: Auto-start Terrain Cleaning ---
const lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length;
const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length;
const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length;
const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length;
const freeLumberjacks = lumberjackCount - clearingTreesCount;
const freeQuarries = quarryCount - clearingStonesCount;
const tileIdsToClear = new Set<number>();
if (freeLumberjacks > 0) {
const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
if (idleTrees.length > 0) {
// For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here.
idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id));
}
}
if (freeQuarries > 0) {
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
if (idleStones.length > 0) {
// For simplicity, just take the first N available stones.
idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id));
}
}
if (tileIdsToClear.size > 0) {
await prisma.villageTile.updateMany({
where: { id: { in: Array.from(tileIdsToClear) } },
data: { terrainState: 'CLEARING', clearingStartedAt: getStartOfDay(now) },
});
// Refetch state after starting new clearings
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
}
// --- Step 7: Final Fetch & Action Enrichment ---
const finalVillageState = await prisma.village.findUnique({
where: { userId },
include: {
user: true,
tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] },
objects: { include: { tile: true } },
},
});
if (!finalVillageState) {
throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' });
}
// --- Step 8: Enrich tiles with available actions ---
const { user } = finalVillageState;
const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK');
const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY');
const housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length;
const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
const freeWorkers = housesCount - producingCount;
const tilesWithActions = finalVillageState.tiles.map(tile => {
const availableActions: any[] = [];
// Action: BUILD
if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = COSTS.BUILD[buildingType];
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
let isEnabled = user.coins >= cost;
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
if (isEnabled && isProducing && freeWorkers <= 0) {
isEnabled = false;
disabledReason = 'Not enough workers';
}
return {
type: 'BUILD',
buildingType,
cost,
isEnabled,
disabledReason,
};
});
availableActions.push(...buildActions);
}
if (tile.object) {
// MOVE and REMOVE actions have been removed as per the refactor request.
}
return { ...tile, availableActions };
});
return { ...finalVillageState, tiles: tilesWithActions } as any;
}
// --- Action Service Functions ---
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
const { VillageObjectType } = await import('@prisma/client');
const validBuildingTypes = Object.keys(VillageObjectType);
if (!validBuildingTypes.includes(buildingType)) {
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` });
}
return prisma.$transaction(async (tx) => {
// 1. Fetch all necessary data
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
// Ownership check
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
}
// Business logic validation
if (tile.terrainType !== 'EMPTY' || tile.object) {
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
}
const cost = COSTS.BUILD[buildingType];
if (user.coins < cost) {
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
}
if (PRODUCING_BUILDINGS.includes(buildingType)) {
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } });
const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length;
const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
if (producingCount >= housesCount) {
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' });
}
}
// 2. Perform mutations
await tx.user.update({
where: { id: userId },
data: { coins: { decrement: cost } },
});
await tx.villageObject.create({
data: {
type: buildingType as keyof typeof VillageObjectType,
villageId: tile.villageId,
tileId: tileId,
},
});
await tx.villageEvent.create({
data: {
villageId: tile.villageId,
type: `BUILD_${buildingType}`,
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: -cost,
exp: 0,
},
});
});
}

View File

@ -1,80 +1,32 @@
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client'; import {
PrismaClient,
User,
Prisma,
VillageObjectType,
VillageTile,
} from '@prisma/client';
import { COSTS, REWARDS } from '../utils/economy'; import { COSTS, REWARDS } from '../utils/economy';
import { applyStreakMultiplier } from '../utils/streak';
import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/* =========================
CONSTANTS
========================= */
export const VILLAGE_WIDTH = 5; export const VILLAGE_WIDTH = 5;
export const VILLAGE_HEIGHT = 7; export const VILLAGE_HEIGHT = 7;
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
export const PRODUCING_BUILDINGS: string[] = [ export const PRODUCING_BUILDINGS = [
'FIELD', 'FIELD',
'LUMBERJACK', 'LUMBERJACK',
'QUARRY', 'QUARRY',
]; ] as const;
// Helper to get the start of a given date for daily EXP checks /* =========================
const getStartOfDay = (date: Date) => { TYPES
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<{ type FullVillage = Prisma.VillageGetPayload<{
include: { include: {
@ -84,279 +36,209 @@ type FullVillage = Prisma.VillageGetPayload<{
}; };
}>; }>;
/* =========================
PUBLIC API
========================= */
/** /**
* Gets the full, updated state of a user's village, calculating all time-based progression. * Главная точка входа.
* Синхронизирует day-based прогресс и возвращает актуальное состояние деревни.
*/ */
export async function getVillageState(userId: number): Promise<FullVillage> { export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
const now = new Date(); try {
const { applyStreakMultiplier } = await import('../utils/streak'); const today = getTodayDay();
// --- 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) { let villageSnapshot = await fetchVillage(userId);
throw createError({ statusCode: 404, statusMessage: 'Village not found' }); if (!villageSnapshot) {
} throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
const userForStreak = villageSnapshot.user; const user = villageSnapshot.user;
// --- Step 2: Terrain Cleaning Completion ---
const finishedClearingTiles = villageSnapshot.tiles.filter(
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
);
if (finishedClearingTiles.length > 0) {
// 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) => { await prisma.$transaction(async (tx) => {
// 1. Update user totals await processFinishedClearing(tx, villageSnapshot, today);
await tx.user.update({ await processFieldExp(tx, villageSnapshot, today);
where: { id: userId }, await autoStartClearing(tx, villageSnapshot, today);
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 } } } })!;
// --- Step 4: Field EXP Processing --- // Re-fetch the village state after the transaction to get the latest data including new objects, etc.
const today = getStartOfDay(now); villageSnapshot = await fetchVillage(userId);
const fieldsForExp = villageSnapshot.objects.filter( if (!villageSnapshot) {
obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today) throw createError({ statusCode: 404, statusMessage: 'Village not found post-transaction' });
); }
if (fieldsForExp.length > 0) { // --- Enrich tiles with available actions (Step 8 from old getVillageState) ---
const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`)); const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
let totalExpFromFields = 0; const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
const eventsToCreate = []; const freeWorkers = housesCount - producingCount;
for (const field of fieldsForExp) { const tilesWithActions = villageSnapshot.tiles.map(tile => {
// First, calculate base EXP with existing game logic (well bonus) const availableActions: any[] = [];
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}`)) { // Action: BUILD
baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes: VillageObjectType[] = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = COSTS.BUILD[buildingType];
const isProducing = (PRODUCING_BUILDINGS as readonly string[]).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);
} }
// Now, apply the daily streak multiplier return { ...tile, availableActions };
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: now },
}); });
// Refetch state after starting new clearings return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!; } catch (error) {
console.error('Error in syncAndGetVillage:', error);
throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' });
} }
// --- 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 generateVillageForUser(user: User) {
// Enforce immutability of the village layout and ensure creation occurs only once.
await prisma.$transaction(async (tx) => {
// 1. Find or create Village
let village = await tx.village.findUnique({
where: { userId: user.id },
});
export async function buildOnTile(userId: number, tileId: number, buildingType: string) { let villageCreated = false;
const { VillageObjectType } = await import('@prisma/client'); if (!village) {
const validBuildingTypes = Object.keys(VillageObjectType); village = await tx.village.create({
if (!validBuildingTypes.includes(buildingType)) { data: { userId: user.id },
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` }); });
} villageCreated = true;
}
return prisma.$transaction(async (tx) => {
// 1. Fetch all necessary data // If village was just created, initialize user resources
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); if (villageCreated) {
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } }); await tx.user.update({
where: { id: user.id },
// Ownership check data: { coins: 10, exp: 0 },
if (tile.village.userId !== userId) { });
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" }); }
// 2. Count existing VillageTiles for this Village
const tilesCount = await tx.villageTile.count({
where: { villageId: village!.id }, // village is guaranteed to exist here
});
// If tiles already exist, layout is immutable. Do nothing.
if (tilesCount > 0) {
// Village layout is immutable once created.
return;
}
// 3. Create tiles ONLY if tilesCount is 0 (broken state or first creation)
// This logic ensures tiles are created exactly once.
const tilesToCreate: Omit<
VillageTile,
'id' | 'clearingStartedDay' | 'villageId'
>[] = [];
const centralXStart = 1;
const centralXEnd = 4;
const centralYStart = 2;
const centralYEnd = 5;
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
for (let x = 0; x < VILLAGE_WIDTH; x++) {
const isCentral =
x >= centralXStart &&
x < centralXEnd &&
y >= centralYStart &&
y < centralYEnd;
tilesToCreate.push({
x,
y,
terrainType: isCentral
? 'EMPTY'
: Math.random() < 0.5
? 'BLOCKED_TREE'
: 'BLOCKED_STONE',
terrainState: 'IDLE',
});
}
}
await tx.villageTile.createMany({
data: tilesToCreate.map((t) => ({
...t,
villageId: village!.id, // village is guaranteed to exist here
})),
});
});
}
/**
* BUILD command
*/
export async function buildOnTile(
userId: number,
tileId: number,
buildingType: VillageObjectType
) {
return prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
const tile = await tx.villageTile.findUniqueOrThrow({
where: { id: tileId },
include: { village: true },
});
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: 'Not your tile' });
} }
// Business logic validation
if (tile.terrainType !== 'EMPTY' || tile.object) { if (tile.terrainType !== 'EMPTY' || tile.object) {
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' }); throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
} }
const cost = COSTS.BUILD[buildingType]; const cost = COSTS.BUILD[buildingType];
if (user.coins < cost) { if (user.coins < cost) {
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' }); throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
} }
if (PRODUCING_BUILDINGS.includes(buildingType)) { if (PRODUCING_BUILDINGS.includes(buildingType as any)) {
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } }); const objects = await tx.villageObject.findMany({
const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length; where: { villageId: tile.villageId },
const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length; });
if (producingCount >= housesCount) {
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' }); const houses = objects.filter(o => o.type === 'HOUSE').length;
} const producing = objects.filter(o =>
PRODUCING_BUILDINGS.includes(o.type as any)
).length;
if (producing >= houses) {
throw createError({
statusCode: 400,
statusMessage: 'Not enough workers',
});
}
} }
// 2. Perform mutations
await tx.user.update({ await tx.user.update({
where: { id: userId }, where: { id: userId },
data: { coins: { decrement: cost } }, data: { coins: { decrement: cost } },
@ -364,24 +246,216 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
await tx.villageObject.create({ await tx.villageObject.create({
data: { data: {
type: buildingType as keyof typeof VillageObjectType, type: buildingType,
villageId: tile.villageId, villageId: tile.villageId,
tileId: tileId, tileId: tile.id,
}, },
}); });
await tx.villageEvent.create({ await tx.villageEvent.create({
data: { data: {
villageId: tile.villageId, villageId: tile.villageId,
type: `BUILD_${buildingType}`, type: `BUILD_${buildingType}`,
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`, message: `Построено ${buildingType} на (${tile.x}, ${tile.y})`,
tileX: tile.x, tileX: tile.x,
tileY: tile.y, tileY: tile.y,
coins: -cost, coins: -cost,
exp: 0, exp: 0,
}, },
}); });
}); });
}
/* =========================
INTERNAL HELPERS
========================= */
function fetchVillage(userId: number) {
return prisma.village.findUnique({
where: { userId },
include: {
user: true,
tiles: { include: { object: true } },
objects: { include: { tile: true } },
},
});
}
/* =========================
DAY-BASED LOGIC
========================= */
async function processFinishedClearing(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
) {
const finishedTiles = village.tiles.filter(
t =>
t.terrainState === 'CLEARING' &&
isBeforeDay(t.clearingStartedDay, today)
);
if (!finishedTiles.length) return;
const baseReward = REWARDS.VILLAGE.CLEARING;
const totalBaseReward = {
coins: baseReward.coins * finishedTiles.length,
exp: baseReward.exp * finishedTiles.length,
};
const finalReward = applyStreakMultiplier(totalBaseReward, village.user.dailyStreak);
await tx.user.update({
where: { id: village.user.id },
data: {
coins: { increment: finalReward.coins },
exp: { increment: finalReward.exp },
},
});
await tx.villageTile.updateMany({
where: { id: { in: finishedTiles.map(t => t.id) } },
data: {
terrainType: 'EMPTY',
terrainState: 'IDLE',
clearingStartedDay: null,
},
});
const streakMultiplier = village.user.dailyStreak > 1 ? village.user.dailyStreak : 0;
let streakBonusText = '';
if (streakMultiplier > 1) {
streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`;
}
const events = finishedTiles.map(t => {
const tileReward = applyStreakMultiplier(baseReward, village.user.dailyStreak);
return {
villageId: village.id,
type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Участок (${t.x}, ${t.y}) расчищен.` + streakBonusText,
tileX: t.x,
tileY: t.y,
coins: tileReward.coins,
exp: tileReward.exp,
};
});
await tx.villageEvent.createMany({
data: events,
});
}
async function processFieldExp(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
) {
const fieldsNeedingUpdate = village.objects.filter(
(o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today)
);
if (!fieldsNeedingUpdate.length) return;
const wells = village.objects.filter(o => o.type === 'WELL');
let totalBaseExpGained = 0;
const eventsToCreate: any[] = [];
const streakMultiplierValue = village.user.dailyStreak > 1 ? village.user.dailyStreak : 1;
let streakBonusText = '';
if (streakMultiplierValue > 1) {
streakBonusText = ` Ваша серия визитов (${streakMultiplierValue}) увеличила награду.`;
} }
for (const field of fieldsNeedingUpdate) {
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
let expGainedForField = daysMissed * REWARDS.VILLAGE.FIELD_EXP.BASE;
const isNearWell = wells.some(well =>
Math.abs(well.tile.x - field.tile.x) <= 1 &&
Math.abs(well.tile.y - field.tile.y) <= 1 &&
(well.tile.x !== field.tile.x || well.tile.y !== field.tile.y)
);
let 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++) {
eventsToCreate.push({
villageId: village.id,
type: 'FIELD_EXP',
message: `Поле (${field.tile.x}, ${field.tile.y}) принесло опыт.` + wellBonusText + streakBonusText,
tileX: field.tile.x,
tileY: field.tile.y,
coins: 0,
exp: finalExpForField.exp / daysMissed,
});
}
}
const finalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, village.user.dailyStreak);
if (totalBaseExpGained > 0) {
await tx.user.update({
where: { id: village.user.id },
data: { exp: { increment: finalExp.exp } },
});
}
await tx.villageObject.updateMany({
where: { id: { in: fieldsNeedingUpdate.map((f) => f.id) } },
data: { lastExpDay: today },
});
if (eventsToCreate.length > 0) {
await tx.villageEvent.createMany({
data: eventsToCreate,
});
}
}
async function autoStartClearing(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
) {
const lumberjacks = village.objects.filter(o => o.type === 'LUMBERJACK').length;
const quarries = village.objects.filter(o => o.type === 'QUARRY').length;
const busyTrees = village.tiles.filter(
t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING'
).length;
const busyStones = village.tiles.filter(
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
).length;
const tilesToStart = [
...village.tiles
.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE')
.slice(0, lumberjacks - busyTrees),
...village.tiles
.filter(
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'
)
.slice(0, quarries - busyStones),
];
if (!tilesToStart.length) return;
await tx.villageTile.updateMany({
where: { id: { in: tilesToStart.map(t => t.id) } },
data: {
terrainState: 'CLEARING',
clearingStartedDay: today,
},
});
}

27
server/tasks/cleanup.ts Normal file
View File

@ -0,0 +1,27 @@
import prisma from '../utils/prisma';
/**
* Deletes anonymous users that were created more than 24 hours ago.
* This is designed to be run as a scheduled task to prevent accumulation
* of stale data from users who start the onboarding but never register.
*/
export async function cleanupAnonymousUsers() {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
try {
const result = await prisma.user.deleteMany({
where: {
isAnonymous: true,
createdAt: {
lt: twentyFourHoursAgo, // lt = less than
},
},
});
console.log(`[CleanupTask] Successfully deleted ${result.count} anonymous users.`);
return result;
} catch (error) {
console.error('[CleanupTask] Error deleting anonymous users:', error);
throw error;
}
}

View File

@ -1,5 +1,5 @@
// server/utils/auth.ts // server/utils/auth.ts
import { useSession } from 'h3'; import type { H3Event } from 'h3';
if (!process.env.SESSION_PASSWORD) { if (!process.env.SESSION_PASSWORD) {
// Fail-fast if the session password is not configured // Fail-fast if the session password is not configured
@ -7,16 +7,30 @@ if (!process.env.SESSION_PASSWORD) {
} }
/** /**
* Gets the user ID from the event context.
* The `server/middleware/auth.ts` middleware is responsible for populating `event.context.user`.
* Throws a 401 Unauthorized error if no user is found in the context.
* @param event The H3 event object.
* @returns The user's ID.
*/
export function getAuthenticatedUserId(event: H3Event): number {
const user = event.context.user;
if (!user || typeof user.id !== 'number') {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
return user.id;
}
/**
* @deprecated Use `getAuthenticatedUserId(event)` instead. This function relies on the old session-only check and is not compatible with anonymous sessions.
* A helper function to safely get the authenticated user's ID from the session. * A helper function to safely get the authenticated user's ID from the session.
* Throws a 401 Unauthorized error if the user is not authenticated. * Throws a 401 Unauthorized error if the user is not authenticated.
*/ */
export async function getUserIdFromSession(event: any): Promise<number> { export async function getUserIdFromSession(event: H3Event): Promise<number> {
const session = await useSession(event, { const user = event.context.user;
password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check if (!user || typeof user.id !== 'number' || user.isAnonymous) {
}); throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
const userId = session.data?.user?.id;
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
} }
return userId; return user.id;
} }

View File

@ -29,11 +29,11 @@ export const REWARDS = {
QUESTS: { QUESTS: {
DAILY_VISIT: { DAILY_VISIT: {
BASE: { coins: 1 }, BASE: { coins: 1 },
STREAK_BONUS: { coins: 10 },
} }
}, },
// Habit-related rewards // Habit-related rewards
HABITS: { HABITS: {
COMPLETION: { coins: 3, exp: 1 }, COMPLETION: { coins: 3, exp: 1 },
ONBOARDING_COMPLETION: { coins: 75, exp: 0 },
} }
}; };

38
server/utils/gameDay.ts Normal file
View File

@ -0,0 +1,38 @@
// server/utils/gameDay.ts
// 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;
};
export function getTodayDay(): string {
const today = getStartOfDay(new Date());
return today.toISOString().split('T')[0]; // Returns "YYYY-MM-DD"
}
export function isBeforeDay(day1: string | null | undefined, day2: string): boolean {
if (!day1) return true; // A null/undefined day is always before any valid day for our logic (e.g., first time processing)
return day1 < day2; // Lexicographical comparison works for "YYYY-MM-DD"
}
export function getPreviousDay(): string {
const yesterday = getStartOfDay(new Date());
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
return yesterday.toISOString().split('T')[0];
}
export function daysSince(pastDay: string, futureDay: string): number {
// We work with UTC dates to avoid timezone issues.
const pastDate = new Date(`${pastDay}T00:00:00Z`);
const futureDate = new Date(`${futureDay}T00:00:00Z`);
// getTime() returns milliseconds since epoch
const diffTime = futureDate.getTime() - pastDate.getTime();
// 1000 ms/s * 60 s/min * 60 min/hr * 24 hr/day
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
return diffDays > 0 ? diffDays : 0;
}

View File

@ -62,19 +62,28 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number)
} }
// 3. Use upsert to create today's visit record and update the user's streak in a transaction // 3. Use upsert to create today's visit record and update the user's streak in a transaction
const [, updatedUser] = await prisma.$transaction([ try {
prisma.dailyVisit.upsert({ console.log(`[Streak] Attempting to upsert DailyVisit for userId: ${userId}, date: ${today.toISOString()}`);
where: { userId_date: { userId, date: today } },
update: {},
create: { userId, date: today },
}),
prisma.user.update({
where: { id: userId },
data: { dailyStreak: newStreak },
}),
]);
return updatedUser; const [, updatedUser] = await prisma.$transaction([
prisma.dailyVisit.upsert({
where: { userId_date: { userId, date: today } },
update: {},
create: { userId, date: today },
}),
prisma.user.update({
where: { id: userId },
data: { dailyStreak: newStreak },
}),
]);
console.log(`[Streak] Successfully updated streak for userId: ${userId} to ${newStreak}`);
return updatedUser;
} catch (error) {
console.error(`[Streak] Error during daily visit transaction for userId: ${userId}`, error);
// Re-throw the error or handle it as needed. For now, re-throwing.
throw error;
}
} }
interface Reward { interface Reward {