Compare commits

..

No commits in common. "45fc71f8bd1ed82c3c51b0bae8aee4a9c500cd86" and "983847187144c8fb46fd92e77035d9948278562a" have entirely different histories.

71 changed files with 2209 additions and 4557 deletions

View File

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

View File

@ -1,10 +0,0 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest"
]
}
}
}

View File

@ -69,53 +69,6 @@ Adhering to these conventions is critical for maintaining project stability.
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`). - Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
- All business logic should reside in the backend. - All business logic should reside in the backend.
### Data Conventions ### AI / Gemini Usage Rules
- **`daysOfWeek` Field:** This array represents the days a habit is active. The week starts on **Monday**.
- Monday: `0`
- Tuesday: `1`
- Wednesday: `2`
- Thursday: `3`
- Friday: `4`
- Saturday: `5`
- Sunday: `6`
- **Daily Streak:** The `dailyStreak` field on the `User` model is an integer representing the user's consecutive daily visits. Its primary function is to act as a **multiplier for rewards** (coins and EXP) earned from completing habits and village activities.
- The multiplier is typically capped (e.g., at 3x).
- It is updated by the `calculateDailyStreak` function located in `server/utils/streak.ts`. This function determines if a visit extends the streak, maintains it, or resets it.
## 4. Date and Time Handling (Game Day Concept)
To ensure consistent and timezone-agnostic handling of daily game mechanics, the project employs a "Game Day" concept. This approach prioritizes the user's local calendar day for relevant actions and uses a standardized string format for persistence.
### Key Principles:
1. **"Game Day" Format:** All dates representing a calendar day (e.g., when a habit was completed, a daily visit occurred, or a village event was processed) are stored and primarily handled as a **`String` in "YYYY-MM-DD" format**. This includes fields like `DailyVisit.date`, `HabitCompletion.date`, `VillageObject.lastExpDay`, `VillageTile.clearingStartedDay`, and `Village.lastTickDay`.
2. **Client as Source of Truth for User's Day:**
* For user-initiated actions (e.g., completing a habit, triggering a daily visit), the **frontend (client-side)** determines the current "Game Day" based on the user's local timezone.
* This "YYYY-MM-DD" string is then sent to the backend with the API request. This prevents timezone conflicts where the server's day might differ from the user's local perception of "today."
3. **Server-Side Consistency with UTC:**
* On the backend, when parsing these "YYYY-MM-DD" strings into `Date` objects for calculations (e.g., to determine the day of the week or calculate `daysSince`), they are explicitly parsed as **UTC dates** (e.g., `new Date(\`${gameDay}T00:00:00Z\`)`). This ensures that date arithmetic (like finding the previous day) is consistent and avoids shifts due to the server's local timezone.
4. **Day-of-Week Convention:** The application adheres to a specific convention for days of the week:
* **Monday: `0`**
* Tuesday: `1`
* Wednesday: `2`
* Thursday: `3`
* Friday: `4`
* Saturday: `5`
* Sunday: `6`
### Example Usage:
* **`DailyVisit.date`**: Stores the "Game Day" on which a user's visit was recorded.
* **`HabitCompletion.date`**: Stores the "Game Day" on which a habit was marked as complete.
* **`Village.lastTickDay`**: Stores the "Game Day" (server-side determined) when the village's background progression was last processed, preventing redundant calculations within the same server day.
* **`VillageObject.lastExpDay`**: Stores the "Game Day" when an object (e.g., a field) last generated experience.
* **`VillageTile.clearingStartedDay`**: Stores the "Game Day" when a tile's clearing process began.
This consistent use of "Game Day" strings and client-provided dates (for user actions) combined with server-side UTC parsing (for calculations) provides a robust solution to managing time-based game mechanics across different timezones.
## 5. AI / Gemini Usage Rules
- **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure. - **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure.
- **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework. - **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework.

View File

@ -61,15 +61,7 @@ node -v
--- ---
## 4. Date and Time Handling ## 4. Prisma Setup (IMPORTANT)
To ensure consistent and timezone-agnostic management of daily game mechanics (like habit completion, streaks, and village progression), the project uses a "Game Day" concept. Dates are primarily stored as `String` in "YYYY-MM-DD" format. The client provides its local "Game Day" for user actions, and the server processes dates consistently using UTC.
For a detailed explanation of the "Game Day" concept and its implementation, please refer to the `GEMINI.md` file.
---
## 5. Prisma Setup (IMPORTANT)
### Prisma version ### Prisma version
@ -120,7 +112,7 @@ Never forget migrations.
--- ---
## 6. Prisma Client Usage ## 5. Prisma Client Usage
Prisma client is initialized here: Prisma client is initialized here:
@ -144,7 +136,7 @@ export default prisma
--- ---
## 7. Development ## 6. Development
Install dependencies: Install dependencies:
@ -166,7 +158,7 @@ npm run dev
--- ---
## 8. API Example ## 7. API Example
Health check: Health check:
@ -185,42 +177,7 @@ Expected response:
--- ---
## 9. Scheduled Cleanup Task ## 8. Deployment Notes
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.
---
## 10. Deployment Notes
- Use Node 20 on hosting - Use Node 20 on hosting
- Run Prisma migrations during deployment - Run Prisma migrations during deployment
@ -229,7 +186,7 @@ In a production environment, this endpoint should be called by an **external sch
--- ---
## 11. AI / Gemini Rules (IMPORTANT) ## 9. AI / Gemini Rules (IMPORTANT)
When using Gemini / AI tools: When using Gemini / AI tools:
@ -246,7 +203,7 @@ When using Gemini / AI tools:
--- ---
## 12. Why these constraints exist ## 10. Why these constraints exist
This setup was intentionally chosen to: This setup was intentionally chosen to:
- avoid unstable Prisma 7 API - avoid unstable Prisma 7 API

View File

@ -1,7 +0,0 @@
Начисление бонусов происходит про посещении страницы /village . А надо на уровне роута сделать, у залогиненного пользователя, что бы при посещении любой страницы происходила расчистка, и если расчистка закончена, выбирался новый тайл для расчистки.
У нас в БД lastExpAt и CleaningStartAt это UTC дата Стрингом. А выполнение привычки идёт как Date. И стрик тоже Date считается. Сделать так же UTC, однотипно.
Так как дата UTC, не учитывается часовой пояс. как то это надо пофиксить
На лидербордах не показывать пользователей анонимусов (они без имени)

View File

@ -10,13 +10,28 @@
</template> </template>
<script setup> <script setup>
const { initialized, initAuth } = useAuth(); const { initialized, fetchMe } = useAuth();
// Initialize the authentication state on client-side load. // Fetch the user state on initial client-side load.
// This will either fetch a logged-in user or create an anonymous session. // The middleware will wait for `initialized` to be true.
onMounted(() => { onMounted(() => {
initAuth(); fetchMe();
}); });
</script> </script>
<style src="../assets/css/main.css"></style> <style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f4f5;
}
.loading-overlay {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5em;
color: #555;
}
</style>

View File

@ -1,94 +0,0 @@
<template>
<div v-if="show" class="modal-overlay" @click.self="$emit('cancel')">
<div class="modal-content">
<h4>{{ title }}</h4>
<p>{{ message }}</p>
<div class="modal-actions">
<button @click="$emit('cancel')" class="btn-cancel">{{ cancelText }}</button>
<button @click="$emit('confirm')" class="btn-confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
show: {
type: Boolean,
required: true,
},
title: {
type: String,
default: 'Подтверждение',
},
message: {
type: String,
required: true,
},
confirmText: {
type: String,
default: 'Да',
},
cancelText: {
type: String,
default: 'Отмена',
},
});
defineEmits(['confirm', 'cancel']);
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-content h4 {
margin-top: 0;
font-size: 1.2em;
}
.modal-actions {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 15px;
}
.modal-actions button {
padding: 10px 20px;
border-radius: 5px;
border: none;
font-size: 1em;
cursor: pointer;
}
.btn-confirm {
background-color: #bf616a; /* Nord Red for destructive actions */
color: white;
}
.btn-cancel {
background-color: #e5e9f0; /* Nord light gray */
color: #4c566a;
}
</style>

View File

