Compare commits
10 Commits
9838471871
...
45fc71f8bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fc71f8bd | |||
| 72f69ad14d | |||
| ff27f664ef | |||
| c9bf46e309 | |||
| 495c81e60e | |||
| 5f8dc428be | |||
| 8e1d026fd4 | |||
| b2b1ba078e | |||
| 1b7520e478 | |||
| da2d69960d |
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Database URL for Prisma
|
||||
DATABASE_URL="file:./dev.db"
|
||||
SESSION_PASSWORD=some-long-random-secret-at-least-32-chars
|
||||
|
||||
# Secret key to authorize the cleanup task endpoint
|
||||
# This should be a strong, unique secret in production
|
||||
CLEANUP_SECRET="changeme"
|
||||
10
.gemini/settings.json
Normal file
10
.gemini/settings.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
49
GEMINI.md
49
GEMINI.md
|
|
@ -69,6 +69,53 @@ Adhering to these conventions is critical for maintaining project stability.
|
|||
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
|
||||
- All business logic should reside in the backend.
|
||||
|
||||
### AI / Gemini Usage Rules
|
||||
### Data Conventions
|
||||
- **`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.
|
||||
- **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.
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -61,7 +61,15 @@ node -v
|
|||
|
||||
---
|
||||
|
||||
## 4. Prisma Setup (IMPORTANT)
|
||||
## 4. Date and Time Handling
|
||||
|
||||
To ensure consistent and timezone-agnostic management of daily game mechanics (like habit completion, streaks, and village progression), the project uses a "Game Day" concept. Dates are primarily stored as `String` in "YYYY-MM-DD" format. The client provides its local "Game Day" for user actions, and the server processes dates consistently using UTC.
|
||||
|
||||
For a detailed explanation of the "Game Day" concept and its implementation, please refer to the `GEMINI.md` file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prisma Setup (IMPORTANT)
|
||||
|
||||
### Prisma version
|
||||
|
||||
|
|
@ -112,7 +120,7 @@ Never forget migrations.
|
|||
|
||||
---
|
||||
|
||||
## 5. Prisma Client Usage
|
||||
## 6. Prisma Client Usage
|
||||
|
||||
Prisma client is initialized here:
|
||||
|
||||
|
|
@ -136,7 +144,7 @@ export default prisma
|
|||
|
||||
---
|
||||
|
||||
## 6. Development
|
||||
## 7. Development
|
||||
|
||||
Install dependencies:
|
||||
|
||||
|
|
@ -158,7 +166,7 @@ npm run dev
|
|||
|
||||
---
|
||||
|
||||
## 7. API Example
|
||||
## 8. API Example
|
||||
|
||||
Health check:
|
||||
|
||||
|
|
@ -177,7 +185,42 @@ Expected response:
|
|||
|
||||
---
|
||||
|
||||
## 8. Deployment Notes
|
||||
## 9. Scheduled Cleanup Task
|
||||
|
||||
To manage anonymous user data, a cleanup task can be triggered via a protected API endpoint. This task deletes anonymous users who were created more than 24 hours ago and have not registered.
|
||||
|
||||
### 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
|
||||
- Run Prisma migrations during deployment
|
||||
|
|
@ -186,7 +229,7 @@ Expected response:
|
|||
|
||||
---
|
||||
|
||||
## 9. AI / Gemini Rules (IMPORTANT)
|
||||
## 11. AI / Gemini Rules (IMPORTANT)
|
||||
|
||||
When using Gemini / AI tools:
|
||||
|
||||
|
|
@ -203,7 +246,7 @@ When using Gemini / AI tools:
|
|||
|
||||
---
|
||||
|
||||
## 10. Why these constraints exist
|
||||
## 12. Why these constraints exist
|
||||
|
||||
This setup was intentionally chosen to:
|
||||
- avoid unstable Prisma 7 API
|
||||
|
|
|
|||
7
TODO.md
Normal file
7
TODO.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Начисление бонусов происходит про посещении страницы /village . А надо на уровне роута сделать, у залогиненного пользователя, что бы при посещении любой страницы происходила расчистка, и если расчистка закончена, выбирался новый тайл для расчистки.
|
||||
|
||||
У нас в БД lastExpAt и CleaningStartAt это UTC дата Стрингом. А выполнение привычки идёт как Date. И стрик тоже Date считается. Сделать так же UTC, однотипно.
|
||||
|
||||
Так как дата UTC, не учитывается часовой пояс. как то это надо пофиксить
|
||||
|
||||
На лидербордах не показывать пользователей анонимусов (они без имени)
|
||||
25
app/app.vue
25
app/app.vue
|
|
@ -10,28 +10,13 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const { initialized, fetchMe } = useAuth();
|
||||
const { initialized, initAuth } = useAuth();
|
||||
|
||||
// Fetch the user state on initial client-side load.
|
||||
// The middleware will wait for `initialized` to be true.
|
||||
// Initialize the authentication state on client-side load.
|
||||
// This will either fetch a logged-in user or create an anonymous session.
|
||||
onMounted(() => {
|
||||
fetchMe();
|
||||
initAuth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<style src="../assets/css/main.css"></style>
|
||||
|
|
|
|||
94
app/components/ConfirmDialog.vue
Normal file
94
app/components/ConfirmDialog.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<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>
|
||||
308
app/components/HabitCard.vue
Normal file
308
app/components/HabitCard.vue
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<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>
|
||||
555
app/components/OnboardingFunnel.vue
Normal file
555
app/components/OnboardingFunnel.vue
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
<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>
|
||||
288
app/components/VillageGrid.vue
Normal file
288
app/components/VillageGrid.vue
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -1,66 +1,132 @@
|
|||
// /composables/useAuth.ts
|
||||
import { computed, watch } from 'vue';
|
||||
import { useVisitTracker } from './useVisitTracker';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
nickname: string;
|
||||
id: number;
|
||||
email: string | null; // Can be null for anonymous users
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
coins: number;
|
||||
exp: number;
|
||||
dailyStreak: number;
|
||||
soundOn: boolean;
|
||||
confettiOn: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isAnonymous?: boolean; // Flag to distinguish anonymous users
|
||||
anonymousSessionId?: string;
|
||||
}
|
||||
|
||||
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 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 isAuthenticated = computed(() => !!user.value);
|
||||
const { visitCalled } = useVisitTracker();
|
||||
|
||||
const fetchMe = async () => {
|
||||
// This function can be called multiple times, but the logic inside
|
||||
// will only run once thanks to the initialized flag.
|
||||
// A user is fully authenticated only if they exist and are NOT anonymous.
|
||||
const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous);
|
||||
// A user is anonymous if they exist and have the isAnonymous flag.
|
||||
const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous);
|
||||
|
||||
// --- This watcher is the new core logic for post-authentication tasks ---
|
||||
watch(isAuthenticated, (newIsAuthenticated, oldIsAuthenticated) => {
|
||||
// We only care about the transition from logged-out to logged-in
|
||||
if (newIsAuthenticated && !oldIsAuthenticated) {
|
||||
if (!visitCalled.value) {
|
||||
visitCalled.value = true;
|
||||
|
||||
const gameDay = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// --- 1. Trigger Daily Visit & Streak Calculation ---
|
||||
api('/api/user/visit', {
|
||||
method: 'POST',
|
||||
body: { gameDay }
|
||||
}).then(updatedUser => {
|
||||
if (updatedUser) {
|
||||
updateUser(updatedUser);
|
||||
}
|
||||
}).catch(e => {
|
||||
console.error('Failed to register daily visit:', e);
|
||||
visitCalled.value = false; // Allow retrying on next navigation if it failed
|
||||
});
|
||||
|
||||
// --- 2. Trigger Village Tick ---
|
||||
api('/api/village/tick', { method: 'POST' })
|
||||
.catch(e => {
|
||||
console.error('Failed to trigger village tick:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the authentication state for EXISTING users.
|
||||
* It should be called once in app.vue.
|
||||
* 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;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
// The backend returns the user object nested under a 'user' key.
|
||||
const response = await api<{ user: User }>('/auth/me', { method: 'GET' });
|
||||
user.value = response.user; // Correctly assign the nested user object
|
||||
const response = await api<{ user: User }>('/auth/me');
|
||||
if (response.user) {
|
||||
user.value = { ...response.user, isAnonymous: false };
|
||||
} else {
|
||||
user.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
user.value = null; // Silently set user to null on 401
|
||||
// It's expected this will fail for non-logged-in users.
|
||||
user.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
initialized.value = true; // Mark as initialized after the first attempt
|
||||
initialized.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
// The calling component is responsible for its own loading state.
|
||||
// This function just performs the action.
|
||||
await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
});
|
||||
// After a successful login, allow a re-fetch of the user state.
|
||||
// After a successful login, force a re-fetch of the new user state.
|
||||
initialized.value = false;
|
||||
await fetchMe();
|
||||
await initAuth();
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
// Always clear state and redirect, regardless of API call success.
|
||||
user.value = null;
|
||||
initialized.value = false;
|
||||
await navigateTo('/login');
|
||||
visitCalled.value = false; // Reset for the next session
|
||||
await navigateTo('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -70,12 +136,14 @@ export function useAuth() {
|
|||
}
|
||||
};
|
||||
|
||||
// Expose the state and methods.
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAnonymous, // Expose this new state
|
||||
initialized,
|
||||
fetchMe,
|
||||
initAuth, // Called from app.vue
|
||||
startOnboarding, // Called from index.vue
|
||||
register, // Expose register function
|
||||
login,
|
||||
logout,
|
||||
updateUser,
|
||||
|
|
|
|||
60
app/composables/useVillageHelpers.ts
Normal file
60
app/composables/useVillageHelpers.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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,
|
||||
};
|
||||
};
|
||||
12
app/composables/useVisitTracker.ts
Normal file
12
app/composables/useVisitTracker.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,10 +2,9 @@
|
|||
<div class="app-container">
|
||||
<header v-if="isAuthenticated" class="top-bar">
|
||||
<div class="user-info-top">
|
||||
<span>{{ user.nickname }}</span>
|
||||
<span>💰 {{ displayedCoins }}</span>
|
||||
<span>✨ {{ displayedExp }}</span>
|
||||
<button @click="handleLogout" class="logout-button">Logout</button>
|
||||
<button @click="handleLogout" class="btn btn-danger btn-sm">Выход</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -94,37 +93,31 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background-color: #f8f8f8;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background-color: var(--container-bg-color);
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Align user info to the right */
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.user-info-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
gap: 20px;
|
||||
font-size: 0.95em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
padding-bottom: 60px; /* Space for bottom nav */
|
||||
padding-bottom: 70px; /* Space for bottom nav */
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
|
|
@ -132,12 +125,12 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
background-color: var(--container-bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 5px 0;
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
|
@ -146,17 +139,23 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: #555;
|
||||
font-size: 0.7em;
|
||||
color: var(--text-color-light);
|
||||
font-size: 0.75em;
|
||||
padding: 5px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
font-size: 1.5em;
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-item.router-link-active {
|
||||
color: #007bff;
|
||||
.nav-item.router-link-exact-active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,85 @@
|
|||
<template>
|
||||
<div class="habits-container">
|
||||
<h3>My Habits</h3>
|
||||
<div class="page-container">
|
||||
<h1>Мои Привычки</h1>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="habits-list">
|
||||
<p v-if="loading.fetch">Loading habits...</p>
|
||||
<p v-if="loading.fetch">Загрузка привычек...</p>
|
||||
<div v-for="habit in habits" :key="habit.id" class="habit-card">
|
||||
<div class="habit-info">
|
||||
<h4>{{ habit.name }}</h4>
|
||||
<div class="habit-days">
|
||||
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
|
||||
<!-- Viewing Mode -->
|
||||
<div v-if="editingHabitId !== habit.id" class="habit-view-content">
|
||||
<div class="habit-info">
|
||||
<h3>{{ habit.name }}</h3>
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
<p v-if="!loading.fetch && habits.length === 0">Пока нет привычек. Добавьте одну!</p>
|
||||
</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>
|
||||
</div>
|
||||
<p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
|
@ -50,15 +99,36 @@ const api = useApi();
|
|||
// --- State ---
|
||||
const habits = ref<Habit[]>([]);
|
||||
const newHabitName = ref('');
|
||||
const dayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const dayMap: { [key: number]: string } = { 0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun' };
|
||||
// Day mapping based on Mon=0, ..., Sun=6
|
||||
const dayOptions = [
|
||||
{ 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 error = ref<string | null>(null);
|
||||
const loading = ref({
|
||||
fetch: 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 ---
|
||||
const fetchHabits = async () => {
|
||||
loading.value.fetch = true;
|
||||
|
|
@ -67,7 +137,7 @@ const fetchHabits = async () => {
|
|||
habits.value = await api<Habit[]>('/habits');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch habits:', err);
|
||||
error.value = 'Could not load habits.';
|
||||
error.value = 'Не удалось загрузить привычки.';
|
||||
} finally {
|
||||
loading.value.fetch = false;
|
||||
}
|
||||
|
|
@ -75,17 +145,16 @@ const fetchHabits = async () => {
|
|||
|
||||
const createHabit = async () => {
|
||||
if (!newHabitName.value || newHabitDays.value.length === 0) {
|
||||
error.value = 'Please provide a name and select at least one day.';
|
||||
error.value = 'Пожалуйста, укажите название и выберите хотя бы один день.';
|
||||
return;
|
||||
}
|
||||
loading.value.create = true;
|
||||
error.value = null;
|
||||
|
||||
// Convert day names to numbers
|
||||
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.indexOf(dayName));
|
||||
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
|
||||
|
||||
try {
|
||||
await api<Habit>('/habits', {
|
||||
const newHabit = await api<Habit>('/habits', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: newHabitName.value,
|
||||
|
|
@ -93,60 +162,115 @@ const createHabit = async () => {
|
|||
},
|
||||
});
|
||||
|
||||
// Clear form and re-fetch the list from the server
|
||||
habits.value.push(newHabit); // Optimistic update
|
||||
newHabitName.value = '';
|
||||
newHabitDays.value = [];
|
||||
await fetchHabits();
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create habit:', err);
|
||||
error.value = err.data?.message || 'Could not create habit.';
|
||||
error.value = err.data?.message || 'Не удалось создать привычку.';
|
||||
// Re-fetch on error to ensure consistency
|
||||
} finally {
|
||||
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 ---
|
||||
onMounted(fetchHabits);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.habits-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Create Form */
|
||||
.create-habit-form {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
.form-container {
|
||||
background-color: var(--container-bg-color);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.days-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
|
|
@ -160,71 +284,115 @@ h3 {
|
|||
}
|
||||
|
||||
.day-label span {
|
||||
display: inline-block;
|
||||
width: 35px;
|
||||
line-height: 35px;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.day-label input:checked + span {
|
||||
background-color: #81a1c1;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: #81a1c1;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.create-habit-form button {
|
||||
.form-container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #5e81ac;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Habits List */
|
||||
.habits-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: 16px;
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.habit-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
background-color: var(--container-bg-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.habit-info h4 {
|
||||
margin: 0 0 10px 0;
|
||||
.habit-view-content {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
padding: 15px 0 5px;
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
background-color: #eceff4;
|
||||
color: #4c566a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #bf616a;
|
||||
background-color: #fbe2e5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--danger-color);
|
||||
background-color: #fee2e2;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
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>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,84 +1,58 @@
|
|||
<template>
|
||||
<div class="leaderboard-container">
|
||||
<h3>Monthly Leaderboard</h3>
|
||||
<ul class="leaderboard-list">
|
||||
<li class="leaderboard-item">
|
||||
<span class="rank">1.</span>
|
||||
<span class="name">Papa Smurf</span>
|
||||
<span class="exp">9800 EXP</span>
|
||||
</li>
|
||||
<li class="leaderboard-item self">
|
||||
<span class="rank">2.</span>
|
||||
<span class="name">Smurfette</span>
|
||||
<span class="exp">8500 EXP</span>
|
||||
</li>
|
||||
<li class="leaderboard-item">
|
||||
<span class="rank">3.</span>
|
||||
<span class="name">Brainy Smurf</span>
|
||||
<span class="exp">8250 EXP</span>
|
||||
</li>
|
||||
<li class="leaderboard-item">
|
||||
<span class="rank">4.</span>
|
||||
<span class="name">Hefty Smurf</span>
|
||||
<span class="exp">7600 EXP</span>
|
||||
</li>
|
||||
<li class="leaderboard-item">
|
||||
<span class="rank">5.</span>
|
||||
<span class="name">Jokey Smurf</span>
|
||||
<span class="exp">6100 EXP</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="page-container">
|
||||
<h1>Доска почёта</h1>
|
||||
<div v-if="pending" class="loading">Loading leaderboard...</div>
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p>An error occurred while fetching the leaderboard. Please try again.</p>
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Место</th>
|
||||
<th>Имя</th>
|
||||
<th>EXP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in leaderboard"
|
||||
:key="entry.rank + entry.nickname"
|
||||
:class="{ 'current-user-row': currentUser && currentUser.nickname === entry.nickname }"
|
||||
>
|
||||
<td>{{ entry.rank }}</td>
|
||||
<td>{{ entry.nickname }}</td>
|
||||
<td>{{ entry.exp }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No logic, just visual placeholders
|
||||
const { user: currentUser } = useAuth(); // Get current authenticated user
|
||||
|
||||
const { data, pending, error } = await useFetch('/api/leaderboard', {
|
||||
lazy: true,
|
||||
server: false,
|
||||
});
|
||||
|
||||
const leaderboard = computed(() => data.value?.leaderboard || []);
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.leaderboard-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.current-user-row > * {
|
||||
background-color: #e0e7ff; /* A light blue/indigo for highlighting */
|
||||
font-weight: 600;
|
||||
color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
h3 {
|
||||
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;
|
||||
.table-hover > tbody > tr.current-user-row:hover > * {
|
||||
background-color: #c7d2fe; /* A slightly darker shade for hover */
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Login</h1>
|
||||
<div class="auth-container">
|
||||
<div class="page-container auth-form">
|
||||
<h1>Вход</h1>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" v-model="email" required />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" v-model="email" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" v-model="password" required />
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" id="password" v-model="password" class="form-control" required />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? 'Входим...' : 'Войти' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="switch-link">
|
||||
<p>
|
||||
Don't have an account?
|
||||
<NuxtLink to="/register">Register here</NuxtLink>
|
||||
|
||||
<NuxtLink to="/">Вернуться назад</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,59 +54,26 @@ definePageMeta({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input {
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
max-width: 420px;
|
||||
}
|
||||
button {
|
||||
.auth-form 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 {
|
||||
color: red;
|
||||
margin-bottom: 16px;
|
||||
color: var(--danger-color);
|
||||
background-color: #fee2e2;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.switch-link {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Register</h1>
|
||||
<div class="auth-container">
|
||||
<div class="page-container auth-form">
|
||||
<h1>Регистрация</h1>
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label for="nickname">Nickname</label>
|
||||
<input type="text" id="nickname" v-model="nickname" required />
|
||||
<label for="nickname" class="form-label">Никнейм</label>
|
||||
<input type="text" id="nickname" v-model="nickname" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" v-model="email" required />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" v-model="email" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password (min 8 characters)</label>
|
||||
<input type="password" id="password" v-model="password" required />
|
||||
<label for="password" class="form-label">Пароль (минимум 8 символов)</label>
|
||||
<input type="password" id="password" v-model="password" class="form-control" required />
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="successMessage" class="success-message">{{ successMessage }}</div>
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? 'Registering...' : 'Register' }}
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? 'Регистрируем...' : 'Зарегистрироваться' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="switch-link">
|
||||
<p>
|
||||
Already have an account?
|
||||
<NuxtLink to="/login">Login here</NuxtLink>
|
||||
Уже есть аккаунт?
|
||||
<NuxtLink to="/login">Войти</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,7 +55,7 @@ const handleRegister = async () => {
|
|||
password: password.value,
|
||||
},
|
||||
});
|
||||
successMessage.value = 'Registration successful! Please log in.';
|
||||
successMessage.value = 'Регистрация прошла успешно! Пожалуйста, войдите в систему.';
|
||||
setTimeout(() => {
|
||||
navigateTo('/login');
|
||||
}, 2000);
|
||||
|
|
@ -72,64 +72,34 @@ definePageMeta({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input {
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
max-width: 420px;
|
||||
}
|
||||
button {
|
||||
.auth-form 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 {
|
||||
color: red;
|
||||
margin-bottom: 16px;
|
||||
color: var(--danger-color);
|
||||
background-color: #fee2e2;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.success-message {
|
||||
color: green;
|
||||
margin-bottom: 16px;
|
||||
color: #16a34a;
|
||||
background-color: #dcfce7;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.switch-link {
|
||||
|
|
|
|||
|
|
@ -1,81 +1,90 @@
|
|||
<template>
|
||||
<div class="village-page">
|
||||
<h1>My Village</h1>
|
||||
<div class="page-container village-page-layout">
|
||||
<h1>Моя деревня</h1>
|
||||
|
||||
<div v-if="pending" class="loading">Loading your village...</div>
|
||||
<div v-if="pending" class="loading">Загрузка вашей деревни...</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>
|
||||
<p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
|
||||
<div v-else>
|
||||
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
|
||||
<pre>{{ error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="villageData" class="village-container">
|
||||
<div class="village-grid-wrapper">
|
||||
<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>
|
||||
<div v-else-if="villageData">
|
||||
<VillageGrid
|
||||
:village-data="villageData"
|
||||
:selected-tile="selectedTile"
|
||||
@tile-click="selectTile"
|
||||
/>
|
||||
|
||||
<!-- Tile Info Overlay -->
|
||||
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
|
||||
<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>
|
||||
|
||||
<h3>Available Actions</h3>
|
||||
<div class="actions-list">
|
||||
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||
<button
|
||||
:disabled="!action.isEnabled || isSubmitting"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
{{ getActionLabel(action) }}
|
||||
</button>
|
||||
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||
<h2>{{ getTileTitle(selectedTile) }}</h2>
|
||||
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
|
||||
|
||||
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
|
||||
<h3 class="actions-header">Что здесь можно сделать?</h3>
|
||||
<div class="actions-list">
|
||||
<!-- Build Actions -->
|
||||
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
|
||||
<div class="build-card-grid">
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||||
<h3>Admin Tools</h3>
|
||||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||||
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
|
||||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||||
<div class="bottom-content">
|
||||
<!-- Admin Panel -->
|
||||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||||
<h3>Admin Tools</h3>
|
||||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||||
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</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>
|
||||
<!-- Event Log -->
|
||||
<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>
|
||||
|
|
@ -83,6 +92,9 @@
|
|||
<script setup lang="ts">
|
||||
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', {
|
||||
lazy: true,
|
||||
server: false, // Ensure this runs on the client-side
|
||||
|
|
@ -95,37 +107,92 @@ const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/vil
|
|||
|
||||
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.cost} монет`; // Return cost instead of "Построить"
|
||||
}
|
||||
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 handleActionClick = async (action) => {
|
||||
|
|
@ -140,7 +207,6 @@ const handleActionClick = async (action) => {
|
|||
actionType: action.type,
|
||||
payload: {
|
||||
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
|
||||
...(action.type === 'MOVE' && { toTileId: action.toTileId }), // Assuming action.toTileId will be present for MOVE
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -149,6 +215,7 @@ const handleActionClick = async (action) => {
|
|||
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
|
||||
} else {
|
||||
villageData.value = response.data.value;
|
||||
updateUser(response.data.value.user); // Update global user state
|
||||
selectedTile.value = null;
|
||||
await refreshEvents(); // Refresh the event log
|
||||
}
|
||||
|
|
@ -162,196 +229,143 @@ const handleActionClick = async (action) => {
|
|||
|
||||
const isSubmittingAdminAction = ref(false);
|
||||
|
||||
async function handleAdminAction(url) {
|
||||
async function handleAdminAction(url: string) {
|
||||
if (isSubmittingAdminAction.value) return;
|
||||
isSubmittingAdminAction.value = true;
|
||||
|
||||
try {
|
||||
const { error } = await useFetch(url, { method: 'POST' });
|
||||
if (error.value) {
|
||||
alert(error.value.data?.statusMessage || 'An admin action failed.');
|
||||
} else {
|
||||
// Refresh both data sources in parallel
|
||||
await Promise.all([refreshVillageData(), refreshEvents()]);
|
||||
// 1. Perform the requested admin action (e.g., reset, trigger tick)
|
||||
const { error: actionError } = await useFetch(url, { method: 'POST' });
|
||||
if (actionError.value) {
|
||||
// If the action itself fails, throw to stop execution
|
||||
throw actionError.value;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to perform admin action:', e);
|
||||
alert('An unexpected error occurred.');
|
||||
|
||||
// 2. Refresh the main village data. This runs the core game logic
|
||||
// on the backend and gets the updated state.
|
||||
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 {
|
||||
isSubmittingAdminAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
|
||||
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
|
||||
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
|
||||
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.village-page {
|
||||
.village-page-layout {
|
||||
--tile-size: clamp(55px, 12vw, 70px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
margin-top: 50px;
|
||||
font-size: 1.2em;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
/* Overlay and other styles */
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end; /* Start from bottom for mobile-first feel */
|
||||
align-items: flex-end;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tile-overlay-panel {
|
||||
background-color: #fff;
|
||||
background-color: var(--background-color);
|
||||
width: 100%;
|
||||
max-width: 500px; /* Limit width for larger screens */
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: 16px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) { /* Center for desktop, less "bottom sheet" */
|
||||
@media (min-width: 768px) {
|
||||
.tile-overlay-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
.tile-overlay-panel {
|
||||
border-radius: 15px;
|
||||
max-height: 80vh; /* Don't cover entire screen */
|
||||
}
|
||||
}
|
||||
|
||||
.tile-overlay-panel h2 {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.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;
|
||||
.tile-description {
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
margin-top: 15px;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-overlay-button:hover {
|
||||
background-color: #5a6268;
|
||||
.bottom-content {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
|
|
@ -359,23 +373,22 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border: 2px dashed #dc3545;
|
||||
border: 2px dashed var(--danger-color);
|
||||
border-radius: 10px;
|
||||
max-width: 350px;
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.admin-panel h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #dc3545;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.admin-panel button {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background-color: #dc3545;
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -387,30 +400,205 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.event-log-container {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.event-log-container h4 {
|
||||
.event-log-container h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-log-table {
|
||||
.event-list {
|
||||
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%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8em;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.event-log-table th, .event-log-table td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
.building-footer .btn-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-log-table th {
|
||||
background-color: #f0f0f0;
|
||||
.building-footer .cost {
|
||||
font-weight: 600;
|
||||
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>
|
||||
237
assets/css/main.css
Normal file
237
assets/css/main.css
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/* 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.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
|
|
@ -1,12 +1,63 @@
|
|||
// /middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { isAuthenticated, initialized } = useAuth();
|
||||
// /middleware/auth.global.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { isAuthenticated, initialized, updateUser } = useAuth();
|
||||
const { visitCalled } = useVisitTracker();
|
||||
const api = useApi();
|
||||
|
||||
// Do not run middleware until auth state is initialized on client-side
|
||||
if (!initialized.value) {
|
||||
// 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 ---
|
||||
// This logic runs once per application load on the client-side for authenticated users.
|
||||
if (process.client && isAuthenticated.value && !visitCalled.value) {
|
||||
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 (isAuthenticated.value && to.path === '/login') {
|
||||
return navigateTo('/', { replace: true });
|
||||
|
|
|
|||
45
middleware/village-tick.global.ts
Normal file
45
middleware/village-tick.global.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// middleware/village-tick.global.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// We only run this logic on the client-side after navigation.
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isAuthenticated, initialized } = useAuth();
|
||||
|
||||
// Helper function to wait for auth initialization, with a timeout.
|
||||
const waitForAuth = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (initialized.value) {
|
||||
return resolve(true);
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
unwatch();
|
||||
resolve(true); // Resolve even if timeout, so middleware doesn't block
|
||||
}, 5000);
|
||||
const unwatch = watch(initialized, (newValue) => {
|
||||
if (newValue) {
|
||||
clearTimeout(timeout);
|
||||
unwatch();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Wait for auth to be initialized
|
||||
await waitForAuth();
|
||||
|
||||
// If user is authenticated, trigger the village tick
|
||||
if (isAuthenticated.value) {
|
||||
const api = useApi();
|
||||
try {
|
||||
// We must await the call to ensure it completes before a new navigation can cancel it.
|
||||
await api('/api/village/tick', { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Even if we don't wait, a catch block is good practice in case the api call itself throws an error.
|
||||
console.error('[Village Middleware] Failed to trigger village tick:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true }
|
||||
devtools: { enabled: true },
|
||||
alias: {
|
||||
'~': resolve(__dirname, './'), // Root directory
|
||||
'~/': resolve(__dirname, './'), // Root directory
|
||||
'@': resolve(__dirname, './app'), // Source directory (app)
|
||||
'@/': resolve(__dirname, './app'), // Source directory (app)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<!-- /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>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<!-- /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>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN DEFAULT true;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"anonymousSessionId" TEXT,
|
||||
"email" TEXT,
|
||||
"password" TEXT,
|
||||
"nickname" TEXT,
|
||||
"avatar" TEXT DEFAULT '/avatars/default.png',
|
||||
"isAnonymous" BOOLEAN DEFAULT true,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"exp" INTEGER NOT NULL DEFAULT 0,
|
||||
"dailyStreak" INTEGER NOT NULL DEFAULT 0,
|
||||
"soundOn" BOOLEAN NOT NULL DEFAULT true,
|
||||
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_anonymousSessionId_key" ON "User"("anonymousSessionId");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "VillageObject" ADD COLUMN "lastExpDay" TEXT;
|
||||
UPDATE "VillageObject" SET "lastExpDay" = strftime('%Y-%m-%d', "lastExpAt") WHERE "lastExpAt" IS NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "VillageTile" ADD COLUMN "clearingStartedDay" TEXT;
|
||||
UPDATE "VillageTile" SET "clearingStartedDay" = strftime('%Y-%m-%d', "clearingStartedAt") WHERE "clearingStartedAt" IS NOT NULL;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `lastExpAt` on the `VillageObject` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `clearingStartedAt` on the `VillageTile` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_VillageObject" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"type" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastExpDay" TEXT,
|
||||
"cropType" TEXT,
|
||||
"plantedAt" DATETIME,
|
||||
"villageId" INTEGER NOT NULL,
|
||||
"tileId" INTEGER NOT NULL,
|
||||
CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "VillageObject_tileId_fkey" FOREIGN KEY ("tileId") REFERENCES "VillageTile" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_VillageObject" ("createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId") SELECT "createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId" FROM "VillageObject";
|
||||
DROP TABLE "VillageObject";
|
||||
ALTER TABLE "new_VillageObject" RENAME TO "VillageObject";
|
||||
CREATE UNIQUE INDEX "VillageObject_tileId_key" ON "VillageObject"("tileId");
|
||||
CREATE TABLE "new_VillageTile" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"x" INTEGER NOT NULL,
|
||||
"y" INTEGER NOT NULL,
|
||||
"terrainType" TEXT NOT NULL,
|
||||
"terrainState" TEXT NOT NULL DEFAULT 'IDLE',
|
||||
"clearingStartedDay" TEXT,
|
||||
"villageId" INTEGER NOT NULL,
|
||||
CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_VillageTile" ("clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y") SELECT "clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y" FROM "VillageTile";
|
||||
DROP TABLE "VillageTile";
|
||||
ALTER TABLE "new_VillageTile" RENAME TO "VillageTile";
|
||||
CREATE UNIQUE INDEX "VillageTile_villageId_x_y_key" ON "VillageTile"("villageId", "x", "y");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Village" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"lastTickDay" TEXT,
|
||||
CONSTRAINT "Village_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Village" ("id", "userId") SELECT "id", "userId" FROM "Village";
|
||||
DROP TABLE "Village";
|
||||
ALTER TABLE "new_Village" RENAME TO "Village";
|
||||
CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_DailyVisit" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"date" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
CONSTRAINT "DailyVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_DailyVisit" ("date", "id", "userId") SELECT "date", "id", "userId" FROM "DailyVisit";
|
||||
DROP TABLE "DailyVisit";
|
||||
ALTER TABLE "new_DailyVisit" RENAME TO "DailyVisit";
|
||||
CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date");
|
||||
CREATE TABLE "new_HabitCompletion" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"date" TEXT NOT NULL,
|
||||
"habitId" INTEGER NOT NULL,
|
||||
CONSTRAINT "HabitCompletion_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "Habit" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_HabitCompletion" ("date", "habitId", "id") SELECT "date", "habitId", "id" FROM "HabitCompletion";
|
||||
DROP TABLE "HabitCompletion";
|
||||
ALTER TABLE "new_HabitCompletion" RENAME TO "HabitCompletion";
|
||||
CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -40,13 +40,17 @@ enum CropType {
|
|||
// settings, and in-game resources like coins and experience points.
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
anonymousSessionId String? @unique
|
||||
|
||||
email String? @unique
|
||||
password String?
|
||||
nickname String?
|
||||
avatar String? @default("/avatars/default.png")
|
||||
isAnonymous Boolean? @default(true)
|
||||
|
||||
coins Int @default(0)
|
||||
exp Int @default(0)
|
||||
dailyStreak Int @default(0)
|
||||
|
||||
// User settings
|
||||
soundOn Boolean @default(true)
|
||||
|
|
@ -80,11 +84,11 @@ model Habit {
|
|||
// HabitCompletion: Records a single completion of a habit on a specific date.
|
||||
// This creates a history of the user's progress for each habit.
|
||||
model HabitCompletion {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime // Store only the date part
|
||||
id Int @id @default(autoincrement())
|
||||
date String // YYYY-MM-DD format
|
||||
|
||||
// Relations
|
||||
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
||||
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
||||
habitId Int
|
||||
|
||||
@@unique([habitId, date]) // A habit can only be completed once per day
|
||||
|
|
@ -93,11 +97,11 @@ model HabitCompletion {
|
|||
// DailyVisit: Tracks the user's daily visit for the "I visited the site today"
|
||||
// quest and for calculating 5-day streaks.
|
||||
model DailyVisit {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime // Store only the date part
|
||||
id Int @id @default(autoincrement())
|
||||
date String // YYYY-MM-DD format
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
@@unique([userId, date]) // A user can only have one recorded visit per day
|
||||
|
|
@ -106,14 +110,14 @@ model DailyVisit {
|
|||
// Village: The user's personal village, which acts as a container for all
|
||||
// village objects. Each user has exactly one village.
|
||||
model Village {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int @unique
|
||||
lastTickDay String?
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int @unique // Each user has only one village
|
||||
objects VillageObject[]
|
||||
tiles VillageTile[]
|
||||
events VillageEvent[]
|
||||
tiles VillageTile[]
|
||||
objects VillageObject[]
|
||||
events VillageEvent[]
|
||||
}
|
||||
|
||||
// VillageObject: An object (e.g., house, field) placed on a village tile.
|
||||
|
|
@ -121,7 +125,7 @@ model VillageObject {
|
|||
id Int @id @default(autoincrement())
|
||||
type VillageObjectType
|
||||
createdAt DateTime @default(now())
|
||||
lastExpAt DateTime?
|
||||
lastExpDay String?
|
||||
|
||||
// Crop details (only if type is FIELD)
|
||||
cropType CropType?
|
||||
|
|
@ -140,7 +144,7 @@ model VillageTile {
|
|||
y Int
|
||||
terrainType TerrainType
|
||||
terrainState TerrainState @default(IDLE)
|
||||
clearingStartedAt DateTime?
|
||||
clearingStartedDay String?
|
||||
|
||||
// Relations
|
||||
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
|
||||
|
|
|
|||
34
server/api/admin/cleanup.post.ts
Normal file
34
server/api/admin/cleanup.post.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { cleanupAnonymousUsers } from '~/server/tasks/cleanup';
|
||||
|
||||
/**
|
||||
* API endpoint to trigger the cleanup of old anonymous users.
|
||||
* This endpoint is protected by a secret key passed in the 'x-cleanup-secret' header.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Read the secret from the request header.
|
||||
const secret = getRequestHeader(event, 'x-cleanup-secret');
|
||||
|
||||
// Ensure the secret is present and matches the one in environment variables.
|
||||
if (!process.env.CLEANUP_SECRET || secret !== process.env.CLEANUP_SECRET) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized. Invalid or missing cleanup secret.'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[API] Cleanup task triggered.');
|
||||
|
||||
try {
|
||||
const result = await cleanupAnonymousUsers();
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleanup successful. Deleted ${result.count} anonymous users.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[API] Error during cleanup task:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Internal Server Error. Failed to execute cleanup task.'
|
||||
});
|
||||
}
|
||||
});
|
||||
24
server/api/admin/user/add-coins.post.ts
Normal file
24
server/api/admin/user/add-coins.post.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
// 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.` };
|
||||
});
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
// server/api/admin/village/reset.post.ts
|
||||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
||||
import { generateVillageForUser } from '../../../services/villageService';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
|
||||
// Simple admin check
|
||||
if (userId !== 1) {
|
||||
|
|
|
|||
|
|
@ -1,49 +1,61 @@
|
|||
// server/api/admin/village/trigger-tick.post.ts
|
||||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
||||
import { getPreviousDay } from '../../../utils/gameDay';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// This is a simplified constant. In a real scenario, this might be shared from a single source.
|
||||
const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* Admin endpoint to manually trigger the game logic tick.
|
||||
* This is useful for testing time-based mechanics without waiting.
|
||||
* It returns the full, updated village state.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
|
||||
// Simple admin check
|
||||
if (userId !== 1) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
||||
}
|
||||
|
||||
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
|
||||
const previousDay = getPreviousDay();
|
||||
|
||||
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({
|
||||
where: {
|
||||
villageId: village.id,
|
||||
terrainState: 'CLEARING',
|
||||
},
|
||||
data: {
|
||||
clearingStartedAt: clearingFastForwardDate,
|
||||
},
|
||||
}),
|
||||
|
||||
// 2. Fast-forward any fields to be ready for EXP gain
|
||||
const [fieldResult, tileResult, villageResult] = await prisma.$transaction([
|
||||
// 1. Update lastExpDay for all FIELD objects for this user's village
|
||||
prisma.villageObject.updateMany({
|
||||
where: {
|
||||
villageId: village.id,
|
||||
village: { userId: userId },
|
||||
type: 'FIELD',
|
||||
},
|
||||
data: {
|
||||
lastExpAt: yesterday,
|
||||
lastExpDay: previousDay,
|
||||
},
|
||||
}),
|
||||
|
||||
// 2. Update clearingStartedDay for all CLEANING VillageTile objects for this user's village
|
||||
prisma.villageTile.updateMany({
|
||||
where: {
|
||||
village: { userId: userId },
|
||||
terrainState: 'CLEARING',
|
||||
},
|
||||
data: {
|
||||
clearingStartedDay: previousDay,
|
||||
},
|
||||
}),
|
||||
|
||||
// 3. Update the village's lastTickDay
|
||||
prisma.village.updateMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
data: {
|
||||
lastTickDay: previousDay,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' };
|
||||
return {
|
||||
success: true,
|
||||
message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}. Village lastTickDay updated.`
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Get user ID from session; this helper handles the 401 check.
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const user = event.context.user;
|
||||
|
||||
// 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();
|
||||
// The auth middleware has already populated event.context.user.
|
||||
// We just need to verify it's a permanent user (has an email).
|
||||
if (!user || !user.email) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized: User not found.',
|
||||
statusMessage: 'Unauthorized: No active session.',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Return user data DTO
|
||||
// Return the user data DTO, which is already available on the context.
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -29,6 +19,7 @@ export default defineEventHandler(async (event) => {
|
|||
avatar: user.avatar,
|
||||
coins: user.coins,
|
||||
exp: user.exp,
|
||||
dailyStreak: user.dailyStreak,
|
||||
soundOn: user.soundOn,
|
||||
confettiOn: user.confettiOn,
|
||||
createdAt: user.createdAt,
|
||||
|
|
|
|||
|
|
@ -1,56 +1,89 @@
|
|||
import { hashPassword } from '../../utils/password';
|
||||
import { generateVillageForUser } from '../../services/villageService';
|
||||
import { hashPassword } from '~/server/utils/password';
|
||||
import { generateVillageForUser } from '~/server/services/villageService';
|
||||
import { useSession } from 'h3';
|
||||
|
||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
||||
|
||||
/**
|
||||
* Handles user registration.
|
||||
*
|
||||
* This endpoint has two main flows:
|
||||
* 1. Anonymous User Conversion: If an anonymous session cookie is present,
|
||||
* it finds the anonymous user, updates their details, makes them permanent,
|
||||
* and logs them in, preserving their progress.
|
||||
* 2. Standard Registration: If no anonymous session is found, it creates a
|
||||
* brand new user and a new village for them.
|
||||
*
|
||||
* In both cases, it automatically logs the user in upon successful registration.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { email, password, nickname } = body;
|
||||
const { email, password, nickname } = await readBody(event);
|
||||
|
||||
// 1. Validate input
|
||||
// --- 1. Input Validation ---
|
||||
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) {
|
||||
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(); // Normalize email
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
// 2. Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
// Check if email is already in use by a permanent account
|
||||
const existingPermanentUser = await prisma.user.findFirst({
|
||||
where: { email: normalizedEmail, isAnonymous: false },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
statusCode: 409, // Conflict
|
||||
statusMessage: 'Email already in use',
|
||||
});
|
||||
if (existingPermanentUser) {
|
||||
throw createError({ statusCode: 409, 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 user = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
nickname: nickname || 'New Smurf',
|
||||
},
|
||||
});
|
||||
let user;
|
||||
|
||||
// 4. Generate the user's village
|
||||
await generateVillageForUser(user);
|
||||
// --- 2. Identify User Flow (Anonymous Conversion vs. Standard) ---
|
||||
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
|
||||
const anonymousUser = anonymousSessionId
|
||||
? await prisma.user.findUnique({ where: { anonymousSessionId } })
|
||||
: null;
|
||||
|
||||
// NOTE: Registration does not automatically log in the user.
|
||||
// The user needs to explicitly call the login endpoint after registration.
|
||||
if (anonymousUser) {
|
||||
// --- Flow A: Convert Anonymous User ---
|
||||
user = await prisma.user.update({
|
||||
where: { id: anonymousUser.id },
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
nickname: nickname || 'New Smurf',
|
||||
isAnonymous: false, // Make the user permanent
|
||||
anonymousSessionId: null, // Invalidate the anonymous session ID
|
||||
},
|
||||
});
|
||||
// The village and progress are already associated with this user.
|
||||
|
||||
// 5. Return the new user, excluding sensitive fields and shortening DTO
|
||||
// Invalidate the anonymous cookie
|
||||
setCookie(event, ANONYMOUS_COOKIE_NAME, '', { maxAge: -1 });
|
||||
|
||||
} else {
|
||||
// --- Flow B: Create New User ---
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
nickname: nickname || 'New Smurf',
|
||||
isAnonymous: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate a new village for the brand new user
|
||||
await generateVillageForUser(user);
|
||||
}
|
||||
|
||||
// --- 3. Automatically log the user in ---
|
||||
const session = await useSession(event, { password: process.env.SESSION_PASSWORD! });
|
||||
await session.update({ user: { id: user.id } });
|
||||
|
||||
// --- 4. Return DTO ---
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
|
|||
15
server/api/economy/constants.get.ts
Normal file
15
server/api/economy/constants.get.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// server/api/economy/constants.get.ts
|
||||
import { COSTS, REWARDS } from '~/server/utils/economy';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
rewards: {
|
||||
onboardingCompletion: REWARDS.HABITS.ONBOARDING_COMPLETION.coins,
|
||||
},
|
||||
costs: {
|
||||
build: {
|
||||
house: COSTS.BUILD.HOUSE,
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -1,62 +1,60 @@
|
|||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
||||
import { REWARDS } from '../../../utils/economy';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from '../../../utils/prisma';
|
||||
import { applyStreakMultiplier } from '../../../utils/streak';
|
||||
import { getDayOfWeekFromGameDay } from '~/server/utils/gameDay';
|
||||
|
||||
interface CompletionResponse {
|
||||
message: string;
|
||||
reward: {
|
||||
coins: number;
|
||||
exp: number; // Added
|
||||
exp: number;
|
||||
};
|
||||
updatedCoins: 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;
|
||||
updatedExp: number;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
||||
const body = await readBody(event);
|
||||
const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD"
|
||||
|
||||
if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' });
|
||||
}
|
||||
|
||||
if (isNaN(habitId)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
||||
}
|
||||
|
||||
const habit = await prisma.habit.findFirst({
|
||||
where: { id: habitId, userId },
|
||||
});
|
||||
// Fetch user and habit in parallel
|
||||
const [user, habit] = await Promise.all([
|
||||
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) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const jsDayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
|
||||
const appDayOfWeek = getDayOfWeekFromGameDay(gameDay);
|
||||
|
||||
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
|
||||
const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
|
||||
|
||||
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
|
||||
// For permanent users, ensure the habit is scheduled for today.
|
||||
// Anonymous users in the onboarding flow can complete it on any day.
|
||||
if (!user.isAnonymous) {
|
||||
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
||||
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({
|
||||
where: {
|
||||
habitId: habitId,
|
||||
date: startOfToday, // Use precise equality check
|
||||
date: gameDay,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -64,25 +62,36 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
||||
}
|
||||
|
||||
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
|
||||
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
|
||||
// Determine the reward based on user type
|
||||
let finalReward: { coins: number, exp: number };
|
||||
if (user.isAnonymous) {
|
||||
// Anonymous users in onboarding get a fixed reward from economy.ts
|
||||
finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION;
|
||||
} else {
|
||||
// Permanent users get rewards based on streak
|
||||
// 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 [, updatedUser] = await prisma.$transaction([
|
||||
prisma.habitCompletion.create({
|
||||
data: {
|
||||
habitId: habitId,
|
||||
date: startOfToday, // Save the normalized date
|
||||
date: gameDay,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coins: {
|
||||
increment: rewardCoins,
|
||||
increment: finalReward.coins,
|
||||
},
|
||||
exp: { // Added
|
||||
increment: rewardExp, // Added
|
||||
exp: {
|
||||
increment: finalReward.exp,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -90,20 +99,18 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
data: {
|
||||
villageId: village.id,
|
||||
type: 'HABIT_COMPLETION',
|
||||
message: `Completed habit: "${habit.name}"`,
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Changed from 0 to rewardExp
|
||||
message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) увеличила награду!` : ''}`,
|
||||
coins: finalReward.coins,
|
||||
exp: finalReward.exp,
|
||||
}
|
||||
})] : []),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Habit completed successfully!',
|
||||
reward: {
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Added
|
||||
},
|
||||
reward: finalReward,
|
||||
updatedCoins: updatedUser.coins,
|
||||
updatedExp: updatedUser.exp, // Added
|
||||
updatedExp: updatedUser.exp,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
36
server/api/habits/[id]/index.delete.ts
Normal file
36
server/api/habits/[id]/index.delete.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
});
|
||||
56
server/api/habits/[id]/index.put.ts
Normal file
56
server/api/habits/[id]/index.put.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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[],
|
||||
};
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
||||
import { Habit } from '@prisma/client';
|
||||
|
||||
// DTO to shape the output
|
||||
|
|
@ -15,7 +15,7 @@ interface HabitDto {
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
|
||||
const habits = await prisma.habit.findMany({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
||||
|
||||
interface HabitDto {
|
||||
id: number;
|
||||
|
|
@ -7,7 +7,7 @@ interface HabitDto {
|
|||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
const { name, daysOfWeek } = await readBody(event);
|
||||
|
||||
// --- Validation ---
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ export default defineEventHandler(async () => {
|
|||
// for "current month's EXP". This should be revisited if true monthly
|
||||
// tracking becomes a requirement.
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
isAnonymous: false,
|
||||
nickname: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
nickname: true,
|
||||
avatar: true,
|
||||
|
|
|
|||
85
server/api/onboarding/complete-habit.post.ts
Normal file
85
server/api/onboarding/complete-habit.post.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { getAuthenticatedUserId } from '~/server/utils/auth';
|
||||
import prisma from '~/server/utils/prisma'; // Ensure prisma is imported
|
||||
|
||||
const ONBOARDING_REWARD_COINS = 75;
|
||||
|
||||
interface OnboardingCompletionResponse {
|
||||
message: string;
|
||||
updatedCoins: number;
|
||||
}
|
||||
|
||||
// Helper to get the start of the day in UTC
|
||||
function getStartOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A special endpoint for the onboarding funnel to complete a habit.
|
||||
* This provides a fixed reward and has simpler logic than the main completion endpoint.
|
||||
* ONLY callable by anonymous users.
|
||||
*/
|
||||
export default defineEventHandler(async (event): Promise<OnboardingCompletionResponse> => {
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
const { habitId } = await readBody(event);
|
||||
|
||||
// Fetch the full user to check their anonymous status
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isAnonymous: true }
|
||||
});
|
||||
|
||||
// Ensure only anonymous users can use this endpoint
|
||||
if (!user || !user.isAnonymous) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: This endpoint is for anonymous onboarding users only.' });
|
||||
}
|
||||
|
||||
if (!habitId || typeof habitId !== 'number') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
||||
}
|
||||
|
||||
const habit = await prisma.habit.findFirst({
|
||||
where: { id: habitId, userId: userId }
|
||||
});
|
||||
|
||||
if (!habit) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found for this user.' });
|
||||
}
|
||||
|
||||
const startOfToday = getStartOfDay(new Date());
|
||||
|
||||
const existingCompletion = await prisma.habitCompletion.findFirst({
|
||||
where: {
|
||||
habitId: habitId,
|
||||
date: startOfToday,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCompletion) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
||||
}
|
||||
|
||||
// Use a transaction to ensure both operations succeed or fail together
|
||||
const [, updatedUser] = await prisma.$transaction([
|
||||
prisma.habitCompletion.create({
|
||||
data: {
|
||||
habitId: habitId,
|
||||
date: startOfToday,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coins: {
|
||||
increment: ONBOARDING_REWARD_COINS,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Onboarding habit completed successfully!',
|
||||
updatedCoins: updatedUser.coins,
|
||||
};
|
||||
});
|
||||
96
server/api/onboarding/initiate.post.ts
Normal file
96
server/api/onboarding/initiate.post.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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;
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
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,
|
||||
};
|
||||
});
|
||||
32
server/api/user/visit.post.ts
Normal file
32
server/api/user/visit.post.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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,
|
||||
}
|
||||
});
|
||||
|
|
@ -1,45 +1,40 @@
|
|||
// server/api/village/action.post.ts
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { buildOnTile, clearTile, moveObject, removeObject } from '../../services/villageService';
|
||||
import { getVillageState } from '../../services/villageService';
|
||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
||||
import {
|
||||
buildOnTile,
|
||||
syncAndGetVillage,
|
||||
} from '../../services/villageService';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
const body = await readBody(event);
|
||||
|
||||
const { tileId, actionType, payload } = body;
|
||||
|
||||
if (!tileId || !actionType) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing tileId or actionType' });
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing tileId or actionType',
|
||||
});
|
||||
}
|
||||
|
||||
switch (actionType) {
|
||||
case 'BUILD':
|
||||
if (!payload?.buildingType) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing buildingType for BUILD action' });
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing buildingType',
|
||||
});
|
||||
}
|
||||
|
||||
await buildOnTile(userId, tileId, payload.buildingType);
|
||||
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:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' });
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid actionType',
|
||||
});
|
||||
}
|
||||
|
||||
// Return the full updated village state
|
||||
return getVillageState(userId);
|
||||
return syncAndGetVillage(userId);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
// server/api/village/events.get.ts
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
|
||||
const village = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// server/api/village/index.get.ts
|
||||
import { getVillageState, generateVillageForUser } from '../../services/villageService';
|
||||
import { syncAndGetVillage, generateVillageForUser } from '../../services/villageService';
|
||||
import { defineEventHandler } from 'h3';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||
await generateVillageForUser(user);
|
||||
|
||||
try {
|
||||
const villageState = await getVillageState(user.id);
|
||||
const villageState = await syncAndGetVillage(user.id);
|
||||
return villageState;
|
||||
} catch (error: any) {
|
||||
// Catch errors from the service and re-throw them as H3 errors
|
||||
|
|
|
|||
39
server/api/village/tick.post.ts
Normal file
39
server/api/village/tick.post.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// server/api/village/tick.post.ts
|
||||
import { getAuthenticatedUserId } from '~/server/utils/auth';
|
||||
import { processVillageTick } from '~/server/services/villageService';
|
||||
|
||||
/**
|
||||
* This endpoint is called on every route change for an authenticated user.
|
||||
* It's responsible for triggering the "tick" of the village simulation,
|
||||
* which can include things like clearing tiles, generating resources, etc.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(event);
|
||||
|
||||
// Delegate the core logic to the villageService
|
||||
const result = await processVillageTick(userId);
|
||||
|
||||
// The response can be simple, or return a meaningful state if the client needs to react.
|
||||
// For now, a success message is sufficient.
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
|
||||
} catch (e: any) {
|
||||
// If getAuthenticatedUserId throws, it will be a 401 error, which should be propagated.
|
||||
if (e.statusCode === 401) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.error('Error processing village tick:', e);
|
||||
|
||||
// For other errors, return a generic 500.
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Internal Server Error',
|
||||
message: 'An unexpected error occurred while processing the village tick.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -2,12 +2,15 @@
|
|||
import { defineEventHandler, useSession } from 'h3';
|
||||
import prisma from '../utils/prisma';
|
||||
|
||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
||||
|
||||
/**
|
||||
* Global server middleware to populate `event.context.user` for every incoming request.
|
||||
*
|
||||
* It safely checks for a session and fetches the user from the database if a
|
||||
* valid session ID is found. It does NOT block requests or throw errors if the
|
||||
* user is not authenticated, as authorization is handled within API endpoints themselves.
|
||||
* It first checks for a logged-in user session. If not found, it checks for an
|
||||
* anonymous user session cookie. It attaches the corresponding user object to
|
||||
* `event.context.user` if found. It does NOT block requests, allowing auth
|
||||
* checks to be handled by individual endpoints.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// This middleware should not run on static assets or internal requests.
|
||||
|
|
@ -16,14 +19,13 @@ export default defineEventHandler(async (event) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Safely get the session
|
||||
// 1. Check for a logged-in user session
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD!,
|
||||
});
|
||||
|
||||
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) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
|
@ -32,11 +34,27 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
if (user) {
|
||||
event.context.user = user;
|
||||
return; // Found a user, no need to check for anonymous session
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,80 +1,33 @@
|
|||
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
|
||||
import {
|
||||
PrismaClient,
|
||||
User,
|
||||
Prisma,
|
||||
VillageObjectType,
|
||||
VillageTile,
|
||||
} from '@prisma/client';
|
||||
import { COSTS, REWARDS } from '../utils/economy';
|
||||
import { applyStreakMultiplier } from '../utils/streak';
|
||||
import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay';
|
||||
import { calculateDailyStreak } from '../utils/streak';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/* =========================
|
||||
CONSTANTS
|
||||
========================= */
|
||||
|
||||
export const VILLAGE_WIDTH = 5;
|
||||
export const VILLAGE_HEIGHT = 7;
|
||||
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export const PRODUCING_BUILDINGS: string[] = [
|
||||
export const PRODUCING_BUILDINGS = [
|
||||
'FIELD',
|
||||
'LUMBERJACK',
|
||||
'QUARRY',
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Helper to get the start of a given date for daily EXP checks
|
||||
const getStartOfDay = (date: Date) => {
|
||||
const d = new Date(date);
|
||||
d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency
|
||||
return d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the initial village for a new user atomically.
|
||||
*/
|
||||
export async function generateVillageForUser(user: User) {
|
||||
const existingVillage = await prisma.village.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (existingVillage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tilesToCreate: Omit<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
|
||||
const central_x_start = 1;
|
||||
const central_x_end = 4;
|
||||
const central_y_start = 2;
|
||||
const central_y_end = 5;
|
||||
|
||||
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
|
||||
for (let x = 0; x < VILLAGE_WIDTH; x++) {
|
||||
const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end;
|
||||
const terrainType = isCentral
|
||||
? 'EMPTY'
|
||||
: Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE';
|
||||
|
||||
tilesToCreate.push({
|
||||
x,
|
||||
y,
|
||||
terrainType,
|
||||
terrainState: 'IDLE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIX: Wrap village generation in a single transaction for atomicity.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const village = await tx.village.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
coins: 10,
|
||||
exp: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.villageTile.createMany({
|
||||
data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })),
|
||||
});
|
||||
});
|
||||
}
|
||||
/* =========================
|
||||
TYPES
|
||||
========================= */
|
||||
|
||||
type FullVillage = Prisma.VillageGetPayload<{
|
||||
include: {
|
||||
|
|
@ -84,251 +37,225 @@ type FullVillage = Prisma.VillageGetPayload<{
|
|||
};
|
||||
}>;
|
||||
|
||||
/* =========================
|
||||
PUBLIC API
|
||||
========================= */
|
||||
|
||||
/**
|
||||
* Gets the full, updated state of a user's village, calculating all time-based progression.
|
||||
* Processes the village's daily "tick" if necessary and returns the
|
||||
* complete, up-to-date village state.
|
||||
* This is the single source of truth for all time-based progression.
|
||||
*/
|
||||
export async function getVillageState(userId: number): Promise<FullVillage> {
|
||||
const now = new Date();
|
||||
export async function processVillageTick(userId: number): Promise<FullVillage> {
|
||||
try {
|
||||
const today = getTodayDay();
|
||||
let villageSnapshot = await fetchVillage(userId);
|
||||
|
||||
// --- Step 1: Initial Snapshot ---
|
||||
let villageSnapshot = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: true,
|
||||
tiles: { include: { object: true } },
|
||||
objects: { include: { tile: true } },
|
||||
},
|
||||
});
|
||||
if (!villageSnapshot) {
|
||||
// This should not happen for a logged-in user with a village.
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
||||
}
|
||||
|
||||
if (!villageSnapshot) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
||||
}
|
||||
|
||||
// --- Step 2: Terrain Cleaning Completion ---
|
||||
const finishedClearingTiles = villageSnapshot.tiles.filter(
|
||||
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
|
||||
);
|
||||
|
||||
if (finishedClearingTiles.length > 0) {
|
||||
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
|
||||
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
|
||||
// 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 user totals
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coins: { increment: totalCoins },
|
||||
exp: { increment: totalExp },
|
||||
},
|
||||
// 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 },
|
||||
});
|
||||
|
||||
// 2. Update all the tiles
|
||||
await tx.villageTile.updateMany({
|
||||
where: { id: { in: finishedClearingTiles.map(t => t.id) } },
|
||||
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
|
||||
});
|
||||
|
||||
// 3. Create an event for each completed tile
|
||||
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,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 3: Refetch for next logic step ---
|
||||
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
|
||||
|
||||
// --- Step 4: Field EXP Processing ---
|
||||
const today = getStartOfDay(now);
|
||||
const fieldsForExp = villageSnapshot.objects.filter(
|
||||
obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today)
|
||||
);
|
||||
|
||||
if (fieldsForExp.length > 0) {
|
||||
const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`));
|
||||
let totalExpFromFields = 0;
|
||||
const eventsToCreate = [];
|
||||
|
||||
for (const field of fieldsForExp) {
|
||||
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({
|
||||
villageId: villageSnapshot.id,
|
||||
type: 'FIELD_EXP',
|
||||
message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
|
||||
tileX: field.tile.x,
|
||||
tileY: field.tile.y,
|
||||
coins: 0,
|
||||
exp: fieldExp,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
|
||||
prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
|
||||
prisma.villageEvent.createMany({ data: eventsToCreate }),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Step 5: Refetch for next logic step ---
|
||||
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
|
||||
|
||||
// --- Step 6: Auto-start Terrain Cleaning ---
|
||||
const lumberjackCount = villageSnapshot.objects.filter(o => o.type === 'LUMBERJACK').length;
|
||||
const quarryCount = villageSnapshot.objects.filter(o => o.type === 'QUARRY').length;
|
||||
|
||||
const clearingTreesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING').length;
|
||||
const clearingStonesCount = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING').length;
|
||||
|
||||
const freeLumberjacks = lumberjackCount - clearingTreesCount;
|
||||
const freeQuarries = quarryCount - clearingStonesCount;
|
||||
|
||||
const tileIdsToClear = new Set<number>();
|
||||
|
||||
if (freeLumberjacks > 0) {
|
||||
const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
|
||||
if (idleTrees.length > 0) {
|
||||
// For simplicity, just take the first N available trees. A more complex distance-based heuristic could go here.
|
||||
idleTrees.slice(0, freeLumberjacks).forEach(t => tileIdsToClear.add(t.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (freeQuarries > 0) {
|
||||
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
|
||||
if (idleStones.length > 0) {
|
||||
// For simplicity, just take the first N available stones.
|
||||
idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (tileIdsToClear.size > 0) {
|
||||
await prisma.villageTile.updateMany({
|
||||
where: { id: { in: Array.from(tileIdsToClear) } },
|
||||
data: { terrainState: 'CLEARING', clearingStartedAt: now },
|
||||
});
|
||||
|
||||
// Refetch state after starting new clearings
|
||||
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
|
||||
// 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.' });
|
||||
}
|
||||
|
||||
// --- Step 7: Final Fetch & Action Enrichment ---
|
||||
const finalVillageState = await prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: true,
|
||||
tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] },
|
||||
objects: { include: { tile: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!finalVillageState) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' });
|
||||
}
|
||||
|
||||
// --- Step 8: Enrich tiles with available actions ---
|
||||
const { user } = finalVillageState;
|
||||
const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK');
|
||||
const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY');
|
||||
|
||||
const housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length;
|
||||
const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
|
||||
const freeWorkers = housesCount - producingCount;
|
||||
|
||||
const tilesWithActions = finalVillageState.tiles.map(tile => {
|
||||
const availableActions: any[] = [];
|
||||
|
||||
// Action: 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 ---
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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}` });
|
||||
// --- 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.' });
|
||||
}
|
||||
}
|
||||
|
||||
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 } });
|
||||
/**
|
||||
* Генерация деревни для нового пользователя
|
||||
*/
|
||||
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 },
|
||||
});
|
||||
|
||||
// Ownership check
|
||||
if (tile.village.userId !== userId) {
|
||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||
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' });
|
||||
}
|
||||
|
||||
// Business logic validation
|
||||
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
|
||||
}
|
||||
|
|
@ -338,16 +265,24 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
|||
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)' });
|
||||
}
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform mutations
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { coins: { decrement: cost } },
|
||||
|
|
@ -355,70 +290,258 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
|||
|
||||
await tx.villageObject.create({
|
||||
data: {
|
||||
type: buildingType as keyof typeof VillageObjectType,
|
||||
type: buildingType,
|
||||
villageId: tile.villageId,
|
||||
tileId: tileId,
|
||||
tileId: tile.id,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 } } },
|
||||
});
|
||||
/* =========================
|
||||
INTERNAL HELPERS
|
||||
========================= */
|
||||
|
||||
if (tile.village.userId !== userId) {
|
||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||
}
|
||||
function fetchVillage(userId: number) {
|
||||
return prisma.village.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: true,
|
||||
tiles: { include: { object: true } },
|
||||
objects: { include: { tile: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (tile.terrainState !== 'IDLE') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
|
||||
}
|
||||
/* =========================
|
||||
DAY-BASED LOGIC
|
||||
========================= */
|
||||
|
||||
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' });
|
||||
}
|
||||
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)
|
||||
);
|
||||
|
||||
await tx.villageTile.update({
|
||||
where: { id: tileId },
|
||||
data: {
|
||||
terrainState: 'CLEARING',
|
||||
clearingStartedAt: new Date(),
|
||||
},
|
||||
});
|
||||
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) {
|
||||
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
|
||||
let expGainedForField = 0;
|
||||
let wellBonusText = '';
|
||||
|
||||
for (let i = 0; i < daysMissed; i++) {
|
||||
let dailyFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
|
||||
|
||||
const isNearWell = wells.some(well =>
|
||||
Math.abs(well.tile.x - field.tile.x) <= 1 &&
|
||||
Math.abs(well.tile.y - field.tile.y) <= 1 &&
|
||||
(well.tile.x !== field.tile.x || well.tile.y !== field.tile.y)
|
||||
);
|
||||
|
||||
if (isNearWell) {
|
||||
dailyFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
|
||||
wellBonusText = ' Рядом с колодцем урожай удвоился!';
|
||||
}
|
||||
expGainedForField += dailyFieldExp;
|
||||
|
||||
// Create an event for each day the field gained experience
|
||||
eventsToCreate.push({
|
||||
villageId: village.id,
|
||||
type: 'FIELD_EXP',
|
||||
message: `Поле (${field.tile.x}, ${field.tile.y}) принесло опыт.` + wellBonusText + streakBonusText,
|
||||
tileX: field.tile.x,
|
||||
tileY: field.tile.y,
|
||||
coins: 0,
|
||||
exp: applyStreakMultiplier({ coins: 0, exp: dailyFieldExp }, currentDailyStreak).exp,
|
||||
});
|
||||
}
|
||||
totalBaseExpGained += expGainedForField; // This is base exp without final streak multiplier for total
|
||||
}
|
||||
|
||||
const finalTotalExp = applyStreakMultiplier({ coins: 0, exp: totalBaseExpGained }, currentDailyStreak);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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' });
|
||||
async function autoStartClearing(
|
||||
tx: Prisma.TransactionClient,
|
||||
village: FullVillage,
|
||||
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 finishedTreeTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_TREE').length;
|
||||
const finishedStoneTiles = justFinishedTiles.filter(t => t.terrainType === 'BLOCKED_STONE').length;
|
||||
|
||||
const busyTreesInSnapshot = village.tiles.filter(
|
||||
t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'CLEARING'
|
||||
).length;
|
||||
const busyStonesInSnapshot = village.tiles.filter(
|
||||
t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'CLEARING'
|
||||
).length;
|
||||
|
||||
// Correctly calculate busy workers by subtracting those that just finished
|
||||
const currentBusyTrees = Math.max(0, busyTreesInSnapshot - finishedTreeTiles);
|
||||
const currentBusyStones = Math.max(0, busyStonesInSnapshot - finishedStoneTiles);
|
||||
|
||||
const freeLumberjacks = Math.max(0, lumberjackCapacity - currentBusyTrees);
|
||||
const freeQuarries = Math.max(0, quarryCapacity - currentBusyStones);
|
||||
|
||||
// Find idle tiles that are not among those just finished (though they should be EMPTY now anyway)
|
||||
const idleTrees = village.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
|
||||
const idleStones = village.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
|
||||
|
||||
const treesToStart = idleTrees.slice(0, freeLumberjacks);
|
||||
const stonesToStart = idleStones.slice(0, freeQuarries);
|
||||
|
||||
const tilesToStart = [...treesToStart, ...stonesToStart];
|
||||
|
||||
if (!tilesToStart.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await tx.villageTile.updateMany({
|
||||
where: { id: { in: tilesToStart.map(t => t.id) } },
|
||||
data: {
|
||||
terrainState: 'CLEARING',
|
||||
clearingStartedDay: today,
|
||||
},
|
||||
});
|
||||
|
||||
return tilesToStart.length;
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
27
server/tasks/cleanup.ts
Normal file
27
server/tasks/cleanup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import prisma from '../utils/prisma';
|
||||
|
||||
/**
|
||||
* Deletes anonymous users that were created more than 24 hours ago.
|
||||
* This is designed to be run as a scheduled task to prevent accumulation
|
||||
* of stale data from users who start the onboarding but never register.
|
||||
*/
|
||||
export async function cleanupAnonymousUsers() {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
try {
|
||||
const result = await prisma.user.deleteMany({
|
||||
where: {
|
||||
isAnonymous: true,
|
||||
createdAt: {
|
||||
lt: twentyFourHoursAgo, // lt = less than
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[CleanupTask] Successfully deleted ${result.count} anonymous users.`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[CleanupTask] Error deleting anonymous users:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// server/utils/auth.ts
|
||||
import { useSession } from 'h3';
|
||||
import type { H3Event } from 'h3';
|
||||
|
||||
if (!process.env.SESSION_PASSWORD) {
|
||||
// Fail-fast if the session password is not configured
|
||||
|
|
@ -7,16 +7,30 @@ if (!process.env.SESSION_PASSWORD) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the user ID from the event context.
|
||||
* The `server/middleware/auth.ts` middleware is responsible for populating `event.context.user`.
|
||||
* Throws a 401 Unauthorized error if no user is found in the context.
|
||||
* @param event The H3 event object.
|
||||
* @returns The user's ID.
|
||||
*/
|
||||
export function getAuthenticatedUserId(event: H3Event): number {
|
||||
const user = event.context.user;
|
||||
if (!user || typeof user.id !== 'number') {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @deprecated Use `getAuthenticatedUserId(event)` instead. This function relies on the old session-only check and is not compatible with anonymous sessions.
|
||||
* A helper function to safely get the authenticated user's ID from the session.
|
||||
* Throws a 401 Unauthorized error if the user is not authenticated.
|
||||
*/
|
||||
export async function getUserIdFromSession(event: any): Promise<number> {
|
||||
const session = await useSession(event, {
|
||||
password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check
|
||||
});
|
||||
const userId = session.data?.user?.id;
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
export async function getUserIdFromSession(event: H3Event): Promise<number> {
|
||||
const user = event.context.user;
|
||||
if (!user || typeof user.id !== 'number' || user.isAnonymous) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
return user.id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
export const COSTS = {
|
||||
BUILD: {
|
||||
HOUSE: 50,
|
||||
HOUSE: 15,
|
||||
FIELD: 15,
|
||||
LUMBERJACK: 30,
|
||||
QUARRY: 30,
|
||||
WELL: 20,
|
||||
LUMBERJACK: 20,
|
||||
QUARRY: 20,
|
||||
WELL: 15,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -19,21 +19,15 @@ export const COSTS = {
|
|||
export const REWARDS = {
|
||||
// Village-related rewards
|
||||
VILLAGE: {
|
||||
CLEARING: { coins: 1, exp: 1 },
|
||||
CLEARING: { coins: 5, exp: 1 },
|
||||
FIELD_EXP: {
|
||||
BASE: 1,
|
||||
WELL_MULTIPLIER: 2,
|
||||
},
|
||||
},
|
||||
// Quest-related rewards
|
||||
QUESTS: {
|
||||
DAILY_VISIT: {
|
||||
BASE: { coins: 1 },
|
||||
STREAK_BONUS: { coins: 10 },
|
||||
}
|
||||
},
|
||||
// Habit-related rewards
|
||||
HABITS: {
|
||||
COMPLETION: { coins: 3, exp: 1 },
|
||||
COMPLETION: { coins: 10, exp: 1 },
|
||||
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
|
||||
}
|
||||
};
|
||||
|
|
|
|||
64
server/utils/gameDay.ts
Normal file
64
server/utils/gameDay.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// 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];
|
||||
}
|
||||
90
server/utils/streak.ts
Normal file
90
server/utils/streak.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ export const OBSTACLE_CLEAR_COST: Record<string, number> = {
|
|||
};
|
||||
|
||||
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) ---
|
||||
export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
||||
|
|
@ -37,12 +36,6 @@ export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
|||
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.
|
||||
* @param plantedAt The ISO string or Date object when the crop was planted.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user