Довольно большой рефакторинг. Осталось поработать над стилями и проект готов
This commit is contained in:
parent
5f8dc428be
commit
495c81e60e
7
.env.example
Normal file
7
.env.example
Normal 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"
|
||||||
41
README.md
41
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
302
app/components/HabitCard.vue
Normal file
302
app/components/HabitCard.vue
Normal 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>
|
||||||
319
app/components/OnboardingFunnel.vue
Normal file
319
app/components/OnboardingFunnel.vue
Normal 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>
|
||||||
197
app/components/VillageGrid.vue
Normal file
197
app/components/VillageGrid.vue
Normal 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>
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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
BIN
assets/bugs/Untitled.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
assets/bugs/check_onbording_habit.jpg
Normal file
BIN
assets/bugs/check_onbording_habit.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
assets/bugs/reward_earned.jpg
Normal file
BIN
assets/bugs/reward_earned.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN DEFAULT true;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
34
server/api/admin/cleanup.post.ts
Normal file
34
server/api/admin/cleanup.post.ts
Normal 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.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 village = await prisma.village.findUniqueOrThrow({ where: { userId } });
|
const previousDay = getPreviousDay();
|
||||||
|
|
||||||
const now = Date.now();
|
const [fieldResult, tileResult] = await prisma.$transaction([
|
||||||
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
|
// 1. Update lastExpDay for all FIELD objects for this user's village
|
||||||
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}.`
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
// --- 2. Identify User Flow (Anonymous Conversion vs. Standard) ---
|
||||||
|
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
|
||||||
|
const anonymousUser = anonymousSessionId
|
||||||
|
? await prisma.user.findUnique({ where: { anonymousSessionId } })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (anonymousUser) {
|
||||||
|
// --- Flow A: Convert Anonymous User ---
|
||||||
|
user = await prisma.user.update({
|
||||||
|
where: { id: anonymousUser.id },
|
||||||
data: {
|
data: {
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
nickname: nickname || 'New Smurf',
|
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.
|
||||||
|
|
||||||
|
// 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Generate the user's village
|
// Generate a new village for the brand new user
|
||||||
await generateVillageForUser(user);
|
await generateVillageForUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Registration does not automatically log in the user.
|
// --- 3. Automatically log the user in ---
|
||||||
// The user needs to explicitly call the login endpoint after registration.
|
const session = await useSession(event, { password: process.env.SESSION_PASSWORD! });
|
||||||
|
await session.update({ user: { id: user.id } });
|
||||||
|
|
||||||
// 5. Return the new user, excluding sensitive fields and shortening DTO
|
// --- 4. Return DTO ---
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
|
||||||
|
|
@ -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,9 +47,13 @@ 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;
|
||||||
|
|
||||||
|
// For permanent users, ensure the habit is scheduled for today.
|
||||||
|
// Anonymous users in the onboarding flow can complete it on any day.
|
||||||
|
if (!user.isAnonymous) {
|
||||||
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
|
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
|
||||||
|
let finalReward: { coins: number, exp: number };
|
||||||
|
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;
|
const baseReward = REWARDS.HABITS.COMPLETION;
|
||||||
const finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
|
finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
|
||||||
|
}
|
||||||
|
|
||||||
const village = await prisma.village.findUnique({ where: { userId } });
|
const village = await prisma.village.findUnique({ where: { userId } });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.' });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
85
server/api/onboarding/complete-habit.post.ts
Normal file
85
server/api/onboarding/complete-habit.post.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
69
server/api/onboarding/initiate.post.ts
Normal file
69
server/api/onboarding/initiate.post.ts
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
387
server/services/OLDvillageService — копия.ts
Normal file
387
server/services/OLDvillageService — копия.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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,211 +36,51 @@ 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 } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let villageSnapshot = await fetchVillage(userId);
|
||||||
if (!villageSnapshot) {
|
if (!villageSnapshot) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
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
|
// Re-fetch the village state after the transaction to get the latest data including new objects, etc.
|
||||||
await tx.villageTile.updateMany({
|
villageSnapshot = await fetchVillage(userId);
|
||||||
where: { id: { in: finishedClearingTiles.map(t => t.id) } },
|
if (!villageSnapshot) {
|
||||||
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
|
throw createError({ statusCode: 404, statusMessage: 'Village not found post-transaction' });
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
// --- Enrich tiles with available actions (Step 8 from old getVillageState) ---
|
||||||
const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень';
|
const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
|
||||||
const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун';
|
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
|
||||||
|
|
||||||
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 ---
|
|
||||||
const today = getStartOfDay(now);
|
|
||||||
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: 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 freeWorkers = housesCount - producingCount;
|
||||||
|
|
||||||
const tilesWithActions = finalVillageState.tiles.map(tile => {
|
const tilesWithActions = villageSnapshot.tiles.map(tile => {
|
||||||
const availableActions: any[] = [];
|
const availableActions: any[] = [];
|
||||||
|
|
||||||
// Action: BUILD
|
// Action: BUILD
|
||||||
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
||||||
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
const buildableObjectTypes: VillageObjectType[] = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
||||||
const buildActions = buildableObjectTypes.map(buildingType => {
|
const buildActions = buildableObjectTypes.map(buildingType => {
|
||||||
const cost = COSTS.BUILD[buildingType];
|
const cost = COSTS.BUILD[buildingType];
|
||||||
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
|
const isProducing = (PRODUCING_BUILDINGS as readonly string[]).includes(buildingType);
|
||||||
let isEnabled = user.coins >= cost;
|
let isEnabled = user.coins >= cost;
|
||||||
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
|
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
|
||||||
|
|
||||||
|
|
@ -308,36 +100,118 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
||||||
availableActions.push(...buildActions);
|
availableActions.push(...buildActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tile.object) {
|
|
||||||
// MOVE and REMOVE actions have been removed as per the refactor request.
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...tile, availableActions };
|
return { ...tile, availableActions };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...finalVillageState, tiles: tilesWithActions } as any;
|
return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in syncAndGetVillage:', error);
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If village was just created, initialize user resources
|
||||||
|
if (villageCreated) {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { coins: 10, exp: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
// 1. Fetch all necessary data
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
where: { id: userId },
|
||||||
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
|
});
|
||||||
|
|
||||||
|
const tile = await tx.villageTile.findUniqueOrThrow({
|
||||||
|
where: { id: tileId },
|
||||||
|
include: { village: true },
|
||||||
|
});
|
||||||
|
|
||||||
// Ownership check
|
|
||||||
if (tile.village.userId !== userId) {
|
if (tile.village.userId !== userId) {
|
||||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
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' });
|
||||||
}
|
}
|
||||||
|
|
@ -347,16 +221,24 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
||||||
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,9 +246,9 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -374,7 +256,7 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
||||||
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,
|
||||||
|
|
@ -382,6 +264,198 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
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
27
server/tasks/cleanup.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
});
|
|
||||||
const userId = session.data?.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
return userId;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
38
server/utils/gameDay.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,9 @@ 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
|
||||||
|
try {
|
||||||
|
console.log(`[Streak] Attempting to upsert DailyVisit for userId: ${userId}, date: ${today.toISOString()}`);
|
||||||
|
|
||||||
const [, updatedUser] = await prisma.$transaction([
|
const [, updatedUser] = await prisma.$transaction([
|
||||||
prisma.dailyVisit.upsert({
|
prisma.dailyVisit.upsert({
|
||||||
where: { userId_date: { userId, date: today } },
|
where: { userId_date: { userId, date: today } },
|
||||||
|
|
@ -74,7 +77,13 @@ export async function calculateDailyStreak(prisma: PrismaClient, userId: number)
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
console.log(`[Streak] Successfully updated streak for userId: ${userId} to ${newStreak}`);
|
||||||
return updatedUser;
|
return updatedUser;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Streak] Error during daily visit transaction for userId: ${userId}`, error);
|
||||||
|
// Re-throw the error or handle it as needed. For now, re-throwing.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Reward {
|
interface Reward {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user