@ -1,308 +0,0 @@
<template>
<div class="habit-card">
<div class="habit-header">
<div class="habit-details" style="flex-grow: 1;">
<h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</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 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 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().toISOString().slice(0, 10); // "YYYY-MM-DD"
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
// Ensure date is in "YYYY-MM-DD" string format for comparison
const comparisonDate = (typeof date === 'string')
? date
: new Date(date).toISOString().slice(0, 10);
return habit.completions.some(c => c.date === comparisonDate);
};
const getScheduleText = (habit) => {
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
};
const exploding = computed(() => props.explodingHabitId === props.habit.id);
// --- History Grid Logic ---
const last14Days = computed(() => {
const dates = [];
const todayDate = new Date();
const todayDay = todayDate.getDay(); // 0 for Sunday, 1 for Monday, etc.
// Adjust so that Monday is 0 and Sunday is 6 for application's convention
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
// Calculate days to subtract to get to the Monday of LAST week
const daysToSubtract = appDayOfWeek + 7;
const startDate = new Date();
startDate.setDate(todayDate.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 getCellClasses = (habit, day) => {
const classes = {};
const dayString = new Date(day).toISOString().slice(0, 10);
const habitCreatedAt = new Date(habit.createdAt).toISOString().slice(0, 10);
if (dayString > today) {
classes['future-day'] = true;
}
if (dayString === today) {
classes['today-highlight'] = true;
}
const dayOfWeek = (new Date(day).getDay() === 0) ? 6 : new Date(day).getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
if (isCompleted(habit, dayString)) {
classes['completed'] = true;
return classes;
}
if (dayString < today && isScheduled && dayString >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = new Date().getDay(); // Sunday is 0
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
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: flex-start; /* Adjust as needed */
align-items: center;
}
.habit-details {
text-align: left;
flex-grow: 1; /* Allow details to take available space */
}
.habit-action {
margin-top: 20px; /* Add some space above the action button */
text-align: center; /* Center the button */
}
.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;
line-height: 0.8;
}
/* 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; }
/* Responsive Styles for the action button */
@media (max-width: 768px) {
.habit-action button {
width: 100%;
}
}
</style>

View File

@ -1,555 +0,0 @@
<template>
<div class="onboarding-funnel">
<!-- New, Modern Progress Bar -->
<div class="onboarding-funnel__progress">
<div class="onboarding-funnel__progress-line"></div>
<div
class="onboarding-funnel__progress-line --completed"
:style="{ width: `${(currentStep - 1) * 25}%` }"
></div>
<div v-for="n in 5" :key="n" class="onboarding-funnel__step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
<div class="onboarding-funnel__step-dot"></div>
<div class="onboarding-funnel__step-label">{{ getStepLabel(n) }}</div>
</div>
</div>
<!-- Content Area -->
<div class="onboarding-funnel__content">
<div class="step-card">
<!-- Key added for transitions -->
<div :key="currentStep" class="step-container">
<!-- Step 1: Create Habit -->
<div v-if="currentStep === 1">
<h2>Создайте первую привычку</h2>
<p>Привычки - это основа продуктивности. С чего начнем?</p>
<form @submit.prevent="handleCreateHabit" class="onboarding-form">
<div class="form-group">
<label for="habit-name" class="form-label">Название привычки</label>
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required class="form-control" />
</div>
<div class="form-group">
<label class="form-label">Дни выполнения</label>
<div class="days-selector">
<button v-for="day in daysOfWeek" :key="day.value" type="button" class="btn-toggle" :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" class="btn btn-primary" :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>
<div class="habit-display-card">
<h3>{{ createdHabit?.name }}</h3>
<p class="habit-schedule-text">{{ getScheduleText(createdHabit) }}</p>
<button @click="handleCompleteOnboardingHabit(createdHabit.id)" :disabled="isLoading" class="btn btn-primary">
{{ isLoading ? 'Выполнение...' : 'Выполнить' }}
</button>
</div>
</div>
<!-- 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" class="btn btn-primary">Продолжить</button>
</div>
<!-- Step 4: Build a House -->
<div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: {{ houseCost }} монет).</p>
<VillageGrid
v-if="villageData"
:village-data="villageData"
:is-onboarding="true"
@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="btn btn-primary next-button">
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
</button>
</div>
<!-- Step 5: Register -->
<div v-if="currentStep === 5">
<h2>Шаг 5: Сохраните прогресс!</h2>
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
<form @submit.prevent="handleRegister" class="onboarding-form">
<div class="form-group">
<label for="nickname" class="form-label">Ваше имя</label>
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required class="form-control" />
</div>
<div class="form-group">
<label for="email" class="form-label">Email</label>
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required class="form-control" />
</div>
<div class="form-group">
<label for="password" class="form-label">Пароль (мин. 8 символов)</label>
<input id="password" type="password" v-model="form.password" required class="form-control"/>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue';
// --- Composables ---
const api = useApi();
const { user, updateUser, register } = useAuth();
// --- Onboardning initiation ---
onMounted(async () => {
if (user.value) {
return;
}
try {
isLoading.value = true;
const anonymousUser = await api('/api/onboarding/initiate', { method: 'POST' });
if (anonymousUser) {
updateUser(anonymousUser);
}
} catch (e: any) {
error.value = 'Не удалось начать сессию. Попробуйте обновить страницу';
} finally {
isLoading.value = false;
}
});
// --- Economy Constants ---
const { data: economy, pending: economyPending } = useAsyncData('economy-constants', () =>
$fetch('/api/economy/constants')
);
const onboardingRewardAmount = computed(() => economy.value?.rewards?.onboardingCompletion ?? 50);
const houseCost = computed(() => economy.value?.costs?.build?.house ?? 15);
// --- State ---
const currentStep = ref(1);
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 >= houseCost.value);
// --- 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 getScheduleText = (habit: any) => {
if (!habit || !habit.daysOfWeek) return '';
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort((a: number, b: number) => a - b).map((dayIndex: number) => dayMap[dayIndex]).join(', ');
};
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 gameDay = new Date().toISOString().slice(0, 10); // Get client's gameDay
const response = await api(`/api/habits/${habitId}/complete`, {
method: 'POST',
body: { gameDay } // Pass gameDay in the body
});
// Manually update the user's coins from the response
if (user.value && response.updatedCoins !== undefined) {
user.value.coins = response.updatedCoins;
}
createdHabit.value.completions.push({ date: gameDay }); // Use gameDay for optimistic update
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 = `Не хватает монет для постройки дома (${houseCost.value} монет).`;
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 Layout --- */
.onboarding-funnel {
width: 100%;
max-width: 720px;
margin: 40px auto;
padding: 0 24px;
}
/* --- New Progress Bar --- */
.onboarding-funnel__progress {
display: flex;
justify-content: space-between;
position: relative;
margin-bottom: 2rem;
padding: 0 10%;
}
.onboarding-funnel__progress-line {
position: absolute;
top: 8px;
left: 10%;
right: 10%;
height: 4px;
background-color: var(--border-color);
z-index: 1;
transition: width 0.4s ease-in-out;
}
.onboarding-funnel__progress-line.--completed {
background-color: var(--secondary-color);
}
.onboarding-funnel__step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
z-index: 2;
}
.onboarding-funnel__step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: var(--container-bg-color);
border: 4px solid var(--border-color);
transition: all 0.3s ease;
}
.onboarding-funnel__step-label {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--text-color-light);
font-weight: 500;
}
.onboarding-funnel__step.active .onboarding-funnel__step-dot {
border-color: var(--primary-color);
transform: scale(1.2);
}
.onboarding-funnel__step.active .onboarding-funnel__step-label {
color: var(--primary-color);
font-weight: 700;
}
.onboarding-funnel__step.completed .onboarding-funnel__step-dot {
border-color: var(--secondary-color);
background-color: var(--secondary-color);
}
/* --- Content Card --- */
.step-card {
background-color: var(--container-bg-color);
border-radius: 16px;
padding: 2.5rem 3rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
margin-top: 1rem;
}
.step-container {
animation: fadeIn 0.5s ease;
text-align: center;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
.step-container h2 {
font-size: 1.75rem;
margin-bottom: 0.75rem;
color: var(--text-color);
font-weight: 700;
}
.step-container p {
font-size: 1.125rem;
color: var(--text-color-light);
margin-bottom: 2.5rem;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
/* --- Form & Interactive Elements --- */
.onboarding-form {
max-width: 450px;
margin: 0 auto;
text-align: left;
}
.days-selector {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 2rem;
}
.btn-toggle {
background-color: transparent;
color: var(--text-color-light);
font-weight: 600;
font-size: 0.9rem;
padding: 0.6rem 1rem;
border-radius: 10px;
border: 2px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-toggle:hover {
background-color: #f7f7f7;
border-color: var(--secondary-color);
}
.btn-toggle.selected {
background-color: var(--secondary-color);
border-color: var(--secondary-color);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.error-message {
color: var(--danger-color);
margin: -1rem 0 1.5rem 0;
text-align: center;
font-weight: 500;
}
.loading-placeholder {
background-color: rgba(0,0,0,0.02);
border: 2px dashed var(--border-color);
padding: 3rem;
text-align: center;
color: var(--text-color-light);
border-radius: 12px;
margin: 0 auto 2rem auto;
}
/* --- Specific Step Styles --- */
.habit-card-wrapper {
max-width: 400px;
margin: 0 auto;
}
.habit-display-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
max-width: 400px;
margin: 0 auto 24px auto; /* Centered with margin-bottom */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.habit-display-card h3 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1.25rem;
color: var(--text-color);
}
.habit-schedule-text {
font-size: 0.95rem;
color: var(--text-color-light);
margin-bottom: 20px;
}
.reward-step { text-align: center; }
.reward-icon { font-size: 6rem; line-height: 1; margin-bottom: 1.5rem; animation: tada 1s ease; }
@keyframes tada {
from { transform: scale3d(1, 1, 1); }
10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
to { transform: scale3d(1, 1, 1); }
}
.reward-caption { margin-bottom: 2.5rem; }
.next-button { margin-top: 2rem; }
.next-button:disabled {
background-color: var(--text-color-light);
border-color: var(--text-color-light);
}
/* --- Responsive Styles --- */
@media (max-width: 768px) {
.onboarding-funnel {
padding: 0;
margin: 24px auto;
}
.onboarding-funnel__progress {
padding: 0 1rem; /* Reduced padding */
}
.onboarding-funnel__progress-line {
left: 1rem;
right: 1rem;
}
.onboarding-funnel__step-label {
display: none; /* Hide labels on mobile to save space */
}
.step-card {
padding: 2rem 1.5rem;
}
.step-container h2 {
font-size: 1.5rem;
}
.step-container p {
font-size: 1rem;
margin-bottom: 2rem;
}
/* Make main buttons full-width */
.step-container .btn-primary {
width: 100%;
}
.days-selector {
gap: 0.5rem;
}
.btn-toggle {
padding: 0.5rem 0.7rem;
}
}
</style>

View File

@ -1,288 +0,0 @@
<template>
<div v-if="villageData && villageData.tiles" class="village-container">
<div :class="villageGridWrapperClass">
<!-- Empty corner for alignment -->
<div class="empty-corner"></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,
},
isOnboarding: {
type: Boolean,
default: false,
},
selectedTile: {
type: Object,
default: null,
},
});
const villageGridWrapperClass = computed(() => ({
'village-grid-wrapper': true,
'is-onboarding': props.isOnboarding,
}));
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;
});
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,
'tile-clearing': tile.terrainState === 'CLEARING',
'selected': props.selectedTile && props.selectedTile.id === tile.id,
};
};
</script>
<style scoped>
.village-container {
display: flex;
justify-content: center;
width: 100%;
margin-top: 20px;
--tile-size: clamp(40px, 10vw, 55px);
padding: 0 16px; /* Add padding for mobile */
box-sizing: border-box;
}
.village-grid-wrapper {
display: grid;
gap: 8px; /* Increased gap */
border-radius: 16px; /* Rounded wrapper */
background-color: var(--container-bg-color);
padding: 12px; /* Increased padding */
width: fit-content;
max-width: 100%; /* Ensure it doesn't overflow parent */
margin: 0 auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
}
.empty-corner, .col-labels, .row-labels {
opacity: 0.6;
font-size: 0.8rem;
}
.col-labels {
grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 8;
display: flex;
justify-content: space-around;
align-items: center;
height: 20px;
}
.col-label {
width: var(--tile-size);
text-align: center;
line-height: 20px;
}
.row-labels {
grid-column: 1;
grid-row: 1 / -1; /* Dynamic spanning */
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 20px;
}
.row-label {
height: var(--tile-size);
display: flex;
align-items: center;
justify-content: center;
line-height: 20px;
}
.village-grid {
grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 1 / -1; /* Dynamic spanning */
display: grid;
gap: 8px; /* Increased gap */
}
.tile {
width: var(--tile-size);
height: var(--tile-size);
display: flex;
justify-content: center;
align-items: center;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
position: relative; /* For selection pseudo-elements */
}
.tile.tile-empty {
background-color: rgba(0,0,0,0.02);
border: 2px dashed var(--border-color);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.03);
}
.tile.tile-empty:hover {
background-color: rgba(0,0,0,0.04);
border-color: var(--primary-color-light);
}
.tile.tile-blocked {
background-color: #EBE5D7; /* A soft, earthy tone */
border: 1px solid #DCD5C6;
}
.tile.tile-blocked:hover {
transform: translateY(-2px);
}
.tile.tile-object {
background-color: #c8ffcf;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
border: 1px solid #39af50;
}
.tile.tile-object:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.tile.tile-clearing {
background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
border: 1px solid #ffcc80;
}
.tile.selected {
box-shadow: 0 0 0 3px var(--primary-color);
transform: scale(1.05);
}
.tile-content {
font-size: calc(var(--tile-size) * 0.5); /* Make emoji scale with tile */
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.village-grid-wrapper.is-onboarding {
padding: 8px;
gap: 4px;
}
.village-grid-wrapper.is-onboarding .col-label,
.village-grid-wrapper.is-onboarding .row-label {
font-size: 0.7rem;
}
@media (max-width: 480px) {
.village-grid-wrapper {
gap: 4px;
padding: 8px;
}
.village-grid {
gap: 4px;
}
}
@media (max-width: 768px) {
/* On mobile, we calculate a tile size that fits the screen.
7 columns (5 tiles + 2 labels) and 6 gaps.
The parent .village-container has 16px padding on each side.
So, (100vw - 32px - (6 * 4px)) / 7 = tile size
We'll use a simplified version with vw units. */
.village-container {
--tile-size: clamp(35px, 11vw, 50px);
}
.tile {
/* aspect-ratio: 1 / 1;
height: auto; */
/* Disabling aspect ratio for now to prevent massive height on narrow screens */
}
.tile.tile-empty:hover {
background-color: rgba(0,0,0,0.02); /* Reset to non-hover state */
border-color: var(--border-color); /* Reset to non-hover state */
}
.tile.tile-blocked:hover {
transform: translateY(0); /* Reset to non-hover state */
}
.tile.tile-object:hover {
transform: translateY(0); /* Reset to non-hover state */
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07); /* Reset to non-hover state */
}
}
</style>

View File

@ -1,132 +1,66 @@
// /composables/useAuth.ts // /composables/useAuth.ts
import { computed, watch } from 'vue';
import { useVisitTracker } from './useVisitTracker';
interface User { interface User {
id: number; id: string;
email: string | null; // Can be null for anonymous users email: string;
nickname: string | null; nickname: string;
avatar: string | null; avatar: string | null;
coins: number; coins: number;
exp: number; exp: number;
dailyStreak: number;
soundOn: boolean; soundOn: boolean;
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 { visitCalled } = useVisitTracker(); const isAuthenticated = computed(() => !!user.value);
// A user is fully authenticated only if they exist and are NOT anonymous. const fetchMe = async () => {
const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous); // This function can be called multiple times, but the logic inside
// A user is anonymous if they exist and have the isAnonymous flag. // will only run once thanks to the initialized flag.
const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous);
// --- This watcher is the new core logic for post-authentication tasks ---
watch(isAuthenticated, (newIsAuthenticated, oldIsAuthenticated) => {
// We only care about the transition from logged-out to logged-in
if (newIsAuthenticated && !oldIsAuthenticated) {
if (!visitCalled.value) {
visitCalled.value = true;
const gameDay = new Date().toISOString().slice(0, 10);
// --- 1. Trigger Daily Visit & Streak Calculation ---
api('/api/user/visit', {
method: 'POST',
body: { gameDay }
}).then(updatedUser => {
if (updatedUser) {
updateUser(updatedUser);
}
}).catch(e => {
console.error('Failed to register daily visit:', e);
visitCalled.value = false; // Allow retrying on next navigation if it failed
});
// --- 2. Trigger Village Tick ---
api('/api/village/tick', { method: 'POST' })
.catch(e => {
console.error('Failed to trigger village tick:', e);
});
}
}
});
/**
* Initializes the authentication state for EXISTING users.
* It should be called once in app.vue.
* 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 {
const response = await api<{ user: User }>('/auth/me'); // The backend returns the user object nested under a 'user' key.
if (response.user) { const response = await api<{ user: User }>('/auth/me', { method: 'GET' });
user.value = { ...response.user, isAnonymous: false }; user.value = response.user; // Correctly assign the nested user object
} else {
user.value = null;
}
} catch (error) { } catch (error) {
// It's expected this will fail for non-logged-in users. user.value = null; // Silently set user to null on 401
user.value = null;
} finally { } finally {
initialized.value = true; loading.value = false;
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, force a re-fetch of the new user state. // After a successful login, allow a re-fetch of the user state.
initialized.value = false; initialized.value = false;
await initAuth(); await fetchMe();
}; };
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;
visitCalled.value = false; // Reset for the next session initialized.value = false;
await navigateTo('/'); await navigateTo('/login');
} }
}; };
@ -136,14 +70,12 @@ export function useAuth() {
} }
}; };
// Expose the state and methods.
return { return {
user, user,
isAuthenticated, isAuthenticated,
isAnonymous, // Expose this new state
initialized, initialized,
initAuth, // Called from app.vue fetchMe,
startOnboarding, // Called from index.vue
register, // Expose register function
login, login,
logout, logout,
updateUser, updateUser,

View File

@ -1,60 +0,0 @@
export const useVillageHelpers = () => {
/**
* Converts data-coordinates (x, y) into a chess-like UI format (e.g. A7).
*
* DATA CONTRACT:
* - x: 0..4 (left right)
* - y: 0..6 (bottom top)
*
* UI CONTRACT:
* - rows are shown top bottom
* - row number = 7 - y
*/
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 row = 7 - y;
return `${col}${row}`;
};
/**
* Formats backend event messages that already contain
* raw data-coordinates in the form "(x, y)".
*
* 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 => {
if (!message) return '';
return message.replace(
/\((\d+),\s*(\d+)\)/g,
(_match, xStr, yStr) => {
const x = Number(xStr);
const y = Number(yStr);
if (Number.isNaN(x) || Number.isNaN(y)) {
return _match;
}
return formatCoordinates(x, y);
}
);
};
return {
formatCoordinates,
formatMessageCoordinates,
};
};

View File

@ -1,12 +0,0 @@
// app/composables/useVisitTracker.ts
import { ref } from 'vue';
// This is a simple, client-side, non-persisted state to ensure the
// daily visit API call is only made once per application lifecycle.
const visitCalled = ref(false);
export function useVisitTracker() {
return {
visitCalled,
};
}

View File

@ -2,9 +2,10 @@
<div class="app-container"> <div class="app-container">
<header v-if="isAuthenticated" class="top-bar"> <header v-if="isAuthenticated" class="top-bar">
<div class="user-info-top"> <div class="user-info-top">
<span>{{ user.nickname }}</span>
<span>💰 {{ displayedCoins }}</span> <span>💰 {{ displayedCoins }}</span>
<span> {{ displayedExp }}</span> <span> {{ displayedExp }}</span>
<button @click="handleLogout" class="btn btn-danger btn-sm">Выход</button> <button @click="handleLogout" class="logout-button">Logout</button>
</div> </div>
</header> </header>
@ -93,31 +94,37 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background-color: var(--background-color);
} }
.top-bar { .top-bar {
background-color: var(--container-bg-color); background-color: #f8f8f8;
padding: 10px 20px; padding: 10px 15px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid #eee;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end; /* Align user info to the right */
align-items: center; align-items: center;
color: var(--text-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.user-info-top { .user-info-top {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 15px;
font-size: 0.95em; font-size: 0.9em;
font-weight: 500; }
.logout-button {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 0.8em;
} }
.main-content { .main-content {
flex-grow: 1; flex-grow: 1;
padding-bottom: 70px; /* Space for bottom nav */ padding-bottom: 60px; /* Space for bottom nav */
} }
.bottom-nav { .bottom-nav {
@ -125,12 +132,12 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
background-color: var(--container-bg-color); background-color: #fff;
border-top: 1px solid var(--border-color); border-top: 1px solid #eee;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: 5px 0; padding: 5px 0;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05); box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
z-index: 1000; z-index: 1000;
} }
@ -139,23 +146,17 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
color: var(--text-color-light); color: #555;
font-size: 0.75em; font-size: 0.7em;
padding: 5px; padding: 5px;
transition: color 0.2s;
}
.nav-item:hover {
color: var(--primary-color);
} }
.nav-item .icon { .nav-item .icon {
font-size: 1.8em; font-size: 1.5em;
margin-bottom: 2px; margin-bottom: 2px;
} }
.nav-item.router-link-exact-active { .nav-item.router-link-active {
color: var(--primary-color); color: #007bff;
font-weight: 600;
} }
</style> </style>

View File

