Compare commits
No commits in common. "45fc71f8bd1ed82c3c51b0bae8aee4a9c500cd86" and "983847187144c8fb46fd92e77035d9948278562a" have entirely different histories.
45fc71f8bd
...
9838471871
|
|
@ -1,7 +0,0 @@
|
||||||
# Database URL for Prisma
|
|
||||||
DATABASE_URL="file:./dev.db"
|
|
||||||
SESSION_PASSWORD=some-long-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Secret key to authorize the cleanup task endpoint
|
|
||||||
# This should be a strong, unique secret in production
|
|
||||||
CLEANUP_SECRET="changeme"
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"chrome-devtools": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"chrome-devtools-mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
49
GEMINI.md
49
GEMINI.md
|
|
@ -69,53 +69,6 @@ Adhering to these conventions is critical for maintaining project stability.
|
||||||
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
|
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
|
||||||
- All business logic should reside in the backend.
|
- All business logic should reside in the backend.
|
||||||
|
|
||||||
### Data Conventions
|
### AI / Gemini Usage Rules
|
||||||
- **`daysOfWeek` Field:** This array represents the days a habit is active. The week starts on **Monday**.
|
|
||||||
- Monday: `0`
|
|
||||||
- Tuesday: `1`
|
|
||||||
- Wednesday: `2`
|
|
||||||
- Thursday: `3`
|
|
||||||
- Friday: `4`
|
|
||||||
- Saturday: `5`
|
|
||||||
- Sunday: `6`
|
|
||||||
- **Daily Streak:** The `dailyStreak` field on the `User` model is an integer representing the user's consecutive daily visits. Its primary function is to act as a **multiplier for rewards** (coins and EXP) earned from completing habits and village activities.
|
|
||||||
- The multiplier is typically capped (e.g., at 3x).
|
|
||||||
- It is updated by the `calculateDailyStreak` function located in `server/utils/streak.ts`. This function determines if a visit extends the streak, maintains it, or resets it.
|
|
||||||
|
|
||||||
## 4. Date and Time Handling (Game Day Concept)
|
|
||||||
|
|
||||||
To ensure consistent and timezone-agnostic handling of daily game mechanics, the project employs a "Game Day" concept. This approach prioritizes the user's local calendar day for relevant actions and uses a standardized string format for persistence.
|
|
||||||
|
|
||||||
### Key Principles:
|
|
||||||
|
|
||||||
1. **"Game Day" Format:** All dates representing a calendar day (e.g., when a habit was completed, a daily visit occurred, or a village event was processed) are stored and primarily handled as a **`String` in "YYYY-MM-DD" format**. This includes fields like `DailyVisit.date`, `HabitCompletion.date`, `VillageObject.lastExpDay`, `VillageTile.clearingStartedDay`, and `Village.lastTickDay`.
|
|
||||||
|
|
||||||
2. **Client as Source of Truth for User's Day:**
|
|
||||||
* For user-initiated actions (e.g., completing a habit, triggering a daily visit), the **frontend (client-side)** determines the current "Game Day" based on the user's local timezone.
|
|
||||||
* This "YYYY-MM-DD" string is then sent to the backend with the API request. This prevents timezone conflicts where the server's day might differ from the user's local perception of "today."
|
|
||||||
|
|
||||||
3. **Server-Side Consistency with UTC:**
|
|
||||||
* On the backend, when parsing these "YYYY-MM-DD" strings into `Date` objects for calculations (e.g., to determine the day of the week or calculate `daysSince`), they are explicitly parsed as **UTC dates** (e.g., `new Date(\`${gameDay}T00:00:00Z\`)`). This ensures that date arithmetic (like finding the previous day) is consistent and avoids shifts due to the server's local timezone.
|
|
||||||
|
|
||||||
4. **Day-of-Week Convention:** The application adheres to a specific convention for days of the week:
|
|
||||||
* **Monday: `0`**
|
|
||||||
* Tuesday: `1`
|
|
||||||
* Wednesday: `2`
|
|
||||||
* Thursday: `3`
|
|
||||||
* Friday: `4`
|
|
||||||
* Saturday: `5`
|
|
||||||
* Sunday: `6`
|
|
||||||
|
|
||||||
### Example Usage:
|
|
||||||
|
|
||||||
* **`DailyVisit.date`**: Stores the "Game Day" on which a user's visit was recorded.
|
|
||||||
* **`HabitCompletion.date`**: Stores the "Game Day" on which a habit was marked as complete.
|
|
||||||
* **`Village.lastTickDay`**: Stores the "Game Day" (server-side determined) when the village's background progression was last processed, preventing redundant calculations within the same server day.
|
|
||||||
* **`VillageObject.lastExpDay`**: Stores the "Game Day" when an object (e.g., a field) last generated experience.
|
|
||||||
* **`VillageTile.clearingStartedDay`**: Stores the "Game Day" when a tile's clearing process began.
|
|
||||||
|
|
||||||
This consistent use of "Game Day" strings and client-provided dates (for user actions) combined with server-side UTC parsing (for calculations) provides a robust solution to managing time-based game mechanics across different timezones.
|
|
||||||
|
|
||||||
## 5. AI / Gemini Usage Rules
|
|
||||||
- **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure.
|
- **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure.
|
||||||
- **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework.
|
- **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework.
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -61,15 +61,7 @@ node -v
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Date and Time Handling
|
## 4. Prisma Setup (IMPORTANT)
|
||||||
|
|
||||||
To ensure consistent and timezone-agnostic management of daily game mechanics (like habit completion, streaks, and village progression), the project uses a "Game Day" concept. Dates are primarily stored as `String` in "YYYY-MM-DD" format. The client provides its local "Game Day" for user actions, and the server processes dates consistently using UTC.
|
|
||||||
|
|
||||||
For a detailed explanation of the "Game Day" concept and its implementation, please refer to the `GEMINI.md` file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Prisma Setup (IMPORTANT)
|
|
||||||
|
|
||||||
### Prisma version
|
### Prisma version
|
||||||
|
|
||||||
|
|
@ -120,7 +112,7 @@ Never forget migrations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Prisma Client Usage
|
## 5. Prisma Client Usage
|
||||||
|
|
||||||
Prisma client is initialized here:
|
Prisma client is initialized here:
|
||||||
|
|
||||||
|
|
@ -144,7 +136,7 @@ export default prisma
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Development
|
## 6. Development
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
|
|
@ -166,7 +158,7 @@ npm run dev
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. API Example
|
## 7. API Example
|
||||||
|
|
||||||
Health check:
|
Health check:
|
||||||
|
|
||||||
|
|
@ -185,42 +177,7 @@ Expected response:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Scheduled Cleanup Task
|
## 8. Deployment Notes
|
||||||
|
|
||||||
To manage anonymous user data, a cleanup task can be triggered via a protected API endpoint. This task deletes anonymous users who were created more than 24 hours ago and have not registered.
|
|
||||||
|
|
||||||
### Environment Variable
|
|
||||||
|
|
||||||
The cleanup endpoint is protected by a secret key. Ensure the following environment variable is set in your `.env` file (and in production environments):
|
|
||||||
|
|
||||||
```env
|
|
||||||
CLEANUP_SECRET="your_strong_secret_key_here"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Replace `"your_strong_secret_key_here"` with a strong, unique secret.**
|
|
||||||
|
|
||||||
### Manual Trigger (for Development/Testing)
|
|
||||||
|
|
||||||
You can manually trigger the cleanup task using `curl` (or Postman, Insomnia, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-cleanup-secret: your_strong_secret_key_here" \
|
|
||||||
http://localhost:3000/api/admin/cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- Replace `http://localhost:3000` with your application's actual URL if not running locally.
|
|
||||||
- Replace `your_strong_secret_key_here` with the value set in your `CLEANUP_SECRET` environment variable.
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
In a production environment, this endpoint should be called by an **external scheduler** (e.g., a cron job service provided by your hosting platform, GitHub Actions, etc.) on a regular basis (e.g., daily). This ensures reliable, automatic cleanup without impacting user experience.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Deployment Notes
|
|
||||||
|
|
||||||
- Use Node 20 on hosting
|
- Use Node 20 on hosting
|
||||||
- Run Prisma migrations during deployment
|
- Run Prisma migrations during deployment
|
||||||
|
|
@ -229,7 +186,7 @@ In a production environment, this endpoint should be called by an **external sch
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. AI / Gemini Rules (IMPORTANT)
|
## 9. AI / Gemini Rules (IMPORTANT)
|
||||||
|
|
||||||
When using Gemini / AI tools:
|
When using Gemini / AI tools:
|
||||||
|
|
||||||
|
|
@ -246,7 +203,7 @@ When using Gemini / AI tools:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Why these constraints exist
|
## 10. Why these constraints exist
|
||||||
|
|
||||||
This setup was intentionally chosen to:
|
This setup was intentionally chosen to:
|
||||||
- avoid unstable Prisma 7 API
|
- avoid unstable Prisma 7 API
|
||||||
|
|
|
||||||
7
TODO.md
7
TODO.md
|
|
@ -1,7 +0,0 @@
|
||||||
Начисление бонусов происходит про посещении страницы /village . А надо на уровне роута сделать, у залогиненного пользователя, что бы при посещении любой страницы происходила расчистка, и если расчистка закончена, выбирался новый тайл для расчистки.
|
|
||||||
|
|
||||||
У нас в БД lastExpAt и CleaningStartAt это UTC дата Стрингом. А выполнение привычки идёт как Date. И стрик тоже Date считается. Сделать так же UTC, однотипно.
|
|
||||||
|
|
||||||
Так как дата UTC, не учитывается часовой пояс. как то это надо пофиксить
|
|
||||||
|
|
||||||
На лидербордах не показывать пользователей анонимусов (они без имени)
|
|
||||||
25
app/app.vue
25
app/app.vue
|
|
@ -10,13 +10,28 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { initialized, initAuth } = useAuth();
|
const { initialized, fetchMe } = useAuth();
|
||||||
|
|
||||||
// Initialize the authentication state on client-side load.
|
// Fetch the user state on initial client-side load.
|
||||||
// This will either fetch a logged-in user or create an anonymous session.
|
// The middleware will wait for `initialized` to be true.
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initAuth();
|
fetchMe();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="../assets/css/main.css"></style>
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="show" class="modal-overlay" @click.self="$emit('cancel')">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h4>{{ title }}</h4>
|
|
||||||
<p>{{ message }}</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button @click="$emit('cancel')" class="btn-cancel">{{ cancelText }}</button>
|
|
||||||
<button @click="$emit('confirm')" class="btn-confirm">{{ confirmText }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps({
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: 'Подтверждение',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
confirmText: {
|
|
||||||
type: String,
|
|
||||||
default: 'Да',
|
|
||||||
},
|
|
||||||
cancelText: {
|
|
||||||
type: String,
|
|
||||||
default: 'Отмена',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(['confirm', 'cancel']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
padding: 25px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content h4 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: none;
|
|
||||||
font-size: 1em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm {
|
|
||||||
background-color: #bf616a; /* Nord Red for destructive actions */
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
background-color: #e5e9f0; /* Nord light gray */
|
|
||||||
color: #4c566a;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="habit-card">
|
|
||||||
<div class="habit-header">
|
|
||||||
<div class="habit-details" style="flex-grow: 1;">
|
|
||||||
<h3>{{ habit.name }}</h3>
|
|
||||||
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar / History Grid (only for full user dashboard, not onboarding) -->
|
|
||||||
<div v-if="showHistoryGrid" class="history-grid">
|
|
||||||
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
|
|
||||||
<span class="day-label">{{ formatDayLabel(day) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="habit-action">
|
|
||||||
<div v-if="isScheduledForToday(habit) || forceShowAction">
|
|
||||||
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
|
|
||||||
<button v-else @click="emitComplete" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
|
|
||||||
Выполнить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="exploding" class="confetti-container">
|
|
||||||
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
habit: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isSubmittingHabit: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
explodingHabitId: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
showHistoryGrid: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true, // Default to true for dashboard, false for onboarding
|
|
||||||
},
|
|
||||||
forceShowAction: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['complete']);
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
|
|
||||||
|
|
||||||
const isCompleted = (habit, date) => {
|
|
||||||
if (!habit || !habit.completions) return false;
|
|
||||||
|
|
||||||
// Ensure date is in "YYYY-MM-DD" string format for comparison
|
|
||||||
const comparisonDate = (typeof date === 'string')
|
|
||||||
? date
|
|
||||||
: new Date(date).toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
return habit.completions.some(c => c.date === comparisonDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getScheduleText = (habit) => {
|
|
||||||
if (habit.daysOfWeek.length === 7) {
|
|
||||||
return 'каждый день';
|
|
||||||
}
|
|
||||||
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
|
|
||||||
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const exploding = computed(() => props.explodingHabitId === props.habit.id);
|
|
||||||
|
|
||||||
// --- History Grid Logic ---
|
|
||||||
const last14Days = computed(() => {
|
|
||||||
const dates = [];
|
|
||||||
const todayDate = new Date();
|
|
||||||
const todayDay = todayDate.getDay(); // 0 for Sunday, 1 for Monday, etc.
|
|
||||||
|
|
||||||
// Adjust so that Monday is 0 and Sunday is 6 for application's convention
|
|
||||||
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
|
||||||
|
|
||||||
// Calculate days to subtract to get to the Monday of LAST week
|
|
||||||
const daysToSubtract = appDayOfWeek + 7;
|
|
||||||
|
|
||||||
const startDate = new Date();
|
|
||||||
startDate.setDate(todayDate.getDate() - daysToSubtract);
|
|
||||||
|
|
||||||
for (let i = 0; i < 14; i++) {
|
|
||||||
const date = new Date(startDate);
|
|
||||||
date.setDate(startDate.getDate() + i);
|
|
||||||
dates.push(date);
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDayLabel = (date) => {
|
|
||||||
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
|
|
||||||
return formatted.replace(' г.', '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCellClasses = (habit, day) => {
|
|
||||||
const classes = {};
|
|
||||||
const dayString = new Date(day).toISOString().slice(0, 10);
|
|
||||||
const habitCreatedAt = new Date(habit.createdAt).toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
if (dayString > today) {
|
|
||||||
classes['future-day'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayString === today) {
|
|
||||||
classes['today-highlight'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayOfWeek = (new Date(day).getDay() === 0) ? 6 : new Date(day).getDay() - 1; // Mon=0, Sun=6
|
|
||||||
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
|
|
||||||
if (isScheduled) {
|
|
||||||
classes['scheduled-day'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCompleted(habit, dayString)) {
|
|
||||||
classes['completed'] = true;
|
|
||||||
return classes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayString < today && isScheduled && dayString >= habitCreatedAt) {
|
|
||||||
classes['missed-day'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return classes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isScheduledForToday = (habit) => {
|
|
||||||
const todayDay = new Date().getDay(); // Sunday is 0
|
|
||||||
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
|
||||||
return habit.daysOfWeek.includes(appDayOfWeek);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emitComplete = () => {
|
|
||||||
emit('complete', props.habit.id);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.habit-card {
|
|
||||||
background: var(--container-bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
margin: 24px auto;
|
|
||||||
max-width: 800px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start; /* Adjust as needed */
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-details {
|
|
||||||
text-align: left;
|
|
||||||
flex-grow: 1; /* Allow details to take available space */
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-action {
|
|
||||||
margin-top: 20px; /* Add some space above the action button */
|
|
||||||
text-align: center; /* Center the button */
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-details h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-schedule {
|
|
||||||
margin: 5px 0 0 0;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.completed-text {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #16a34a; /* A nice green */
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-cell {
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #f8fafc;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-cell.completed {
|
|
||||||
background-color: #4ade80;
|
|
||||||
color: white;
|
|
||||||
border-color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-cell.missed-day {
|
|
||||||
background-color: #fee2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-cell.scheduled-day {
|
|
||||||
border-width: 2px;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.future-day .day-label {
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-cell.today-highlight::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 4px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-label {
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confetti Animation */
|
|
||||||
.confetti-container {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confetti-particle {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 0;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: confetti-fall 1s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes confetti-fall {
|
|
||||||
from {
|
|
||||||
transform: translateY(0) translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(-200px) translateX(var(--x-end)) rotate(360deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Particle Colors & random-ish trajectories */
|
|
||||||
.confetti-particle:nth-child(1) { background-color: #d88e8e; --x-end: -150px; animation-delay: 0s; }
|
|
||||||
.confetti-particle:nth-child(2) { background-color: #a3be8c; --x-end: 150px; animation-delay: 0.1s; }
|
|
||||||
.confetti-particle:nth-child(3) { background-color: #ebcb8b; --x-end: 100px; animation-delay: 0.05s; }
|
|
||||||
.confetti-particle:nth-child(4) { background-color: #81a1c1; --x-end: -100px; animation-delay: 0.2s; }
|
|
||||||
.confetti-particle:nth-child(5) { background-color: #b48ead; --x-end: 50px; animation-delay: 0.15s; }
|
|
||||||
.confetti-particle:nth-child(6) { background-color: #d88e8e; --x-end: -50px; animation-delay: 0.3s; }
|
|
||||||
.confetti-particle:nth-child(7) { background-color: #a3be8c; --x-end: -80px; animation-delay: 0.25s; }
|
|
||||||
.confetti-particle:nth-child(8) { background-color: #ebcb8b; --x-end: 80px; animation-delay: 0.4s; }
|
|
||||||
.confetti-particle:nth-child(9) { background-color: #81a1c1; --x-end: 120px; animation-delay: 0.35s; }
|
|
||||||
.confetti-particle:nth-child(10) { background-color: #b48ead; --x-end: -120px; animation-delay: 0.45s; }
|
|
||||||
.confetti-particle:nth-child(11) { background-color: #d88e8e; --x-end: -180px; animation-delay: 0.08s; }
|
|
||||||
.confetto-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
|
|
||||||
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
|
|
||||||
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
|
|
||||||
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
|
|
||||||
|
|
||||||
/* Responsive Styles for the action button */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.habit-action button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,555 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="onboarding-funnel">
|
|
||||||
<!-- New, Modern Progress Bar -->
|
|
||||||
<div class="onboarding-funnel__progress">
|
|
||||||
<div class="onboarding-funnel__progress-line"></div>
|
|
||||||
<div
|
|
||||||
class="onboarding-funnel__progress-line --completed"
|
|
||||||
:style="{ width: `${(currentStep - 1) * 25}%` }"
|
|
||||||
></div>
|
|
||||||
<div v-for="n in 5" :key="n" class="onboarding-funnel__step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
|
|
||||||
<div class="onboarding-funnel__step-dot"></div>
|
|
||||||
<div class="onboarding-funnel__step-label">{{ getStepLabel(n) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<div class="onboarding-funnel__content">
|
|
||||||
<div class="step-card">
|
|
||||||
<!-- Key added for transitions -->
|
|
||||||
<div :key="currentStep" class="step-container">
|
|
||||||
|
|
||||||
<!-- Step 1: Create Habit -->
|
|
||||||
<div v-if="currentStep === 1">
|
|
||||||
<h2>Создайте первую привычку</h2>
|
|
||||||
<p>Привычки - это основа продуктивности. С чего начнем?</p>
|
|
||||||
<form @submit.prevent="handleCreateHabit" class="onboarding-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="habit-name" class="form-label">Название привычки</label>
|
|
||||||
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Дни выполнения</label>
|
|
||||||
<div class="days-selector">
|
|
||||||
<button v-for="day in daysOfWeek" :key="day.value" type="button" class="btn-toggle" :class="{ 'selected': form.selectedDays.includes(day.value) }" @click="toggleDay(day.value)">{{ day.label }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Complete Habit -->
|
|
||||||
<div v-if="currentStep === 2">
|
|
||||||
<h2>Шаг 2: Завершите привычку</h2>
|
|
||||||
<p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p>
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
|
||||||
<div class="habit-display-card">
|
|
||||||
<h3>{{ createdHabit?.name }}</h3>
|
|
||||||
<p class="habit-schedule-text">{{ getScheduleText(createdHabit) }}</p>
|
|
||||||
<button @click="handleCompleteOnboardingHabit(createdHabit.id)" :disabled="isLoading" class="btn btn-primary">
|
|
||||||
{{ isLoading ? 'Выполнение...' : 'Выполнить' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: Reward Screen -->
|
|
||||||
<div v-if="currentStep === 3" class="reward-step">
|
|
||||||
<div class="reward-icon">🎉</div>
|
|
||||||
<h2>Вы получили {{ onboardingRewardAmount }} монет!</h2>
|
|
||||||
<p class="reward-caption">Монеты можно тратить на строительство вашей деревни.</p>
|
|
||||||
<button @click="nextStep" class="btn btn-primary">Продолжить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 4: Build a House -->
|
|
||||||
<div v-if="currentStep === 4">
|
|
||||||
<h2>Шаг 4: Постройте дом</h2>
|
|
||||||
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: {{ houseCost }} монет).</p>
|
|
||||||
|
|
||||||
<VillageGrid
|
|
||||||
v-if="villageData"
|
|
||||||
:village-data="villageData"
|
|
||||||
:is-onboarding="true"
|
|
||||||
@tile-click="handleTileClickToBuild"
|
|
||||||
/> <div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
|
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
|
||||||
|
|
||||||
<button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="btn btn-primary next-button">
|
|
||||||
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 5: Register -->
|
|
||||||
<div v-if="currentStep === 5">
|
|
||||||
<h2>Шаг 5: Сохраните прогресс!</h2>
|
|
||||||
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
|
|
||||||
<form @submit.prevent="handleRegister" class="onboarding-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="nickname" class="form-label">Ваше имя</label>
|
|
||||||
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email" class="form-label">Email</label>
|
|
||||||
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password" class="form-label">Пароль (мин. 8 символов)</label>
|
|
||||||
<input id="password" type="password" v-model="form.password" required class="form-control"/>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, watch, computed, onMounted } from 'vue';
|
|
||||||
|
|
||||||
// --- Composables ---
|
|
||||||
const api = useApi();
|
|
||||||
const { user, updateUser, register } = useAuth();
|
|
||||||
|
|
||||||
// --- Onboardning initiation ---
|
|
||||||
onMounted(async () => {
|
|
||||||
if (user.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const anonymousUser = await api('/api/onboarding/initiate', { method: 'POST' });
|
|
||||||
if (anonymousUser) {
|
|
||||||
updateUser(anonymousUser);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = 'Не удалось начать сессию. Попробуйте обновить страницу';
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Economy Constants ---
|
|
||||||
const { data: economy, pending: economyPending } = useAsyncData('economy-constants', () =>
|
|
||||||
$fetch('/api/economy/constants')
|
|
||||||
);
|
|
||||||
|
|
||||||
const onboardingRewardAmount = computed(() => economy.value?.rewards?.onboardingCompletion ?? 50);
|
|
||||||
const houseCost = computed(() => economy.value?.costs?.build?.house ?? 15);
|
|
||||||
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const currentStep = ref(1);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
habitName: 'Читать 15 минут',
|
|
||||||
selectedDays: [0, 1, 2, 3, 4, 5, 6],
|
|
||||||
nickname: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const daysOfWeek = [
|
|
||||||
{ label: 'Пн', value: 0 }, { label: 'Вт', value: 1 }, { label: 'Ср', value: 2 },
|
|
||||||
{ label: 'Чт', value: 3 }, { label: 'Пт', value: 4 }, { label: 'Сб', value: 5 }, { label: 'Вс', value: 6 }
|
|
||||||
];
|
|
||||||
const createdHabit = ref<{ id: number; name: string; completions: any[] } | null>(null);
|
|
||||||
|
|
||||||
const villageData = ref(null);
|
|
||||||
const villagePending = ref(false);
|
|
||||||
|
|
||||||
// --- Computed ---
|
|
||||||
const isHouseBuilt = computed(() => villageData.value?.objects.some(o => o.type === 'HOUSE'));
|
|
||||||
const hasEnoughCoinsForHouse = computed(() => user.value && user.value.coins >= houseCost.value);
|
|
||||||
|
|
||||||
// --- Watchers ---
|
|
||||||
watch(currentStep, async (newStep) => {
|
|
||||||
if (newStep === 4 && !villageData.value) { // Was step 3, now 4
|
|
||||||
villagePending.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
villageData.value = await api('/api/village');
|
|
||||||
} catch (e: any) { error.value = 'Не удалось загрузить данные деревни.'; }
|
|
||||||
finally { villagePending.value = false; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Methods ---
|
|
||||||
const getStepLabel = (step: number): string => {
|
|
||||||
switch (step) {
|
|
||||||
case 1: return 'Привычка';
|
|
||||||
case 2: return 'Выполнение';
|
|
||||||
case 3: return 'Награда';
|
|
||||||
case 4: return 'Стройка';
|
|
||||||
case 5: return 'Регистрация';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getScheduleText = (habit: any) => {
|
|
||||||
if (!habit || !habit.daysOfWeek) return '';
|
|
||||||
if (habit.daysOfWeek.length === 7) {
|
|
||||||
return 'каждый день';
|
|
||||||
}
|
|
||||||
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
|
|
||||||
return habit.daysOfWeek.sort((a: number, b: number) => a - b).map((dayIndex: number) => dayMap[dayIndex]).join(', ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
if (currentStep.value < 5) { // Max 5 steps now
|
|
||||||
error.value = null;
|
|
||||||
currentStep.value++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDay = (day: number) => {
|
|
||||||
const index = form.selectedDays.indexOf(day);
|
|
||||||
if (index > -1) {
|
|
||||||
if (form.selectedDays.length > 1) form.selectedDays.splice(index, 1);
|
|
||||||
} else {
|
|
||||||
form.selectedDays.push(day);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- API Methods ---
|
|
||||||
const handleCreateHabit = async () => {
|
|
||||||
if (!form.habitName.trim() || form.selectedDays.length === 0) {
|
|
||||||
error.value = 'Пожалуйста, введите название и выберите хотя бы один день.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await api('/api/habits', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name: form.habitName, daysOfWeek: form.selectedDays },
|
|
||||||
});
|
|
||||||
createdHabit.value = { ...response, completions: [] };
|
|
||||||
nextStep();
|
|
||||||
} catch (e: any) { error.value = e.data?.message || 'Не удалось создать привычку.'; }
|
|
||||||
finally { isLoading.value = false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCompleteOnboardingHabit = async (habitId: number) => {
|
|
||||||
if (!createdHabit.value) {
|
|
||||||
error.value = 'Ошибка: не найдена созданная привычка.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const gameDay = new Date().toISOString().slice(0, 10); // Get client's gameDay
|
|
||||||
const response = await api(`/api/habits/${habitId}/complete`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { gameDay } // Pass gameDay in the body
|
|
||||||
});
|
|
||||||
// Manually update the user's coins from the response
|
|
||||||
if (user.value && response.updatedCoins !== undefined) {
|
|
||||||
user.value.coins = response.updatedCoins;
|
|
||||||
}
|
|
||||||
createdHabit.value.completions.push({ date: gameDay }); // Use gameDay for optimistic update
|
|
||||||
nextStep();
|
|
||||||
} catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; }
|
|
||||||
finally { isLoading.value = false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTileClickToBuild = async (tile: any) => {
|
|
||||||
if (isLoading.value || isHouseBuilt.value) return;
|
|
||||||
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
|
||||||
error.value = 'Выберите пустой участок для строительства.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!hasEnoughCoinsForHouse.value) {
|
|
||||||
error.value = `Не хватает монет для постройки дома (${houseCost.value} монет).`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api('/api/village/action', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
tileId: tile.id,
|
|
||||||
actionType: 'BUILD',
|
|
||||||
payload: { buildingType: 'HOUSE' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
villageData.value = response;
|
|
||||||
if (updateUser && response.user) {
|
|
||||||
updateUser(response.user);
|
|
||||||
}
|
|
||||||
} catch (e: any) { error.value = e.data?.message || 'Не удалось построить дом.'; }
|
|
||||||
finally { isLoading.value = false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
if (!form.email || !form.password || !form.nickname) {
|
|
||||||
error.value = "Пожалуйста, заполните все поля.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
error.value = "Пароль должен быть не менее 8 символов.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await register(form.email, form.password, form.nickname);
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e.data?.message || 'Ошибка регистрации. Попробуйте другой email.';
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* --- Main Funnel Layout --- */
|
|
||||||
.onboarding-funnel {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 40px auto;
|
|
||||||
padding: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- New Progress Bar --- */
|
|
||||||
.onboarding-funnel__progress {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding: 0 10%;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__progress-line {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 10%;
|
|
||||||
right: 10%;
|
|
||||||
height: 4px;
|
|
||||||
background-color: var(--border-color);
|
|
||||||
z-index: 1;
|
|
||||||
transition: width 0.4s ease-in-out;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__progress-line.--completed {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step-dot {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--container-bg-color);
|
|
||||||
border: 4px solid var(--border-color);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step-label {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step.active .onboarding-funnel__step-dot {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step.active .onboarding-funnel__step-label {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.onboarding-funnel__step.completed .onboarding-funnel__step-dot {
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Content Card --- */
|
|
||||||
.step-card {
|
|
||||||
background-color: var(--container-bg-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 3rem;
|
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-container {
|
|
||||||
animation: fadeIn 0.5s ease;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(15px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-container h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.step-container p {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
max-width: 500px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Form & Interactive Elements --- */
|
|
||||||
.onboarding-form {
|
|
||||||
max-width: 450px;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.days-selector {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.btn-toggle {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.btn-toggle:hover {
|
|
||||||
background-color: #f7f7f7;
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
.btn-toggle.selected {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: var(--danger-color);
|
|
||||||
margin: -1rem 0 1.5rem 0;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-placeholder {
|
|
||||||
background-color: rgba(0,0,0,0.02);
|
|
||||||
border: 2px dashed var(--border-color);
|
|
||||||
padding: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
border-radius: 12px;
|
|
||||||
margin: 0 auto 2rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Specific Step Styles --- */
|
|
||||||
.habit-card-wrapper {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.habit-display-card {
|
|
||||||
background: var(--container-bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto 24px auto; /* Centered with margin-bottom */
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
.habit-display-card h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
.habit-schedule-text {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.reward-step { text-align: center; }
|
|
||||||
.reward-icon { font-size: 6rem; line-height: 1; margin-bottom: 1.5rem; animation: tada 1s ease; }
|
|
||||||
@keyframes tada {
|
|
||||||
from { transform: scale3d(1, 1, 1); }
|
|
||||||
10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
|
|
||||||
30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
|
|
||||||
40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
|
|
||||||
to { transform: scale3d(1, 1, 1); }
|
|
||||||
}
|
|
||||||
.reward-caption { margin-bottom: 2.5rem; }
|
|
||||||
|
|
||||||
.next-button { margin-top: 2rem; }
|
|
||||||
.next-button:disabled {
|
|
||||||
background-color: var(--text-color-light);
|
|
||||||
border-color: var(--text-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Responsive Styles --- */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.onboarding-funnel {
|
|
||||||
padding: 0;
|
|
||||||
margin: 24px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-funnel__progress {
|
|
||||||
padding: 0 1rem; /* Reduced padding */
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-funnel__progress-line {
|
|
||||||
left: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-funnel__step-label {
|
|
||||||
display: none; /* Hide labels on mobile to save space */
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-card {
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-container h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-container p {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make main buttons full-width */
|
|
||||||
.step-container .btn-primary {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.days-selector {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.btn-toggle {
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="villageData && villageData.tiles" class="village-container">
|
|
||||||
<div :class="villageGridWrapperClass">
|
|
||||||
<!-- Empty corner for alignment -->
|
|
||||||
<div class="empty-corner"></div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- The actual grid -->
|
|
||||||
<div class="village-grid" :style="gridStyle">
|
|
||||||
<div
|
|
||||||
v-for="tile in villageData.tiles"
|
|
||||||
:key="tile.id"
|
|
||||||
class="tile"
|
|
||||||
:class="tileClasses(tile)"
|
|
||||||
:style="{ 'grid-column': tile.x + 1, 'grid-row': gridHeight - tile.y }"
|
|
||||||
@click="$emit('tile-click', tile)"
|
|
||||||
>
|
|
||||||
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="loading">
|
|
||||||
Загрузка деревни...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
// --- Props & Emits ---
|
|
||||||
const props = defineProps({
|
|
||||||
villageData: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isOnboarding: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
selectedTile: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const villageGridWrapperClass = computed(() => ({
|
|
||||||
'village-grid-wrapper': true,
|
|
||||||
'is-onboarding': props.isOnboarding,
|
|
||||||
}));
|
|
||||||
|
|
||||||
defineEmits(['tile-click']);
|
|
||||||
|
|
||||||
// --- Grid Dimensions ---
|
|
||||||
const gridWidth = computed(() => {
|
|
||||||
if (!props.villageData?.tiles || props.villageData.tiles.length === 0) return 5; // Default
|
|
||||||
return Math.max(...props.villageData.tiles.map(t => t.x)) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const gridHeight = computed(() => {
|
|
||||||
if (!props.villageData?.tiles || props.villageData.tiles.length === 0) return 7; // Default
|
|
||||||
return Math.max(...props.villageData.tiles.map(t => t.y)) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const gridStyle = computed(() => ({
|
|
||||||
'grid-template-columns': `repeat(${gridWidth.value}, var(--tile-size))`,
|
|
||||||
'grid-template-rows': `repeat(${gridHeight.value}, var(--tile-size))`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// --- Labels ---
|
|
||||||
const colLabels = computed(() => {
|
|
||||||
return Array.from({ length: gridWidth.value }, (_, i) => String.fromCharCode(65 + i));
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowLabels = computed(() => {
|
|
||||||
return Array.from({ length: gridHeight.value }, (_, i) => gridHeight.value - i);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Tile Display Logic ---
|
|
||||||
const getTileEmoji = (tile) => {
|
|
||||||
if (tile.terrainState === 'CLEARING') return '⏳';
|
|
||||||
if (tile.object) {
|
|
||||||
switch (tile.object.type) {
|
|
||||||
case 'HOUSE': return '🏠';
|
|
||||||
case 'FIELD': return '🌱';
|
|
||||||
case 'LUMBERJACK': return '🪓';
|
|
||||||
case 'QUARRY': return '⛏️';
|
|
||||||
case 'WELL': return '💧';
|
|
||||||
default: return '❓';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (tile.terrainType) {
|
|
||||||
case 'BLOCKED_TREE': return '🌳';
|
|
||||||
case 'BLOCKED_STONE': return '🪨';
|
|
||||||
case 'EMPTY': return '';
|
|
||||||
default: return '❓';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tileClasses = (tile) => {
|
|
||||||
return {
|
|
||||||
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
|
|
||||||
'tile-object': !!tile.object,
|
|
||||||
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
|
|
||||||
'tile-clearing': tile.terrainState === 'CLEARING',
|
|
||||||
'selected': props.selectedTile && props.selectedTile.id === tile.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.village-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
--tile-size: clamp(40px, 10vw, 55px);
|
|
||||||
padding: 0 16px; /* Add padding for mobile */
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.village-grid-wrapper {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px; /* Increased gap */
|
|
||||||
border-radius: 16px; /* Rounded wrapper */
|
|
||||||
background-color: var(--container-bg-color);
|
|
||||||
padding: 12px; /* Increased padding */
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%; /* Ensure it doesn't overflow parent */
|
|
||||||
margin: 0 auto;
|
|
||||||
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-corner, .col-labels, .row-labels {
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-labels {
|
|
||||||
grid-column: 2 / -1; /* Dynamic spanning */
|
|
||||||
grid-row: 8;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-label {
|
|
||||||
width: var(--tile-size);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-labels {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1 / -1; /* Dynamic spanning */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-label {
|
|
||||||
height: var(--tile-size);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.village-grid {
|
|
||||||
grid-column: 2 / -1; /* Dynamic spanning */
|
|
||||||
grid-row: 1 / -1; /* Dynamic spanning */
|
|
||||||
display: grid;
|
|
||||||
gap: 8px; /* Increased gap */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
width: var(--tile-size);
|
|
||||||
height: var(--tile-size);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative; /* For selection pseudo-elements */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-empty {
|
|
||||||
background-color: rgba(0,0,0,0.02);
|
|
||||||
border: 2px dashed var(--border-color);
|
|
||||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.03);
|
|
||||||
}
|
|
||||||
.tile.tile-empty:hover {
|
|
||||||
background-color: rgba(0,0,0,0.04);
|
|
||||||
border-color: var(--primary-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-blocked {
|
|
||||||
background-color: #EBE5D7; /* A soft, earthy tone */
|
|
||||||
border: 1px solid #DCD5C6;
|
|
||||||
}
|
|
||||||
.tile.tile-blocked:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-object {
|
|
||||||
background-color: #c8ffcf;
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
|
|
||||||
border: 1px solid #39af50;
|
|
||||||
}
|
|
||||||
.tile.tile-object:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-clearing {
|
|
||||||
background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
|
|
||||||
border: 1px solid #ffcc80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.selected {
|
|
||||||
box-shadow: 0 0 0 3px var(--primary-color);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-content {
|
|
||||||
font-size: calc(var(--tile-size) * 0.5); /* Make emoji scale with tile */
|
|
||||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
||||||
}
|
|
||||||
|
|
||||||
.village-grid-wrapper.is-onboarding {
|
|
||||||
padding: 8px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.village-grid-wrapper.is-onboarding .col-label,
|
|
||||||
.village-grid-wrapper.is-onboarding .row-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.village-grid-wrapper {
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.village-grid {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* On mobile, we calculate a tile size that fits the screen.
|
|
||||||
7 columns (5 tiles + 2 labels) and 6 gaps.
|
|
||||||
The parent .village-container has 16px padding on each side.
|
|
||||||
So, (100vw - 32px - (6 * 4px)) / 7 = tile size
|
|
||||||
We'll use a simplified version with vw units. */
|
|
||||||
.village-container {
|
|
||||||
--tile-size: clamp(35px, 11vw, 50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
/* aspect-ratio: 1 / 1;
|
|
||||||
height: auto; */
|
|
||||||
/* Disabling aspect ratio for now to prevent massive height on narrow screens */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-empty:hover {
|
|
||||||
background-color: rgba(0,0,0,0.02); /* Reset to non-hover state */
|
|
||||||
border-color: var(--border-color); /* Reset to non-hover state */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-blocked:hover {
|
|
||||||
transform: translateY(0); /* Reset to non-hover state */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.tile-object:hover {
|
|
||||||
transform: translateY(0); /* Reset to non-hover state */
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07); /* Reset to non-hover state */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
@ -1,132 +1,66 @@
|
||||||
// /composables/useAuth.ts
|
// /composables/useAuth.ts
|
||||||
import { computed, watch } from 'vue';
|
|
||||||
import { useVisitTracker } from './useVisitTracker';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: string;
|
||||||
email: string | null; // Can be null for anonymous users
|
email: string;
|
||||||
nickname: string | null;
|
nickname: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
coins: number;
|
coins: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
dailyStreak: number;
|
|
||||||
soundOn: boolean;
|
soundOn: boolean;
|
||||||
confettiOn: boolean;
|
confettiOn: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isAnonymous?: boolean; // Flag to distinguish anonymous users
|
|
||||||
anonymousSessionId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
// All Nuxt composables that require instance access MUST be called inside the setup function.
|
||||||
|
// useState is how we create shared, SSR-safe state in Nuxt.
|
||||||
const user = useState<User | null>('user', () => null);
|
const user = useState<User | null>('user', () => null);
|
||||||
const initialized = useState('auth_initialized', () => false);
|
const initialized = useState('auth_initialized', () => false);
|
||||||
|
const loading = ref(false); // This is a local, non-shared loading ref for fetchMe's internal use
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { visitCalled } = useVisitTracker();
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
|
|
||||||
// A user is fully authenticated only if they exist and are NOT anonymous.
|
const fetchMe = async () => {
|
||||||
const isAuthenticated = computed(() => !!user.value && !user.value.isAnonymous);
|
// This function can be called multiple times, but the logic inside
|
||||||
// A user is anonymous if they exist and have the isAnonymous flag.
|
// will only run once thanks to the initialized flag.
|
||||||
const isAnonymous = computed(() => !!user.value && !!user.value.isAnonymous);
|
|
||||||
|
|
||||||
// --- This watcher is the new core logic for post-authentication tasks ---
|
|
||||||
watch(isAuthenticated, (newIsAuthenticated, oldIsAuthenticated) => {
|
|
||||||
// We only care about the transition from logged-out to logged-in
|
|
||||||
if (newIsAuthenticated && !oldIsAuthenticated) {
|
|
||||||
if (!visitCalled.value) {
|
|
||||||
visitCalled.value = true;
|
|
||||||
|
|
||||||
const gameDay = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// --- 1. Trigger Daily Visit & Streak Calculation ---
|
|
||||||
api('/api/user/visit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { gameDay }
|
|
||||||
}).then(updatedUser => {
|
|
||||||
if (updatedUser) {
|
|
||||||
updateUser(updatedUser);
|
|
||||||
}
|
|
||||||
}).catch(e => {
|
|
||||||
console.error('Failed to register daily visit:', e);
|
|
||||||
visitCalled.value = false; // Allow retrying on next navigation if it failed
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 2. Trigger Village Tick ---
|
|
||||||
api('/api/village/tick', { method: 'POST' })
|
|
||||||
.catch(e => {
|
|
||||||
console.error('Failed to trigger village tick:', e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the authentication state for EXISTING users.
|
|
||||||
* It should be called once in app.vue.
|
|
||||||
* It will only try to fetch a logged-in user via /api/auth/me.
|
|
||||||
* If it fails, the user state remains null.
|
|
||||||
*/
|
|
||||||
const initAuth = async () => {
|
|
||||||
if (initialized.value) return;
|
if (initialized.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await api<{ user: User }>('/auth/me');
|
// The backend returns the user object nested under a 'user' key.
|
||||||
if (response.user) {
|
const response = await api<{ user: User }>('/auth/me', { method: 'GET' });
|
||||||
user.value = { ...response.user, isAnonymous: false };
|
user.value = response.user; // Correctly assign the nested user object
|
||||||
} else {
|
|
||||||
user.value = null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// It's expected this will fail for non-logged-in users.
|
user.value = null; // Silently set user to null on 401
|
||||||
user.value = null;
|
|
||||||
} finally {
|
} finally {
|
||||||
initialized.value = true;
|
loading.value = false;
|
||||||
|
initialized.value = true; // Mark as initialized after the first attempt
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the onboarding process by creating a new anonymous user.
|
|
||||||
*/
|
|
||||||
const startOnboarding = async () => {
|
|
||||||
try {
|
|
||||||
const anonymousUserData = await api<User>('/onboarding/initiate', { method: 'POST' });
|
|
||||||
// Explicitly set isAnonymous to true for robustness
|
|
||||||
user.value = { ...anonymousUserData, isAnonymous: true };
|
|
||||||
} catch (anonError) {
|
|
||||||
console.error('Could not initiate anonymous session:', anonError);
|
|
||||||
// Optionally, show an error message to the user
|
|
||||||
user.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const register = async (email, password, nickname) => {
|
|
||||||
await api('/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { email, password, nickname },
|
|
||||||
});
|
|
||||||
// After a successful registration, force a re-fetch of the new user state.
|
|
||||||
initialized.value = false;
|
|
||||||
await initAuth();
|
|
||||||
};
|
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
|
// The calling component is responsible for its own loading state.
|
||||||
|
// This function just performs the action.
|
||||||
await api('/auth/login', {
|
await api('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
});
|
});
|
||||||
// After a successful login, force a re-fetch of the new user state.
|
// After a successful login, allow a re-fetch of the user state.
|
||||||
initialized.value = false;
|
initialized.value = false;
|
||||||
await initAuth();
|
await fetchMe();
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await api('/auth/logout', { method: 'POST' });
|
await api('/auth/logout', { method: 'POST' });
|
||||||
} finally {
|
} finally {
|
||||||
|
// Always clear state and redirect, regardless of API call success.
|
||||||
user.value = null;
|
user.value = null;
|
||||||
visitCalled.value = false; // Reset for the next session
|
initialized.value = false;
|
||||||
await navigateTo('/');
|
await navigateTo('/login');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -136,14 +70,12 @@ export function useAuth() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expose the state and methods.
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAnonymous, // Expose this new state
|
|
||||||
initialized,
|
initialized,
|
||||||
initAuth, // Called from app.vue
|
fetchMe,
|
||||||
startOnboarding, // Called from index.vue
|
|
||||||
register, // Expose register function
|
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
export const useVillageHelpers = () => {
|
|
||||||
/**
|
|
||||||
* Converts data-coordinates (x, y) into a chess-like UI format (e.g. A7).
|
|
||||||
*
|
|
||||||
* DATA CONTRACT:
|
|
||||||
* - x: 0..4 (left → right)
|
|
||||||
* - y: 0..6 (bottom → top)
|
|
||||||
*
|
|
||||||
* UI CONTRACT:
|
|
||||||
* - rows are shown top → bottom
|
|
||||||
* - row number = 7 - y
|
|
||||||
*/
|
|
||||||
const formatCoordinates = (x: number, y: number): string => {
|
|
||||||
if (
|
|
||||||
typeof x !== 'number' ||
|
|
||||||
typeof y !== 'number' ||
|
|
||||||
x < 0 ||
|
|
||||||
y < 0
|
|
||||||
) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const col = String.fromCharCode('A'.charCodeAt(0) + x);
|
|
||||||
const row = 7 - y;
|
|
||||||
|
|
||||||
return `${col}${row}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats backend event messages that already contain
|
|
||||||
* raw data-coordinates in the form "(x, y)".
|
|
||||||
*
|
|
||||||
* IMPORTANT:
|
|
||||||
* - This function is PRESENTATION-ONLY
|
|
||||||
* - It assumes (x, y) are DATA coordinates
|
|
||||||
* - It does NOT change semantics, only visual output
|
|
||||||
*/
|
|
||||||
const formatMessageCoordinates = (message: string): string => {
|
|
||||||
if (!message) return '';
|
|
||||||
|
|
||||||
return message.replace(
|
|
||||||
/\((\d+),\s*(\d+)\)/g,
|
|
||||||
(_match, xStr, yStr) => {
|
|
||||||
const x = Number(xStr);
|
|
||||||
const y = Number(yStr);
|
|
||||||
|
|
||||||
if (Number.isNaN(x) || Number.isNaN(y)) {
|
|
||||||
return _match;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatCoordinates(x, y);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
formatCoordinates,
|
|
||||||
formatMessageCoordinates,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
// app/composables/useVisitTracker.ts
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
// This is a simple, client-side, non-persisted state to ensure the
|
|
||||||
// daily visit API call is only made once per application lifecycle.
|
|
||||||
const visitCalled = ref(false);
|
|
||||||
|
|
||||||
export function useVisitTracker() {
|
|
||||||
return {
|
|
||||||
visitCalled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header v-if="isAuthenticated" class="top-bar">
|
<header v-if="isAuthenticated" class="top-bar">
|
||||||
<div class="user-info-top">
|
<div class="user-info-top">
|
||||||
|
<span>{{ user.nickname }}</span>
|
||||||
<span>💰 {{ displayedCoins }}</span>
|
<span>💰 {{ displayedCoins }}</span>
|
||||||
<span>✨ {{ displayedExp }}</span>
|
<span>✨ {{ displayedExp }}</span>
|
||||||
<button @click="handleLogout" class="btn btn-danger btn-sm">Выход</button>
|
<button @click="handleLogout" class="logout-button">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -93,31 +94,37 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--background-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
background-color: var(--container-bg-color);
|
background-color: #f8f8f8;
|
||||||
padding: 10px 20px;
|
padding: 10px 15px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid #eee;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end; /* Align user info to the right */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--text-color);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info-top {
|
.user-info-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 15px;
|
||||||
font-size: 0.95em;
|
font-size: 0.9em;
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-bottom: 70px; /* Space for bottom nav */
|
padding-bottom: 60px; /* Space for bottom nav */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
|
|
@ -125,12 +132,12 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--container-bg-color);
|
background-color: #fff;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid #eee;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,23 +146,17 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-color-light);
|
color: #555;
|
||||||
font-size: 0.75em;
|
font-size: 0.7em;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item .icon {
|
.nav-item .icon {
|
||||||
font-size: 1.8em;
|
font-size: 1.5em;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.router-link-exact-active {
|
.nav-item.router-link-active {
|
||||||
color: var(--primary-color);
|
color: #007bff;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,36 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="habits-container">
|
||||||
<h1>Мои Привычки</h1>
|
<h3>My Habits</h3>
|
||||||
|
|
||||||
|
<!-- Create Habit Form -->
|
||||||
|
<form @submit.prevent="createHabit" class="create-habit-form">
|
||||||
|
<h4>Create a New Habit</h4>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
<input v-model="newHabitName" type="text" placeholder="e.g., Read for 15 minutes" required />
|
||||||
|
<div class="days-selector">
|
||||||
|
<label v-for="day in dayOptions" :key="day" class="day-label">
|
||||||
|
<input type="checkbox" :value="day" v-model="newHabitDays" />
|
||||||
|
<span>{{ day }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" :disabled="loading.create">
|
||||||
|
{{ loading.create ? 'Adding...' : 'Add Habit' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Habits List -->
|
<!-- Habits List -->
|
||||||
<div class="habits-list">
|
<div class="habits-list">
|
||||||
<p v-if="loading.fetch">Загрузка привычек...</p>
|
<p v-if="loading.fetch">Loading habits...</p>
|
||||||
<div v-for="habit in habits" :key="habit.id" class="habit-card">
|
<div v-for="habit in habits" :key="habit.id" class="habit-card">
|
||||||
<!-- Viewing Mode -->
|
|
||||||
<div v-if="editingHabitId !== habit.id" class="habit-view-content">
|
|
||||||
<div class="habit-info">
|
<div class="habit-info">
|
||||||
<h3>{{ habit.name }}</h3>
|
<h4>{{ habit.name }}</h4>
|
||||||
<div class="habit-days">
|
<div class="habit-days">
|
||||||
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
|
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
|
||||||
</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>
|
|
||||||
|
|
||||||
<button type="submit" :disabled="loading.create" class="btn btn-primary">
|
|
||||||
{{ loading.create ? 'Добавляем...' : 'Добавить Привычку' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Deletion Confirmation Dialog -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="isConfirmDialogOpen"
|
|
||||||
title="Подтвердить удаление"
|
|
||||||
message="Вы уверены, что хотите удалить эту привычку? Это действие нельзя отменить."
|
|
||||||
confirm-text="Удалить"
|
|
||||||
cancel-text="Отмена"
|
|
||||||
@confirm="handleDeleteConfirm"
|
|
||||||
@cancel="handleDeleteCancel"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -99,36 +50,15 @@ const api = useApi();
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const habits = ref<Habit[]>([]);
|
const habits = ref<Habit[]>([]);
|
||||||
const newHabitName = ref('');
|
const newHabitName = ref('');
|
||||||
// Day mapping based on Mon=0, ..., Sun=6
|
const dayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
const dayOptions = [
|
const dayMap: { [key: number]: string } = { 0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun' };
|
||||||
{ name: 'Пн', value: 0 },
|
|
||||||
{ name: 'Вт', value: 1 },
|
|
||||||
{ name: 'Ср', value: 2 },
|
|
||||||
{ name: 'Чт', value: 3 },
|
|
||||||
{ name: 'Пт', value: 4 },
|
|
||||||
{ name: 'Сб', value: 5 },
|
|
||||||
{ name: 'Вс', value: 6 },
|
|
||||||
];
|
|
||||||
const dayMap: { [key: number]: string } = Object.fromEntries(dayOptions.map(d => [d.value, d.name]));
|
|
||||||
const newHabitDays = ref<string[]>([]);
|
const newHabitDays = ref<string[]>([]);
|
||||||
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const loading = ref({
|
const loading = ref({
|
||||||
fetch: false,
|
fetch: false,
|
||||||
create: false,
|
create: false,
|
||||||
edit: false,
|
|
||||||
delete: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editing state
|
|
||||||
const editingHabitId = ref<number | null>(null);
|
|
||||||
const editHabitName = ref('');
|
|
||||||
const editHabitDays = ref<string[]>([]);
|
|
||||||
|
|
||||||
// Deletion state
|
|
||||||
const isConfirmDialogOpen = ref(false);
|
|
||||||
const habitToDeleteId = ref<number | null>(null);
|
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchHabits = async () => {
|
const fetchHabits = async () => {
|
||||||
loading.value.fetch = true;
|
loading.value.fetch = true;
|
||||||
|
|
@ -137,7 +67,7 @@ const fetchHabits = async () => {
|
||||||
habits.value = await api<Habit[]>('/habits');
|
habits.value = await api<Habit[]>('/habits');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch habits:', err);
|
console.error('Failed to fetch habits:', err);
|
||||||
error.value = 'Не удалось загрузить привычки.';
|
error.value = 'Could not load habits.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value.fetch = false;
|
loading.value.fetch = false;
|
||||||
}
|
}
|
||||||
|
|
@ -145,16 +75,17 @@ const fetchHabits = async () => {
|
||||||
|
|
||||||
const createHabit = async () => {
|
const createHabit = async () => {
|
||||||
if (!newHabitName.value || newHabitDays.value.length === 0) {
|
if (!newHabitName.value || newHabitDays.value.length === 0) {
|
||||||
error.value = 'Пожалуйста, укажите название и выберите хотя бы один день.';
|
error.value = 'Please provide a name and select at least one day.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value.create = true;
|
loading.value.create = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
|
// Convert day names to numbers
|
||||||
|
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.indexOf(dayName));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newHabit = await api<Habit>('/habits', {
|
await api<Habit>('/habits', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
name: newHabitName.value,
|
name: newHabitName.value,
|
||||||
|
|
@ -162,115 +93,60 @@ const createHabit = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
habits.value.push(newHabit); // Optimistic update
|
// Clear form and re-fetch the list from the server
|
||||||
newHabitName.value = '';
|
newHabitName.value = '';
|
||||||
newHabitDays.value = [];
|
newHabitDays.value = [];
|
||||||
|
await fetchHabits();
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create habit:', err);
|
console.error('Failed to create habit:', err);
|
||||||
error.value = err.data?.message || 'Не удалось создать привычку.';
|
error.value = err.data?.message || 'Could not create habit.';
|
||||||
// Re-fetch on error to ensure consistency
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value.create = false;
|
loading.value.create = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditing = (habit: Habit) => {
|
|
||||||
editingHabitId.value = habit.id;
|
|
||||||
editHabitName.value = habit.name;
|
|
||||||
editHabitDays.value = habit.daysOfWeek.map(dayValue => dayMap[dayValue]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEditing = () => {
|
|
||||||
editingHabitId.value = null;
|
|
||||||
editHabitName.value = '';
|
|
||||||
editHabitDays.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHabit = async (habitId: number) => {
|
|
||||||
loading.value.edit = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
const dayNumbers = editHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedHabit = await api<Habit>(`/habits/${habitId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
name: editHabitName.value,
|
|
||||||
daysOfWeek: dayNumbers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
const index = habits.value.findIndex(h => h.id === habitId);
|
|
||||||
if (index !== -1) {
|
|
||||||
habits.value[index] = updatedHabit;
|
|
||||||
}
|
|
||||||
cancelEditing();
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to save habit:', err);
|
|
||||||
error.value = err.data?.message || 'Не удалось сохранить привычку.';
|
|
||||||
} finally {
|
|
||||||
loading.value.edit = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const promptForDelete = (habitId: number) => {
|
|
||||||
habitToDeleteId.value = habitId;
|
|
||||||
isConfirmDialogOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCancel = () => {
|
|
||||||
isConfirmDialogOpen.value = false;
|
|
||||||
habitToDeleteId.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (habitToDeleteId.value !== null) {
|
|
||||||
deleteHabit(habitToDeleteId.value);
|
|
||||||
}
|
|
||||||
handleDeleteCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteHabit = async (habitId: number) => {
|
|
||||||
loading.value.delete = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
await api(`/habits/${habitId}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
habits.value = habits.value.filter(h => h.id !== habitId);
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to delete habit:', err);
|
|
||||||
error.value = err.data?.message || 'Не удалось удалить привычку.';
|
|
||||||
} finally {
|
|
||||||
loading.value.delete = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// --- Lifecycle Hooks ---
|
// --- Lifecycle Hooks ---
|
||||||
onMounted(fetchHabits);
|
onMounted(fetchHabits);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-container {
|
.habits-container {
|
||||||
background-color: var(--container-bg-color);
|
max-width: 600px;
|
||||||
padding: 24px;
|
margin: 0 auto;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Form */
|
||||||
|
.create-habit-form {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
margin-bottom: 32px;
|
margin-bottom: 30px;
|
||||||
border: 1px solid var(--border-color);
|
}
|
||||||
|
|
||||||
|
.create-habit-form h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-habit-form input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.days-selector {
|
.days-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,115 +160,71 @@ onMounted(fetchHabits);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label span {
|
.day-label span {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
justify-content: center;
|
width: 35px;
|
||||||
align-items: center;
|
line-height: 35px;
|
||||||
width: 33px;
|
border: 1px solid #ccc;
|
||||||
height: 33px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label input:checked + span {
|
.day-label input:checked + span {
|
||||||
background-color: var(--primary-color);
|
background-color: #81a1c1;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--primary-color);
|
border-color: #81a1c1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container button {
|
.create-habit-form button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #5e81ac;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Habits List */
|
/* Habits List */
|
||||||
.habits-list {
|
.habits-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 15px;
|
||||||
margin-bottom: 45px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.habit-card {
|
.habit-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px;
|
padding: 15px;
|
||||||
background-color: var(--container-bg-color);
|
background-color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.habit-view-content {
|
.habit-info h4 {
|
||||||
display: flex;
|
margin: 0 0 10px 0;
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px; /* Spacing between name, days, and actions */
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-info h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.habit-days {
|
.habit-days {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
gap: 5px;
|
||||||
gap: 6px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 15px 0 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-chip {
|
.day-chip {
|
||||||
background-color: #e5e7eb;
|
background-color: #eceff4;
|
||||||
color: #4b5563;
|
color: #4c566a;
|
||||||
padding: 4px 10px;
|
padding: 2px 6px;
|
||||||
border-radius: 16px;
|
border-radius: 10px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: var(--danger-color);
|
color: #bf616a;
|
||||||
background-color: #fee2e2;
|
background-color: #fbe2e5;
|
||||||
padding: 1rem;
|
padding: 10px;
|
||||||
border-radius: 0.375rem;
|
border-radius: 4px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 15px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.habit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Edit Form Specific Styles */
|
|
||||||
.habit-edit-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-edit-form .form-group {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.habit-edit-form .days-selector {
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,58 +1,84 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="leaderboard-container">
|
||||||
<h1>Доска почёта</h1>
|
<h3>Monthly Leaderboard</h3>
|
||||||
<div v-if="pending" class="loading">Loading leaderboard...</div>
|
<ul class="leaderboard-list">
|
||||||
<div v-else-if="error" class="error-container">
|
<li class="leaderboard-item">
|
||||||
<p>An error occurred while fetching the leaderboard. Please try again.</p>
|
<span class="rank">1.</span>
|
||||||
</div>
|
<span class="name">Papa Smurf</span>
|
||||||
<div v-else class="table-container">
|
<span class="exp">9800 EXP</span>
|
||||||
<table class="table table-striped table-hover">
|
</li>
|
||||||
<thead>
|
<li class="leaderboard-item self">
|
||||||
<tr>
|
<span class="rank">2.</span>
|
||||||
<th>Место</th>
|
<span class="name">Smurfette</span>
|
||||||
<th>Имя</th>
|
<span class="exp">8500 EXP</span>
|
||||||
<th>EXP</th>
|
</li>
|
||||||
</tr>
|
<li class="leaderboard-item">
|
||||||
</thead>
|
<span class="rank">3.</span>
|
||||||
<tbody>
|
<span class="name">Brainy Smurf</span>
|
||||||
<tr
|
<span class="exp">8250 EXP</span>
|
||||||
v-for="entry in leaderboard"
|
</li>
|
||||||
:key="entry.rank + entry.nickname"
|
<li class="leaderboard-item">
|
||||||
:class="{ 'current-user-row': currentUser && currentUser.nickname === entry.nickname }"
|
<span class="rank">4.</span>
|
||||||
>
|
<span class="name">Hefty Smurf</span>
|
||||||
<td>{{ entry.rank }}</td>
|
<span class="exp">7600 EXP</span>
|
||||||
<td>{{ entry.nickname }}</td>
|
</li>
|
||||||
<td>{{ entry.exp }}</td>
|
<li class="leaderboard-item">
|
||||||
</tr>
|
<span class="rank">5.</span>
|
||||||
</tbody>
|
<span class="name">Jokey Smurf</span>
|
||||||
</table>
|
<span class="exp">6100 EXP</span>
|
||||||
</div>
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { user: currentUser } = useAuth(); // Get current authenticated user
|
// No logic, just visual placeholders
|
||||||
|
|
||||||
const { data, pending, error } = await useFetch('/api/leaderboard', {
|
|
||||||
lazy: true,
|
|
||||||
server: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const leaderboard = computed(() => data.value?.leaderboard || []);
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.table-container {
|
.leaderboard-container {
|
||||||
overflow-x: auto;
|
max-width: 600px;
|
||||||
}
|
margin: 0 auto;
|
||||||
.current-user-row > * {
|
|
||||||
background-color: #e0e7ff; /* A light blue/indigo for highlighting */
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary-color-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-hover > tbody > tr.current-user-row:hover > * {
|
h3 {
|
||||||
background-color: #c7d2fe; /* A slightly darker shade for hover */
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-item.self {
|
||||||
|
background-color: #d8e1e9;
|
||||||
|
border: 1px solid #81a1c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4c566a;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="page-container auth-form">
|
<h1>Login</h1>
|
||||||
<h1>Вход</h1>
|
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" v-model="email" class="form-control" required />
|
<input type="email" id="email" v-model="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password" class="form-label">Пароль</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" v-model="password" class="form-control" required />
|
<input type="password" id="password" v-model="password" required />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
<button type="submit" :disabled="loading">
|
||||||
{{ loading ? 'Входим...' : 'Войти' }}
|
{{ loading ? 'Logging in...' : 'Login' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="switch-link">
|
<div class="switch-link">
|
||||||
<p>
|
<p>
|
||||||
|
Don't have an account?
|
||||||
<NuxtLink to="/">Вернуться назад</NuxtLink>
|
<NuxtLink to="/register">Register here</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,26 +54,59 @@ definePageMeta({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-container {
|
.auth-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
background-color: #f0f2f5;
|
||||||
}
|
}
|
||||||
.auth-form {
|
.auth-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
.auth-form button {
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #0056b3;
|
||||||
}
|
}
|
||||||
.error-message {
|
.error-message {
|
||||||
color: var(--danger-color);
|
color: red;
|
||||||
background-color: #fee2e2;
|
margin-bottom: 16px;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.switch-link {
|
.switch-link {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="page-container auth-form">
|
<h1>Register</h1>
|
||||||
<h1>Регистрация</h1>
|
|
||||||
<form @submit.prevent="handleRegister">
|
<form @submit.prevent="handleRegister">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="nickname" class="form-label">Никнейм</label>
|
<label for="nickname">Nickname</label>
|
||||||
<input type="text" id="nickname" v-model="nickname" class="form-control" required />
|
<input type="text" id="nickname" v-model="nickname" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" v-model="email" class="form-control" required />
|
<input type="email" id="email" v-model="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password" class="form-label">Пароль (минимум 8 символов)</label>
|
<label for="password">Password (min 8 characters)</label>
|
||||||
<input type="password" id="password" v-model="password" class="form-control" required />
|
<input type="password" id="password" v-model="password" required />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<div v-if="successMessage" class="success-message">{{ successMessage }}</div>
|
<div v-if="successMessage" class="success-message">{{ successMessage }}</div>
|
||||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
<button type="submit" :disabled="loading">
|
||||||
{{ loading ? 'Регистрируем...' : 'Зарегистрироваться' }}
|
{{ loading ? 'Registering...' : 'Register' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="switch-link">
|
<div class="switch-link">
|
||||||
<p>
|
<p>
|
||||||
Уже есть аккаунт?
|
Already have an account?
|
||||||
<NuxtLink to="/login">Войти</NuxtLink>
|
<NuxtLink to="/login">Login here</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,7 +55,7 @@ const handleRegister = async () => {
|
||||||
password: password.value,
|
password: password.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
successMessage.value = 'Регистрация прошла успешно! Пожалуйста, войдите в систему.';
|
successMessage.value = 'Registration successful! Please log in.';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigateTo('/login');
|
navigateTo('/login');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
@ -72,34 +72,64 @@ definePageMeta({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-container {
|
.auth-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
background-color: #f0f2f5;
|
||||||
}
|
}
|
||||||
.auth-form {
|
.auth-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
.auth-form button {
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #218838;
|
||||||
}
|
}
|
||||||
.error-message {
|
.error-message {
|
||||||
color: var(--danger-color);
|
color: red;
|
||||||
background-color: #fee2e2;
|
margin-bottom: 16px;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.success-message {
|
.success-message {
|
||||||
color: #16a34a;
|
color: green;
|
||||||
background-color: #dcfce7;
|
margin-bottom: 16px;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.switch-link {
|
.switch-link {
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,81 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container village-page-layout">
|
<div class="village-page">
|
||||||
<h1>Моя деревня</h1>
|
<h1>My Village</h1>
|
||||||
|
|
||||||
<div v-if="pending" class="loading">Загрузка вашей деревни...</div>
|
<div v-if="pending" class="loading">Loading your village...</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="error-container">
|
<div v-else-if="error" class="error-container">
|
||||||
<p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
|
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
|
||||||
<div v-else>
|
<p v-else>An error occurred while fetching your village data. Please try again.</p>
|
||||||
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
|
|
||||||
<pre>{{ error }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="villageData">
|
<div v-else-if="villageData" class="village-container">
|
||||||
<VillageGrid
|
<div class="village-grid-wrapper">
|
||||||
:village-data="villageData"
|
<div class="village-grid">
|
||||||
:selected-tile="selectedTile"
|
<div
|
||||||
@tile-click="selectTile"
|
v-for="tile in villageData.tiles"
|
||||||
/>
|
:key="tile.id"
|
||||||
|
class="tile"
|
||||||
|
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
|
||||||
|
@click="selectTile(tile)"
|
||||||
|
>
|
||||||
|
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tile Info Overlay -->
|
<!-- Tile Info Overlay -->
|
||||||
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
|
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
|
||||||
<div class="tile-overlay-panel" @click.stop>
|
<div class="tile-overlay-panel" @click.stop>
|
||||||
|
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
|
||||||
|
<p>Terrain: {{ selectedTile.terrainType }}</p>
|
||||||
|
<p v-if="selectedTile.object">Object: {{ selectedTile.object.type }}</p>
|
||||||
|
|
||||||
<h2>{{ getTileTitle(selectedTile) }}</h2>
|
<h3>Available Actions</h3>
|
||||||
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
|
|
||||||
|
|
||||||
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
|
|
||||||
<h3 class="actions-header">Что здесь можно сделать?</h3>
|
|
||||||
<div class="actions-list">
|
<div class="actions-list">
|
||||||
<!-- Build Actions -->
|
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||||
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
|
<button
|
||||||
<div class="build-card-grid">
|
:disabled="!action.isEnabled || isSubmitting"
|
||||||
<div
|
@click="handleActionClick(action)"
|
||||||
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) }}
|
{{ getActionLabel(action) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||||
<div v-if="!action.isEnabled" class="disabled-overlay">
|
|
||||||
<span>{{ getDisabledReasonText(action) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="selectedTile = null" class="close-overlay-button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="selectedTile = null" class="btn btn-secondary close-overlay-button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-content">
|
|
||||||
<!-- Admin Panel -->
|
<!-- Admin Panel -->
|
||||||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||||||
<h3>Admin Tools</h3>
|
<h3>Admin Tools</h3>
|
||||||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||||||
|
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
|
||||||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||||||
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event Log -->
|
<!-- Event Log -->
|
||||||
<div v-if="villageEvents?.length" class="event-log-container">
|
<div v-if="villageEvents?.length" class="event-log-container">
|
||||||
<h2>Журнал событий</h2>
|
<h4>Activity Log</h4>
|
||||||
<div class="event-list">
|
<table class="event-log-table">
|
||||||
<div v-for="event in villageEvents" :key="event.id" class="event-card">
|
<thead>
|
||||||
<div class="event-card-header">
|
<tr>
|
||||||
<span class="event-date">{{ new Date(event.createdAt).toLocaleString() }}</span>
|
<th>Date</th>
|
||||||
<div class="event-rewards">
|
<th>Event</th>
|
||||||
<span v-if="event.coins" class="event-reward-tag coins">{{ event.coins }} 💰</span>
|
<th>Coins</th>
|
||||||
<span v-if="event.exp" class="event-reward-tag exp">{{ event.exp }} ✨</span>
|
<th>EXP</th>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<p class="event-message">{{ formatMessageCoordinates(event.message) }}</p>
|
<tbody>
|
||||||
</div>
|
<tr v-for="event in villageEvents" :key="event.id">
|
||||||
</div>
|
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
|
||||||
</div>
|
<td>{{ event.message }}</td>
|
||||||
</div>
|
<td>{{ event.coins }}</td>
|
||||||
|
<td>{{ event.exp }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -92,9 +83,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
|
|
||||||
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser
|
|
||||||
|
|
||||||
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
|
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
|
||||||
lazy: true,
|
lazy: true,
|
||||||
server: false, // Ensure this runs on the client-side
|
server: false, // Ensure this runs on the client-side
|
||||||
|
|
@ -107,92 +95,37 @@ const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/vil
|
||||||
|
|
||||||
const selectedTile = ref(null);
|
const selectedTile = ref(null);
|
||||||
|
|
||||||
|
const getTileEmoji = (tile) => {
|
||||||
|
if (tile.terrainState === 'CLEARING') return '⏳';
|
||||||
|
if (tile.object) {
|
||||||
|
switch (tile.object.type) {
|
||||||
|
case 'HOUSE': return '🏠';
|
||||||
|
case 'FIELD': return '🌱';
|
||||||
|
case 'LUMBERJACK': return '🪓';
|
||||||
|
case 'QUARRY': return '⛏️';
|
||||||
|
case 'WELL': return '💧';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (tile.terrainType) {
|
||||||
|
case 'BLOCKED_TREE': return '🌳';
|
||||||
|
case 'BLOCKED_STONE': return '🪨';
|
||||||
|
case 'EMPTY': return '⬜';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectTile = (tile) => {
|
const selectTile = (tile) => {
|
||||||
selectedTile.value = tile;
|
selectedTile.value = tile;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionLabel = (action) => {
|
const getActionLabel = (action) => {
|
||||||
if (action.type === 'BUILD') {
|
if (action.type === 'BUILD') {
|
||||||
return `${action.cost} монет`; // Return cost instead of "Построить"
|
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
|
||||||
}
|
}
|
||||||
return action.type;
|
return action.type;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBuildingName = (buildingType) => {
|
|
||||||
const buildingNameMap = {
|
|
||||||
'HOUSE': 'Дом',
|
|
||||||
'FIELD': 'Поле',
|
|
||||||
'LUMBERJACK': 'Домик лесоруба',
|
|
||||||
'QUARRY': 'Каменоломня',
|
|
||||||
'WELL': 'Колодец',
|
|
||||||
};
|
|
||||||
return buildingNameMap[buildingType] || buildingType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBuildingEmoji = (buildingType) => {
|
|
||||||
const emojiMap = {
|
|
||||||
'HOUSE': '🏠',
|
|
||||||
'FIELD': '🌱',
|
|
||||||
'LUMBERJACK': '🪓',
|
|
||||||
'QUARRY': '⛏️',
|
|
||||||
'WELL': '💧',
|
|
||||||
};
|
|
||||||
return emojiMap[buildingType] || '❓';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const getTileTitle = (tile) => {
|
|
||||||
if (tile.object) {
|
|
||||||
const buildingMap = {
|
|
||||||
'HOUSE': 'Дом',
|
|
||||||
'FIELD': 'Поле',
|
|
||||||
'LUMBERJACK': 'Домик лесоруба',
|
|
||||||
'QUARRY': 'Каменоломня',
|
|
||||||
'WELL': 'Колодец',
|
|
||||||
};
|
|
||||||
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} ${formatCoordinates(tile.x, tile.y)}`;
|
|
||||||
}
|
|
||||||
const terrainMap = {
|
|
||||||
'BLOCKED_TREE': 'Лесной участок',
|
|
||||||
'BLOCKED_STONE': 'Каменистый участок',
|
|
||||||
'EMPTY': 'Пустырь',
|
|
||||||
};
|
|
||||||
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} ${formatCoordinates(tile.x, tile.y)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTileDescription = (tile) => {
|
|
||||||
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
|
|
||||||
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
|
|
||||||
|
|
||||||
const descriptionMap = {
|
|
||||||
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
|
|
||||||
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
|
|
||||||
'EMPTY': 'Свободное место, готовое к застройке.',
|
|
||||||
};
|
|
||||||
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBuildingDescription = (buildingType) => {
|
|
||||||
const descriptions = {
|
|
||||||
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
|
|
||||||
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
|
|
||||||
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
|
|
||||||
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
|
|
||||||
'WELL': 'Увеличивает производство опыта на соседних полях.',
|
|
||||||
};
|
|
||||||
return descriptions[buildingType] || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDisabledReasonText = (action) => {
|
|
||||||
const reasons = {
|
|
||||||
'Not enough coins': 'Не хватает монет.',
|
|
||||||
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
|
|
||||||
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
|
|
||||||
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
|
|
||||||
};
|
|
||||||
return reasons[action.disabledReason] || action.disabledReason;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const handleActionClick = async (action) => {
|
const handleActionClick = async (action) => {
|
||||||
|
|
@ -207,6 +140,7 @@ const handleActionClick = async (action) => {
|
||||||
actionType: action.type,
|
actionType: action.type,
|
||||||
payload: {
|
payload: {
|
||||||
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
|
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
|
||||||
|
...(action.type === 'MOVE' && { toTileId: action.toTileId }), // Assuming action.toTileId will be present for MOVE
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -215,7 +149,6 @@ const handleActionClick = async (action) => {
|
||||||
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
|
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
|
||||||
} else {
|
} else {
|
||||||
villageData.value = response.data.value;
|
villageData.value = response.data.value;
|
||||||
updateUser(response.data.value.user); // Update global user state
|
|
||||||
selectedTile.value = null;
|
selectedTile.value = null;
|
||||||
await refreshEvents(); // Refresh the event log
|
await refreshEvents(); // Refresh the event log
|
||||||
}
|
}
|
||||||
|
|
@ -229,143 +162,196 @@ const handleActionClick = async (action) => {
|
||||||
|
|
||||||
const isSubmittingAdminAction = ref(false);
|
const isSubmittingAdminAction = ref(false);
|
||||||
|
|
||||||
async function handleAdminAction(url: string) {
|
async function handleAdminAction(url) {
|
||||||
if (isSubmittingAdminAction.value) return;
|
if (isSubmittingAdminAction.value) return;
|
||||||
isSubmittingAdminAction.value = true;
|
isSubmittingAdminAction.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Perform the requested admin action (e.g., reset, trigger tick)
|
const { error } = await useFetch(url, { method: 'POST' });
|
||||||
const { error: actionError } = await useFetch(url, { method: 'POST' });
|
if (error.value) {
|
||||||
if (actionError.value) {
|
alert(error.value.data?.statusMessage || 'An admin action failed.');
|
||||||
// If the action itself fails, throw to stop execution
|
} else {
|
||||||
throw actionError.value;
|
// Refresh both data sources in parallel
|
||||||
|
await Promise.all([refreshVillageData(), refreshEvents()]);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// 2. Refresh the main village data. This runs the core game logic
|
console.error('Failed to perform admin action:', e);
|
||||||
// on the backend and gets the updated state.
|
alert('An unexpected error occurred.');
|
||||||
await refreshVillageData();
|
|
||||||
|
|
||||||
// 3. The `villageData` ref is now updated. If it contains a user object,
|
|
||||||
// we sync it with the global auth state to update the header.
|
|
||||||
if (villageData.value?.user) {
|
|
||||||
updateUser(villageData.value.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Refresh the event log to show any new events created by the action.
|
|
||||||
await refreshEvents();
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`Failed to perform admin action at ${url}:`, e);
|
|
||||||
// Use the error's data object if it exists for a more specific message
|
|
||||||
alert(e.data?.statusMessage || e.message || 'An unexpected error occurred during the admin action.');
|
|
||||||
} finally {
|
} finally {
|
||||||
isSubmittingAdminAction.value = false;
|
isSubmittingAdminAction.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
|
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
|
||||||
|
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
|
||||||
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
|
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
|
||||||
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.village-page-layout {
|
.village-page {
|
||||||
--tile-size: clamp(55px, 12vw, 70px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
min-height: calc(100vh - 120px); /* Adjust for top/bottom bars */
|
||||||
|
box-sizing: border-box; /* Include padding in element's total width and height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .error-container {
|
.loading, .error-container {
|
||||||
margin-top: 50px;
|
margin-top: 50px;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
color: #555;
|
color: #555;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay and other styles */
|
.village-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* Center grid */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 350px; /* Adjust max-width for mobile view of grid (5*60px + 4*4px gap + 2*4px padding)*/
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.village-grid-wrapper {
|
||||||
|
overflow-x: auto; /* In case grid ever exceeds viewport (though it shouldn't with max-width) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.village-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 60px);
|
||||||
|
grid-template-rows: repeat(7, 60px);
|
||||||
|
gap: 4px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
width: fit-content; /* Ensure grid does not expand unnecessarily */
|
||||||
|
margin: 0 auto; /* Center grid within its wrapper */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover {
|
||||||
|
background-color: #e9e9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.selected {
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-content {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay Styles */
|
||||||
.tile-overlay-backdrop {
|
.tile-overlay-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-end;
|
align-items: flex-end; /* Start from bottom for mobile-first feel */
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-overlay-panel {
|
.tile-overlay-panel {
|
||||||
background-color: var(--background-color);
|
background-color: #fff;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px; /* Limit width for larger screens */
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
border-top-left-radius: 15px;
|
border-top-left-radius: 15px;
|
||||||
border-top-right-radius: 15px;
|
border-top-right-radius: 15px;
|
||||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
transform: translateY(0); /* For potential slide-in animation */
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 15px;
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) { /* Center for desktop, less "bottom sheet" */
|
||||||
.tile-overlay-backdrop {
|
.tile-overlay-backdrop {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.tile-overlay-panel {
|
.tile-overlay-panel {
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
|
max-height: 80vh; /* Don't cover entire screen */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-overlay-panel h2 {
|
.tile-overlay-panel h2 {
|
||||||
|
margin-top: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-description {
|
.tile-overlay-panel p {
|
||||||
text-align: center;
|
color: #666;
|
||||||
margin: -10px;
|
margin-bottom: 5px;
|
||||||
color: var(--text-color-light);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-container {
|
.actions-list {
|
||||||
margin-top: 15px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-header {
|
.action-item button {
|
||||||
text-align: center;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-section {
|
.action-item button:hover:not(:disabled) {
|
||||||
padding-top: 15px;
|
background-color: #0056b3;
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.building-description {
|
.action-item button:disabled {
|
||||||
font-size: 0.85em;
|
background-color: #e9ecef;
|
||||||
color: var(--text-color-light);
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-reason {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #dc3545;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-overlay-button {
|
.close-overlay-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-content {
|
.close-overlay-button:hover {
|
||||||
width: 100%;
|
background-color: #5a6268;
|
||||||
max-width: 800px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel {
|
.admin-panel {
|
||||||
|
|
@ -373,22 +359,23 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 2px dashed var(--danger-color);
|
border: 2px dashed #dc3545;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 100%;
|
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel h3 {
|
.admin-panel h3 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
color: var(--danger-color);
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel button {
|
.admin-panel button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: var(--danger-color);
|
background-color: #dc3545;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
@ -400,205 +387,30 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.event-log-container {
|
.event-log-container {
|
||||||
|
margin-top: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-log-container h2 {
|
.event-log-container h4 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-list {
|
.event-log-table {
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 10px; /* For scrollbar spacing */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card {
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-date {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-rewards {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-reward-tag {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-reward-tag.coins {
|
|
||||||
background-color: var(--warning-color-light);
|
|
||||||
color: var(--warning-color-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-reward-tag.exp {
|
|
||||||
background-color: var(--info-color-light);
|
|
||||||
color: var(--info-color-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-message {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.build-card-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 140px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card:not(.disabled):hover {
|
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card.disabled {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-icon {
|
|
||||||
font-size: 2.5em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card h5 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 1.05em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card .building-description {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-color-light);
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: auto;
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.building-footer .btn-full-width {
|
.event-log-table th, .event-log-table td {
|
||||||
width: 100%;
|
border: 1px solid #ccc;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.building-footer .cost {
|
.event-log-table th {
|
||||||
font-weight: 600;
|
background-color: #f0f0f0;
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(249, 250, 251, 0.85);
|
|
||||||
color: var(--danger-color);
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled-overlay span {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.village-grid-wrapper {
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.village-grid {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Responsive styles for mobile --- */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* No changes to village-grid-wrapper or village-grid for 'fr' units,
|
|
||||||
as the --tile-size clamping already handles mobile scaling without fr.
|
|
||||||
Removed margin/padding adjustments to keep it centered and scaled.
|
|
||||||
Removed position: relative and ::after pseudo-element for scroll shadow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.bottom-content {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-overlay-panel {
|
|
||||||
padding: 16px; /* Reduce padding on mobile */
|
|
||||||
margin-bottom: 60px;
|
|
||||||
gap: 12px;
|
|
||||||
max-width: 95%; /* Make it almost full width */
|
|
||||||
border-top-left-radius: 15px;
|
|
||||||
border-top-right-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.build-card-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-card {
|
|
||||||
width: 130px; /* A fixed width that allows for 2 cards per row on most phones */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
/* assets/css/main.css */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* New playful color palette */
|
|
||||||
--primary-color: #FF6B6B; /* Coral Red for high-contrast CTA */
|
|
||||||
--primary-color-hover: #FF4F4F;
|
|
||||||
--secondary-color: #4ECDC4; /* Teal for accents */
|
|
||||||
--secondary-color-hover: #3DB8AE;
|
|
||||||
--danger-color: #ef4444;
|
|
||||||
--danger-color-hover: #dc2626;
|
|
||||||
|
|
||||||
/* Softer backgrounds and text colors */
|
|
||||||
--background-color: #FDF8E9; /* Soft Cream */
|
|
||||||
--container-bg-color: #ffffff;
|
|
||||||
--text-color: #333333; /* Darker grey for better contrast on cream */
|
|
||||||
--text-color-light: #757575;
|
|
||||||
--border-color: #797979;
|
|
||||||
|
|
||||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
||||||
'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
background-color: var(--container-bg-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: 700; /* Bolder headings */
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem; /* 40px */
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.75rem; /* 28px */
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.5rem; /* 24px */
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Buttons (New Style) --- */
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
|
||||||
user-select: none;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
padding: 0.875rem 1.75rem; /* Increased padding */
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
border-radius: 12px; /* Rounded corners */
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
text-decoration: none; /* For NuxtLink styled as button */
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
color: #fff;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--primary-color-hover);
|
|
||||||
border-color: var(--primary-color-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary button is now an outline button */
|
|
||||||
.btn-secondary {
|
|
||||||
color: var(--primary-color);
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
color: #fff;
|
|
||||||
background-color: var(--danger-color);
|
|
||||||
border-color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background-color: var(--danger-color-hover);
|
|
||||||
border-color: var(--danger-color-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- Forms --- */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: #fff;
|
|
||||||
background-clip: padding-box;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px; /* More rounded */
|
|
||||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
||||||
box-sizing: border-box; /* Add this line */
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: 0;
|
|
||||||
border-color: var(--secondary-color); /* Use accent color for focus */
|
|
||||||
box-shadow: 0 0 0 0.25rem rgb(78 205 196 / 25%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 2.25rem 0.75rem 1rem;
|
|
||||||
-moz-padding-start: calc(1rem - 3px);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: #fff;
|
|
||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 1rem center;
|
|
||||||
background-size: 16px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px; /* More rounded */
|
|
||||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- Tables --- */
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
vertical-align: top;
|
|
||||||
border-color: var(--border-color);
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table > :not(caption) > * > * {
|
|
||||||
padding: 0.75rem 0.75rem;
|
|
||||||
background-color: var(--container-bg-color);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table > thead {
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table > thead > tr > th {
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: #f7f7f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-striped > tbody > tr:nth-of-type(odd) > * {
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-hover > tbody > tr:hover > * {
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
}
|
|
||||||
BIN
assets/ui-references/photo_2026-01-03_13-24-52.jpg
Normal file
BIN
assets/ui-references/photo_2026-01-03_13-24-52.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/ui-references/photo_2026-01-03_13-25-16.jpg
Normal file
BIN
assets/ui-references/photo_2026-01-03_13-25-16.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/ui-references/photo_2026-01-03_13-25-21.jpg
Normal file
BIN
assets/ui-references/photo_2026-01-03_13-25-21.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/ui-references/smurf1.jpg
Normal file
BIN
assets/ui-references/smurf1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
BIN
assets/ui-references/smurf2.jpg
Normal file
BIN
assets/ui-references/smurf2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
BIN
assets/ui-references/smurf3.jpg
Normal file
BIN
assets/ui-references/smurf3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
|
|
@ -1,63 +1,12 @@
|
||||||
// /middleware/auth.global.ts
|
// /middleware/auth.ts
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
const { isAuthenticated, initialized, updateUser } = useAuth();
|
const { isAuthenticated, initialized } = useAuth();
|
||||||
const { visitCalled } = useVisitTracker();
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
// Helper function to wait for auth initialization, with a timeout.
|
// Do not run middleware until auth state is initialized on client-side
|
||||||
const waitForAuth = () => {
|
if (!initialized.value) {
|
||||||
return new Promise((resolve) => {
|
|
||||||
// If already initialized, resolve immediately.
|
|
||||||
if (initialized.value) {
|
|
||||||
return resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a timeout to prevent waiting indefinitely
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
console.warn('[Auth Middleware] Waited 5 seconds for auth, but it did not initialize. Proceeding anyway.');
|
|
||||||
unwatch();
|
|
||||||
resolve(false);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// Watch for the initialized value to change to true.
|
|
||||||
const unwatch = watch(initialized, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
unwatch();
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only run the waiting logic on the client-side
|
|
||||||
if (process.client) {
|
|
||||||
await waitForAuth();
|
|
||||||
} else if (!initialized.value) {
|
|
||||||
// On the server, if not initialized, we cannot wait.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 the user is authenticated and tries to access /login, redirect to home
|
||||||
if (isAuthenticated.value && to.path === '/login') {
|
if (isAuthenticated.value && to.path === '/login') {
|
||||||
return navigateTo('/', { replace: true });
|
return navigateTo('/', { replace: true });
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// middleware/village-tick.global.ts
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
|
||||||
// We only run this logic on the client-side after navigation.
|
|
||||||
if (process.server) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isAuthenticated, initialized } = useAuth();
|
|
||||||
|
|
||||||
// Helper function to wait for auth initialization, with a timeout.
|
|
||||||
const waitForAuth = () => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (initialized.value) {
|
|
||||||
return resolve(true);
|
|
||||||
}
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
unwatch();
|
|
||||||
resolve(true); // Resolve even if timeout, so middleware doesn't block
|
|
||||||
}, 5000);
|
|
||||||
const unwatch = watch(initialized, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
unwatch();
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for auth to be initialized
|
|
||||||
await waitForAuth();
|
|
||||||
|
|
||||||
// If user is authenticated, trigger the village tick
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
const api = useApi();
|
|
||||||
try {
|
|
||||||
// We must await the call to ensure it completes before a new navigation can cancel it.
|
|
||||||
await api('/api/village/tick', { method: 'POST' });
|
|
||||||
} catch (e) {
|
|
||||||
// Even if we don't wait, a catch block is good practice in case the api call itself throws an error.
|
|
||||||
console.error('[Village Middleware] Failed to trigger village tick:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: true },
|
compatibilityDate: '2025-07-15',
|
||||||
alias: {
|
devtools: { enabled: true }
|
||||||
'~': resolve(__dirname, './'), // Root directory
|
|
||||||
'~/': resolve(__dirname, './'), // Root directory
|
|
||||||
'@': resolve(__dirname, './app'), // Source directory (app)
|
|
||||||
'@/': resolve(__dirname, './app'), // Source directory (app)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
12
pages/index.vue
Normal file
12
pages/index.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!-- /pages/index.vue -->
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p v-if="user">Welcome, {{ user.nickname }}!</p>
|
||||||
|
<button @click="logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
</script>
|
||||||
62
pages/login.vue
Normal file
62
pages/login.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<!-- /pages/login.vue -->
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Login or Register</h1>
|
||||||
|
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()">
|
||||||
|
<div>
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" v-model="email" required />
|
||||||
|
</div>
|
||||||
|
<div v-if="isRegistering">
|
||||||
|
<label for="nickname">Nickname</label>
|
||||||
|
<input type="text" id="nickname" v-model="nickname" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" v-model="password" required />
|
||||||
|
</div>
|
||||||
|
<div v-if="error">{{ error }}</div>
|
||||||
|
<button type="submit" :disabled="loading">
|
||||||
|
{{ isRegistering ? 'Register' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button @click="isRegistering = !isRegistering">
|
||||||
|
{{ isRegistering ? 'Switch to Login' : 'Switch to Register' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default', // Using the default layout
|
||||||
|
});
|
||||||
|
|
||||||
|
const { login, register, loading } = useAuth();
|
||||||
|
|
||||||
|
const isRegistering = ref(false);
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const nickname = ref('');
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await login(email.value, password.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.data?.message || 'Login failed.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await register(email.value, password.value, nickname.value);
|
||||||
|
// On successful registration, switch to the login view
|
||||||
|
isRegistering.value = false;
|
||||||
|
// You might want to auto-login or show a success message instead
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.data?.message || 'Registration failed.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
197
pages/village.vue
Normal file
197
pages/village.vue
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
<template>
|
||||||
|
<div class="village-page">
|
||||||
|
<h1>My Village</h1>
|
||||||
|
|
||||||
|
<div v-if="pending" class="loading">Loading your village...</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-container">
|
||||||
|
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
|
||||||
|
<p v-else>An error occurred while fetching your village data. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="villageData" class="village-container">
|
||||||
|
<div class="village-grid">
|
||||||
|
<div
|
||||||
|
v-for="tile in villageData.tiles"
|
||||||
|
:key="tile.id"
|
||||||
|
class="tile"
|
||||||
|
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
|
||||||
|
@click="selectTile(tile)"
|
||||||
|
>
|
||||||
|
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTile" class="action-panel">
|
||||||
|
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
|
||||||
|
<div class="actions-list">
|
||||||
|
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||||
|
<button
|
||||||
|
:disabled="!action.isEnabled"
|
||||||
|
@click="handleActionClick(action)"
|
||||||
|
>
|
||||||
|
{{ getActionLabel(action) }}
|
||||||
|
</button>
|
||||||
|
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="selectedTile = null" class="close-panel">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const { data: villageData, pending, error } = await useFetch('/api/village', {
|
||||||
|
lazy: true,
|
||||||
|
server: false, // Ensure this runs on the client-side
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTile = ref(null);
|
||||||
|
|
||||||
|
const getTileEmoji = (tile) => {
|
||||||
|
if (tile.terrainState === 'CLEARING') return '⏳';
|
||||||
|
if (tile.object) {
|
||||||
|
switch (tile.object.type) {
|
||||||
|
case 'HOUSE': return '🏠';
|
||||||
|
case 'FIELD': return '🌱';
|
||||||
|
case 'LUMBERJACK': return '🪓';
|
||||||
|
case 'QUARRY': return '⛏️';
|
||||||
|
case 'WELL': return '💧';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (tile.terrainType) {
|
||||||
|
case 'BLOCKED_TREE': return '🌳';
|
||||||
|
case 'BLOCKED_STONE': return '🪨';
|
||||||
|
case 'EMPTY': return '⬜';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTile = (tile) => {
|
||||||
|
selectedTile.value = tile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionLabel = (action) => {
|
||||||
|
if (action.type === 'BUILD') {
|
||||||
|
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
|
||||||
|
}
|
||||||
|
return action.type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionClick = (action) => {
|
||||||
|
console.log('Action clicked:', action);
|
||||||
|
// In a future task, this will dispatch the action to the backend.
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.village-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error-container {
|
||||||
|
margin-top: 50px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.village-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.village-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 60px);
|
||||||
|
grid-template-rows: repeat(7, 60px);
|
||||||
|
gap: 4px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover {
|
||||||
|
background-color: #e9e9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.selected {
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-content {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel {
|
||||||
|
width: 300px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item button {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #eee;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-reason {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #d9534f;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_User" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"password" TEXT NOT NULL,
|
|
||||||
"nickname" TEXT,
|
|
||||||
"avatar" TEXT DEFAULT '/avatars/default.png',
|
|
||||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"exp" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"dailyStreak" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"soundOn" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt" FROM "User";
|
|
||||||
DROP TABLE "User";
|
|
||||||
ALTER TABLE "new_User" RENAME TO "User";
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN DEFAULT true;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_User" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"anonymousSessionId" TEXT,
|
|
||||||
"email" TEXT,
|
|
||||||
"password" TEXT,
|
|
||||||
"nickname" TEXT,
|
|
||||||
"avatar" TEXT DEFAULT '/avatars/default.png',
|
|
||||||
"isAnonymous" BOOLEAN DEFAULT true,
|
|
||||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"exp" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"dailyStreak" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"soundOn" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "dailyStreak", "email", "exp", "id", "isAnonymous", "nickname", "password", "soundOn", "updatedAt" FROM "User";
|
|
||||||
DROP TABLE "User";
|
|
||||||
ALTER TABLE "new_User" RENAME TO "User";
|
|
||||||
CREATE UNIQUE INDEX "User_anonymousSessionId_key" ON "User"("anonymousSessionId");
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "VillageObject" ADD COLUMN "lastExpDay" TEXT;
|
|
||||||
UPDATE "VillageObject" SET "lastExpDay" = strftime('%Y-%m-%d', "lastExpAt") WHERE "lastExpAt" IS NOT NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "VillageTile" ADD COLUMN "clearingStartedDay" TEXT;
|
|
||||||
UPDATE "VillageTile" SET "clearingStartedDay" = strftime('%Y-%m-%d', "clearingStartedAt") WHERE "clearingStartedAt" IS NOT NULL;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `lastExpAt` on the `VillageObject` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `clearingStartedAt` on the `VillageTile` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_VillageObject" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"lastExpDay" TEXT,
|
|
||||||
"cropType" TEXT,
|
|
||||||
"plantedAt" DATETIME,
|
|
||||||
"villageId" INTEGER NOT NULL,
|
|
||||||
"tileId" INTEGER NOT NULL,
|
|
||||||
CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "VillageObject_tileId_fkey" FOREIGN KEY ("tileId") REFERENCES "VillageTile" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_VillageObject" ("createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId") SELECT "createdAt", "cropType", "id", "lastExpDay", "plantedAt", "tileId", "type", "villageId" FROM "VillageObject";
|
|
||||||
DROP TABLE "VillageObject";
|
|
||||||
ALTER TABLE "new_VillageObject" RENAME TO "VillageObject";
|
|
||||||
CREATE UNIQUE INDEX "VillageObject_tileId_key" ON "VillageObject"("tileId");
|
|
||||||
CREATE TABLE "new_VillageTile" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"x" INTEGER NOT NULL,
|
|
||||||
"y" INTEGER NOT NULL,
|
|
||||||
"terrainType" TEXT NOT NULL,
|
|
||||||
"terrainState" TEXT NOT NULL DEFAULT 'IDLE',
|
|
||||||
"clearingStartedDay" TEXT,
|
|
||||||
"villageId" INTEGER NOT NULL,
|
|
||||||
CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_VillageTile" ("clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y") SELECT "clearingStartedDay", "id", "terrainState", "terrainType", "villageId", "x", "y" FROM "VillageTile";
|
|
||||||
DROP TABLE "VillageTile";
|
|
||||||
ALTER TABLE "new_VillageTile" RENAME TO "VillageTile";
|
|
||||||
CREATE UNIQUE INDEX "VillageTile_villageId_x_y_key" ON "VillageTile"("villageId", "x", "y");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_Village" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
"lastTickDay" TEXT,
|
|
||||||
CONSTRAINT "Village_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_Village" ("id", "userId") SELECT "id", "userId" FROM "Village";
|
|
||||||
DROP TABLE "Village";
|
|
||||||
ALTER TABLE "new_Village" RENAME TO "Village";
|
|
||||||
CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_DailyVisit" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"date" TEXT NOT NULL,
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
CONSTRAINT "DailyVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_DailyVisit" ("date", "id", "userId") SELECT "date", "id", "userId" FROM "DailyVisit";
|
|
||||||
DROP TABLE "DailyVisit";
|
|
||||||
ALTER TABLE "new_DailyVisit" RENAME TO "DailyVisit";
|
|
||||||
CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date");
|
|
||||||
CREATE TABLE "new_HabitCompletion" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"date" TEXT NOT NULL,
|
|
||||||
"habitId" INTEGER NOT NULL,
|
|
||||||
CONSTRAINT "HabitCompletion_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "Habit" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_HabitCompletion" ("date", "habitId", "id") SELECT "date", "habitId", "id" FROM "HabitCompletion";
|
|
||||||
DROP TABLE "HabitCompletion";
|
|
||||||
ALTER TABLE "new_HabitCompletion" RENAME TO "HabitCompletion";
|
|
||||||
CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
|
|
@ -40,17 +40,13 @@ enum CropType {
|
||||||
// settings, and in-game resources like coins and experience points.
|
// settings, and in-game resources like coins and experience points.
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
anonymousSessionId String? @unique
|
email String @unique
|
||||||
|
password String
|
||||||
email String? @unique
|
|
||||||
password String?
|
|
||||||
nickname String?
|
nickname String?
|
||||||
avatar String? @default("/avatars/default.png")
|
avatar String? @default("/avatars/default.png")
|
||||||
isAnonymous Boolean? @default(true)
|
|
||||||
|
|
||||||
coins Int @default(0)
|
coins Int @default(0)
|
||||||
exp Int @default(0)
|
exp Int @default(0)
|
||||||
dailyStreak Int @default(0)
|
|
||||||
|
|
||||||
// User settings
|
// User settings
|
||||||
soundOn Boolean @default(true)
|
soundOn Boolean @default(true)
|
||||||
|
|
@ -85,7 +81,7 @@ model Habit {
|
||||||
// This creates a history of the user's progress for each habit.
|
// This creates a history of the user's progress for each habit.
|
||||||
model HabitCompletion {
|
model HabitCompletion {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date String // YYYY-MM-DD format
|
date DateTime // Store only the date part
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
||||||
|
|
@ -98,7 +94,7 @@ model HabitCompletion {
|
||||||
// quest and for calculating 5-day streaks.
|
// quest and for calculating 5-day streaks.
|
||||||
model DailyVisit {
|
model DailyVisit {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date String // YYYY-MM-DD format
|
date DateTime // Store only the date part
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
@ -111,12 +107,12 @@ model DailyVisit {
|
||||||
// village objects. Each user has exactly one village.
|
// village objects. Each user has exactly one village.
|
||||||
model Village {
|
model Village {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
userId Int @unique
|
|
||||||
lastTickDay String?
|
|
||||||
|
|
||||||
tiles VillageTile[]
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId Int @unique // Each user has only one village
|
||||||
objects VillageObject[]
|
objects VillageObject[]
|
||||||
|
tiles VillageTile[]
|
||||||
events VillageEvent[]
|
events VillageEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +121,7 @@ model VillageObject {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type VillageObjectType
|
type VillageObjectType
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastExpDay String?
|
lastExpAt DateTime?
|
||||||
|
|
||||||
// Crop details (only if type is FIELD)
|
// Crop details (only if type is FIELD)
|
||||||
cropType CropType?
|
cropType CropType?
|
||||||
|
|
@ -144,7 +140,7 @@ model VillageTile {
|
||||||
y Int
|
y Int
|
||||||
terrainType TerrainType
|
terrainType TerrainType
|
||||||
terrainState TerrainState @default(IDLE)
|
terrainState TerrainState @default(IDLE)
|
||||||
clearingStartedDay String?
|
clearingStartedAt DateTime?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
|
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { cleanupAnonymousUsers } from '~/server/tasks/cleanup';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API endpoint to trigger the cleanup of old anonymous users.
|
|
||||||
* This endpoint is protected by a secret key passed in the 'x-cleanup-secret' header.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
// Read the secret from the request header.
|
|
||||||
const secret = getRequestHeader(event, 'x-cleanup-secret');
|
|
||||||
|
|
||||||
// Ensure the secret is present and matches the one in environment variables.
|
|
||||||
if (!process.env.CLEANUP_SECRET || secret !== process.env.CLEANUP_SECRET) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: 'Unauthorized. Invalid or missing cleanup secret.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[API] Cleanup task triggered.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await cleanupAnonymousUsers();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Cleanup successful. Deleted ${result.count} anonymous users.`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[API] Error during cleanup task:', error);
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Internal Server Error. Failed to execute cleanup task.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
|
|
||||||
// Simple admin check
|
|
||||||
if (userId !== 1) {
|
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
coins: {
|
|
||||||
increment: 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Added 1000 coins.',
|
|
||||||
newBalance: updatedUser.coins,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
65
server/api/admin/village/complete-clearing.post.ts
Normal file
65
server/api/admin/village/complete-clearing.post.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// server/api/admin/village/complete-clearing.post.ts
|
||||||
|
import { getUserIdFromSession } from '../../../utils/auth';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { REWARDS } from '../../../utils/economy';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
|
// Simple admin check
|
||||||
|
if (userId !== 1) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
|
||||||
|
|
||||||
|
const tilesToComplete = await prisma.villageTile.findMany({
|
||||||
|
where: {
|
||||||
|
villageId: village.id,
|
||||||
|
terrainState: 'CLEARING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tilesToComplete.length === 0) {
|
||||||
|
return { success: true, message: 'No clearing tasks to complete.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCoins = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.coins;
|
||||||
|
const totalExp = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.exp;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Update user totals
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
coins: { increment: totalCoins },
|
||||||
|
exp: { increment: totalExp },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Update all the tiles
|
||||||
|
await tx.villageTile.updateMany({
|
||||||
|
where: { id: { in: tilesToComplete.map(t => t.id) } },
|
||||||
|
data: { terrainState: 'IDLE', terrainType: 'EMPTY', clearingStartedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Create an event for each completed tile
|
||||||
|
for (const tile of tilesToComplete) {
|
||||||
|
await tx.villageEvent.create({
|
||||||
|
data: {
|
||||||
|
villageId: village.id,
|
||||||
|
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
|
||||||
|
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
|
||||||
|
tileX: tile.x,
|
||||||
|
tileY: tile.y,
|
||||||
|
coins: REWARDS.VILLAGE.CLEARING.coins,
|
||||||
|
exp: REWARDS.VILLAGE.CLEARING.exp,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: `Completed ${tilesToComplete.length} clearing tasks.` };
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// server/api/admin/village/reset.post.ts
|
// server/api/admin/village/reset.post.ts
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
import { getUserIdFromSession } from '../../../utils/auth';
|
||||||
import { generateVillageForUser } from '../../../services/villageService';
|
import { generateVillageForUser } from '../../../services/villageService';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
// Simple admin check
|
// Simple admin check
|
||||||
if (userId !== 1) {
|
if (userId !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,49 @@
|
||||||
// server/api/admin/village/trigger-tick.post.ts
|
// server/api/admin/village/trigger-tick.post.ts
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
import { getUserIdFromSession } from '../../../utils/auth';
|
||||||
import { getPreviousDay } from '../../../utils/gameDay';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/**
|
// This is a simplified constant. In a real scenario, this might be shared from a single source.
|
||||||
* Admin endpoint to manually trigger the game logic tick.
|
const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
* This is useful for testing time-based mechanics without waiting.
|
|
||||||
* It returns the full, updated village state.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
// Simple admin check
|
// Simple admin check
|
||||||
if (userId !== 1) {
|
if (userId !== 1) {
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousDay = getPreviousDay();
|
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
|
||||||
|
|
||||||
const [fieldResult, tileResult, villageResult] = await prisma.$transaction([
|
const now = Date.now();
|
||||||
// 1. Update lastExpDay for all FIELD objects for this user's village
|
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
|
||||||
prisma.villageObject.updateMany({
|
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS + 5000); // 5 seconds past completion
|
||||||
where: {
|
|
||||||
village: { userId: userId },
|
|
||||||
type: 'FIELD',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
lastExpDay: previousDay,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 2. Update clearingStartedDay for all CLEANING VillageTile objects for this user's village
|
await prisma.$transaction([
|
||||||
|
// 1. Fast-forward any tiles that are currently being cleared
|
||||||
prisma.villageTile.updateMany({
|
prisma.villageTile.updateMany({
|
||||||
where: {
|
where: {
|
||||||
village: { userId: userId },
|
villageId: village.id,
|
||||||
terrainState: 'CLEARING',
|
terrainState: 'CLEARING',
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
clearingStartedDay: previousDay,
|
clearingStartedAt: clearingFastForwardDate,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 3. Update the village's lastTickDay
|
// 2. Fast-forward any fields to be ready for EXP gain
|
||||||
prisma.village.updateMany({
|
prisma.villageObject.updateMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId,
|
villageId: village.id,
|
||||||
|
type: 'FIELD',
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
lastTickDay: previousDay,
|
lastExpAt: yesterday,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' };
|
||||||
success: true,
|
|
||||||
message: `Triggered tick preparation. Fields updated: ${fieldResult.count}, Clearing tiles updated: ${tileResult.count}. Village lastTickDay updated.`
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
export default defineEventHandler(async (event) => {
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
const user = event.context.user;
|
|
||||||
|
|
||||||
// The auth middleware has already populated event.context.user.
|
export default defineEventHandler(async (event) => {
|
||||||
// We just need to verify it's a permanent user (has an email).
|
// 1. Get user ID from session; this helper handles the 401 check.
|
||||||
if (!user || !user.email) {
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
|
// 2. Fetch the full user from the database
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// This case might happen if the user was deleted but the session still exists.
|
||||||
|
// The helper can't handle this, so we clear the session here.
|
||||||
|
const session = await useSession(event, { password: process.env.SESSION_PASSWORD });
|
||||||
|
await session.clear();
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Unauthorized: No active session.',
|
statusMessage: 'Unauthorized: User not found.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the user data DTO, which is already available on the context.
|
// 3. Return user data DTO
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
@ -19,7 +29,6 @@ export default defineEventHandler(async (event) => {
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
coins: user.coins,
|
coins: user.coins,
|
||||||
exp: user.exp,
|
exp: user.exp,
|
||||||
dailyStreak: user.dailyStreak,
|
|
||||||
soundOn: user.soundOn,
|
soundOn: user.soundOn,
|
||||||
confettiOn: user.confettiOn,
|
confettiOn: user.confettiOn,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,56 @@
|
||||||
import { hashPassword } from '~/server/utils/password';
|
import { hashPassword } from '../../utils/password';
|
||||||
import { generateVillageForUser } from '~/server/services/villageService';
|
import { generateVillageForUser } from '../../services/villageService';
|
||||||
import { useSession } from 'h3';
|
|
||||||
|
|
||||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles user registration.
|
|
||||||
*
|
|
||||||
* This endpoint has two main flows:
|
|
||||||
* 1. Anonymous User Conversion: If an anonymous session cookie is present,
|
|
||||||
* it finds the anonymous user, updates their details, makes them permanent,
|
|
||||||
* and logs them in, preserving their progress.
|
|
||||||
* 2. Standard Registration: If no anonymous session is found, it creates a
|
|
||||||
* brand new user and a new village for them.
|
|
||||||
*
|
|
||||||
* In both cases, it automatically logs the user in upon successful registration.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { email, password, nickname } = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
const { email, password, nickname } = body;
|
||||||
|
|
||||||
// --- 1. Input Validation ---
|
// 1. Validate input
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Email and password are required' });
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Email and password are required',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' });
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Password must be at least 8 characters long',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase(); // Normalize email
|
||||||
|
|
||||||
// Check if email is already in use by a permanent account
|
// 2. Check if user already exists
|
||||||
const existingPermanentUser = await prisma.user.findFirst({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email: normalizedEmail, isAnonymous: false },
|
where: { email: normalizedEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingPermanentUser) {
|
if (existingUser) {
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Email already in use' });
|
throw createError({
|
||||||
|
statusCode: 409, // Conflict
|
||||||
|
statusMessage: 'Email already in use',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Hash password and create user
|
||||||
|
// WARNING: This hashPassword is a mock. Replace with a secure library like bcrypt before production.
|
||||||
const hashedPassword = await hashPassword(password);
|
const hashedPassword = await hashPassword(password);
|
||||||
let user;
|
const user = await prisma.user.create({
|
||||||
|
|
||||||
// --- 2. Identify User Flow (Anonymous Conversion vs. Standard) ---
|
|
||||||
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
|
|
||||||
const anonymousUser = anonymousSessionId
|
|
||||||
? await prisma.user.findUnique({ where: { anonymousSessionId } })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (anonymousUser) {
|
|
||||||
// --- Flow A: Convert Anonymous User ---
|
|
||||||
user = await prisma.user.update({
|
|
||||||
where: { id: anonymousUser.id },
|
|
||||||
data: {
|
data: {
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
nickname: nickname || 'New Smurf',
|
nickname: nickname || 'New Smurf',
|
||||||
isAnonymous: false, // Make the user permanent
|
|
||||||
anonymousSessionId: null, // Invalidate the anonymous session ID
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// The village and progress are already associated with this user.
|
|
||||||
|
|
||||||
// Invalidate the anonymous cookie
|
|
||||||
setCookie(event, ANONYMOUS_COOKIE_NAME, '', { maxAge: -1 });
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// --- Flow B: Create New User ---
|
|
||||||
user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: normalizedEmail,
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: nickname || 'New Smurf',
|
|
||||||
isAnonymous: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate a new village for the brand new user
|
// 4. Generate the user's village
|
||||||
await generateVillageForUser(user);
|
await generateVillageForUser(user);
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3. Automatically log the user in ---
|
// NOTE: Registration does not automatically log in the user.
|
||||||
const session = await useSession(event, { password: process.env.SESSION_PASSWORD! });
|
// The user needs to explicitly call the login endpoint after registration.
|
||||||
await session.update({ user: { id: user.id } });
|
|
||||||
|
|
||||||
// --- 4. Return DTO ---
|
// 5. Return the new user, excluding sensitive fields and shortening DTO
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
// server/api/economy/constants.get.ts
|
|
||||||
import { COSTS, REWARDS } from '~/server/utils/economy';
|
|
||||||
|
|
||||||
export default defineEventHandler(() => {
|
|
||||||
return {
|
|
||||||
rewards: {
|
|
||||||
onboardingCompletion: REWARDS.HABITS.ONBOARDING_COMPLETION.coins,
|
|
||||||
},
|
|
||||||
costs: {
|
|
||||||
build: {
|
|
||||||
house: COSTS.BUILD.HOUSE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,60 +1,62 @@
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
import { getUserIdFromSession } from '../../../utils/auth';
|
||||||
import { REWARDS } from '../../../utils/economy';
|
import { REWARDS } from '../../../utils/economy';
|
||||||
import prisma from '../../../utils/prisma';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { applyStreakMultiplier } from '../../../utils/streak';
|
|
||||||
import { getDayOfWeekFromGameDay } from '~/server/utils/gameDay';
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
interface CompletionResponse {
|
interface CompletionResponse {
|
||||||
message: string;
|
message: string;
|
||||||
reward: {
|
reward: {
|
||||||
coins: number;
|
coins: number;
|
||||||
exp: number;
|
exp: number; // Added
|
||||||
};
|
};
|
||||||
updatedCoins: number;
|
updatedCoins: number;
|
||||||
updatedExp: number;
|
updatedExp: number; // Added
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Date object for the start of a given day in UTC.
|
||||||
|
* This is duplicated here as per the instruction not to create new shared utilities.
|
||||||
|
*/
|
||||||
|
function getStartOfDay(date: Date): Date {
|
||||||
|
const startOfDay = new Date(date);
|
||||||
|
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||||
|
return startOfDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
const habitId = parseInt(event.context.params?.id || '', 10);
|
||||||
const body = await readBody(event);
|
|
||||||
const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD"
|
|
||||||
|
|
||||||
if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(habitId)) {
|
if (isNaN(habitId)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user and habit in parallel
|
const habit = await prisma.habit.findFirst({
|
||||||
const [user, habit] = await Promise.all([
|
where: { id: habitId, userId },
|
||||||
prisma.user.findUnique({ where: { id: userId } }),
|
});
|
||||||
prisma.habit.findFirst({ where: { id: habitId, userId } })
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: 'User not found.' });
|
|
||||||
}
|
|
||||||
if (!habit) {
|
if (!habit) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
|
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const appDayOfWeek = getDayOfWeekFromGameDay(gameDay);
|
const today = new Date();
|
||||||
|
const jsDayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
|
||||||
|
|
||||||
|
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
|
||||||
|
const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
|
||||||
|
|
||||||
// For permanent users, ensure the habit is scheduled for today.
|
|
||||||
// Anonymous users in the onboarding flow can complete it on any day.
|
|
||||||
if (!user.isAnonymous) {
|
|
||||||
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
|
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Normalize date to the beginning of the day for consistent checks
|
||||||
|
const startOfToday = getStartOfDay(new Date()); // Correctly get a Date object
|
||||||
|
|
||||||
const existingCompletion = await prisma.habitCompletion.findFirst({
|
const existingCompletion = await prisma.habitCompletion.findFirst({
|
||||||
where: {
|
where: {
|
||||||
habitId: habitId,
|
habitId: habitId,
|
||||||
date: gameDay,
|
date: startOfToday, // Use precise equality check
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,36 +64,25 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the reward based on user type
|
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
|
||||||
let finalReward: { coins: number, exp: number };
|
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
|
||||||
if (user.isAnonymous) {
|
|
||||||
// Anonymous users in onboarding get a fixed reward from economy.ts
|
|
||||||
finalReward = REWARDS.HABITS.ONBOARDING_COMPLETION;
|
|
||||||
} else {
|
|
||||||
// Permanent users get rewards based on streak
|
|
||||||
// Streak defaults to 1 for multiplier if it's 0 or null
|
|
||||||
const currentDailyStreak = user.dailyStreak && user.dailyStreak > 0 ? user.dailyStreak : 1;
|
|
||||||
const baseReward = REWARDS.HABITS.COMPLETION;
|
|
||||||
finalReward = applyStreakMultiplier(baseReward, currentDailyStreak);
|
|
||||||
}
|
|
||||||
|
|
||||||
const village = await prisma.village.findUnique({ where: { userId } });
|
const village = await prisma.village.findUnique({ where: { userId } });
|
||||||
|
|
||||||
const [, updatedUser] = await prisma.$transaction([
|
const [, updatedUser] = await prisma.$transaction([
|
||||||
prisma.habitCompletion.create({
|
prisma.habitCompletion.create({
|
||||||
data: {
|
data: {
|
||||||
habitId: habitId,
|
habitId: habitId,
|
||||||
date: gameDay,
|
date: startOfToday, // Save the normalized date
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.user.update({
|
prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
coins: {
|
coins: {
|
||||||
increment: finalReward.coins,
|
increment: rewardCoins,
|
||||||
},
|
},
|
||||||
exp: {
|
exp: { // Added
|
||||||
increment: finalReward.exp,
|
increment: rewardExp, // Added
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -99,18 +90,20 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
||||||
data: {
|
data: {
|
||||||
villageId: village.id,
|
villageId: village.id,
|
||||||
type: 'HABIT_COMPLETION',
|
type: 'HABIT_COMPLETION',
|
||||||
message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) увеличила награду!` : ''}`,
|
message: `Completed habit: "${habit.name}"`,
|
||||||
coins: finalReward.coins,
|
coins: rewardCoins,
|
||||||
exp: finalReward.exp,
|
exp: rewardExp, // Changed from 0 to rewardExp
|
||||||
}
|
}
|
||||||
})] : []),
|
})] : []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Habit completed successfully!',
|
message: 'Habit completed successfully!',
|
||||||
reward: finalReward,
|
reward: {
|
||||||
|
coins: rewardCoins,
|
||||||
|
exp: rewardExp, // Added
|
||||||
|
},
|
||||||
updatedCoins: updatedUser.coins,
|
updatedCoins: updatedUser.coins,
|
||||||
updatedExp: updatedUser.exp,
|
updatedExp: updatedUser.exp, // Added
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
const habitId = parseInt(event.context.params?.id || '', 10);
|
|
||||||
|
|
||||||
if (isNaN(habitId)) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Authorization & Deletion ---
|
|
||||||
// First, verify the habit exists and belongs to the user.
|
|
||||||
const habit = await prisma.habit.findUnique({
|
|
||||||
where: {
|
|
||||||
id: habitId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!habit || habit.userId !== userId) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now, delete the habit
|
|
||||||
await prisma.habit.delete({
|
|
||||||
where: {
|
|
||||||
id: habitId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Response ---
|
|
||||||
// Send 204 No Content status
|
|
||||||
setResponseStatus(event, 204);
|
|
||||||
|
|
||||||
// Return null or an empty body
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { getAuthenticatedUserId } from '../../../utils/auth';
|
|
||||||
|
|
||||||
interface HabitDto {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
daysOfWeek: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
const habitId = Number(event.context.params?.id);
|
|
||||||
const { name, daysOfWeek } = await readBody(event);
|
|
||||||
|
|
||||||
if (isNaN(habitId)) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validation ---
|
|
||||||
if (!name || !Array.isArray(daysOfWeek)) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid input: name and daysOfWeek are required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize daysOfWeek to ensure it's a unique set of valid numbers
|
|
||||||
const validDays = daysOfWeek.filter(day => typeof day === 'number' && day >= 0 && day <= 6);
|
|
||||||
const sanitizedDays = [...new Set(validDays)].sort();
|
|
||||||
|
|
||||||
// --- Authorization & Update ---
|
|
||||||
// First, verify the habit exists and belongs to the user.
|
|
||||||
const habit = await prisma.habit.findUnique({
|
|
||||||
where: {
|
|
||||||
id: habitId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!habit || habit.userId !== userId) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now, update the habit
|
|
||||||
const updatedHabit = await prisma.habit.update({
|
|
||||||
where: {
|
|
||||||
id: habitId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
daysOfWeek: sanitizedDays,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return DTO
|
|
||||||
return {
|
|
||||||
id: updatedHabit.id,
|
|
||||||
name: updatedHabit.name,
|
|
||||||
daysOfWeek: updatedHabit.daysOfWeek as number[],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
import { Habit } from '@prisma/client';
|
import { Habit } from '@prisma/client';
|
||||||
|
|
||||||
// DTO to shape the output
|
// DTO to shape the output
|
||||||
|
|
@ -15,7 +15,7 @@ interface HabitDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
|
export default defineEventHandler(async (event): Promise<HabitDto[]> => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
const habits = await prisma.habit.findMany({
|
const habits = await prisma.habit.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
|
|
||||||
interface HabitDto {
|
interface HabitDto {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -7,7 +7,7 @@ interface HabitDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
export default defineEventHandler(async (event): Promise<HabitDto> => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
const { name, daysOfWeek } = await readBody(event);
|
const { name, daysOfWeek } = await readBody(event);
|
||||||
|
|
||||||
// --- Validation ---
|
// --- Validation ---
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,6 @@ export default defineEventHandler(async () => {
|
||||||
// for "current month's EXP". This should be revisited if true monthly
|
// for "current month's EXP". This should be revisited if true monthly
|
||||||
// tracking becomes a requirement.
|
// tracking becomes a requirement.
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
|
||||||
isAnonymous: false,
|
|
||||||
nickname: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
nickname: true,
|
nickname: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { getAuthenticatedUserId } from '~/server/utils/auth';
|
|
||||||
import prisma from '~/server/utils/prisma'; // Ensure prisma is imported
|
|
||||||
|
|
||||||
const ONBOARDING_REWARD_COINS = 75;
|
|
||||||
|
|
||||||
interface OnboardingCompletionResponse {
|
|
||||||
message: string;
|
|
||||||
updatedCoins: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get the start of the day in UTC
|
|
||||||
function getStartOfDay(date: Date): Date {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setUTCHours(0, 0, 0, 0);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A special endpoint for the onboarding funnel to complete a habit.
|
|
||||||
* This provides a fixed reward and has simpler logic than the main completion endpoint.
|
|
||||||
* ONLY callable by anonymous users.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event): Promise<OnboardingCompletionResponse> => {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
const { habitId } = await readBody(event);
|
|
||||||
|
|
||||||
// Fetch the full user to check their anonymous status
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { id: true, isAnonymous: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure only anonymous users can use this endpoint
|
|
||||||
if (!user || !user.isAnonymous) {
|
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: This endpoint is for anonymous onboarding users only.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!habitId || typeof habitId !== 'number') {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const habit = await prisma.habit.findFirst({
|
|
||||||
where: { id: habitId, userId: userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!habit) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found for this user.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const startOfToday = getStartOfDay(new Date());
|
|
||||||
|
|
||||||
const existingCompletion = await prisma.habitCompletion.findFirst({
|
|
||||||
where: {
|
|
||||||
habitId: habitId,
|
|
||||||
date: startOfToday,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCompletion) {
|
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a transaction to ensure both operations succeed or fail together
|
|
||||||
const [, updatedUser] = await prisma.$transaction([
|
|
||||||
prisma.habitCompletion.create({
|
|
||||||
data: {
|
|
||||||
habitId: habitId,
|
|
||||||
date: startOfToday,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
coins: {
|
|
||||||
increment: ONBOARDING_REWARD_COINS,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Onboarding habit completed successfully!',
|
|
||||||
updatedCoins: updatedUser.coins,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import prisma from '~/server/utils/prisma';
|
|
||||||
import { generateVillageForUser } from '~/server/services/villageService';
|
|
||||||
|
|
||||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiates an anonymous user session.
|
|
||||||
* 1. Checks for an existing session cookie.
|
|
||||||
* 2. If found, verifies it corresponds to an existing anonymous user.
|
|
||||||
* 3. If not found or invalid, creates a new anonymous user and a corresponding village.
|
|
||||||
* 4. Sets the session ID in a long-lived cookie.
|
|
||||||
* 5. Returns the session ID and basic user info.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const existingSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
|
|
||||||
|
|
||||||
if (existingSessionId) {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
anonymousSessionId: existingSessionId,
|
|
||||||
isAnonymous: true
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
anonymousSessionId: true,
|
|
||||||
village: { select: { id: true } } // Include village ID for deletion
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If a valid anonymous user is found for this session, reset their progress.
|
|
||||||
if (user) {
|
|
||||||
const villageId = user.village?.id;
|
|
||||||
|
|
||||||
// Use a transaction to atomically delete all progress.
|
|
||||||
await prisma.$transaction([
|
|
||||||
// 1. Delete all habits (HabitCompletion records will cascade)
|
|
||||||
prisma.habit.deleteMany({ where: { userId: user.id } }),
|
|
||||||
|
|
||||||
// 2. Delete all village objects to break the dependency on tiles before deleting the village
|
|
||||||
...(villageId ? [prisma.villageObject.deleteMany({ where: { villageId } })] : []),
|
|
||||||
|
|
||||||
// 3. Delete the village if it exists (all related records will cascade)
|
|
||||||
...(villageId ? [prisma.village.delete({ where: { id: villageId } })] : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Re-create the village from scratch. This service will also set initial coins.
|
|
||||||
await generateVillageForUser(user);
|
|
||||||
|
|
||||||
// Fetch the final, reset state of the user to return
|
|
||||||
const resetUser = await prisma.user.findUnique({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
anonymousSessionId: true,
|
|
||||||
nickname: true,
|
|
||||||
coins: true,
|
|
||||||
exp: true,
|
|
||||||
isAnonymous: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return resetUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No valid session found, create a new anonymous user.
|
|
||||||
const newSessionId = randomUUID();
|
|
||||||
const newUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
isAnonymous: true,
|
|
||||||
anonymousSessionId: newSessionId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true, // Also return ID
|
|
||||||
anonymousSessionId: true,
|
|
||||||
nickname: true,
|
|
||||||
coins: true,
|
|
||||||
exp: true,
|
|
||||||
isAnonymous: true // Ensure this flag is returned
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now, generate the village with default tiles for the new anonymous user
|
|
||||||
await generateVillageForUser(newUser);
|
|
||||||
|
|
||||||
// Set a long-lived cookie (e.g., 1 year)
|
|
||||||
setCookie(event, ANONYMOUS_COOKIE_NAME, newSessionId, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 365 * 24 * 60 * 60 // 1 year in seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
return newUser;
|
|
||||||
});
|
|
||||||
109
server/api/quests/daily-visit.post.ts
Normal file
109
server/api/quests/daily-visit.post.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
|
import { REWARDS } from '../../utils/economy';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface DailyVisitResponse {
|
||||||
|
message: string;
|
||||||
|
reward: {
|
||||||
|
coins: number;
|
||||||
|
streakBonus: boolean;
|
||||||
|
};
|
||||||
|
updatedCoins: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Date object for the start of a given day in UTC.
|
||||||
|
*/
|
||||||
|
function getStartOfDay(date: Date): Date {
|
||||||
|
const startOfDay = new Date(date);
|
||||||
|
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||||
|
return startOfDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<DailyVisitResponse> => {
|
||||||
|
const userId = await getUserIdFromSession(event);
|
||||||
|
const today = getStartOfDay(new Date());
|
||||||
|
|
||||||
|
// 1. Check if the user has already claimed the reward today
|
||||||
|
const existingVisit = await prisma.dailyVisit.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_date: {
|
||||||
|
userId,
|
||||||
|
date: today,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingVisit) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Daily visit reward has already been claimed today.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for a 5-day consecutive streak (i.e., visits on the 4 previous days)
|
||||||
|
const previousDates = Array.from({ length: 4 }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setUTCDate(d.getUTCDate() - (i + 1));
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorVisitsCount = await prisma.dailyVisit.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date: {
|
||||||
|
in: previousDates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasStreak = priorVisitsCount === 4;
|
||||||
|
|
||||||
|
// 3. Calculate rewards and update the database in a transaction
|
||||||
|
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
|
||||||
|
let message = 'Daily visit claimed!';
|
||||||
|
if (hasStreak) {
|
||||||
|
totalReward += REWARDS.QUESTS.DAILY_VISIT.STREAK_BONUS.coins;
|
||||||
|
message = 'Daily visit and streak bonus claimed!';
|
||||||
|
}
|
||||||
|
|
||||||
|
const village = await prisma.village.findUnique({ where: { userId } });
|
||||||
|
|
||||||
|
const [, updatedUser] = await prisma.$transaction([
|
||||||
|
prisma.dailyVisit.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
date: today,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
coins: {
|
||||||
|
increment: totalReward,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(village ? [prisma.villageEvent.create({
|
||||||
|
data: {
|
||||||
|
villageId: village.id,
|
||||||
|
type: 'QUEST_DAILY_VISIT',
|
||||||
|
message,
|
||||||
|
coins: totalReward,
|
||||||
|
exp: 0,
|
||||||
|
}
|
||||||
|
})] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Return the response
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
reward: {
|
||||||
|
coins: totalReward,
|
||||||
|
streakBonus: hasStreak,
|
||||||
|
},
|
||||||
|
updatedCoins: updatedUser.coins,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
|
||||||
import prisma from '../../utils/prisma';
|
|
||||||
import { calculateDailyStreak } from '../../utils/streak';
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
const body = await readBody(event);
|
|
||||||
const gameDay: string = body.gameDay; // Expecting "YYYY-MM-DD"
|
|
||||||
|
|
||||||
if (!gameDay || !/^\d{4}-\d{2}-\d{2}$/.test(gameDay)) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid or missing gameDay property in request body. Expected YYYY-MM-DD.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the streak and create today's visit record
|
|
||||||
const updatedUser = await calculateDailyStreak(prisma, userId, gameDay);
|
|
||||||
|
|
||||||
// The consumer of this endpoint needs the most up-to-date user info,
|
|
||||||
// including the newly calculated streak.
|
|
||||||
return {
|
|
||||||
id: updatedUser.id,
|
|
||||||
email: updatedUser.email,
|
|
||||||
nickname: updatedUser.nickname,
|
|
||||||
avatar: updatedUser.avatar,
|
|
||||||
coins: updatedUser.coins,
|
|
||||||
exp: updatedUser.exp,
|
|
||||||
dailyStreak: updatedUser.dailyStreak,
|
|
||||||
soundOn: updatedUser.soundOn,
|
|
||||||
confettiOn: updatedUser.confettiOn,
|
|
||||||
createdAt: updatedUser.createdAt,
|
|
||||||
updatedAt: updatedUser.updatedAt,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +1,45 @@
|
||||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
// server/api/village/action.post.ts
|
||||||
import {
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
buildOnTile,
|
import { buildOnTile, clearTile, moveObject, removeObject } from '../../services/villageService';
|
||||||
syncAndGetVillage,
|
import { getVillageState } from '../../services/villageService';
|
||||||
} from '../../services/villageService';
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
||||||
const { tileId, actionType, payload } = body;
|
const { tileId, actionType, payload } = body;
|
||||||
|
|
||||||
if (!tileId || !actionType) {
|
if (!tileId || !actionType) {
|
||||||
throw createError({
|
throw createError({ statusCode: 400, statusMessage: 'Missing tileId or actionType' });
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Missing tileId or actionType',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case 'BUILD':
|
case 'BUILD':
|
||||||
if (!payload?.buildingType) {
|
if (!payload?.buildingType) {
|
||||||
throw createError({
|
throw createError({ statusCode: 400, statusMessage: 'Missing buildingType for BUILD action' });
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Missing buildingType',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await buildOnTile(userId, tileId, payload.buildingType);
|
await buildOnTile(userId, tileId, payload.buildingType);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'CLEAR':
|
||||||
|
await clearTile(userId, tileId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'MOVE':
|
||||||
|
if (!payload?.toTileId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing toTileId for MOVE action' });
|
||||||
|
}
|
||||||
|
await moveObject(userId, tileId, payload.toTileId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'REMOVE':
|
||||||
|
await removeObject(userId, tileId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw createError({
|
throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' });
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Invalid actionType',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return syncAndGetVillage(userId);
|
// Return the full updated village state
|
||||||
|
return getVillageState(userId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
// server/api/village/events.get.ts
|
// server/api/village/events.get.ts
|
||||||
import { getAuthenticatedUserId } from '../../utils/auth';
|
import { getUserIdFromSession } from '../../utils/auth';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const userId = getAuthenticatedUserId(event);
|
const userId = await getUserIdFromSession(event);
|
||||||
|
|
||||||
const village = await prisma.village.findUnique({
|
const village = await prisma.village.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// server/api/village/index.get.ts
|
// server/api/village/index.get.ts
|
||||||
import { syncAndGetVillage, generateVillageForUser } from '../../services/villageService';
|
import { getVillageState, generateVillageForUser } from '../../services/villageService';
|
||||||
import { defineEventHandler } from 'h3';
|
import { defineEventHandler } from 'h3';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
await generateVillageForUser(user);
|
await generateVillageForUser(user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const villageState = await syncAndGetVillage(user.id);
|
const villageState = await getVillageState(user.id);
|
||||||
return villageState;
|
return villageState;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Catch errors from the service and re-throw them as H3 errors
|
// Catch errors from the service and re-throw them as H3 errors
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
// server/api/village/tick.post.ts
|
|
||||||
import { getAuthenticatedUserId } from '~/server/utils/auth';
|
|
||||||
import { processVillageTick } from '~/server/services/villageService';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This endpoint is called on every route change for an authenticated user.
|
|
||||||
* It's responsible for triggering the "tick" of the village simulation,
|
|
||||||
* which can include things like clearing tiles, generating resources, etc.
|
|
||||||
*/
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const userId = getAuthenticatedUserId(event);
|
|
||||||
|
|
||||||
// Delegate the core logic to the villageService
|
|
||||||
const result = await processVillageTick(userId);
|
|
||||||
|
|
||||||
// The response can be simple, or return a meaningful state if the client needs to react.
|
|
||||||
// For now, a success message is sufficient.
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
// If getAuthenticatedUserId throws, it will be a 401 error, which should be propagated.
|
|
||||||
if (e.statusCode === 401) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error processing village tick:', e);
|
|
||||||
|
|
||||||
// For other errors, return a generic 500.
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Internal Server Error',
|
|
||||||
message: 'An unexpected error occurred while processing the village tick.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -2,15 +2,12 @@
|
||||||
import { defineEventHandler, useSession } from 'h3';
|
import { defineEventHandler, useSession } from 'h3';
|
||||||
import prisma from '../utils/prisma';
|
import prisma from '../utils/prisma';
|
||||||
|
|
||||||
const ANONYMOUS_COOKIE_NAME = 'smurf-anonymous-session';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global server middleware to populate `event.context.user` for every incoming request.
|
* Global server middleware to populate `event.context.user` for every incoming request.
|
||||||
*
|
*
|
||||||
* It first checks for a logged-in user session. If not found, it checks for an
|
* It safely checks for a session and fetches the user from the database if a
|
||||||
* anonymous user session cookie. It attaches the corresponding user object to
|
* valid session ID is found. It does NOT block requests or throw errors if the
|
||||||
* `event.context.user` if found. It does NOT block requests, allowing auth
|
* user is not authenticated, as authorization is handled within API endpoints themselves.
|
||||||
* checks to be handled by individual endpoints.
|
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// This middleware should not run on static assets or internal requests.
|
// This middleware should not run on static assets or internal requests.
|
||||||
|
|
@ -19,13 +16,14 @@ export default defineEventHandler(async (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check for a logged-in user session
|
// Safely get the session
|
||||||
const session = await useSession(event, {
|
const session = await useSession(event, {
|
||||||
password: process.env.SESSION_PASSWORD!,
|
password: process.env.SESSION_PASSWORD!,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = session.data?.user?.id;
|
const userId = session.data?.user?.id;
|
||||||
|
|
||||||
|
// If a userId is found in the session, fetch the user and attach it to the context.
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|
@ -34,27 +32,11 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
event.context.user = user;
|
event.context.user = user;
|
||||||
return; // Found a user, no need to check for anonymous session
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If there's an error fetching the user (e.g., DB connection issue),
|
||||||
|
// we log it but don't block the request. The user will be treated as unauthenticated.
|
||||||
console.error('Error fetching user in auth middleware:', error);
|
console.error('Error fetching user in auth middleware:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If no logged-in user, check for an anonymous session
|
|
||||||
const anonymousSessionId = getCookie(event, ANONYMOUS_COOKIE_NAME);
|
|
||||||
|
|
||||||
if (anonymousSessionId) {
|
|
||||||
try {
|
|
||||||
const anonymousUser = await prisma.user.findUnique({
|
|
||||||
where: { anonymousSessionId: anonymousSessionId, isAnonymous: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (anonymousUser) {
|
|
||||||
event.context.user = anonymousUser;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching anonymous user in auth middleware:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,80 @@
|
||||||
import {
|
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
|
||||||
PrismaClient,
|
|
||||||
User,
|
|
||||||
Prisma,
|
|
||||||
VillageObjectType,
|
|
||||||
VillageTile,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { COSTS, REWARDS } from '../utils/economy';
|
import { COSTS, REWARDS } from '../utils/economy';
|
||||||
import { applyStreakMultiplier } from '../utils/streak';
|
|
||||||
import { getTodayDay, isBeforeDay, daysSince } from '../utils/gameDay';
|
|
||||||
import { calculateDailyStreak } from '../utils/streak';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/* =========================
|
|
||||||
CONSTANTS
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
export const VILLAGE_WIDTH = 5;
|
export const VILLAGE_WIDTH = 5;
|
||||||
export const VILLAGE_HEIGHT = 7;
|
export const VILLAGE_HEIGHT = 7;
|
||||||
|
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
export const PRODUCING_BUILDINGS = [
|
export const PRODUCING_BUILDINGS: string[] = [
|
||||||
'FIELD',
|
'FIELD',
|
||||||
'LUMBERJACK',
|
'LUMBERJACK',
|
||||||
'QUARRY',
|
'QUARRY',
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
/* =========================
|
// Helper to get the start of a given date for daily EXP checks
|
||||||
TYPES
|
const getStartOfDay = (date: Date) => {
|
||||||
========================= */
|
const d = new Date(date);
|
||||||
|
d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the initial village for a new user atomically.
|
||||||
|
*/
|
||||||
|
export async function generateVillageForUser(user: User) {
|
||||||
|
const existingVillage = await prisma.village.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingVillage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tilesToCreate: Omit<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
|
||||||
|
const central_x_start = 1;
|
||||||
|
const central_x_end = 4;
|
||||||
|
const central_y_start = 2;
|
||||||
|
const central_y_end = 5;
|
||||||
|
|
||||||
|
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
|
||||||
|
for (let x = 0; x < VILLAGE_WIDTH; x++) {
|
||||||
|
const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end;
|
||||||
|
const terrainType = isCentral
|
||||||
|
? 'EMPTY'
|
||||||
|
: Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE';
|
||||||
|
|
||||||
|
tilesToCreate.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
terrainType,
|
||||||
|
terrainState: 'IDLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Wrap village generation in a single transaction for atomicity.
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const village = await tx.village.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
coins: 10,
|
||||||
|
exp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.villageTile.createMany({
|
||||||
|
data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type FullVillage = Prisma.VillageGetPayload<{
|
type FullVillage = Prisma.VillageGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -37,92 +84,190 @@ type FullVillage = Prisma.VillageGetPayload<{
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/* =========================
|
|
||||||
PUBLIC API
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes the village's daily "tick" if necessary and returns the
|
* Gets the full, updated state of a user's village, calculating all time-based progression.
|
||||||
* complete, up-to-date village state.
|
|
||||||
* This is the single source of truth for all time-based progression.
|
|
||||||
*/
|
*/
|
||||||
export async function processVillageTick(userId: number): Promise<FullVillage> {
|
export async function getVillageState(userId: number): Promise<FullVillage> {
|
||||||
try {
|
const now = new Date();
|
||||||
const today = getTodayDay();
|
|
||||||
let villageSnapshot = await fetchVillage(userId);
|
// --- Step 1: Initial Snapshot ---
|
||||||
|
let villageSnapshot = await prisma.village.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
tiles: { include: { object: true } },
|
||||||
|
objects: { include: { tile: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!villageSnapshot) {
|
if (!villageSnapshot) {
|
||||||
// This should not happen for a logged-in user with a village.
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Even if tick is done, we should ensure the streak is updated for the day.
|
// --- Step 2: Terrain Cleaning Completion ---
|
||||||
// The calculateDailyStreak function is idempotent.
|
const finishedClearingTiles = villageSnapshot.tiles.filter(
|
||||||
if (villageSnapshot.lastTickDay === today) {
|
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
|
||||||
villageSnapshot.user = await calculateDailyStreak(prisma, userId, today);
|
);
|
||||||
return villageSnapshot;
|
|
||||||
}
|
if (finishedClearingTiles.length > 0) {
|
||||||
|
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
|
||||||
|
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
|
||||||
|
|
||||||
// The tick for today has not run. Execute all daily logic in a transaction.
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// 1. UPDATE STREAK FIRST. This is critical for all reward calculations.
|
// 1. Update user totals
|
||||||
const updatedUser = await calculateDailyStreak(tx, userId, today);
|
await tx.user.update({
|
||||||
villageSnapshot.user = updatedUser; // Update snapshot with fresh user data.
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
// 2. Process other daily logic using the updated snapshot
|
coins: { increment: totalCoins },
|
||||||
const finishedTiles = await processFinishedClearing(tx, villageSnapshot, today);
|
exp: { increment: totalExp },
|
||||||
await processFieldExp(tx, villageSnapshot, today);
|
},
|
||||||
await autoStartClearing(tx, villageSnapshot, today, finishedTiles);
|
|
||||||
|
|
||||||
// 3. Update the last tick day to prevent re-processing
|
|
||||||
await tx.village.update({
|
|
||||||
where: { id: villageSnapshot.id },
|
|
||||||
data: { lastTickDay: today },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// After the transaction, the original villageSnapshot is stale.
|
// 2. Update all the tiles
|
||||||
// Re-fetch to get the latest state with all changes.
|
await tx.villageTile.updateMany({
|
||||||
const updatedVillage = await fetchVillage(userId);
|
where: { id: { in: finishedClearingTiles.map(t => t.id) } },
|
||||||
if (!updatedVillage) {
|
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
|
||||||
// This would be a critical error, as the village existed moments ago.
|
});
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Village disappeared post-transaction' });
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedVillage;
|
// --- 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 } } } })!;
|
||||||
|
|
||||||
} catch (error) {
|
// --- Step 4: Field EXP Processing ---
|
||||||
// Log the error and re-throw it to be handled by the calling API endpoint.
|
const today = getStartOfDay(now);
|
||||||
console.error(`Error in processVillageTick for user ${userId}:`, error);
|
const fieldsForExp = villageSnapshot.objects.filter(
|
||||||
if ((error as any).statusCode) throw error; // Re-throw h3 errors
|
obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today)
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Failed to process village tick.' });
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
* Main entry point for the frontend to get the village state.
|
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
|
||||||
* It ensures the daily tick is processed, then enriches the state with UI-specific data.
|
if (idleStones.length > 0) {
|
||||||
*/
|
// For simplicity, just take the first N available stones.
|
||||||
export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
|
idleStones.slice(0, freeQuarries).forEach(t => tileIdsToClear.add(t.id));
|
||||||
try {
|
}
|
||||||
// This function will now run the tick (if needed) AND return the up-to-date village state.
|
}
|
||||||
const villageSnapshot = await processVillageTick(userId);
|
|
||||||
|
|
||||||
// --- Enrich tiles with available actions ---
|
if (tileIdsToClear.size > 0) {
|
||||||
const user = villageSnapshot.user;
|
await prisma.villageTile.updateMany({
|
||||||
const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
|
where: { id: { in: Array.from(tileIdsToClear) } },
|
||||||
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type as any)).length;
|
data: { terrainState: 'CLEARING', clearingStartedAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch state after starting new clearings
|
||||||
|
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 7: Final Fetch & Action Enrichment ---
|
||||||
|
const finalVillageState = await prisma.village.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] },
|
||||||
|
objects: { include: { tile: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalVillageState) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 8: Enrich tiles with available actions ---
|
||||||
|
const { user } = finalVillageState;
|
||||||
|
const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK');
|
||||||
|
const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY');
|
||||||
|
|
||||||
|
const housesCount = finalVillageState.objects.filter(o => o.type === 'HOUSE').length;
|
||||||
|
const producingCount = finalVillageState.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
|
||||||
const freeWorkers = housesCount - producingCount;
|
const freeWorkers = housesCount - producingCount;
|
||||||
|
|
||||||
const tilesWithActions = villageSnapshot.tiles.map(tile => {
|
const tilesWithActions = finalVillageState.tiles.map(tile => {
|
||||||
const availableActions: any[] = [];
|
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
|
// Action: BUILD
|
||||||
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
||||||
const buildableObjectTypes: VillageObjectType[] = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
||||||
const buildActions = buildableObjectTypes.map(buildingType => {
|
const buildActions = buildableObjectTypes.map(buildingType => {
|
||||||
const cost = COSTS.BUILD[buildingType];
|
const cost = COSTS.BUILD[buildingType];
|
||||||
const isProducing = (PRODUCING_BUILDINGS as readonly string[]).includes(buildingType);
|
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
|
||||||
let isEnabled = user.coins >= cost;
|
let isEnabled = user.coins >= cost;
|
||||||
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
|
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
|
||||||
|
|
||||||
|
|
@ -142,120 +287,48 @@ export async function syncAndGetVillage(userId: number): Promise<FullVillage> {
|
||||||
availableActions.push(...buildActions);
|
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 { ...tile, availableActions };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...villageSnapshot, tiles: tilesWithActions } as FullVillage;
|
return { ...finalVillageState, tiles: tilesWithActions } as any;
|
||||||
} 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.' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Action Service Functions ---
|
||||||
* Генерация деревни для нового пользователя
|
|
||||||
*/
|
|
||||||
export async function generateVillageForUser(user: User) {
|
|
||||||
// Enforce immutability of the village layout and ensure creation occurs only once.
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// 1. Find or create Village
|
|
||||||
let village = await tx.village.findUnique({
|
|
||||||
where: { userId: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
let villageCreated = false;
|
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
|
||||||
if (!village) {
|
const { VillageObjectType } = await import('@prisma/client');
|
||||||
village = await tx.village.create({
|
const validBuildingTypes = Object.keys(VillageObjectType);
|
||||||
data: { userId: user.id },
|
if (!validBuildingTypes.includes(buildingType)) {
|
||||||
});
|
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` });
|
||||||
villageCreated = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If village was just created, initialize user resources
|
|
||||||
if (villageCreated) {
|
|
||||||
await tx.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { coins: 10, exp: 0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Count existing VillageTiles for this Village
|
|
||||||
const tilesCount = await tx.villageTile.count({
|
|
||||||
where: { villageId: village!.id }, // village is guaranteed to exist here
|
|
||||||
});
|
|
||||||
|
|
||||||
// If tiles already exist, layout is immutable. Do nothing.
|
|
||||||
if (tilesCount > 0) {
|
|
||||||
// Village layout is immutable once created.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create tiles ONLY if tilesCount is 0 (broken state or first creation)
|
|
||||||
// This logic ensures tiles are created exactly once.
|
|
||||||
const tilesToCreate: Omit<
|
|
||||||
VillageTile,
|
|
||||||
'id' | 'clearingStartedDay' | 'villageId'
|
|
||||||
>[] = [];
|
|
||||||
|
|
||||||
const centralXStart = 1;
|
|
||||||
const centralXEnd = 4;
|
|
||||||
const centralYStart = 2;
|
|
||||||
const centralYEnd = 5;
|
|
||||||
|
|
||||||
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
|
|
||||||
for (let x = 0; x < VILLAGE_WIDTH; x++) {
|
|
||||||
const isCentral =
|
|
||||||
x >= centralXStart &&
|
|
||||||
x < centralXEnd &&
|
|
||||||
y >= centralYStart &&
|
|
||||||
y < centralYEnd;
|
|
||||||
|
|
||||||
tilesToCreate.push({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
terrainType: isCentral
|
|
||||||
? 'EMPTY'
|
|
||||||
: Math.random() < 0.5
|
|
||||||
? 'BLOCKED_TREE'
|
|
||||||
: 'BLOCKED_STONE',
|
|
||||||
terrainState: 'IDLE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.villageTile.createMany({
|
|
||||||
data: tilesToCreate.map((t) => ({
|
|
||||||
...t,
|
|
||||||
villageId: village!.id, // village is guaranteed to exist here
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BUILD command
|
|
||||||
*/
|
|
||||||
export async function buildOnTile(
|
|
||||||
userId: number,
|
|
||||||
tileId: number,
|
|
||||||
buildingType: VillageObjectType
|
|
||||||
) {
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
// 1. Fetch all necessary data
|
||||||
where: { id: userId },
|
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
});
|
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
|
||||||
|
|
||||||
const tile = await tx.villageTile.findUniqueOrThrow({
|
|
||||||
where: { id: tileId },
|
|
||||||
include: { village: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
if (tile.village.userId !== userId) {
|
if (tile.village.userId !== userId) {
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Not your tile' });
|
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Business logic validation
|
||||||
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
|
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
|
||||||
}
|
}
|
||||||
|
|
@ -265,24 +338,16 @@ export async function buildOnTile(
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
|
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PRODUCING_BUILDINGS.includes(buildingType as any)) {
|
if (PRODUCING_BUILDINGS.includes(buildingType)) {
|
||||||
const objects = await tx.villageObject.findMany({
|
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } });
|
||||||
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) {
|
||||||
const houses = objects.filter(o => o.type === 'HOUSE').length;
|
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' });
|
||||||
const producing = objects.filter(o =>
|
|
||||||
PRODUCING_BUILDINGS.includes(o.type as any)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
if (producing >= houses) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Not enough workers',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Perform mutations
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { coins: { decrement: cost } },
|
data: { coins: { decrement: cost } },
|
||||||
|
|
@ -290,9 +355,9 @@ export async function buildOnTile(
|
||||||
|
|
||||||
await tx.villageObject.create({
|
await tx.villageObject.create({
|
||||||
data: {
|
data: {
|
||||||
type: buildingType,
|
type: buildingType as keyof typeof VillageObjectType,
|
||||||
villageId: tile.villageId,
|
villageId: tile.villageId,
|
||||||
tileId: tile.id,
|
tileId: tileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -300,248 +365,60 @@ export async function buildOnTile(
|
||||||
data: {
|
data: {
|
||||||
villageId: tile.villageId,
|
villageId: tile.villageId,
|
||||||
type: `BUILD_${buildingType}`,
|
type: `BUILD_${buildingType}`,
|
||||||
message: `Построено ${buildingType} на (${tile.x}, ${tile.y})`,
|
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`,
|
||||||
tileX: tile.x,
|
tileX: tile.x,
|
||||||
tileY: tile.y,
|
tileY: tile.y,
|
||||||
coins: -cost,
|
coins: -cost,
|
||||||
exp: 0,
|
exp: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If a clearing building was built, immediately try to start a new clearing job.
|
|
||||||
// This makes new buildings feel responsive and start working right away.
|
|
||||||
if (buildingType === 'LUMBERJACK' || buildingType === 'QUARRY') {
|
|
||||||
const today = getTodayDay();
|
|
||||||
// We need a fresh, full snapshot of the village *within the transaction*
|
|
||||||
// to correctly calculate clearing capacity.
|
|
||||||
const villageSnapshot = await tx.village.findUnique({
|
|
||||||
where: { id: tile.villageId },
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
tiles: { include: { object: true } },
|
|
||||||
objects: { include: { tile: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (villageSnapshot) {
|
|
||||||
await autoStartClearing(tx, villageSnapshot, today);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
export async function clearTile(userId: number, tileId: number) {
|
||||||
INTERNAL HELPERS
|
return prisma.$transaction(async (tx) => {
|
||||||
========================= */
|
const tile = await tx.villageTile.findUniqueOrThrow({
|
||||||
|
where: { id: tileId },
|
||||||
function fetchVillage(userId: number) {
|
include: { village: { include: { objects: true } } },
|
||||||
return prisma.village.findUnique({
|
|
||||||
where: { userId },
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
tiles: { include: { object: true } },
|
|
||||||
objects: { include: { tile: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
DAY-BASED LOGIC
|
|
||||||
========================= */
|
|
||||||
|
|
||||||
async function processFinishedClearing(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
village: FullVillage,
|
|
||||||
today: string
|
|
||||||
): Promise<VillageTile[]> {
|
|
||||||
const finishedTiles = village.tiles.filter(
|
|
||||||
t =>
|
|
||||||
t.terrainState === 'CLEARING' &&
|
|
||||||
isBeforeDay(t.clearingStartedDay, today)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!finishedTiles.length) return [];
|
|
||||||
|
|
||||||
const baseReward = REWARDS.VILLAGE.CLEARING;
|
|
||||||
const totalBaseReward = {
|
|
||||||
coins: baseReward.coins * finishedTiles.length,
|
|
||||||
exp: baseReward.exp * finishedTiles.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure dailyStreak is at least 1 for multiplier calculation if it's 0 or null
|
|
||||||
const currentDailyStreak = village.user.dailyStreak && village.user.dailyStreak > 0 ? village.user.dailyStreak : 1;
|
|
||||||
const finalReward = applyStreakMultiplier(totalBaseReward, currentDailyStreak);
|
|
||||||
|
|
||||||
await tx.user.update({
|
|
||||||
where: { id: village.user.id },
|
|
||||||
data: {
|
|
||||||
coins: { increment: finalReward.coins },
|
|
||||||
exp: { increment: finalReward.exp },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.villageTile.updateMany({
|
if (tile.village.userId !== userId) {
|
||||||
where: { id: { in: finishedTiles.map(t => t.id) } },
|
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||||
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 => {
|
if (tile.terrainState !== 'IDLE') {
|
||||||
// Apply streak multiplier with a default of 1 if streak is not active
|
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
|
||||||
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(
|
if (tile.terrainType === 'BLOCKED_TREE') {
|
||||||
tx: Prisma.TransactionClient,
|
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
|
||||||
village: FullVillage,
|
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
|
||||||
today: string
|
} else if (tile.terrainType === 'BLOCKED_STONE') {
|
||||||
): Promise<number> {
|
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
|
||||||
const fieldsNeedingUpdate = village.objects.filter(
|
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
|
||||||
(o) => o.type === 'FIELD' && isBeforeDay(o.lastExpDay, today)
|
} else {
|
||||||
);
|
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
|
||||||
|
|
||||||
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) {
|
await tx.villageTile.update({
|
||||||
const daysMissed = field.lastExpDay ? daysSince(field.lastExpDay, today) : 1;
|
where: { id: tileId },
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
data: {
|
||||||
terrainState: 'CLEARING',
|
terrainState: 'CLEARING',
|
||||||
clearingStartedDay: today,
|
clearingStartedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return tilesToStart.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeObject(userId: number, tileId: number) {
|
||||||
|
// As requested, this is a stub for now.
|
||||||
|
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
|
||||||
|
// As requested, this is a stub for now.
|
||||||
|
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import prisma from '../utils/prisma';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes anonymous users that were created more than 24 hours ago.
|
|
||||||
* This is designed to be run as a scheduled task to prevent accumulation
|
|
||||||
* of stale data from users who start the onboarding but never register.
|
|
||||||
*/
|
|
||||||
export async function cleanupAnonymousUsers() {
|
|
||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await prisma.user.deleteMany({
|
|
||||||
where: {
|
|
||||||
isAnonymous: true,
|
|
||||||
createdAt: {
|
|
||||||
lt: twentyFourHoursAgo, // lt = less than
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[CleanupTask] Successfully deleted ${result.count} anonymous users.`);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[CleanupTask] Error deleting anonymous users:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// server/utils/auth.ts
|
// server/utils/auth.ts
|
||||||
import type { H3Event } from 'h3';
|
import { useSession } from 'h3';
|
||||||
|
|
||||||
if (!process.env.SESSION_PASSWORD) {
|
if (!process.env.SESSION_PASSWORD) {
|
||||||
// Fail-fast if the session password is not configured
|
// Fail-fast if the session password is not configured
|
||||||
|
|
@ -7,30 +7,16 @@ if (!process.env.SESSION_PASSWORD) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the user ID from the event context.
|
|
||||||
* The `server/middleware/auth.ts` middleware is responsible for populating `event.context.user`.
|
|
||||||
* Throws a 401 Unauthorized error if no user is found in the context.
|
|
||||||
* @param event The H3 event object.
|
|
||||||
* @returns The user's ID.
|
|
||||||
*/
|
|
||||||
export function getAuthenticatedUserId(event: H3Event): number {
|
|
||||||
const user = event.context.user;
|
|
||||||
if (!user || typeof user.id !== 'number') {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
return user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use `getAuthenticatedUserId(event)` instead. This function relies on the old session-only check and is not compatible with anonymous sessions.
|
|
||||||
* A helper function to safely get the authenticated user's ID from the session.
|
* A helper function to safely get the authenticated user's ID from the session.
|
||||||
* Throws a 401 Unauthorized error if the user is not authenticated.
|
* Throws a 401 Unauthorized error if the user is not authenticated.
|
||||||
*/
|
*/
|
||||||
export async function getUserIdFromSession(event: H3Event): Promise<number> {
|
export async function getUserIdFromSession(event: any): Promise<number> {
|
||||||
const user = event.context.user;
|
const session = await useSession(event, {
|
||||||
if (!user || typeof user.id !== 'number' || user.isAnonymous) {
|
password: process.env.SESSION_PASSWORD, // No fallback here, rely on the fail-fast check
|
||||||
|
});
|
||||||
|
const userId = session.data?.user?.id;
|
||||||
|
if (!userId) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
return user.id;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@
|
||||||
*/
|
*/
|
||||||
export const COSTS = {
|
export const COSTS = {
|
||||||
BUILD: {
|
BUILD: {
|
||||||
HOUSE: 15,
|
HOUSE: 50,
|
||||||
FIELD: 15,
|
FIELD: 15,
|
||||||
LUMBERJACK: 20,
|
LUMBERJACK: 30,
|
||||||
QUARRY: 20,
|
QUARRY: 30,
|
||||||
WELL: 15,
|
WELL: 20,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -19,15 +19,21 @@ export const COSTS = {
|
||||||
export const REWARDS = {
|
export const REWARDS = {
|
||||||
// Village-related rewards
|
// Village-related rewards
|
||||||
VILLAGE: {
|
VILLAGE: {
|
||||||
CLEARING: { coins: 5, exp: 1 },
|
CLEARING: { coins: 1, exp: 1 },
|
||||||
FIELD_EXP: {
|
FIELD_EXP: {
|
||||||
BASE: 1,
|
BASE: 1,
|
||||||
WELL_MULTIPLIER: 2,
|
WELL_MULTIPLIER: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Quest-related rewards
|
||||||
|
QUESTS: {
|
||||||
|
DAILY_VISIT: {
|
||||||
|
BASE: { coins: 1 },
|
||||||
|
STREAK_BONUS: { coins: 10 },
|
||||||
|
}
|
||||||
|
},
|
||||||
// Habit-related rewards
|
// Habit-related rewards
|
||||||
HABITS: {
|
HABITS: {
|
||||||
COMPLETION: { coins: 10, exp: 1 },
|
COMPLETION: { coins: 3, exp: 1 },
|
||||||
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// server/utils/gameDay.ts
|
|
||||||
|
|
||||||
// Helper to get the start of a given date for daily EXP checks
|
|
||||||
const getStartOfDay = (date: Date) => {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getTodayDay(): string {
|
|
||||||
const today = getStartOfDay(new Date());
|
|
||||||
return today.toISOString().split('T')[0]; // Returns "YYYY-MM-DD"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBeforeDay(day1: string | null | undefined, day2: string): boolean {
|
|
||||||
if (!day1) return true; // A null/undefined day is always before any valid day for our logic (e.g., first time processing)
|
|
||||||
return day1 < day2; // Lexicographical comparison works for "YYYY-MM-DD"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPreviousDay(): string {
|
|
||||||
const yesterday = getStartOfDay(new Date());
|
|
||||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
||||||
return yesterday.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function daysSince(pastDay: string, futureDay: string): number {
|
|
||||||
// We work with UTC dates to avoid timezone issues.
|
|
||||||
const pastDate = new Date(`${pastDay}T00:00:00Z`);
|
|
||||||
const futureDate = new Date(`${futureDay}T00:00:00Z`);
|
|
||||||
|
|
||||||
// getTime() returns milliseconds since epoch
|
|
||||||
const diffTime = futureDate.getTime() - pastDate.getTime();
|
|
||||||
|
|
||||||
// 1000 ms/s * 60 s/min * 60 min/hr * 24 hr/day
|
|
||||||
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
return diffDays > 0 ? diffDays : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a "YYYY-MM-DD" string into the application's day-of-week convention.
|
|
||||||
* @param gameDay A string in "YYYY-MM-DD" format.
|
|
||||||
* @returns A number where Monday is 0 and Sunday is 6.
|
|
||||||
*/
|
|
||||||
export function getDayOfWeekFromGameDay(gameDay: string): number {
|
|
||||||
// Create a date object from the string. Appending 'T00:00:00Z' ensures it's parsed as UTC,
|
|
||||||
// preventing the user's server timezone from shifting the date.
|
|
||||||
const date = new Date(`${gameDay}T00:00:00Z`);
|
|
||||||
const jsDayOfWeek = date.getUTCDay(); // 0 (Sunday) to 6 (Saturday)
|
|
||||||
|
|
||||||
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
|
|
||||||
return (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the previous day from a given "YYYY-MM-DD" string.
|
|
||||||
* @param gameDay A string in "YYYY-MM-DD" format.
|
|
||||||
* @returns The previous day as a "YYYY-MM-DD" string.
|
|
||||||
*/
|
|
||||||
export function getPreviousGameDay(gameDay: string): string {
|
|
||||||
const date = new Date(`${gameDay}T00:00:00Z`);
|
|
||||||
date.setUTCDate(date.getUTCDate() - 1);
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { PrismaClient, User } from '@prisma/client';
|
|
||||||
import { getPreviousGameDay } from '~/server/utils/gameDay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the user's daily visit streak based on a client-provided "Game Day".
|
|
||||||
* It checks for consecutive daily visits and updates the user's streak count.
|
|
||||||
* This function is idempotent and creates a visit record for the provided day.
|
|
||||||
*
|
|
||||||
* @param prisma The Prisma client instance.
|
|
||||||
* @param userId The ID of the user.
|
|
||||||
* @param gameDay The client's current day in "YYYY-MM-DD" format.
|
|
||||||
* @returns The updated User object with the new streak count.
|
|
||||||
*/
|
|
||||||
export async function calculateDailyStreak(db: PrismaClient | Prisma.TransactionClient, userId: number, gameDay: string): Promise<User> {
|
|
||||||
const yesterdayGameDay = getPreviousGameDay(gameDay);
|
|
||||||
|
|
||||||
// 1. Find the user and their most recent visit
|
|
||||||
const [user, lastVisit] = await Promise.all([
|
|
||||||
db.user.findUnique({ where: { id: userId } }),
|
|
||||||
db.dailyVisit.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
let newStreak = user.dailyStreak;
|
|
||||||
|
|
||||||
// 2. Determine the new streak count
|
|
||||||
if (lastVisit) {
|
|
||||||
if (lastVisit.date === gameDay) {
|
|
||||||
// Already visited today, streak doesn't change.
|
|
||||||
newStreak = user.dailyStreak;
|
|
||||||
} else if (lastVisit.date === yesterdayGameDay) {
|
|
||||||
// Visited yesterday, so increment the streak (capped at 3).
|
|
||||||
newStreak = Math.min(user.dailyStreak + 1, 3);
|
|
||||||
} else {
|
|
||||||
// Missed a day, reset streak to 1.
|
|
||||||
newStreak = 1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No previous visits, so this is the first day of the streak.
|
|
||||||
newStreak = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newStreak === 0) {
|
|
||||||
newStreak = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Upsert today's visit record.
|
|
||||||
await db.dailyVisit.upsert({
|
|
||||||
where: { userId_date: { userId, date: gameDay } },
|
|
||||||
update: {},
|
|
||||||
create: { userId, date: gameDay },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Update the user's streak.
|
|
||||||
const updatedUser = await db.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { dailyStreak: newStreak },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Reward {
|
|
||||||
coins: number;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a streak-based multiplier to a given reward.
|
|
||||||
* The multiplier is the streak count, capped at 3x. A streak of 0 is treated as 1x.
|
|
||||||
*
|
|
||||||
* @param reward The base reward object { coins, exp }.
|
|
||||||
* @param streak The user's current daily streak.
|
|
||||||
* @returns The new reward object with the multiplier applied.
|
|
||||||
*/
|
|
||||||
export function applyStreakMultiplier(reward: Reward, streak: number | null | undefined): Reward {
|
|
||||||
const effectiveStreak = streak || 1; // Treat a null/0 streak as 1x
|
|
||||||
const multiplier = Math.max(1, Math.min(effectiveStreak, 3));
|
|
||||||
|
|
||||||
return {
|
|
||||||
coins: reward.coins * multiplier,
|
|
||||||
exp: reward.exp * multiplier,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const OBSTACLE_CLEAR_COST: Record<string, number> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PLANTING_COST = 2; // A small, flat cost for seeds
|
export const PLANTING_COST = 2; // A small, flat cost for seeds
|
||||||
|
export const MOVE_COST = 1; // Cost to move any player-built item
|
||||||
|
|
||||||
// --- Crop Timings (in milliseconds) ---
|
// --- Crop Timings (in milliseconds) ---
|
||||||
export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
||||||
|
|
@ -36,6 +37,12 @@ export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
||||||
CORN: 4 * 60 * 60 * 1000, // 4 hours
|
CORN: 4 * 60 * 60 * 1000, // 4 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Rewards ---
|
||||||
|
export const CROP_HARVEST_REWARD: Record<CropKind, { exp: number, coins: number }> = {
|
||||||
|
BLUEBERRIES: { exp: 5, coins: 0 },
|
||||||
|
CORN: { exp: 10, coins: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a crop is grown based on when it was planted.
|
* Checks if a crop is grown based on when it was planted.
|
||||||
* @param plantedAt The ISO string or Date object when the crop was planted.
|
* @param plantedAt The ISO string or Date object when the crop was planted.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user