@ -1,85 +1,36 @@
<template> <template>
<div class="page-container"> <div class="habits-container">
<h1>Мои Привычки</h1> <h3>My Habits</h3>
<!-- Create Habit Form -->
<form @submit.prevent="createHabit" class="create-habit-form">
<h4>Create a New Habit</h4>
<div v-if="error" class="error-message">{{ error }}</div>
<input v-model="newHabitName" type="text" placeholder="e.g., Read for 15 minutes" required />
<div class="days-selector">
<label v-for="day in dayOptions" :key="day" class="day-label">
<input type="checkbox" :value="day" v-model="newHabitDays" />
<span>{{ day }}</span>
</label>
</div>
<button type="submit" :disabled="loading.create">
{{ loading.create ? 'Adding...' : 'Add Habit' }}
</button>
</form>
<!-- Habits List --> <!-- Habits List -->
<div class="habits-list"> <div class="habits-list">
<p v-if="loading.fetch">Загрузка привычек...</p> <p v-if="loading.fetch">Loading habits...</p>
<div v-for="habit in habits" :key="habit.id" class="habit-card"> <div v-for="habit in habits" :key="habit.id" class="habit-card">
<!-- Viewing Mode --> <div class="habit-info">
<div v-if="editingHabitId !== habit.id" class="habit-view-content"> <h4>{{ habit.name }}</h4>
<div class="habit-info"> <div class="habit-days">
<h3>{{ habit.name }}</h3> <span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
<div class="habit-days">
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
</div>
</div>
<div class="habit-actions">
<button @click="startEditing(habit)" class="btn btn-secondary btn-sm">Редактировать</button>
<button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="btn btn-danger btn-sm">Удалить</button>
</div>
</div>
<!-- Editing Mode -->
<form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form">
<div class="form-group">
<input v-model="editHabitName" type="text" class="form-control" required />
</div> </div>
<div class="form-group"> </div>
<div class="days-selector edit-days">
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="editHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div>
<div class="edit-actions">
<button type="submit" :disabled="loading.edit" class="btn btn-primary btn-sm">Сохранить</button>
<button type="button" @click="cancelEditing" class="btn btn-secondary btn-sm">Отмена</button>
</div>
</form>
</div> </div>
<p v-if="!loading.fetch && habits.length === 0">Пока нет привычек. Добавьте одну!</p> <p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
</div> </div>
<!-- Create Habit Form -->
<div class="form-container">
<h2>Новая привычка</h2>
<form @submit.prevent="createHabit">
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label for="newHabitName" class="form-label">Название привычки</label>
<input id="newHabitName" v-model="newHabitName" type="text" placeholder="Например, читать 15 минут" class="form-control" required />
</div>
<div class="form-group">
<label class="form-label">Дни недели</label>
<div class="days-selector">
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="newHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div>
<button type="submit" :disabled="loading.create" class="btn btn-primary">
{{ loading.create ? 'Добавляем...' : 'Добавить Привычку' }}
</button>
</form>
</div>
<!-- Deletion Confirmation Dialog -->
<ConfirmDialog
:show="isConfirmDialogOpen"
title="Подтвердить удаление"
message="Вы уверены, что хотите удалить эту привычку? Это действие нельзя отменить."
confirm-text="Удалить"
cancel-text="Отмена"
@confirm="handleDeleteConfirm"
@cancel="handleDeleteCancel"
/>
</div> </div>
</template> </template>
@ -99,36 +50,15 @@ const api = useApi();
// --- State --- // --- State ---
const habits = ref<Habit[]>([]); const habits = ref<Habit[]>([]);
const newHabitName = ref(''); const newHabitName = ref('');
// Day mapping based on Mon=0, ..., Sun=6 const dayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const dayOptions = [ const dayMap: { [key: number]: string } = { 0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun' };
{ name: 'Пн', value: 0 },
{ name: 'Вт', value: 1 },
{ name: 'Ср', value: 2 },
{ name: 'Чт', value: 3 },
{ name: 'Пт', value: 4 },
{ name: 'Сб', value: 5 },
{ name: 'Вс', value: 6 },
];
const dayMap: { [key: number]: string } = Object.fromEntries(dayOptions.map(d => [d.value, d.name]));
const newHabitDays = ref<string[]>([]); const newHabitDays = ref<string[]>([]);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const loading = ref({ const loading = ref({
fetch: false, fetch: false,
create: false, create: false,
edit: false,
delete: false,
}); });
// Editing state
const editingHabitId = ref<number | null>(null);
const editHabitName = ref('');
const editHabitDays = ref<string[]>([]);
// Deletion state
const isConfirmDialogOpen = ref(false);
const habitToDeleteId = ref<number | null>(null);
// --- API Functions --- // --- API Functions ---
const fetchHabits = async () => { const fetchHabits = async () => {
loading.value.fetch = true; loading.value.fetch = true;
@ -137,7 +67,7 @@ const fetchHabits = async () => {
habits.value = await api<Habit[]>('/habits'); habits.value = await api<Habit[]>('/habits');
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch habits:', err); console.error('Failed to fetch habits:', err);
error.value = 'Не удалось загрузить привычки.'; error.value = 'Could not load habits.';
} finally { } finally {
loading.value.fetch = false; loading.value.fetch = false;
} }
@ -145,16 +75,17 @@ const fetchHabits = async () => {
const createHabit = async () => { const createHabit = async () => {
if (!newHabitName.value || newHabitDays.value.length === 0) { if (!newHabitName.value || newHabitDays.value.length === 0) {
error.value = 'Пожалуйста, укажите название и выберите хотя бы один день.'; error.value = 'Please provide a name and select at least one day.';
return; return;
} }
loading.value.create = true; loading.value.create = true;
error.value = null; error.value = null;
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value); // Convert day names to numbers
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.indexOf(dayName));
try { try {
const newHabit = await api<Habit>('/habits', { await api<Habit>('/habits', {
method: 'POST', method: 'POST',
body: { body: {
name: newHabitName.value, name: newHabitName.value,
@ -162,115 +93,60 @@ const createHabit = async () => {
}, },
}); });
habits.value.push(newHabit); // Optimistic update // Clear form and re-fetch the list from the server
newHabitName.value = ''; newHabitName.value = '';
newHabitDays.value = []; newHabitDays.value = [];
await fetchHabits();
} catch (err: any) { } catch (err: any) {
console.error('Failed to create habit:', err); console.error('Failed to create habit:', err);
error.value = err.data?.message || 'Не удалось создать привычку.'; error.value = err.data?.message || 'Could not create habit.';
// Re-fetch on error to ensure consistency
} finally { } finally {
loading.value.create = false; loading.value.create = false;
} }
}; };
const startEditing = (habit: Habit) => {
editingHabitId.value = habit.id;
editHabitName.value = habit.name;
editHabitDays.value = habit.daysOfWeek.map(dayValue => dayMap[dayValue]);
};
const cancelEditing = () => {
editingHabitId.value = null;
editHabitName.value = '';
editHabitDays.value = [];
};
const saveHabit = async (habitId: number) => {
loading.value.edit = true;
error.value = null;
const dayNumbers = editHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
try {
const updatedHabit = await api<Habit>(`/habits/${habitId}`, {
method: 'PUT',
body: {
name: editHabitName.value,
daysOfWeek: dayNumbers,
},
});
// Update local state
const index = habits.value.findIndex(h => h.id === habitId);
if (index !== -1) {
habits.value[index] = updatedHabit;
}
cancelEditing();
} catch (err: any) {
console.error('Failed to save habit:', err);
error.value = err.data?.message || 'Не удалось сохранить привычку.';
} finally {
loading.value.edit = false;
}
};
const promptForDelete = (habitId: number) => {
habitToDeleteId.value = habitId;
isConfirmDialogOpen.value = true;
};
const handleDeleteCancel = () => {
isConfirmDialogOpen.value = false;
habitToDeleteId.value = null;
};
const handleDeleteConfirm = () => {
if (habitToDeleteId.value !== null) {
deleteHabit(habitToDeleteId.value);
}
handleDeleteCancel();
};
const deleteHabit = async (habitId: number) => {
loading.value.delete = true;
error.value = null;
try {
await api(`/habits/${habitId}`, { method: 'DELETE' });
// Update local state
habits.value = habits.value.filter(h => h.id !== habitId);
} catch (err: any) {
console.error('Failed to delete habit:', err);
error.value = err.data?.message || 'Не удалось удалить привычку.';
} finally {
loading.value.delete = false;
}
};
// --- Lifecycle Hooks --- // --- Lifecycle Hooks ---
onMounted(fetchHabits); onMounted(fetchHabits);
</script> </script>
<style scoped> <style scoped>
.form-container { .habits-container {
background-color: var(--container-bg-color); max-width: 600px;
padding: 24px; margin: 0 auto;
padding-bottom: 40px;
}
h3 {
text-align: center;
margin-bottom: 20px;
}
/* Create Form */
.create-habit-form {
background-color: #fff;
padding: 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05); box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 32px; margin-bottom: 30px;
border: 1px solid var(--border-color); }
.create-habit-form h4 {
margin-top: 0;
text-align: center;
}
.create-habit-form input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 15px;
} }
.days-selector { .days-selector {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
} }
@ -284,115 +160,71 @@ onMounted(fetchHabits);
} }
.day-label span { .day-label span {
display: flex; display: inline-block;
justify-content: center; width: 35px;
align-items: center; line-height: 35px;
width: 33px; border: 1px solid #ccc;
height: 33px;
border: 1px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s ease-in-out;
} }
.day-label input:checked + span { .day-label input:checked + span {
background-color: var(--primary-color); background-color: #81a1c1;
color: white; color: white;
border-color: var(--primary-color); border-color: #81a1c1;
} }
.form-container button { .create-habit-form button {
width: 100%; width: 100%;
padding: 10px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
} }
/* Habits List */ /* Habits List */
.habits-list { .habits-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 15px;
margin-bottom: 45px;
} }
.habit-card { .habit-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px; padding: 15px;
background-color: var(--container-bg-color); background-color: #fff;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.05); box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.habit-view-content { .habit-info h4 {
display: flex; margin: 0 0 10px 0;
flex-direction: column;
gap: 15px; /* Spacing between name, days, and actions */
width: 100%;
justify-content: flex-start;
align-items: flex-start;
}
.habit-info h3 {
margin: 0;
font-size: 1.15rem;
} }
.habit-days { .habit-days {
display: flex; display: flex;
flex-wrap: wrap; gap: 5px;
gap: 6px;
justify-content: flex-start;
padding: 15px 0 5px;
} }
.day-chip { .day-chip {
background-color: #e5e7eb; background-color: #eceff4;
color: #4b5563; color: #4c566a;
padding: 4px 10px; padding: 2px 6px;
border-radius: 16px; border-radius: 10px;
font-size: 0.8em; font-size: 0.8em;
font-weight: 500;
} }
.error-message { .error-message {
color: var(--danger-color); color: #bf616a;
background-color: #fee2e2; background-color: #fbe2e5;
padding: 1rem; padding: 10px;
border-radius: 0.375rem; border-radius: 4px;
margin-bottom: 1rem; margin-bottom: 15px;
text-align: center; text-align: center;
} }
.habit-actions {
display: flex;
gap: 10px;
justify-content: flex-start;
width: 100%;
}
/* Edit Form Specific Styles */
.habit-edit-form {
display: flex;
flex-direction: column;
width: 100%;
gap: 16px;
}
.habit-edit-form .form-group {
margin-bottom: 0;
}
.habit-edit-form .days-selector {
justify-content: flex-start;
margin-bottom: 0;
}
.edit-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +1,84 @@
<template> <template>
<div class="page-container"> <div class="leaderboard-container">
<h1>Доска почёта</h1> <h3>Monthly Leaderboard</h3>
<div v-if="pending" class="loading">Loading leaderboard...</div> <ul class="leaderboard-list">
<div v-else-if="error" class="error-container"> <li class="leaderboard-item">
<p>An error occurred while fetching the leaderboard. Please try again.</p> <span class="rank">1.</span>
</div> <span class="name">Papa Smurf</span>
<div v-else class="table-container"> <span class="exp">9800 EXP</span>
<table class="table table-striped table-hover"> </li>
<thead> <li class="leaderboard-item self">
<tr> <span class="rank">2.</span>
<th>Место</th> <span class="name">Smurfette</span>
<th>Имя</th> <span class="exp">8500 EXP</span>
<th>EXP</th> </li>
</tr> <li class="leaderboard-item">
</thead> <span class="rank">3.</span>
<tbody> <span class="name">Brainy Smurf</span>
<tr <span class="exp">8250 EXP</span>
v-for="entry in leaderboard" </li>
:key="entry.rank + entry.nickname" <li class="leaderboard-item">
:class="{ 'current-user-row': currentUser && currentUser.nickname === entry.nickname }" <span class="rank">4.</span>
> <span class="name">Hefty Smurf</span>
<td>{{ entry.rank }}</td> <span class="exp">7600 EXP</span>
<td>{{ entry.nickname }}</td> </li>
<td>{{ entry.exp }}</td> <li class="leaderboard-item">
</tr> <span class="rank">5.</span>
</tbody> <span class="name">Jokey Smurf</span>
</table> <span class="exp">6100 EXP</span>
</div> </li>
</ul>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { user: currentUser } = useAuth(); // Get current authenticated user // No logic, just visual placeholders
const { data, pending, error } = await useFetch('/api/leaderboard', {
lazy: true,
server: false,
});
const leaderboard = computed(() => data.value?.leaderboard || []);
</script> </script>
<style scoped> <style scoped>
.table-container { .leaderboard-container {
overflow-x: auto; max-width: 600px;
} margin: 0 auto;
.current-user-row > * {
background-color: #e0e7ff; /* A light blue/indigo for highlighting */
font-weight: 600;
color: var(--primary-color-hover);
} }
.table-hover > tbody > tr.current-user-row:hover > * { h3 {
background-color: #c7d2fe; /* A slightly darker shade for hover */ text-align: center;
margin-bottom: 20px;
}
.leaderboard-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.leaderboard-item {
display: flex;
align-items: center;
padding: 15px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.leaderboard-item.self {
background-color: #d8e1e9;
border: 1px solid #81a1c1;
}
.rank {
font-weight: bold;
width: 40px;
}
.name {
flex-grow: 1;
}
.exp {
font-weight: bold;
color: #4c566a;
} }
</style> </style>

View File

@ -1,25 +1,25 @@
<template> <template>
<div class="auth-container"> <div class="auth-page">
<div class="page-container auth-form"> <div class="auth-container">
<h1>Вход</h1> <h1>Login</h1>
<form @submit.prevent="handleLogin"> <form @submit.prevent="handleLogin">
<div class="form-group"> <div class="form-group">
<label for="email" class="form-label">Email</label> <label for="email">Email</label>
<input type="email" id="email" v-model="email" class="form-control" required /> <input type="email" id="email" v-model="email" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="form-label">Пароль</label> <label for="password">Password</label>
<input type="password" id="password" v-model="password" class="form-control" required /> <input type="password" id="password" v-model="password" required />
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="loading" class="btn btn-primary"> <button type="submit" :disabled="loading">
{{ loading ? 'Входим...' : 'Войти' }} {{ loading ? 'Logging in...' : 'Login' }}
</button> </button>
</form> </form>
<div class="switch-link"> <div class="switch-link">
<p> <p>
Don't have an account?
<NuxtLink to="/">Вернуться назад</NuxtLink> <NuxtLink to="/register">Register here</NuxtLink>
</p> </p>
</div> </div>
</div> </div>
@ -54,26 +54,59 @@ definePageMeta({
</script> </script>
<style scoped> <style scoped>
.auth-container { .auth-page {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
width: 100%; background-color: #f0f2f5;
} }
.auth-form { .auth-container {
width: 100%; width: 100%;
max-width: 420px; max-width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.auth-form button { h1 {
text-align: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
}
input {
width: 100%; width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #0056b3;
} }
.error-message { .error-message {
color: var(--danger-color); color: red;
background-color: #fee2e2; margin-bottom: 16px;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.switch-link { .switch-link {

View File

@ -1,30 +1,30 @@
<template> <template>
<div class="auth-container"> <div class="auth-page">
<div class="page-container auth-form"> <div class="auth-container">
<h1>Регистрация</h1> <h1>Register</h1>
<form @submit.prevent="handleRegister"> <form @submit.prevent="handleRegister">
<div class="form-group"> <div class="form-group">
<label for="nickname" class="form-label">Никнейм</label> <label for="nickname">Nickname</label>
<input type="text" id="nickname" v-model="nickname" class="form-control" required /> <input type="text" id="nickname" v-model="nickname" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email" class="form-label">Email</label> <label for="email">Email</label>
<input type="email" id="email" v-model="email" class="form-control" required /> <input type="email" id="email" v-model="email" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="form-label">Пароль (минимум 8 символов)</label> <label for="password">Password (min 8 characters)</label>
<input type="password" id="password" v-model="password" class="form-control" required /> <input type="password" id="password" v-model="password" required />
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<div v-if="successMessage" class="success-message">{{ successMessage }}</div> <div v-if="successMessage" class="success-message">{{ successMessage }}</div>
<button type="submit" :disabled="loading" class="btn btn-primary"> <button type="submit" :disabled="loading">
{{ loading ? 'Регистрируем...' : 'Зарегистрироваться' }} {{ loading ? 'Registering...' : 'Register' }}
</button> </button>
</form> </form>
<div class="switch-link"> <div class="switch-link">
<p> <p>
Уже есть аккаунт? Already have an account?
<NuxtLink to="/login">Войти</NuxtLink> <NuxtLink to="/login">Login here</NuxtLink>
</p> </p>
</div> </div>
</div> </div>
@ -55,7 +55,7 @@ const handleRegister = async () => {
password: password.value, password: password.value,
}, },
}); });
successMessage.value = 'Регистрация прошла успешно! Пожалуйста, войдите в систему.'; successMessage.value = 'Registration successful! Please log in.';
setTimeout(() => { setTimeout(() => {
navigateTo('/login'); navigateTo('/login');
}, 2000); }, 2000);
@ -72,34 +72,64 @@ definePageMeta({
</script> </script>
<style scoped> <style scoped>
.auth-container { .auth-page {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
width: 100%; background-color: #f0f2f5;
} }
.auth-form { .auth-container {
width: 100%; width: 100%;
max-width: 420px; max-width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.auth-form button { h1 {
text-align: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
}
input {
width: 100%; width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #218838;
} }
.error-message { .error-message {
color: var(--danger-color); color: red;
background-color: #fee2e2; margin-bottom: 16px;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.success-message { .success-message {
color: #16a34a; color: green;
background-color: #dcfce7; margin-bottom: 16px;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.switch-link { .switch-link {

View File

@ -1,90 +1,81 @@
<template> <template>
<div class="page-container village-page-layout"> <div class="village-page">
<h1>Моя деревня</h1> <h1>My Village</h1>
<div v-if="pending" class="loading">Загрузка вашей деревни...</div> <div v-if="pending" class="loading">Loading your village...</div>
<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">Please log in to view your village.</p>
<div v-else> <p v-else>An error occurred while fetching your village data. Please try again.</p>
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
<pre>{{ error }}</pre>
</div>
</div> </div>
<div v-else-if="villageData"> <div v-else-if="villageData" class="village-container">
<VillageGrid <div class="village-grid-wrapper">
:village-data="villageData" <div class="village-grid">
:selected-tile="selectedTile" <div
@tile-click="selectTile" v-for="tile in villageData.tiles"
/> :key="tile.id"
class="tile"
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
@click="selectTile(tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div>
</div>
</div>
<!-- Tile Info Overlay --> <!-- Tile Info Overlay -->
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null"> <div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
<div class="tile-overlay-panel" @click.stop> <div class="tile-overlay-panel" @click.stop>
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
<p>Terrain: {{ selectedTile.terrainType }}</p>
<p v-if="selectedTile.object">Object: {{ selectedTile.object.type }}</p>
<h2>{{ getTileTitle(selectedTile) }}</h2> <h3>Available Actions</h3>
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p> <div class="actions-list">
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container"> <button
<h3 class="actions-header">Что здесь можно сделать?</h3> :disabled="!action.isEnabled || isSubmitting"
<div class="actions-list"> @click="handleActionClick(action)"
<!-- Build Actions --> >
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section"> {{ getActionLabel(action) }}
<div class="build-card-grid"> </button>
<div <span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
v-for="action in selectedTile.availableActions.filter(a => a.type === 'BUILD')"
:key="action.buildingType"
class="building-card"
:class="{ disabled: !action.isEnabled }"
>
<div class="building-icon">{{ getBuildingEmoji(action.buildingType) }}</div>
<h5>{{ getBuildingName(action.buildingType) }}</h5>
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
<div class="building-footer">
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm btn-full-width">
{{ getActionLabel(action) }}
</button>
</div>
<div v-if="!action.isEnabled" class="disabled-overlay">
<span>{{ getDisabledReasonText(action) }}</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<button @click="selectedTile = null" class="close-overlay-button">Close</button>
<button @click="selectedTile = null" class="btn btn-secondary close-overlay-button">Закрыть</button>
</div> </div>
</div> </div>
</div>
<div class="bottom-content">
<!-- Admin Panel --> <!-- Admin Panel -->
<div v-if="villageData?.user?.id === 1" class="admin-panel"> <div v-if="villageData?.user?.id === 1" class="admin-panel">
<h3>Admin Tools</h3> <h3>Admin Tools</h3>
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button> <button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button> <button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</button> <button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2>
<div class="event-list">
<div v-for="event in villageEvents" :key="event.id" class="event-card">
<div class="event-card-header">
<span class="event-date">{{ new Date(event.createdAt).toLocaleString() }}</span>
<div class="event-rewards">
<span v-if="event.coins" class="event-reward-tag coins">{{ event.coins }} 💰</span>
<span v-if="event.exp" class="event-reward-tag exp">{{ event.exp }} </span>
</div>
</div>
<p class="event-message">{{ formatMessageCoordinates(event.message) }}</p>
</div>
</div> </div>
</div> <!-- Event Log -->
</div> <div v-if="villageEvents?.length" class="event-log-container">
<h4>Activity Log</h4>
<table class="event-log-table">
<thead>
<tr>
<th>Date</th>
<th>Event</th>
<th>Coins</th>
<th>EXP</th>
</tr>
</thead>
<tbody>
<tr v-for="event in villageEvents" :key="event.id">
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
<td>{{ event.message }}</td>
<td>{{ event.coins }}</td>
<td>{{ event.exp }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</template> </template>
@ -92,9 +83,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', { const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
lazy: true, lazy: true,
server: false, // Ensure this runs on the client-side server: false, // Ensure this runs on the client-side
@ -107,92 +95,37 @@ const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/vil
const selectedTile = ref(null); const selectedTile = ref(null);
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 selectTile = (tile) => { const selectTile = (tile) => {
selectedTile.value = tile; selectedTile.value = tile;
}; };
const getActionLabel = (action) => { const getActionLabel = (action) => {
if (action.type === 'BUILD') { if (action.type === 'BUILD') {
return `${action.cost} монет`; // Return cost instead of "Построить" return `${action.type} ${action.buildingType} (${action.cost} coins)`;
} }
return action.type; return action.type;
}; };
const getBuildingName = (buildingType) => {
const buildingNameMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return buildingNameMap[buildingType] || buildingType;
};
const getBuildingEmoji = (buildingType) => {
const emojiMap = {
'HOUSE': '🏠',
'FIELD': '🌱',
'LUMBERJACK': '🪓',
'QUARRY': '⛏️',
'WELL': '💧',
};
return emojiMap[buildingType] || '❓';
};
const getTileTitle = (tile) => {
if (tile.object) {
const buildingMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} ${formatCoordinates(tile.x, tile.y)}`;
}
const terrainMap = {
'BLOCKED_TREE': 'Лесной участок',
'BLOCKED_STONE': 'Каменистый участок',
'EMPTY': 'Пустырь',
};
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} ${formatCoordinates(tile.x, tile.y)}`;
};
const getTileDescription = (tile) => {
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
const descriptionMap = {
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
'EMPTY': 'Свободное место, готовое к застройке.',
};
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
};
const getBuildingDescription = (buildingType) => {
const descriptions = {
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
'WELL': 'Увеличивает производство опыта на соседних полях.',
};
return descriptions[buildingType] || '';
};
const getDisabledReasonText = (action) => {
const reasons = {
'Not enough coins': 'Не хватает монет.',
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
};
return reasons[action.disabledReason] || action.disabledReason;
};
const isSubmitting = ref(false); const isSubmitting = ref(false);
const handleActionClick = async (action) => { const handleActionClick = async (action) => {
@ -207,6 +140,7 @@ const handleActionClick = async (action) => {
actionType: action.type, actionType: action.type,
payload: { payload: {
...(action.type === 'BUILD' && { buildingType: action.buildingType }), ...(action.type === 'BUILD' && { buildingType: action.buildingType }),
...(action.type === 'MOVE' && { toTileId: action.toTileId }), // Assuming action.toTileId will be present for MOVE
}, },
}, },
}); });
@ -215,7 +149,6 @@ const handleActionClick = async (action) => {
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.'); alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
} else { } else {
villageData.value = response.data.value; villageData.value = response.data.value;
updateUser(response.data.value.user); // Update global user state
selectedTile.value = null; selectedTile.value = null;
await refreshEvents(); // Refresh the event log await refreshEvents(); // Refresh the event log
} }
@ -229,143 +162,196 @@ const handleActionClick = async (action) => {
const isSubmittingAdminAction = ref(false); const isSubmittingAdminAction = ref(false);
async function handleAdminAction(url: string) { async function handleAdminAction(url) {
if (isSubmittingAdminAction.value) return; if (isSubmittingAdminAction.value) return;
isSubmittingAdminAction.value = true; isSubmittingAdminAction.value = true;
try { try {
// 1. Perform the requested admin action (e.g., reset, trigger tick) const { error } = await useFetch(url, { method: 'POST' });
const { error: actionError } = await useFetch(url, { method: 'POST' }); if (error.value) {
if (actionError.value) { alert(error.value.data?.statusMessage || 'An admin action failed.');
// If the action itself fails, throw to stop execution } else {
throw actionError.value; // Refresh both data sources in parallel
await Promise.all([refreshVillageData(), refreshEvents()]);
} }
} catch (e) {
// 2. Refresh the main village data. This runs the core game logic console.error('Failed to perform admin action:', e);
// on the backend and gets the updated state. alert('An unexpected error occurred.');
await refreshVillageData();
// 3. The `villageData` ref is now updated. If it contains a user object,
// we sync it with the global auth state to update the header.
if (villageData.value?.user) {
updateUser(villageData.value.user);
}
// 4. Refresh the event log to show any new events created by the action.
await refreshEvents();
} catch (e: any) {
console.error(`Failed to perform admin action at ${url}:`, e);
// Use the error's data object if it exists for a more specific message
alert(e.data?.statusMessage || e.message || 'An unexpected error occurred during the admin action.');
} finally { } finally {
isSubmittingAdminAction.value = false; isSubmittingAdminAction.value = false;
} }
} }
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset'); const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick'); const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
</script> </script>
<style scoped> <style scoped>
.village-page-layout { .village-page {
--tile-size: clamp(55px, 12vw, 70px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 20px;
font-family: sans-serif;
min-height: calc(100vh - 120px); /* Adjust for top/bottom bars */
box-sizing: border-box; /* Include padding in element's total width and height */
} }
.loading, .error-container { .loading, .error-container {
margin-top: 50px; margin-top: 50px;
font-size: 1.2em; font-size: 1.2em;
color: #555; color: #555;
text-align: center;
} }
/* Overlay and other styles */ .village-container {
display: flex;
justify-content: center; /* Center grid */
width: 100%;
max-width: 350px; /* Adjust max-width for mobile view of grid (5*60px + 4*4px gap + 2*4px padding)*/
margin-top: 20px;
}
.village-grid-wrapper {
overflow-x: auto; /* In case grid ever exceeds viewport (though it shouldn't with max-width) */
}
.village-grid {
display: grid;
grid-template-columns: repeat(5, 60px);
grid-template-rows: repeat(7, 60px);
gap: 4px;
border: 2px solid #333;
padding: 4px;
background-color: #f0f0f0;
width: fit-content; /* Ensure grid does not expand unnecessarily */
margin: 0 auto; /* Center grid within its wrapper */
}
.tile {
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #ccc;
background-color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.tile:hover {
background-color: #e9e9e9;
}
.tile.selected {
border: 2px solid #007bff;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
}
.tile-content {
font-size: 2em;
}
/* Overlay Styles */
.tile-overlay-backdrop { .tile-overlay-backdrop {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.5);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-end; align-items: flex-end; /* Start from bottom for mobile-first feel */
z-index: 1000; z-index: 1000;
} }
.tile-overlay-panel { .tile-overlay-panel {
background-color: var(--background-color); background-color: #fff;
width: 100%; width: 100%;
max-width: 500px; max-width: 500px; /* Limit width for larger screens */
padding: 24px; padding: 20px;
border-top-left-radius: 15px; border-top-left-radius: 15px;
border-top-right-radius: 15px; border-top-right-radius: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
transform: translateY(0); /* For potential slide-in animation */
transition: transform 0.3s ease-out;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 15px;
max-height: 90vh;
overflow-y: auto;
} }
@media (min-width: 768px) { @media (min-width: 768px) { /* Center for desktop, less "bottom sheet" */
.tile-overlay-backdrop { .tile-overlay-backdrop {
align-items: center; align-items: center;
} }
.tile-overlay-panel { .tile-overlay-panel {
border-radius: 15px; border-radius: 15px;
max-height: 80vh; /* Don't cover entire screen */
} }
} }
.tile-overlay-panel h2 { .tile-overlay-panel h2 {
margin-top: 0;
text-align: center; text-align: center;
color: #333;
} }
.tile-description { .tile-overlay-panel p {
color: #666;
margin-bottom: 5px;
}
.actions-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.action-item button {
width: 100%;
padding: 10px 15px;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.2s, opacity 0.2s;
}
.action-item button:hover:not(:disabled) {
background-color: #0056b3;
}
.action-item button:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
border-color: #e9ecef;
}
.disabled-reason {
font-size: 0.8em;
color: #dc3545;
margin-top: 5px;
text-align: center; text-align: center;
margin: -10px;
color: var(--text-color-light);
font-style: italic;
}
.actions-container {
margin-top: 15px;
}
.actions-header {
text-align: center;
}
.build-section {
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.building-description {
font-size: 0.85em;
color: var(--text-color-light);
margin-top: 5px;
text-align: center;
} }
.close-overlay-button { .close-overlay-button {
width: 100%; width: 100%;
padding: 10px 15px;
margin-top: 15px; margin-top: 15px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
} }
.bottom-content { .close-overlay-button:hover {
width: 100%; background-color: #5a6268;
max-width: 800px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
margin-top: 24px;
} }
.admin-panel { .admin-panel {
@ -373,22 +359,23 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-top: 20px;
padding: 15px; padding: 15px;
border: 2px dashed var(--danger-color); border: 2px dashed #dc3545;
border-radius: 10px; border-radius: 10px;
width: 100%;
max-width: 350px; max-width: 350px;
width: 100%;
} }
.admin-panel h3 { .admin-panel h3 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: var(--danger-color); color: #dc3545;
} }
.admin-panel button { .admin-panel button {
width: 100%; width: 100%;
padding: 8px; padding: 8px;
background-color: var(--danger-color); background-color: #dc3545;
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@ -400,205 +387,30 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
cursor: not-allowed; cursor: not-allowed;
} }
.event-log-container { .event-log-container {
margin-top: 20px;
width: 100%; width: 100%;
max-width: 800px; max-width: 350px;
} }
.event-log-container h2 { .event-log-container h4 {
text-align: center; text-align: center;
margin-bottom: 16px; margin-bottom: 10px;
} }
.event-list { .event-log-table {
max-height: 400px;
overflow-y: auto;
padding-right: 10px; /* For scrollbar spacing */
display: flex;
flex-direction: column;
gap: 12px;
}
.event-card {
background-color: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.event-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.event-date {
font-size: 0.8rem;
color: var(--text-color-light);
}
.event-rewards {
display: flex;
gap: 8px;
}
.event-reward-tag {
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
}
.event-reward-tag.coins {
background-color: var(--warning-color-light);
color: var(--warning-color-dark);
}
.event-reward-tag.exp {
background-color: var(--info-color-light);
color: var(--info-color-dark);
}
.event-message {
font-size: 0.95rem;
line-height: 1.5;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.building-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 140px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: #fff;
text-align: center;
transition: box-shadow 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.building-card:not(.disabled):hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.building-card.disabled {
background-color: #f9fafb;
}
.building-icon {
font-size: 2.5em;
margin-bottom: 8px;
}
.building-card h5 {
margin: 0 0 8px 0;
font-size: 1.05em;
font-weight: 600;
}
.building-card .building-description {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-color-light);
flex-grow: 1;
margin-bottom: 16px;
}
.building-footer {
display: flex;
justify-content: center;
align-items: center;
width: 100%; width: 100%;
margin-top: auto; border-collapse: collapse;
font-size: 0.8em;
} }
.building-footer .btn-full-width { .event-log-table th, .event-log-table td {
width: 100%; border: 1px solid #ccc;
padding: 6px;
text-align: left;
} }
.building-footer .cost { .event-log-table th {
font-weight: 600; background-color: #f0f0f0;
font-size: 0.9em;
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(249, 250, 251, 0.85);
color: var(--danger-color);
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 10px;
border-radius: 8px;
cursor: not-allowed;
}
.disabled-overlay span {
font-size: 0.9em;
}
@media (max-width: 480px) {
.village-grid-wrapper {
gap: 4px;
padding: 8px;
}
.village-grid {
gap: 4px;
}
}
/* --- Responsive styles for mobile --- */
@media (max-width: 768px) {
/* No changes to village-grid-wrapper or village-grid for 'fr' units,
as the --tile-size clamping already handles mobile scaling without fr.
Removed margin/padding adjustments to keep it centered and scaled.
Removed position: relative and ::after pseudo-element for scroll shadow.
*/
.bottom-content {
width: 100%;
padding: 0 16px;
box-sizing: border-box;
}
.tile-overlay-panel {
padding: 16px; /* Reduce padding on mobile */
margin-bottom: 60px;
gap: 12px;
max-width: 95%; /* Make it almost full width */
border-top-left-radius: 15px;
border-top-right-radius: 15px;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.building-card {
width: 130px; /* A fixed width that allows for 2 cards per row on most phones */
}
} }
</style> </style>

View File

@ -1,237 +0,0 @@
/* assets/css/main.css */
:root {
/* New playful color palette */
--primary-color: #FF6B6B; /* Coral Red for high-contrast CTA */
--primary-color-hover: #FF4F4F;
--secondary-color: #4ECDC4; /* Teal for accents */
--secondary-color-hover: #3DB8AE;
--danger-color: #ef4444;
--danger-color-hover: #dc2626;
/* Softer backgrounds and text colors */
--background-color: #FDF8E9; /* Soft Cream */
--container-bg-color: #ffffff;
--text-color: #333333; /* Darker grey for better contrast on cream */
--text-color-light: #757575;
--border-color: #797979;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
padding: 0;
}
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background-color: var(--container-bg-color);
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
margin-top: 24px;
margin-bottom: 24px;
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
font-weight: 700; /* Bolder headings */
margin-top: 0;
}
h1 {
font-size: 2.5rem; /* 40px */
margin-bottom: 1rem;
text-align: center;
}
h2 {
font-size: 1.75rem; /* 28px */
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h3 {
font-size: 1.5rem; /* 24px */
margin-bottom: 0.75rem;
}
p {
line-height: 1.6;
margin-bottom: 1rem;
}
/* --- Buttons (New Style) --- */
.btn {
display: inline-block;
font-weight: 600;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 2px solid transparent;
padding: 0.875rem 1.75rem; /* Increased padding */
font-size: 1rem;
line-height: 1.5;
border-radius: 12px; /* Rounded corners */
cursor: pointer;
transition: all 0.2s ease-in-out;
text-decoration: none; /* For NuxtLink styled as button */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-primary {
color: #fff;
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-color-hover);
border-color: var(--primary-color-hover);
}
/* Secondary button is now an outline button */
.btn-secondary {
color: var(--primary-color);
background-color: transparent;
border-color: var(--primary-color);
}
.btn-secondary:hover {
background-color: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
}
.btn-danger {
color: #fff;
background-color: var(--danger-color);
border-color: var(--danger-color);
}
.btn-danger:hover {
background-color: var(--danger-color-hover);
border-color: var(--danger-color-hover);
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 8px;
}
/* --- Forms --- */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-control {
display: block;
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
line-height: 1.5;
color: var(--text-color);
background-color: #fff;
background-clip: padding-box;
border: 1px solid var(--border-color);
border-radius: 8px; /* More rounded */
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
box-sizing: border-box; /* Add this line */
}
.form-control:focus {
outline: 0;
border-color: var(--secondary-color); /* Use accent color for focus */
box-shadow: 0 0 0 0.25rem rgb(78 205 196 / 25%);
}
.form-select {
display: block;
width: 100%;
padding: 0.75rem 2.25rem 0.75rem 1rem;
-moz-padding-start: calc(1rem - 3px);
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: var(--text-color);
background-color: #fff;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 16px 12px;
border: 1px solid var(--border-color);
border-radius: 8px; /* More rounded */
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* --- Tables --- */
.table {
width: 100%;
margin-bottom: 1rem;
color: var(--text-color);
vertical-align: top;
border-color: var(--border-color);
border-collapse: collapse;
}
.table > :not(caption) > * > * {
padding: 0.75rem 0.75rem;
background-color: var(--container-bg-color);
border-bottom-width: 1px;
}
.table > thead {
vertical-align: bottom;
}
.table > thead > tr > th {
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: #f7f7f7;
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
background-color: #fafafa;
}
.table-hover > tbody > tr:hover > * {
background-color: #f1f1f1;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -1,61 +1,10 @@
// /middleware/auth.global.ts // /middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware((to) => {
const { isAuthenticated, initialized, updateUser } = useAuth(); const { isAuthenticated, initialized } = useAuth();
const { visitCalled } = useVisitTracker();
const api = useApi();
// Helper function to wait for auth initialization, with a timeout.
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;
}
// --- Daily Visit Registration --- // Do not run middleware until auth state is initialized on client-side
// This logic runs once per application load on the client-side for authenticated users. if (!initialized.value) {
if (process.client && isAuthenticated.value && !visitCalled.value) { return;
visitCalled.value = true; // Set flag immediately to prevent race conditions
try {
// Get the client's current date in "YYYY-MM-DD" format.
const gameDay = new Date().toISOString().slice(0, 10);
const updatedUser = await api('/api/user/visit', {
method: 'POST',
body: { gameDay }
});
if (updatedUser) {
updateUser(updatedUser);
}
} catch (e: any) {
console.error("[Auth Middleware] Failed to register daily visit.", e);
}
} }
// if the user is authenticated and tries to access /login, redirect to home // if the user is authenticated and tries to access /login, redirect to home

View File

@ -1,45 +0,0 @@
// middleware/village-tick.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
// We only run this logic on the client-side after navigation.
if (process.server) {
return;
}
const { isAuthenticated, initialized } = useAuth();
// Helper function to wait for auth initialization, with a timeout.
const waitForAuth = () => {
return new Promise((resolve) => {
if (initialized.value) {
return resolve(true);
}
const timeout = setTimeout(() => {
unwatch();
resolve(true); // Resolve even if timeout, so middleware doesn't block
}, 5000);
const unwatch = watch(initialized, (newValue) => {
if (newValue) {
clearTimeout(timeout);
unwatch();
resolve(true);
}
});
});
};
// Wait for auth to be initialized
await waitForAuth();
// If user is authenticated, trigger the village tick
if (isAuthenticated.value) {
const api = useApi();
try {
// We must await the call to ensure it completes before a new navigation can cancel it.
await api('/api/village/tick', { method: 'POST' });
} catch (e) {
// Even if we don't wait, a catch block is good practice in case the api call itself throws an error.
console.error('[Village Middleware] Failed to trigger village tick:', e);
}
}
});

View File

@ -1,12 +1,5 @@
// 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 }, compatibilityDate: '2025-07-15',
alias: { devtools: { enabled: true }
'~': resolve(__dirname, './'), // Root directory
'~/': resolve(__dirname, './'), // Root directory
'@': resolve(__dirname, './app'), // Source directory (app)
'@/': resolve(__dirname, './app'), // Source directory (app)
}
}) })

12
pages/index.vue Normal file
View File

@ -0,0 +1,12 @@
<!-- /pages/index.vue -->
<template>
<div>
<h1>Dashboard</h1>
<p v-if="user">Welcome, {{ user.nickname }}!</p>
<button @click="logout">Logout</button>
</div>
</template>
<script setup lang="ts">
const { user, logout } = useAuth();
</script>

62
pages/login.vue Normal file
View File

@ -0,0 +1,62 @@
<!-- /pages/login.vue -->
<template>
<div>
<h1>Login or Register</h1>
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()">
<div>
<label for="email">Email</label>
<input type="email" id="email" v-model="email" required />
</div>
<div v-if="isRegistering">
<label for="nickname">Nickname</label>
<input type="text" id="nickname" v-model="nickname" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" v-model="password" required />
</div>
<div v-if="error">{{ error }}</div>
<button type="submit" :disabled="loading">
{{ isRegistering ? 'Register' : 'Login' }}
</button>
</form>
<button @click="isRegistering = !isRegistering">
{{ isRegistering ? 'Switch to Login' : 'Switch to Register' }}
</button>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default', // Using the default layout
});
const { login, register, loading } = useAuth();
const isRegistering = ref(false);
const email = ref('');
const password = ref('');
const nickname = ref('');
const error = ref<string | null>(null);
const handleLogin = async () => {
error.value = null;
try {
await login(email.value, password.value);
} catch (e: any) {
error.value = e.data?.message || 'Login failed.';
}
};
const handleRegister = async () => {
error.value = null;
try {
await register(email.value, password.value, nickname.value);
// On successful registration, switch to the login view
isRegistering.value = false;
// You might want to auto-login or show a success message instead
} catch (e: any) {
error.value = e.data?.message || 'Registration failed.';
}
};
</script>

197
pages/village.vue Normal file
View File

@ -0,0 +1,197 @@
<template>
<div class="village-page">
<h1>My Village</h1>
<div v-if="pending" class="loading">Loading your village...</div>
<div v-else-if="error" class="error-container">
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
<p v-else>An error occurred while fetching your village data. Please try again.</p>
</div>
<div v-else-if="villageData" class="village-container">
<div class="village-grid">
<div
v-for="tile in villageData.tiles"
:key="tile.id"
class="tile"
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
@click="selectTile(tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div>
</div>
<div v-if="selectedTile" class="action-panel">
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
<div class="actions-list">
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
<button
:disabled="!action.isEnabled"
@click="handleActionClick(action)"
>
{{ getActionLabel(action) }}
</button>
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
</div>
</div>
<button @click="selectedTile = null" class="close-panel">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const { data: villageData, pending, error } = await useFetch('/api/village', {
lazy: true,
server: false, // Ensure this runs on the client-side
});
const selectedTile = ref(null);
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 selectTile = (tile) => {
selectedTile.value = tile;
};
const getActionLabel = (action) => {
if (action.type === 'BUILD') {
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
}
return action.type;
};
const handleActionClick = (action) => {
console.log('Action clicked:', action);
// In a future task, this will dispatch the action to the backend.
};
</script>
<style scoped>
.village-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
font-family: sans-serif;
}
.loading, .error-container {
margin-top: 50px;
font-size: 1.2em;
color: #555;
}
.village-container {
display: flex;
gap: 20px;
margin-top: 20px;
width: 100%;
justify-content: center;
}
.village-grid {
display: grid;
grid-template-columns: repeat(5, 60px);
grid-template-rows: repeat(7, 60px);
gap: 4px;
border: 2px solid #333;
padding: 4px;
background-color: #f0f0f0;
}
.tile {
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #ccc;
background-color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.tile:hover {
background-color: #e9e9e9;
}
.tile.selected {
border: 2px solid #007bff;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
}
.tile-content {
font-size: 2em;
}
.action-panel {
width: 300px;
border: 1px solid #ccc;
padding: 20px;
background-color: #fafafa;
}
.action-panel h2 {
margin-top: 0;
text-align: center;
}
.actions-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.action-item {
display: flex;
flex-direction: column;
}
.action-item button {
padding: 10px;
font-size: 1em;
cursor: pointer;
border: 1px solid #ccc;
background-color: #fff;
}
.action-item button:disabled {
cursor: not-allowed;
background-color: #eee;
color: #999;
}
.disabled-reason {
font-size: 0.8em;
color: #d9534f;
margin-top: 5px;
}
.close-panel {
margin-top: 20px;
width: 100%;
padding: 10px;
}
</style>

View File

@ -1,23 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"nickname" TEXT,
"avatar" TEXT DEFAULT '/avatars/default.png',
"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", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Village" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"lastTickDay" TEXT,
CONSTRAINT "Village_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Village" ("id", "userId") SELECT "id", "userId" FROM "Village";
DROP TABLE "Village";
ALTER TABLE "new_Village" RENAME TO "Village";
CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -1,25 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyVisit" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "DailyVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_DailyVisit" ("date", "id", "userId") SELECT "date", "id", "userId" FROM "DailyVisit";
DROP TABLE "DailyVisit";
ALTER TABLE "new_DailyVisit" RENAME TO "DailyVisit";
CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date");
CREATE TABLE "new_HabitCompletion" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"habitId" INTEGER NOT NULL,
CONSTRAINT "HabitCompletion_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "Habit" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_HabitCompletion" ("date", "habitId", "id") SELECT "date", "habitId", "id" FROM "HabitCompletion";
DROP TABLE "HabitCompletion";
ALTER TABLE "new_HabitCompletion" RENAME TO "HabitCompletion";
CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -40,17 +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())
anonymousSessionId String? @unique email 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)
dailyStreak Int @default(0)
// User settings // User settings
soundOn Boolean @default(true) soundOn Boolean @default(true)
@ -84,11 +80,11 @@ model Habit {
// HabitCompletion: Records a single completion of a habit on a specific date. // HabitCompletion: Records a single completion of a habit on a specific date.
// This creates a history of the user's progress for each habit. // This creates a history of the user's progress for each habit.
model HabitCompletion { model HabitCompletion {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
date String // YYYY-MM-DD format date DateTime // Store only the date part
// Relations // Relations
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade) habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
habitId Int habitId Int
@@unique([habitId, date]) // A habit can only be completed once per day @@unique([habitId, date]) // A habit can only be completed once per day
@ -97,11 +93,11 @@ model HabitCompletion {
// DailyVisit: Tracks the user's daily visit for the "I visited the site today" // DailyVisit: Tracks the user's daily visit for the "I visited the site today"
// quest and for calculating 5-day streaks. // quest and for calculating 5-day streaks.
model DailyVisit { model DailyVisit {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
date String // YYYY-MM-DD format date DateTime // Store only the date part
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int userId Int
@@unique([userId, date]) // A user can only have one recorded visit per day @@unique([userId, date]) // A user can only have one recorded visit per day
@ -110,14 +106,14 @@ model DailyVisit {
// Village: The user's personal village, which acts as a container for all // Village: The user's personal village, which acts as a container for all
// village objects. Each user has exactly one village. // village objects. Each user has exactly one village.
model Village { model Village {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int @unique
lastTickDay String?
tiles VillageTile[] // Relations
objects VillageObject[] user User @relation(fields: [userId], references: [id], onDelete: Cascade)
events VillageEvent[] userId Int @unique // Each user has only one village
objects VillageObject[]
tiles VillageTile[]
events VillageEvent[]
} }
// VillageObject: An object (e.g., house, field) placed on a village tile. // VillageObject: An object (e.g., house, field) placed on a village tile.
@ -125,7 +121,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())
lastExpDay String? lastExpAt DateTime?
// Crop details (only if type is FIELD) // Crop details (only if type is FIELD)
cropType CropType? cropType CropType?
@ -144,7 +140,7 @@ model VillageTile {
y Int y Int
terrainType TerrainType terrainType TerrainType
terrainState TerrainState @default(IDLE) terrainState TerrainState @default(IDLE)
clearingStartedDay String? clearingStartedAt DateTime?
// Relations // Relations
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)

View File

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

View File

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

View File

@ -0,0 +1,65 @@
// server/api/admin/village/complete-clearing.post.ts
import { getUserIdFromSession } from '../../../utils/auth';
import { PrismaClient } from '@prisma/client';
import { REWARDS } from '../../../utils/economy';
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
// Simple admin check
if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
}
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
const tilesToComplete = await prisma.villageTile.findMany({
where: {
villageId: village.id,
terrainState: 'CLEARING',
},
});
if (tilesToComplete.length === 0) {
return { success: true, message: 'No clearing tasks to complete.' };
}
const totalCoins = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.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: tilesToComplete.map(t => t.id) } },
data: { terrainState: 'IDLE', terrainType: 'EMPTY', clearingStartedAt: null },
});
// 3. Create an event for each completed tile
for (const tile of tilesToComplete) {
await tx.villageEvent.create({
data: {
villageId: village.id,
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
}
});
}
});
return { success: true, message: `Completed ${tilesToComplete.length} clearing tasks.` };
});

View File

@ -1,12 +1,12 @@
// server/api/admin/village/reset.post.ts // server/api/admin/village/reset.post.ts
import { getAuthenticatedUserId } from '../../../utils/auth'; import { getUserIdFromSession } 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 = getAuthenticatedUserId(event); const userId = await getUserIdFromSession(event);
// Simple admin check // Simple admin check
if (userId !== 1) { if (userId !== 1) {

View File

@ -1,61 +1,49 @@
// server/api/admin/village/trigger-tick.post.ts // server/api/admin/village/trigger-tick.post.ts
import { getAuthenticatedUserId } from '../../../utils/auth'; import { getUserIdFromSession } 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.
* Admin endpoint to manually trigger the game logic tick. const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours
* 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 = getAuthenticatedUserId(event); const userId = await getUserIdFromSession(event);
// Simple admin check // Simple admin check
if (userId !== 1) { if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
} }
const previousDay = getPreviousDay();
const [fieldResult, tileResult, villageResult] = await prisma.$transaction([ const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
// 1. Update lastExpDay for all FIELD objects for this user's village
prisma.villageObject.updateMany({
where: {
village: { userId: userId },
type: 'FIELD',
},
data: {
lastExpDay: previousDay,
},
}),
// 2. Update clearingStartedDay for all CLEANING VillageTile objects for this user's village const now = Date.now();
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS + 5000); // 5 seconds past completion
await prisma.$transaction([
// 1. Fast-forward any tiles that are currently being cleared
prisma.villageTile.updateMany({ prisma.villageTile.updateMany({
where: { where: {
village: { userId: userId }, villageId: village.id,
terrainState: 'CLEARING', terrainState: 'CLEARING',
}, },
data: { data: {
clearingStartedDay: previousDay, clearingStartedAt: clearingFastForwardDate,
}, },
}), }),
// 3. Update the village's lastTickDay // 2. Fast-forward any fields to be ready for EXP gain
prisma.village.updateMany({ prisma.villageObject.updateMany({
where: { where: {
userId: userId, villageId: village.id,
type: 'FIELD',
}, },
data: { data: {
lastTickDay: previousDay, lastExpAt: yesterday,
}, },
}), }),
]); ]);
return { return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' };
success: true,
message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}. Village lastTickDay updated.`
};
}); });

View File

@ -1,16 +1,26 @@
export default defineEventHandler(async (event) => { import { getUserIdFromSession } from '../../utils/auth';
const user = event.context.user;
// The auth middleware has already populated event.context.user. export default defineEventHandler(async (event) => {
// We just need to verify it's a permanent user (has an email). // 1. Get user ID from session; this helper handles the 401 check.
if (!user || !user.email) { const userId = await getUserIdFromSession(event);
// 2. Fetch the full user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
});
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: No active session.', statusMessage: 'Unauthorized: User not found.',
}); });
} }
// Return the user data DTO, which is already available on the context. // 3. Return user data DTO
return { return {
user: { user: {
id: user.id, id: user.id,
@ -19,7 +29,6 @@ export default defineEventHandler(async (event) => {
avatar: user.avatar, avatar: user.avatar,
coins: user.coins, coins: user.coins,
exp: user.exp, exp: user.exp,
dailyStreak: user.dailyStreak,
soundOn: user.soundOn, soundOn: user.soundOn,
confettiOn: user.confettiOn, confettiOn: user.confettiOn,
createdAt: user.createdAt, createdAt: user.createdAt,

View File

@ -1,89 +1,56 @@
import { hashPassword } from '~/server/utils/password'; import { hashPassword } from '../../utils/password';
import { generateVillageForUser } from '~/server/services/villageService'; import { generateVillageForUser } from '../../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 { email, password, nickname } = await readBody(event); const body = await readBody(event);
const { email, password, nickname } = body;
// --- 1. Input Validation --- // 1. Validate input
if (!email || !password) { if (!email || !password) {
throw createError({ statusCode: 400, statusMessage: 'Email and password are required' }); throw createError({
statusCode: 400,
statusMessage: 'Email and password are required',
});
} }
if (password.length < 8) { if (password.length < 8) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' }); throw createError({
statusCode: 400,
statusMessage: 'Password must be at least 8 characters long',
});
} }
const normalizedEmail = email.toLowerCase(); const normalizedEmail = email.toLowerCase(); // Normalize email
// Check if email is already in use by a permanent account // 2. Check if user already exists
const existingPermanentUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findUnique({
where: { email: normalizedEmail, isAnonymous: false }, where: { email: normalizedEmail },
}); });
if (existingPermanentUser) { if (existingUser) {
throw createError({ statusCode: 409, statusMessage: 'Email already in use' }); throw createError({
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);
let user; const user = await prisma.user.create({
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
},
});
// --- 2. Identify User Flow (Anonymous Conversion vs. Standard) --- // 4. Generate the user's village
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME); await generateVillageForUser(user);
const anonymousUser = anonymousSessionId
? await prisma.user.findUnique({ where: { anonymousSessionId } })
: null;
if (anonymousUser) { // NOTE: Registration does not automatically log in the user.
// --- Flow A: Convert Anonymous User --- // The user needs to explicitly call the login endpoint after registration.
user = await prisma.user.update({
where: { id: anonymousUser.id },
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
isAnonymous: false, // Make the user permanent
anonymousSessionId: null, // Invalidate the anonymous session ID
},
});
// The village and progress are already associated with this user.
// Invalidate the anonymous cookie // 5. Return the new user, excluding sensitive fields and shortening DTO
setCookie(event, ANONYMOUS_COOKIE_NAME, '', { maxAge: -1 });
} else {
// --- Flow B: Create New User ---
user = await prisma.user.create({
data: {
email: normalizedEmail,
password: hashedPassword,
nickname: nickname || 'New Smurf',
isAnonymous: false,
},
});
// Generate a new village for the brand new user
await generateVillageForUser(user);
}
// --- 3. Automatically log the user in ---
const session = await useSession(event, { password: process.env.SESSION_PASSWORD! });
await session.update({ user: { id: user.id } });
// --- 4. Return DTO ---
return { return {
user: { user: {
id: user.id, id: user.id,
@ -91,4 +58,4 @@ export default defineEventHandler(async (event) => {
nickname: user.nickname, nickname: user.nickname,
} }
}; };
}); });

View File

@ -1,15 +0,0 @@
// server/api/economy/constants.get.ts
import { COSTS, REWARDS } from '~/server/utils/economy';
export default defineEventHandler(() => {
return {
rewards: {
onboardingCompletion: REWARDS.HABITS.ONBOARDING_COMPLETION.coins,
},
costs: {
build: {
house: COSTS.BUILD.HOUSE,
}
}
};
});

View File

@ -1,60 +1,62 @@
import { getAuthenticatedUserId } from '../../../utils/auth'; import { getUserIdFromSession } from '../../../utils/auth';
import { REWARDS } from '../../../utils/economy'; import { REWARDS } from '../../../utils/economy';
import prisma from '../../../utils/prisma'; import { PrismaClient } from '@prisma/client';
import { applyStreakMultiplier } from '../../../utils/streak';
import { getDayOfWeekFromGameDay } from '~/server/utils/gameDay'; const prisma = new PrismaClient();
interface CompletionResponse { interface CompletionResponse {
message: string; message: string;
reward: { reward: {
coins: number; coins: number;
exp: number; exp: number; // Added
}; };
updatedCoins: number; updatedCoins: number;
updatedExp: number; updatedExp: number; // Added
}
/**
* Creates a Date object for the start of a given day in UTC.
* This is duplicated here as per the instruction not to create new shared utilities.
*/
function getStartOfDay(date: Date): Date {
const startOfDay = new Date(date);
startOfDay.setUTCHours(0, 0, 0, 0);
return startOfDay;
} }
export default defineEventHandler(async (event): Promise<CompletionResponse> => { export default defineEventHandler(async (event): Promise<CompletionResponse> => {
const userId = getAuthenticatedUserId(event); const userId = await getUserIdFromSession(event);
const habitId = parseInt(event.context.params?.id || '', 10); const habitId = parseInt(event.context.params?.id || '', 10);
const body = await readBody(event);
const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD"
if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' });
}
if (isNaN(habitId)) { if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' }); throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
} }
// Fetch user and habit in parallel const habit = await prisma.habit.findFirst({
const [user, habit] = await Promise.all([ where: { id: habitId, userId },
prisma.user.findUnique({ where: { id: userId } }), });
prisma.habit.findFirst({ where: { id: habitId, userId } })
]);
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'User not found.' });
}
if (!habit) { if (!habit) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' }); throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
} }
const appDayOfWeek = getDayOfWeekFromGameDay(gameDay); const today = new Date();
const jsDayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
// For permanent users, ensure the habit is scheduled for today. if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
// Anonymous users in the onboarding flow can complete it on any day. throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
if (!user.isAnonymous) {
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
}
} }
// Normalize date to the beginning of the day for consistent checks
const startOfToday = getStartOfDay(new Date()); // Correctly get a Date object
const existingCompletion = await prisma.habitCompletion.findFirst({ const existingCompletion = await prisma.habitCompletion.findFirst({
where: { where: {
habitId: habitId, habitId: habitId,
date: gameDay, date: startOfToday, // Use precise equality check
}, },
}); });
@ -62,36 +64,25 @@ 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.' });
} }
// Determine the reward based on user type const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
let finalReward: { coins: number, exp: number }; const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
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
// Streak defaults to 1 for multiplier if it's 0 or null
const currentDailyStreak = user.dailyStreak && user.dailyStreak > 0 ? user.dailyStreak : 1;
const baseReward = REWARDS.HABITS.COMPLETION;
finalReward = applyStreakMultiplier(baseReward, currentDailyStreak);
}
const village = await prisma.village.findUnique({ where: { userId } }); const village = await prisma.village.findUnique({ where: { userId } });
const [, updatedUser] = await prisma.$transaction([ const [, updatedUser] = await prisma.$transaction([
prisma.habitCompletion.create({ prisma.habitCompletion.create({
data: { data: {
habitId: habitId, habitId: habitId,
date: gameDay, date: startOfToday, // Save the normalized date
}, },
}), }),
prisma.user.update({ prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
coins: { coins: {
increment: finalReward.coins, increment: rewardCoins,
}, },
exp: { exp: { // Added
increment: finalReward.exp, increment: rewardExp, // Added
}, },
}, },
}), }),
@ -99,18 +90,20 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
data: { data: {
villageId: village.id, villageId: village.id,
type: 'HABIT_COMPLETION', type: 'HABIT_COMPLETION',
message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) увеличила награду!` : ''}`, message: `Completed habit: "${habit.name}"`,
coins: finalReward.coins, coins: rewardCoins,
exp: finalReward.exp, exp: rewardExp, // Changed from 0 to rewardExp
} }
})] : []), })] : []),
]); ]);
return { return {
message: 'Habit completed successfully!', message: 'Habit completed successfully!',
reward: finalReward, reward: {
coins: rewardCoins,
exp: rewardExp, // Added
},
updatedCoins: updatedUser.coins, updatedCoins: updatedUser.coins,
updatedExp: updatedUser.exp, updatedExp: updatedUser.exp, // Added
}; };
}); });

View File

@ -1,36 +0,0 @@
import { getAuthenticatedUserId } from '../../../utils/auth';
export default defineEventHandler(async (event) => {
const userId = getAuthenticatedUserId(event);
const habitId = parseInt(event.context.params?.id || '', 10);
if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
// --- Authorization & Deletion ---
// First, verify the habit exists and belongs to the user.
const habit = await prisma.habit.findUnique({
where: {
id: habitId,
},
});
if (!habit || habit.userId !== userId) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
}
// Now, delete the habit
await prisma.habit.delete({
where: {
id: habitId,
},
});
// --- Response ---
// Send 204 No Content status
setResponseStatus(event, 204);
// Return null or an empty body
return null;
});

View File

@ -1,56 +0,0 @@
import { getAuthenticatedUserId } from '../../../utils/auth';
interface HabitDto {
id: number;
name: string;
daysOfWeek: number[];
}
export default defineEventHandler(async (event): Promise<HabitDto> => {
const userId = getAuthenticatedUserId(event);
const habitId = Number(event.context.params?.id);
const { name, daysOfWeek } = await readBody(event);
if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
// --- Validation ---
if (!name || !Array.isArray(daysOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid input: name and daysOfWeek are required.' });
}
// Sanitize daysOfWeek to ensure it's a unique set of valid numbers
const validDays = daysOfWeek.filter(day => typeof day === 'number' && day >= 0 && day <= 6);
const sanitizedDays = [...new Set(validDays)].sort();
// --- Authorization & Update ---
// First, verify the habit exists and belongs to the user.
const habit = await prisma.habit.findUnique({
where: {
id: habitId,
},
});
if (!habit || habit.userId !== userId) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
}
// Now, update the habit
const updatedHabit = await prisma.habit.update({
where: {
id: habitId,
},
data: {
name,
daysOfWeek: sanitizedDays,
},
});
// Return DTO
return {
id: updatedHabit.id,
name: updatedHabit.name,
daysOfWeek: updatedHabit.daysOfWeek as number[],
};
});

View File

@ -1,4 +1,4 @@
import { getAuthenticatedUserId } from '../../utils/auth'; import { getUserIdFromSession } 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 = getAuthenticatedUserId(event); const userId = await getUserIdFromSession(event);
const habits = await prisma.habit.findMany({ const habits = await prisma.habit.findMany({
where: { where: {

View File

@ -1,4 +1,4 @@
import { getAuthenticatedUserId } from '../../utils/auth'; import { getUserIdFromSession } 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 = getAuthenticatedUserId(event); const userId = await getUserIdFromSession(event);
const { name, daysOfWeek } = await readBody(event); const { name, daysOfWeek } = await readBody(event);
// --- Validation --- // --- Validation ---

View File

@ -7,12 +7,6 @@ export default defineEventHandler(async () => {
// for "current month's EXP". This should be revisited if true monthly // for "current month's EXP". This should be revisited if true monthly
// tracking becomes a requirement. // tracking becomes a requirement.
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: {
isAnonymous: false,
nickname: {
not: null,
},
},
select: { select: {
nickname: true, nickname: true,
avatar: true, avatar: true,

View File

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

View File

@ -1,96 +0,0 @@
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,
anonymousSessionId: true,
village: { select: { id: true } } // Include village ID for deletion
}
});
// If a valid anonymous user is found for this session, reset their progress.
if (user) {
const villageId = user.village?.id;
// Use a transaction to atomically delete all progress.
await prisma.$transaction([
// 1. Delete all habits (HabitCompletion records will cascade)
prisma.habit.deleteMany({ where: { userId: user.id } }),
// 2. Delete all village objects to break the dependency on tiles before deleting the village
...(villageId ? [prisma.villageObject.deleteMany({ where: { villageId } })] : []),
// 3. Delete the village if it exists (all related records will cascade)
...(villageId ? [prisma.village.delete({ where: { id: villageId } })] : []),
]);
// Re-create the village from scratch. This service will also set initial coins.
await generateVillageForUser(user);
// Fetch the final, reset state of the user to return
const resetUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
anonymousSessionId: true,
nickname: true,
coins: true,
exp: true,
isAnonymous: true
}
});
return resetUser;
}
}
// No valid session found, create a new anonymous user.
const newSessionId = randomUUID();
const newUser = await prisma.user.create({
data: {
isAnonymous: true,
anonymousSessionId: newSessionId,
},
select: {
id: true, // Also return ID
anonymousSessionId: true,
nickname: true,
coins: true,
exp: true,
isAnonymous: true // Ensure this flag is returned
}
});
// Now, generate the village with default tiles for the new anonymous user
await generateVillageForUser(newUser);
// Set a long-lived cookie (e.g., 1 year)
setCookie(event, ANONYMOUS_COOKIE_NAME, newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60 // 1 year in seconds
});
return newUser;
});

View File

@ -0,0 +1,109 @@
import { getUserIdFromSession } from '../../utils/auth';
import { REWARDS } from '../../utils/economy';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface DailyVisitResponse {
message: string;
reward: {
coins: number;
streakBonus: boolean;
};
updatedCoins: number;
}
/**
* Creates a Date object for the start of a given day in UTC.
*/
function getStartOfDay(date: Date): Date {
const startOfDay = new Date(date);
startOfDay.setUTCHours(0, 0, 0, 0);
return startOfDay;
}
export default defineEventHandler(async (event): Promise<DailyVisitResponse> => {
const userId = await getUserIdFromSession(event);
const today = getStartOfDay(new Date());
// 1. Check if the user has already claimed the reward today
const existingVisit = await prisma.dailyVisit.findUnique({
where: {
userId_date: {
userId,
date: today,
},
},
});
if (existingVisit) {
throw createError({
statusCode: 409,
statusMessage: 'Daily visit reward has already been claimed today.',
});
}
// 2. Check for a 5-day consecutive streak (i.e., visits on the 4 previous days)
const previousDates = Array.from({ length: 4 }, (_, i) => {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - (i + 1));
return d;
});
const priorVisitsCount = await prisma.dailyVisit.count({
where: {
userId,
date: {
in: previousDates,
},
},
});
const hasStreak = priorVisitsCount === 4;
// 3. Calculate rewards and update the database in a transaction
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
let message = 'Daily visit claimed!';
if (hasStreak) {
totalReward += REWARDS.QUESTS.DAILY_VISIT.STREAK_BONUS.coins;
message = 'Daily visit and streak bonus claimed!';
}
const village = await prisma.village.findUnique({ where: { userId } });
const [, updatedUser] = await prisma.$transaction([
prisma.dailyVisit.create({
data: {
userId,
date: today,
},
}),
prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: totalReward,
},
},
}),
...(village ? [prisma.villageEvent.create({
data: {
villageId: village.id,
type: 'QUEST_DAILY_VISIT',
message,
coins: totalReward,
exp: 0,
}
})] : []),
]);
// 4. Return the response
return {
message,
reward: {
coins: totalReward,
streakBonus: hasStreak,
},
updatedCoins: updatedUser.coins,
};
});

View File

@ -1,32 +0,0 @@
import { getAuthenticatedUserId } from '../../utils/auth';
import prisma from '../../utils/prisma';
import { calculateDailyStreak } from '../../utils/streak';
export default defineEventHandler(async (event) => {
const userId = getAuthenticatedUserId(event);
const body = await readBody(event);
const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD"
if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' });
}
// Calculate the streak and create today's visit record
const updatedUser = await calculateDailyStreak(prisma, userId, gameDay);
// The consumer of this endpoint needs the most up-to-date user info,
// including the newly calculated streak.
return {
id: updatedUser.id,
email: updatedUser.email,
nickname: updatedUser.nickname,
avatar: updatedUser.avatar,
coins: updatedUser.coins,
exp: updatedUser.exp,
dailyStreak: updatedUser.dailyStreak,
soundOn: updatedUser.soundOn,
confettiOn: updatedUser.confettiOn,
createdAt: updatedUser.createdAt,
updatedAt: updatedUser.updatedAt,
}
});

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// server/api/village/index.get.ts // server/api/village/index.get.ts
import { syncAndGetVillage, generateVillageForUser } from '../../services/villageService'; import { getVillageState, 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 syncAndGetVillage(user.id); const villageState = await getVillageState(user.id);
return villageState; return villageState;
} catch (error: any) { } catch (error: any) {
// Catch errors from the service and re-throw them as H3 errors // Catch errors from the service and re-throw them as H3 errors

View File

@ -1,39 +0,0 @@
// server/api/village/tick.post.ts
import { getAuthenticatedUserId } from '~/server/utils/auth';
import { processVillageTick } from '~/server/services/villageService';
/**
* This endpoint is called on every route change for an authenticated user.
* It's responsible for triggering the "tick" of the village simulation,
* which can include things like clearing tiles, generating resources, etc.
*/
export default defineEventHandler(async (event) => {
try {
const userId = getAuthenticatedUserId(event);
// Delegate the core logic to the villageService
const result = await processVillageTick(userId);
// The response can be simple, or return a meaningful state if the client needs to react.
// For now, a success message is sufficient.
return {
success: true,
data: result,
};
} catch (e: any) {
// If getAuthenticatedUserId throws, it will be a 401 error, which should be propagated.
if (e.statusCode === 401) {
throw e;
}
console.error('Error processing village tick:', e);
// For other errors, return a generic 500.
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error',
message: 'An unexpected error occurred while processing the village tick.',
});
}
});

View File

@ -2,15 +2,12 @@
import { defineEventHandler, useSession } from 'h3'; import { defineEventHandler, useSession } from 'h3';
import prisma from '../utils/prisma'; import prisma from '../utils/prisma';
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 first checks for a logged-in user session. If not found, it checks for an * It safely checks for a session and fetches the user from the database if a
* anonymous user session cookie. It attaches the corresponding user object to * valid session ID is found. It does NOT block requests or throw errors if the
* `event.context.user` if found. It does NOT block requests, allowing auth * user is not authenticated, as authorization is handled within API endpoints themselves.
* 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.
@ -19,13 +16,14 @@ export default defineEventHandler(async (event) => {
return; return;
} }
// 1. Check for a logged-in user session // Safely get the 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({
@ -34,27 +32,11 @@ export default defineEventHandler(async (event) => {
if (user) { if (user) {
event.context.user = user; event.context.user = user;
return; // Found a 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),
// 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); console.error('Error fetching user in auth middleware:', error);
} }
} }
// 2. If no logged-in user, check for an anonymous session
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
if (anonymousSessionId) {
try {
const anonymousUser = await prisma.user.findUnique({
where: { anonymousSessionId: anonymousSessionId, isAnonymous: true },
});
if (anonymousUser) {
event.context.user = anonymousUser;
}
} catch (error) {
console.error('Error fetching anonymous user in auth middleware:', error);
}
}
}); });

View File

@ -1,33 +1,80 @@
import { import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
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';
import { calculateDailyStreak } from '../utils/streak';
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 = [ export const PRODUCING_BUILDINGS: string[] = [
'FIELD', 'FIELD',
'LUMBERJACK', 'LUMBERJACK',
'QUARRY', 'QUARRY',
] as const; ];
/* ========================= // Helper to get the start of a given date for daily EXP checks
TYPES 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<{ type FullVillage = Prisma.VillageGetPayload<{
include: { include: {
@ -37,305 +84,14 @@ type FullVillage = Prisma.VillageGetPayload<{
}; };
}>; }>;
/* =========================
PUBLIC API
========================= */
/** /**
* Processes the village's daily "tick" if necessary and returns the * Gets the full, updated state of a user's village, calculating all time-based progression.
* complete, up-to-date village state.
* This is the single source of truth for all time-based progression.
*/ */
export async function processVillageTick(userId: number): Promise<FullVillage> { export async function getVillageState(userId: number): Promise<FullVillage> {
try { const now = new Date();
const today = getTodayDay();
let villageSnapshot = await fetchVillage(userId); // --- Step 1: Initial Snapshot ---
let villageSnapshot = await prisma.village.findUnique({
if (!villageSnapshot) {
// This should not happen for a logged-in user with a village.
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
// Even if tick is done, we should ensure the streak is updated for the day.
// The calculateDailyStreak function is idempotent.
if (villageSnapshot.lastTickDay === today) {
villageSnapshot.user = await calculateDailyStreak(prisma, userId, today);
return villageSnapshot;
}
// The tick for today has not run. Execute all daily logic in a transaction.
await prisma.$transaction(async (tx) => {
// 1. UPDATE STREAK FIRST. This is critical for all reward calculations.
const updatedUser = await calculateDailyStreak(tx, userId, today);
villageSnapshot.user = updatedUser; // Update snapshot with fresh user data.
// 2. Process other daily logic using the updated snapshot
const finishedTiles = await processFinishedClearing(tx, villageSnapshot, today);
await processFieldExp(tx, villageSnapshot, today);
await autoStartClearing(tx, villageSnapshot, today, finishedTiles);
// 3. Update the last tick day to prevent re-processing
await tx.village.update({
where: { id: villageSnapshot.id },
data: { lastTickDay: today },
});
});
// After the transaction, the original villageSnapshot is stale.
// Re-fetch to get the latest state with all changes.
const updatedVillage = await fetchVillage(userId);
if (!updatedVillage) {
// This would be a critical error, as the village existed moments ago.
throw createError({ statusCode: 500, statusMessage: 'Village disappeared post-transaction' });
}
return updatedVillage;
} catch (error) {
// Log the error and re-throw it to be handled by the calling API endpoint.
console.error(`Error in processVillageTick for user ${userId}:`, error);
if ((error as any).statusCode) throw error; // Re-throw h3 errors
throw createError({ statusCode: 500, statusMessage: 'Failed to process village tick.' });
}
}
/**
* Main entry point for the frontend to get the village state.
* It ensures the daily tick is processed, then enriches the state with UI-specific data.
*/
export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
try {
// This function will now run the tick (if needed) AND return the up-to-date village state.
const villageSnapshot = await processVillageTick(userId);
// --- Enrich tiles with available actions ---
const user = villageSnapshot.user;
const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
const freeWorkers = housesCount - producingCount;
const tilesWithActions = villageSnapshot.tiles.map(tile => {
const availableActions: any[] = [];
// Action: BUILD
if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes: VillageObjectType[] = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = COSTS.BUILD[buildingType];
const isProducing = (PRODUCING_BUILDINGS as readonly string[]).includes(buildingType);
let isEnabled = user.coins >= cost;
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
if (isEnabled && isProducing && freeWorkers <= 0) {
isEnabled = false;
disabledReason = 'Not enough workers';
}
return {
type: 'BUILD',
buildingType,
cost,
isEnabled,
disabledReason,
};
});
availableActions.push(...buildActions);
}
return { ...tile, availableActions };
});
return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
} catch (error) {
console.error('Error in syncAndGetVillage:', error);
// Let the API endpoint handle the final error response.
if ((error as any).statusCode) throw error; // Re-throw h3 errors
throw createError({ statusCode: 500, statusMessage: 'An error occurred during village synchronization.' });
}
}
/**
* Генерация деревни для нового пользователя
*/
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 },
});
let villageCreated = false;
if (!village) {
village = await tx.village.create({
data: { userId: user.id },
});
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) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
const tile = await tx.villageTile.findUniqueOrThrow({
where: { id: tileId },
include: { village: true },
});
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: 'Not your tile' });
}
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 as any)) {
const objects = await tx.villageObject.findMany({
where: { villageId: tile.villageId },
});
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',
});
}
}
await tx.user.update({
where: { id: userId },
data: { coins: { decrement: cost } },
});
await tx.villageObject.create({
data: {
type: buildingType,
villageId: tile.villageId,
tileId: tile.id,
},
});
await tx.villageEvent.create({
data: {
villageId: tile.villageId,
type: `BUILD_${buildingType}`,
message: `Построено ${buildingType} на (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: -cost,
exp: 0,
},
});
// If a clearing building was built, immediately try to start a new clearing job.
// This makes new buildings feel responsive and start working right away.
if (buildingType === 'LUMBERJACK' || buildingType === 'QUARRY') {
const today = getTodayDay();
// We need a fresh, full snapshot of the village *within the transaction*
// to correctly calculate clearing capacity.
const villageSnapshot = await tx.village.findUnique({
where: { id: tile.villageId },
include: {
user: true,
tiles: { include: { object: true } },
objects: { include: { tile: true } },
},
});
if (villageSnapshot) {
await autoStartClearing(tx, villageSnapshot, today);
}
}
});
}
/* =========================
INTERNAL HELPERS
========================= */
function fetchVillage(userId: number) {
return prisma.village.findUnique({
where: { userId }, where: { userId },
include: { include: {
user: true, user: true,
@ -343,205 +99,326 @@ function fetchVillage(userId: number) {
objects: { include: { tile: true } }, objects: { include: { tile: true } },
}, },
}); });
}
/* ========================= if (!villageSnapshot) {
DAY-BASED LOGIC throw createError({ statusCode: 404, statusMessage: 'Village not found' });
========================= */
async function processFinishedClearing(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
): Promise<VillageTile[]> {
const finishedTiles = village.tiles.filter(
t =>
t.terrainState === 'CLEARING' &&
isBeforeDay(t.clearingStartedDay, today)
);
if (!finishedTiles.length) return [];
const baseReward = REWARDS.VILLAGE.CLEARING;
const totalBaseReward = {
coins: baseReward.coins * finishedTiles.length,
exp: baseReward.exp * finishedTiles.length,
};
// Ensure dailyStreak is at least 1 for multiplier calculation if it's 0 or null
const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1;
const finalReward = applyStreakMultiplier(totalBaseReward, currentDailyStreak);
await tx.user.update({
where: { id: village.user.id },
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 && village.user.dailyStreak > 1 ? village.user.dailyStreak : 0;
let streakBonusText = '';
if (streakMultiplier > 1) {
streakBonusText = ` Ваша серия визитов (${streakMultiplier}) увеличила награду.`;
}
const events = finishedTiles.map(t => {
// Apply streak multiplier with a default of 1 if streak is not active
const tileReward = applyStreakMultiplier(baseReward, currentDailyStreak);
return {
villageId: village.id,
type: t.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Участок (${t.x}, ${t.y}) расчищен.` + streakBonusText,
tileX: t.x,
tileY: t.y,
coins: tileReward.coins,
exp: tileReward.exp,
};
});
await tx.villageEvent.createMany({
data: events,
});
return finishedTiles;
}
async function processFieldExp(
tx: Prisma.TransactionClient,
village: FullVillage,
today: string
): Promise<number> {
const fieldsNeedingUpdate = village.objects.filter(
(o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today)
);
if (!fieldsNeedingUpdate.length) return 0;
const wells = village.objects.filter(o => o.type === 'WELL');
let totalBaseExpGained = 0;
const eventsToCreate: any[] = [];
const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1;
let streakBonusText = '';
if (currentDailyStreak > 1) {
streakBonusText = ` Ваша серия визитов (${currentDailyStreak}) увеличила награду.`;
} }
for (const field of fieldsNeedingUpdate) { // --- Step 2: Terrain Cleaning Completion ---
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1; const finishedClearingTiles = villageSnapshot.tiles.filter(
let expGainedForField = 0; t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
let wellBonusText = ''; );
for (let i = 0; i < daysMissed; i++) { if (finishedClearingTiles.length > 0) {
let dailyFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE; const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
const isNearWell = wells.some(well => await prisma.$transaction(async (tx) => {
Math.abs(well.tile.x - field.tile.x) <= 1 && // 1. Update user totals
Math.abs(well.tile.y - field.tile.y) <= 1 && await tx.user.update({
(well.tile.x !== field.tile.x || well.tile.y !== field.tile.y) where: { id: userId },
); data: {
coins: { increment: totalCoins },
exp: { increment: totalExp },
},
});
if (isNearWell) { // 2. Update all the tiles
dailyFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER; await tx.villageTile.updateMany({
wellBonusText = ' Рядом с колодцем урожай удвоился!'; where: { id: { in: finishedClearingTiles.map(t => t.id) } },
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
});
// 3. Create an event for each completed tile
for (const tile of finishedClearingTiles) {
await tx.villageEvent.create({
data: {
villageId: villageSnapshot.id,
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
}
});
} }
expGainedForField += dailyFieldExp; });
}
// --- 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 } } } })!;
// Create an event for each day the field gained experience // --- 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) {
let fieldExp = 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}`)) {
fieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
}
totalExpFromFields += fieldExp;
eventsToCreate.push({ eventsToCreate.push({
villageId: village.id, villageId: villageSnapshot.id,
type: 'FIELD_EXP', type: 'FIELD_EXP',
message: `Поле (${field.tile.x}, ${field.tile.y}) принесло опыт.` + wellBonusText + streakBonusText, message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
tileX: field.tile.x, tileX: field.tile.x,
tileY: field.tile.y, tileY: field.tile.y,
coins: 0, coins: 0,
exp: applyStreakMultiplier({ coins: 0, exp: dailyFieldExp }, currentDailyStreak).exp, exp: fieldExp,
}); });
} }
totalBaseExpGained += expGainedForField; // This is base exp without final streak multiplier for total
} await prisma.$transaction([
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
const finalTotalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, currentDailyStreak); prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
prisma.villageEvent.createMany({ data: eventsToCreate }),
if (finalTotalExp.exp > 0) { // Check final total exp after multiplier ]);
await tx.user.update({
where: { id: village.user.id },
data: { exp: { increment: finalTotalExp.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,
});
} }
return finalTotalExp.exp; // Return the final experience gained // --- 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 } } } })!;
async function autoStartClearing( // --- Step 6: Auto-start Terrain Cleaning ---
tx: Prisma.TransactionClient, const lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length;
village: FullVillage, const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length;
today: string,
justFinishedTiles: VillageTile[] = []
): Promise<number> {
// Count total capacity for clearing
const lumberjackCapacity = village.objects.filter(o => o.type === 'LUMBERJACK').length;
const quarryCapacity = village.objects.filter(o => o.type === 'QUARRY').length;
// We must account for the tiles that were *just* finished in this transaction. const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length;
const finishedTreeTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_TREE').length; const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length;
const finishedStoneTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_STONE').length;
const busyTreesInSnapshot = village.tiles.filter( const freeLumberjacks = lumberjackCount - clearingTreesCount;
t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING' const freeQuarries = quarryCount - clearingStonesCount;
).length;
const busyStonesInSnapshot = village.tiles.filter(
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
).length;
// Correctly calculate busy workers by subtracting those that just finished const tileIdsToClear = new Set<number>();
const currentBusyTrees = Math.max(0, busyTreesInSnapshot - finishedTreeTiles);
const currentBusyStones = Math.max(0, busyStonesInSnapshot - finishedStoneTiles);
const freeLumberjacks = Math.max(0, lumberjackCapacity - currentBusyTrees); if (freeLumberjacks > 0) {
const freeQuarries = Math.max(0, quarryCapacity - currentBusyStones); const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
if (idleTrees.length > 0) {
// Find idle tiles that are not among those just finished (though they should be EMPTY now anyway) // For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here.
const idleTrees = village.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE'); idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id));
const idleStones = village.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE'); }
const treesToStart = idleTrees.slice(0, freeLumberjacks);
const stonesToStart = idleStones.slice(0, freeQuarries);
const tilesToStart = [...treesToStart, ...stonesToStart];
if (!tilesToStart.length) {
return 0;
} }
await tx.villageTile.updateMany({ if (freeQuarries > 0) {
where: { id: { in: tilesToStart.map(t => t.id) } }, const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
data: { if (idleStones.length > 0) {
terrainState: 'CLEARING', // For simplicity, just take the first N available stones.
clearingStartedDay: today, 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 } },
}, },
}); });
return tilesToStart.length; 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: CLEAR
if (tile.terrainState === 'IDLE' && (tile.terrainType === 'BLOCKED_STONE' || tile.terrainType === 'BLOCKED_TREE')) {
const canClearTree = tile.terrainType === 'BLOCKED_TREE' && hasLumberjack;
const canClearStone = tile.terrainType === 'BLOCKED_STONE' && hasQuarry;
availableActions.push({
type: 'CLEAR',
isEnabled: canClearTree || canClearStone,
disabledReason: !(canClearTree || canClearStone) ? `Requires ${tile.terrainType === 'BLOCKED_TREE' ? 'Lumberjack' : 'Quarry'}` : undefined,
});
}
// 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) {
const isHouse = tile.object.type === 'HOUSE';
// Action: MOVE
availableActions.push({
type: 'MOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be moved' : undefined,
});
// Action: REMOVE
availableActions.push({
type: 'REMOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be removed' : undefined,
});
}
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,
},
});
});
}
export async function clearTile(userId: number, tileId: number) {
return prisma.$transaction(async (tx) => {
const tile = await tx.villageTile.findUniqueOrThrow({
where: { id: tileId },
include: { village: { include: { objects: true } } },
});
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
}
if (tile.terrainState !== 'IDLE') {
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
}
if (tile.terrainType === 'BLOCKED_TREE') {
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
} else if (tile.terrainType === 'BLOCKED_STONE') {
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
} else {
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
}
await tx.villageTile.update({
where: { id: tileId },
data: {
terrainState: 'CLEARING',
clearingStartedAt: new Date(),
},
});
});
}
export async function removeObject(userId: number, tileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
}
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
}

View File

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

View File

@ -1,5 +1,5 @@
// server/utils/auth.ts // server/utils/auth.ts
import type { H3Event } from 'h3'; import { useSession } 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,30 +7,16 @@ 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: H3Event): Promise<number> { export async function getUserIdFromSession(event: any): Promise<number> {
const user = event.context.user; const session = await useSession(event, {
if (!user || typeof user.id !== 'number' || user.isAnonymous) { password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }); });
const userId = session.data?.user?.id;
if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
} }
return user.id; return userId;
} }

View File

@ -5,11 +5,11 @@
*/ */
export const COSTS = { export const COSTS = {
BUILD: { BUILD: {
HOUSE: 15, HOUSE: 50,
FIELD: 15, FIELD: 15,
LUMBERJACK: 20, LUMBERJACK: 30,
QUARRY: 20, QUARRY: 30,
WELL: 15, WELL: 20,
} }
}; };
@ -19,15 +19,21 @@ export const COSTS = {
export const REWARDS = { export const REWARDS = {
// Village-related rewards // Village-related rewards
VILLAGE: { VILLAGE: {
CLEARING: { coins: 5, exp: 1 }, CLEARING: { coins: 1, exp: 1 },
FIELD_EXP: { FIELD_EXP: {
BASE: 1, BASE: 1,
WELL_MULTIPLIER: 2, WELL_MULTIPLIER: 2,
}, },
}, },
// Quest-related rewards
QUESTS: {
DAILY_VISIT: {
BASE: { coins: 1 },
STREAK_BONUS: { coins: 10 },
}
},
// Habit-related rewards // Habit-related rewards
HABITS: { HABITS: {
COMPLETION: { coins: 10, exp: 1 }, COMPLETION: { coins: 3, exp: 1 },
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
} }
}; };

View File

@ -1,64 +0,0 @@
// 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;
}
/**
* Converts a "YYYY-MM-DD" string into the application's day-of-week convention.
* @param gameDay A string in "YYYY-MM-DD" format.
* @returns A number where Monday is 0 and Sunday is 6.
*/
export function getDayOfWeekFromGameDay(gameDay: string): number {
// Create a date object from the string. Appending 'T00:00:00Z' ensures it's parsed as UTC,
// preventing the user's server timezone from shifting the date.
const date = new Date(`${gameDay}T00:00:00Z`);
const jsDayOfWeek = date.getUTCDay(); // 0 (Sunday) to 6 (Saturday)
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
return (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
}
/**
* Calculates the previous day from a given "YYYY-MM-DD" string.
* @param gameDay A string in "YYYY-MM-DD" format.
* @returns The previous day as a "YYYY-MM-DD" string.
*/
export function getPreviousGameDay(gameDay: string): string {
const date = new Date(`${gameDay}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() - 1);
return date.toISOString().split('T')[0];
}

View File

@ -1,90 +0,0 @@
import { PrismaClient, User } from '@prisma/client';
import { getPreviousGameDay } from '~/server/utils/gameDay';
/**
* Calculates the user's daily visit streak based on a client-provided "Game Day".
* It checks for consecutive daily visits and updates the user's streak count.
* This function is idempotent and creates a visit record for the provided day.
*
* @param prisma The Prisma client instance.
* @param userId The ID of the user.
* @param gameDay The client's current day in "YYYY-MM-DD" format.
* @returns The updated User object with the new streak count.
*/
export async function calculateDailyStreak(db: PrismaClient | Prisma.TransactionClient, userId: number, gameDay: string): Promise<User> {
const yesterdayGameDay = getPreviousGameDay(gameDay);
// 1. Find the user and their most recent visit
const [user, lastVisit] = await Promise.all([
db.user.findUnique({ where: { id: userId } }),
db.dailyVisit.findFirst({
where: { userId },
orderBy: { date: 'desc' },
}),
]);
if (!user) {
throw new Error('User not found');
}
let newStreak = user.dailyStreak;
// 2. Determine the new streak count
if (lastVisit) {
if (lastVisit.date === gameDay) {
// Already visited today, streak doesn't change.
newStreak = user.dailyStreak;
} else if (lastVisit.date === yesterdayGameDay) {
// Visited yesterday, so increment the streak (capped at 3).
newStreak = Math.min(user.dailyStreak + 1, 3);
} else {
// Missed a day, reset streak to 1.
newStreak = 1;
}
} else {
// No previous visits, so this is the first day of the streak.
newStreak = 1;
}
if (newStreak === 0) {
newStreak = 1;
}
// 3. Upsert today's visit record.
await db.dailyVisit.upsert({
where: { userId_date: { userId, date: gameDay } },
update: {},
create: { userId, date: gameDay },
});
// 4. Update the user's streak.
const updatedUser = await db.user.update({
where: { id: userId },
data: { dailyStreak: newStreak },
});
return updatedUser;
}
interface Reward {
coins: number;
exp: number;
}
/**
* Applies a streak-based multiplier to a given reward.
* The multiplier is the streak count, capped at 3x. A streak of 0 is treated as 1x.
*
* @param reward The base reward object { coins, exp }.
* @param streak The user's current daily streak.
* @returns The new reward object with the multiplier applied.
*/
export function applyStreakMultiplier(reward: Reward, streak: number | null | undefined): Reward {
const effectiveStreak = streak || 1; // Treat a null/0 streak as 1x
const multiplier = Math.max(1, Math.min(effectiveStreak, 3));
return {
coins: reward.coins * multiplier,
exp: reward.exp * multiplier,
};
}

View File

@ -29,6 +29,7 @@ export const OBSTACLE_CLEAR_COST: Record<string, number> = {
}; };
export const PLANTING_COST = 2; // A small, flat cost for seeds export const PLANTING_COST = 2; // A small, flat cost for seeds
export const MOVE_COST = 1; // Cost to move any player-built item
// --- Crop Timings (in milliseconds) --- // --- Crop Timings (in milliseconds) ---
export const CROP_GROWTH_TIME: Record<CropKind, number> = { export const CROP_GROWTH_TIME: Record<CropKind, number> = {
@ -36,6 +37,12 @@ export const CROP_GROWTH_TIME: Record<CropKind, number> = {
CORN: 4 * 60 * 60 * 1000, // 4 hours CORN: 4 * 60 * 60 * 1000, // 4 hours
}; };
// --- Rewards ---
export const CROP_HARVEST_REWARD: Record<CropKind, { exp: number, coins: number }> = {
BLUEBERRIES: { exp: 5, coins: 0 },
CORN: { exp: 10, coins: 1 },
};
/** /**
* Checks if a crop is grown based on when it was planted. * Checks if a crop is grown based on when it was planted.
* @param plantedAt The ISO string or Date object when the crop was planted. * @param plantedAt The ISO string or Date object when the crop was planted.