работа с деревней по визуалу. Сейчас в рабочм состоянии всё
This commit is contained in:
parent
9838471871
commit
da2d69960d
|
|
@ -7,6 +7,7 @@ interface User {
|
|||
avatar: string | null;
|
||||
coins: number;
|
||||
exp: number;
|
||||
dailyStreak: number;
|
||||
soundOn: boolean;
|
||||
confettiOn: boolean;
|
||||
createdAt: string;
|
||||
|
|
|
|||
12
app/composables/useVisitTracker.ts
Normal file
12
app/composables/useVisitTracker.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// app/composables/useVisitTracker.ts
|
||||
import { ref } from 'vue';
|
||||
|
||||
// This is a simple, client-side, non-persisted state to ensure the
|
||||
// daily visit API call is only made once per application lifecycle.
|
||||
const visitCalled = ref(false);
|
||||
|
||||
export function useVisitTracker() {
|
||||
return {
|
||||
visitCalled,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,25 @@
|
|||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="isAuthenticated && user" class="dashboard-content">
|
||||
|
||||
<h1>Ваши цели на сегодня</h1>
|
||||
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
|
||||
|
||||
<div class="streak-section">
|
||||
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">
|
||||
<h2>x1</h2>
|
||||
<p>Базовые</p>
|
||||
</div>
|
||||
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 2 }">
|
||||
<h2>x2</h2>
|
||||
<p>Двойные</p>
|
||||
</div>
|
||||
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak >= 3 }">
|
||||
<h2>x3</h2>
|
||||
<p>Тройные</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="habits-section">
|
||||
<div v-if="habitsPending">Loading habits...</div>
|
||||
<div v-else-if="habitsError">Could not load habits.</div>
|
||||
|
|
@ -40,11 +56,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<NuxtLink to="/habits" class="button">Manage Habits</NuxtLink>
|
||||
<NuxtLink to="/village" class="button">My Village</NuxtLink>
|
||||
<NuxtLink to="/leaderboard" class="button">Leaderboard</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="welcome-content">
|
||||
<h1>Добро пожаловать в SmurfHabits!</h1>
|
||||
|
|
@ -58,427 +69,153 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
|
||||
|
||||
const { user, isAuthenticated, updateUser } = useAuth();
|
||||
|
||||
const api = useApi();
|
||||
|
||||
|
||||
|
||||
// --- Habits Data ---
|
||||
|
||||
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
|
||||
|
||||
lazy: true,
|
||||
|
||||
server: false,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
// --- Date Logic & Helpers ---
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const todayNormalized = new Date();
|
||||
|
||||
todayNormalized.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
|
||||
const russianDayMap = { 0: 'Вс', 1: 'Пн', 2: 'Вт', 3: 'Ср', 4: 'Чт', 5: 'Пт', 6: 'Сб' };
|
||||
|
||||
|
||||
|
||||
const last14Days = computed(() => {
|
||||
|
||||
const dates = [];
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc.
|
||||
|
||||
|
||||
|
||||
// Adjust so that Monday is 1 and Sunday is 7
|
||||
|
||||
const dayOfWeek = todayDay === 0 ? 7 : todayDay;
|
||||
|
||||
|
||||
|
||||
// Calculate days to subtract to get to the Monday of LAST week
|
||||
|
||||
// (dayOfWeek - 1) gets us to this week's Monday. +7 gets us to last week's Monday.
|
||||
|
||||
const daysToSubtract = (dayOfWeek - 1) + 7;
|
||||
|
||||
|
||||
|
||||
const startDate = new Date();
|
||||
|
||||
startDate.setDate(today.getDate() - daysToSubtract);
|
||||
|
||||
|
||||
|
||||
for (let i = 0; i < 14; i++) {
|
||||
|
||||
const date = new Date(startDate);
|
||||
|
||||
date.setDate(startDate.getDate() + i);
|
||||
|
||||
dates.push(date);
|
||||
|
||||
}
|
||||
|
||||
return dates;
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const formatDayLabel = (date) => {
|
||||
|
||||
// Use Intl for robust localization. 'янв' needs a specific format.
|
||||
|
||||
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
|
||||
|
||||
// The format might include a " г.", remove it.
|
||||
|
||||
return formatted.replace(' г.', '');
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isSameDay = (d1, d2) => {
|
||||
|
||||
d1 = new Date(d1);
|
||||
|
||||
d2 = new Date(d2);
|
||||
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
|
||||
d1.getDate() === d2.getDate();
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isCompleted = (habit, date) => {
|
||||
|
||||
if (!habit || !habit.completions) return false;
|
||||
|
||||
return habit.completions.some(c => isSameDay(c.date, date));
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getCellClasses = (habit, day) => {
|
||||
|
||||
const classes = {};
|
||||
|
||||
const dayNormalized = new Date(day);
|
||||
|
||||
dayNormalized.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
|
||||
const habitCreatedAt = new Date(habit.createdAt);
|
||||
|
||||
habitCreatedAt.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
|
||||
// Is the day in the future?
|
||||
|
||||
|
||||
|
||||
if (dayNormalized > todayNormalized) {
|
||||
|
||||
|
||||
|
||||
classes['future-day'] = true;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Is the day today?
|
||||
|
||||
|
||||
|
||||
if (isSameDay(dayNormalized, todayNormalized)) {
|
||||
|
||||
|
||||
|
||||
classes['today-highlight'] = true;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Is the habit scheduled for this day?
|
||||
|
||||
|
||||
|
||||
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
|
||||
|
||||
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
|
||||
|
||||
if (isScheduled) {
|
||||
|
||||
classes['scheduled-day'] = true;
|
||||
|
||||
if (dayNormalized > todayNormalized) {
|
||||
classes['future-day'] = true;
|
||||
}
|
||||
|
||||
if (isSameDay(dayNormalized, todayNormalized)) {
|
||||
classes['today-highlight'] = true;
|
||||
}
|
||||
|
||||
|
||||
// Is the habit completed on this day?
|
||||
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
|
||||
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
|
||||
if (isScheduled) {
|
||||
classes['scheduled-day'] = true;
|
||||
}
|
||||
|
||||
if (isCompleted(habit, dayNormalized)) {
|
||||
|
||||
classes['completed'] = true;
|
||||
|
||||
return classes; // Completion state overrides all others
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Is it a missed day?
|
||||
|
||||
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
|
||||
|
||||
classes['missed-day'] = true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return classes;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isScheduledForToday = (habit) => {
|
||||
|
||||
const todayDay = today.getDay(); // Sunday is 0
|
||||
|
||||
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
|
||||
|
||||
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
|
||||
|
||||
return habit.daysOfWeek.includes(appDayOfWeek);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const getScheduleText = (habit) => {
|
||||
|
||||
if (habit.daysOfWeek.length === 7) {
|
||||
|
||||
return 'каждый день';
|
||||
|
||||
}
|
||||
|
||||
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
|
||||
|
||||
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --- Actions & UI State ---
|
||||
|
||||
const isSubmittingHabit = ref(false);
|
||||
|
||||
const explodingHabitId = ref(null);
|
||||
|
||||
|
||||
|
||||
const completeHabit = async (habitId, event) => {
|
||||
|
||||
|
||||
|
||||
if (event) {
|
||||
|
||||
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isSubmittingHabit.value) return;
|
||||
|
||||
|
||||
|
||||
isSubmittingHabit.value = true;
|
||||
|
||||
|
||||
|
||||
try {
|
||||
|
||||
|
||||
|
||||
// The API returns the updated user stats and reward info
|
||||
|
||||
|
||||
|
||||
const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Update the global user state for coins and exp
|
||||
|
||||
|
||||
|
||||
if (updateUser && response) {
|
||||
|
||||
|
||||
|
||||
updateUser({
|
||||
|
||||
|
||||
|
||||
coins: response.updatedCoins,
|
||||
|
||||
|
||||
|
||||
exp: response.updatedExp,
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Optimistically update the local habit state to show completion
|
||||
|
||||
|
||||
|
||||
const habit = habits.value.find(h => h.id === habitId);
|
||||
|
||||
|
||||
|
||||
if (habit) {
|
||||
|
||||
|
||||
|
||||
habit.completions.push({
|
||||
|
||||
|
||||
|
||||
id: Math.random(), // Temporary ID for the key
|
||||
|
||||
|
||||
|
||||
id: Math.random(),
|
||||
habitId: habitId,
|
||||
|
||||
|
||||
|
||||
date: new Date().toISOString(),
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Trigger confetti
|
||||
|
||||
|
||||
|
||||
explodingHabitId.value = habitId;
|
||||
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
|
||||
|
||||
explodingHabitId.value = null;
|
||||
|
||||
|
||||
|
||||
}, 1000); // Animation duration
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
|
||||
|
||||
|
||||
alert(err.data?.message || 'Failed to complete habit.');
|
||||
|
||||
|
||||
|
||||
} finally {
|
||||
|
||||
|
||||
|
||||
isSubmittingHabit.value = false;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -487,6 +224,53 @@ const completeHabit = async (habitId, event) => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.streak-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px; /* Reduced gap */
|
||||
margin-top: 20px; /* Added margin-top to separate from paragraph */
|
||||
margin-bottom: 30px; /* Slightly reduced margin-bottom */
|
||||
}
|
||||
|
||||
.streak-card {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 10px; /* Slightly reduced border-radius */
|
||||
padding: 10px 15px; /* Reduced padding */
|
||||
width: 100px; /* Reduced width */
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9em; /* Reduced base font size */
|
||||
}
|
||||
|
||||
.streak-card h2 {
|
||||
margin: 0 0 5px 0; /* Reduced margin */
|
||||
font-size: 1.8em; /* Reduced font size */
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.streak-card p {
|
||||
margin: 0;
|
||||
font-size: 0.8em; /* Reduced font size */
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.active-streak {
|
||||
border-color: #81a1c1;
|
||||
background-color: #eceff4;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Reduced shadow */
|
||||
transform: translateY(-3px); /* Reduced transform */
|
||||
}
|
||||
|
||||
.active-streak h2 {
|
||||
color: #4c566a;
|
||||
}
|
||||
|
||||
.active-streak p {
|
||||
color: #3b4252;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.habits-section {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
|
|
@ -509,8 +293,7 @@ const completeHabit = async (habitId, event) => {
|
|||
}
|
||||
|
||||
.day-cell {
|
||||
/* Removed fixed width/height for responsiveness */
|
||||
aspect-ratio: 1 / 1; /* Keep cells square */
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
|
@ -520,22 +303,22 @@ const completeHabit = async (habitId, event) => {
|
|||
}
|
||||
|
||||
.day-cell.completed {
|
||||
background-color: #4ade80; /* Green for completed */
|
||||
background-color: #4ade80;
|
||||
color: white;
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
.day-cell.missed-day {
|
||||
background-color: #feecf0; /* Light red for missed */
|
||||
background-color: #feecf0;
|
||||
}
|
||||
|
||||
.day-cell.scheduled-day {
|
||||
border-width: 2px;
|
||||
border-color: #81a1c1; /* Blueish accent for scheduled */
|
||||
border-color: #81a1c1;
|
||||
}
|
||||
|
||||
.future-day .day-label {
|
||||
color: #adb5bd; /* Muted color for future days */
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.day-cell.today-highlight .day-label {
|
||||
|
|
@ -544,7 +327,7 @@ const completeHabit = async (habitId, event) => {
|
|||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 0.9em; /* Make font size relative to parent for responsiveness */
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
|
|
@ -615,8 +398,8 @@ const completeHabit = async (habitId, event) => {
|
|||
|
||||
/* New Habit Card Styles */
|
||||
.habit-card {
|
||||
position: relative; /* For confetti positioning */
|
||||
overflow: hidden; /* Hide confetti that flies out of bounds */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.habit-header {
|
||||
|
|
@ -656,7 +439,7 @@ const completeHabit = async (habitId, event) => {
|
|||
|
||||
.completed-text {
|
||||
font-weight: bold;
|
||||
color: #28a745; /* Joyful green */
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Confetti Animation */
|
||||
|
|
@ -703,7 +486,7 @@ const completeHabit = async (habitId, event) => {
|
|||
.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; }
|
||||
.confetti-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -27,34 +27,54 @@
|
|||
<!-- Tile Info Overlay -->
|
||||
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
|
||||
<div class="tile-overlay-panel" @click.stop>
|
||||
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
|
||||
<p>Terrain: {{ selectedTile.terrainType }}</p>
|
||||
<p v-if="selectedTile.object">Object: {{ selectedTile.object.type }}</p>
|
||||
|
||||
<h3>Available Actions</h3>
|
||||
<div class="actions-list">
|
||||
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||
<button
|
||||
:disabled="!action.isEnabled || isSubmitting"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
{{ getActionLabel(action) }}
|
||||
</button>
|
||||
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||
<h2>{{ getTileTitle(selectedTile) }}</h2>
|
||||
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
|
||||
|
||||
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
|
||||
<h3 class="actions-header">Что здесь можно сделать?</h3>
|
||||
<div class="actions-list">
|
||||
<!-- Build Actions -->
|
||||
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
|
||||
<div class="build-card-grid">
|
||||
<div
|
||||
v-for="action in selectedTile.availableActions.filter(a => a.type === 'BUILD')"
|
||||
:key="action.buildingType"
|
||||
class="building-card"
|
||||
:class="{ disabled: !action.isEnabled }"
|
||||
>
|
||||
<div class="building-icon">{{ getBuildingEmoji(action.buildingType) }}</div>
|
||||
<h5>{{ getBuildingName(action.buildingType) }}</h5>
|
||||
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
|
||||
<div class="building-footer">
|
||||
<div class="cost">
|
||||
{{ action.cost }} монет
|
||||
</div>
|
||||
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)">
|
||||
{{ getActionLabel(action) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!action.isEnabled" class="disabled-overlay">
|
||||
<span>{{ getDisabledReasonText(action) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="selectedTile = null" class="close-overlay-button">Close</button>
|
||||
|
||||
<button @click="selectedTile = null" class="close-overlay-button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||||
<h3>Admin Tools</h3>
|
||||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||||
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
|
||||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||||
</div>
|
||||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||||
<h3>Admin Tools</h3>
|
||||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||||
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
|
||||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||||
</div>
|
||||
<!-- Event Log -->
|
||||
<div v-if="villageEvents?.length" class="event-log-container">
|
||||
<h4>Activity Log</h4>
|
||||
|
|
@ -121,11 +141,86 @@ const selectTile = (tile) => {
|
|||
|
||||
const getActionLabel = (action) => {
|
||||
if (action.type === 'BUILD') {
|
||||
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
|
||||
return `Построить`;
|
||||
}
|
||||
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] || 'Неизвестное строение'} (${tile.x}, ${tile.y})`;
|
||||
}
|
||||
const terrainMap = {
|
||||
'BLOCKED_TREE': 'Лесной участок',
|
||||
'BLOCKED_STONE': 'Каменистый участок',
|
||||
'EMPTY': 'Пустырь',
|
||||
};
|
||||
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} (${tile.x}, ${tile.y})`;
|
||||
};
|
||||
|
||||
const getTileDescription = (tile) => {
|
||||
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
|
||||
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
|
||||
|
||||
const descriptionMap = {
|
||||
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
|
||||
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
|
||||
'EMPTY': 'Свободное место, готовое к застройке.',
|
||||
};
|
||||
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
|
||||
};
|
||||
|
||||
const getBuildingDescription = (buildingType) => {
|
||||
const descriptions = {
|
||||
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
|
||||
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
|
||||
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
|
||||
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
|
||||
'WELL': 'Увеличивает производство опыта на соседних полях.',
|
||||
};
|
||||
return descriptions[buildingType] || '';
|
||||
};
|
||||
|
||||
const getDisabledReasonText = (action) => {
|
||||
const reasons = {
|
||||
'Not enough coins': 'Не хватает монет.',
|
||||
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
|
||||
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
|
||||
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
|
||||
};
|
||||
return reasons[action.disabledReason] || action.disabledReason;
|
||||
};
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const handleActionClick = async (action) => {
|
||||
|
|
@ -140,7 +235,6 @@ const handleActionClick = async (action) => {
|
|||
actionType: action.type,
|
||||
payload: {
|
||||
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
|
||||
...(action.type === 'MOVE' && { toTileId: action.toTileId }), // Assuming action.toTileId will be present for MOVE
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -171,7 +265,6 @@ async function handleAdminAction(url) {
|
|||
if (error.value) {
|
||||
alert(error.value.data?.statusMessage || 'An admin action failed.');
|
||||
} else {
|
||||
// Refresh both data sources in parallel
|
||||
await Promise.all([refreshVillageData(), refreshEvents()]);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -194,8 +287,8 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
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 */
|
||||
min-height: calc(100vh - 120px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading, .error-container {
|
||||
|
|
@ -206,14 +299,14 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
|
||||
.village-container {
|
||||
display: flex;
|
||||
justify-content: center; /* Center grid */
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 350px; /* Adjust max-width for mobile view of grid (5*60px + 4*4px gap + 2*4px padding)*/
|
||||
max-width: 350px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.village-grid-wrapper {
|
||||
overflow-x: auto; /* In case grid ever exceeds viewport (though it shouldn't with max-width) */
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.village-grid {
|
||||
|
|
@ -224,8 +317,8 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
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 */
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tile {
|
||||
|
|
@ -253,7 +346,6 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
font-size: 2em;
|
||||
}
|
||||
|
||||
/* Overlay Styles */
|
||||
.tile-overlay-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
@ -263,32 +355,32 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end; /* Start from bottom for mobile-first feel */
|
||||
align-items: flex-end;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tile-overlay-panel {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
max-width: 500px; /* Limit width for larger screens */
|
||||
max-width: 500px;
|
||||
padding: 20px;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(0); /* For potential slide-in animation */
|
||||
transform: translateY(0);
|
||||
transition: transform 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) { /* Center for desktop, less "bottom sheet" */
|
||||
@media (min-width: 768px) {
|
||||
.tile-overlay-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
.tile-overlay-panel {
|
||||
border-radius: 15px;
|
||||
max-height: 80vh; /* Don't cover entire screen */
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,9 +390,22 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
color: #333;
|
||||
}
|
||||
|
||||
.tile-overlay-panel p {
|
||||
.tile-description {
|
||||
text-align: center;
|
||||
margin-top: -10px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
color: #444;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
|
|
@ -309,6 +414,10 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.action-item button {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
|
|
@ -332,6 +441,25 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
border-color: #e9ecef;
|
||||
}
|
||||
|
||||
.build-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.build-section h4 {
|
||||
text-align: center;
|
||||
color: #555;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.building-description {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disabled-reason {
|
||||
font-size: 0.8em;
|
||||
color: #dc3545;
|
||||
|
|
@ -413,4 +541,92 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
|
|||
.event-log-table th {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* New Build Card Styles */
|
||||
.build-card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.building-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 130px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.building-card:not(.disabled):hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.building-card.disabled {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.building-icon {
|
||||
font-size: 2em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.building-card h5 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.building-card .building-description {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
flex-grow: 1; /* Pushes footer down */
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.building-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: auto; /* Pushes footer to bottom */
|
||||
}
|
||||
|
||||
.building-footer .cost {
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.building-footer button {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.disabled-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(233, 236, 239, 0.8);
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled-overlay span {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,12 +1,28 @@
|
|||
// /middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { isAuthenticated, initialized } = useAuth();
|
||||
// /middleware/auth.global.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { isAuthenticated, initialized, updateUser } = useAuth();
|
||||
const { visitCalled } = useVisitTracker();
|
||||
const api = useApi();
|
||||
|
||||
// Do not run middleware until auth state is initialized on client-side
|
||||
if (!initialized.value) {
|
||||
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 {
|
||||
const updatedUser = await api('/api/user/visit', { method: 'POST' });
|
||||
if (updatedUser) {
|
||||
updateUser(updatedUser);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to register daily visit from middleware:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// if the user is authenticated and tries to access /login, redirect to home
|
||||
if (isAuthenticated.value && to.path === '/login') {
|
||||
return navigateTo('/', { replace: true });
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<!-- /pages/index.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p v-if="user">Welcome, {{ user.nickname }}!</p>
|
||||
<button @click="logout">Logout</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { user, logout } = useAuth();
|
||||
</script>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<!-- /pages/login.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<h1>Login or Register</h1>
|
||||
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()">
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" v-model="email" required />
|
||||
</div>
|
||||
<div v-if="isRegistering">
|
||||
<label for="nickname">Nickname</label>
|
||||
<input type="text" id="nickname" v-model="nickname" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" v-model="password" required />
|
||||
</div>
|
||||
<div v-if="error">{{ error }}</div>
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ isRegistering ? 'Register' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
<button @click="isRegistering = !isRegistering">
|
||||
{{ isRegistering ? 'Switch to Login' : 'Switch to Register' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default', // Using the default layout
|
||||
});
|
||||
|
||||
const { login, register, loading } = useAuth();
|
||||
|
||||
const isRegistering = ref(false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const nickname = ref('');
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = null;
|
||||
try {
|
||||
await login(email.value, password.value);
|
||||
} catch (e: any) {
|
||||
error.value = e.data?.message || 'Login failed.';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
error.value = null;
|
||||
try {
|
||||
await register(email.value, password.value, nickname.value);
|
||||
// On successful registration, switch to the login view
|
||||
isRegistering.value = false;
|
||||
// You might want to auto-login or show a success message instead
|
||||
} catch (e: any) {
|
||||
error.value = e.data?.message || 'Registration failed.';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<template>
|
||||
<div class="village-page">
|
||||
<h1>My Village</h1>
|
||||
|
||||
<div v-if="pending" class="loading">Loading your village...</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
|
||||
<p v-else>An error occurred while fetching your village data. Please try again.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="villageData" class="village-container">
|
||||
<div class="village-grid">
|
||||
<div
|
||||
v-for="tile in villageData.tiles"
|
||||
:key="tile.id"
|
||||
class="tile"
|
||||
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
|
||||
@click="selectTile(tile)"
|
||||
>
|
||||
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTile" class="action-panel">
|
||||
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
|
||||
<div class="actions-list">
|
||||
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||
<button
|
||||
:disabled="!action.isEnabled"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
{{ getActionLabel(action) }}
|
||||
</button>
|
||||
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="selectedTile = null" class="close-panel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { data: villageData, pending, error } = await useFetch('/api/village', {
|
||||
lazy: true,
|
||||
server: false, // Ensure this runs on the client-side
|
||||
});
|
||||
|
||||
const selectedTile = ref(null);
|
||||
|
||||
const getTileEmoji = (tile) => {
|
||||
if (tile.terrainState === 'CLEARING') return '⏳';
|
||||
if (tile.object) {
|
||||
switch (tile.object.type) {
|
||||
case 'HOUSE': return '🏠';
|
||||
case 'FIELD': return '🌱';
|
||||
case 'LUMBERJACK': return '🪓';
|
||||
case 'QUARRY': return '⛏️';
|
||||
case 'WELL': return '💧';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
switch (tile.terrainType) {
|
||||
case 'BLOCKED_TREE': return '🌳';
|
||||
case 'BLOCKED_STONE': return '🪨';
|
||||
case 'EMPTY': return '⬜';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const selectTile = (tile) => {
|
||||
selectedTile.value = tile;
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
if (action.type === 'BUILD') {
|
||||
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
|
||||
}
|
||||
return action.type;
|
||||
};
|
||||
|
||||
const handleActionClick = (action) => {
|
||||
console.log('Action clicked:', action);
|
||||
// In a future task, this will dispatch the action to the backend.
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.village-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.loading, .error-container {
|
||||
margin-top: 50px;
|
||||
font-size: 1.2em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.village-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.village-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 60px);
|
||||
grid-template-rows: repeat(7, 60px);
|
||||
gap: 4px;
|
||||
border: 2px solid #333;
|
||||
padding: 4px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.tile.selected {
|
||||
border: 2px solid #007bff;
|
||||
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
.tile-content {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.action-panel {
|
||||
width: 300px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 20px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.action-panel h2 {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-item button {
|
||||
padding: 10px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.action-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: #eee;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.disabled-reason {
|
||||
font-size: 0.8em;
|
||||
color: #d9534f;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.close-panel {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"nickname" TEXT,
|
||||
"avatar" TEXT DEFAULT '/avatars/default.png',
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"exp" INTEGER NOT NULL DEFAULT 0,
|
||||
"dailyStreak" INTEGER NOT NULL DEFAULT 0,
|
||||
"soundOn" BOOLEAN NOT NULL DEFAULT true,
|
||||
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -47,6 +47,7 @@ model User {
|
|||
|
||||
coins Int @default(0)
|
||||
exp Int @default(0)
|
||||
dailyStreak Int @default(0)
|
||||
|
||||
// User settings
|
||||
soundOn Boolean @default(true)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => {
|
|||
avatar: user.avatar,
|
||||
coins: user.coins,
|
||||
exp: user.exp,
|
||||
dailyStreak: user.dailyStreak,
|
||||
soundOn: user.soundOn,
|
||||
confettiOn: user.confettiOn,
|
||||
createdAt: user.createdAt,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { REWARDS } from '../../../utils/economy';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from '../../../utils/prisma';
|
||||
import { applyStreakMultiplier } from '../../../utils/streak';
|
||||
|
||||
interface CompletionResponse {
|
||||
message: string;
|
||||
reward: {
|
||||
coins: number;
|
||||
exp: number; // Added
|
||||
exp: number;
|
||||
};
|
||||
updatedCoins: number;
|
||||
updatedExp: number; // Added
|
||||
updatedExp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Helper to get the start of the day in UTC
|
||||
function getStartOfDay(date: Date): Date {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||
return startOfDay;
|
||||
const d = new Date(date);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
|
||||
|
|
@ -32,10 +28,15 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
|
||||
}
|
||||
|
||||
const habit = await prisma.habit.findFirst({
|
||||
where: { id: habitId, userId },
|
||||
});
|
||||
// Fetch user and habit in parallel
|
||||
const [user, habit] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.habit.findFirst({ where: { id: habitId, userId } })
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'User not found.' });
|
||||
}
|
||||
if (!habit) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
|
||||
}
|
||||
|
|
@ -50,13 +51,12 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
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 startOfToday = getStartOfDay(today);
|
||||
|
||||
const existingCompletion = await prisma.habitCompletion.findFirst({
|
||||
where: {
|
||||
habitId: habitId,
|
||||
date: startOfToday, // Use precise equality check
|
||||
date: startOfToday,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -64,25 +64,27 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
||||
}
|
||||
|
||||
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
|
||||
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
|
||||
// Apply the streak multiplier to the base reward
|
||||
const baseReward = REWARDS.HABITS.COMPLETION;
|
||||
const finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
|
||||
|
||||
const village = await prisma.village.findUnique({ where: { userId } });
|
||||
|
||||
const [, updatedUser] = await prisma.$transaction([
|
||||
prisma.habitCompletion.create({
|
||||
data: {
|
||||
habitId: habitId,
|
||||
date: startOfToday, // Save the normalized date
|
||||
date: startOfToday,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coins: {
|
||||
increment: rewardCoins,
|
||||
increment: finalReward.coins,
|
||||
},
|
||||
exp: { // Added
|
||||
increment: rewardExp, // Added
|
||||
exp: {
|
||||
increment: finalReward.exp,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -90,20 +92,17 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
data: {
|
||||
villageId: village.id,
|
||||
type: 'HABIT_COMPLETION',
|
||||
message: `Completed habit: "${habit.name}"`,
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Changed from 0 to rewardExp
|
||||
message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) ${user.dailyStreak === 2 ? 'удвоила' : 'утроила'} награду!` : ''}`,
|
||||
coins: finalReward.coins,
|
||||
exp: finalReward.exp,
|
||||
}
|
||||
})] : []),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Habit completed successfully!',
|
||||
reward: {
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Added
|
||||
},
|
||||
reward: finalReward,
|
||||
updatedCoins: updatedUser.coins,
|
||||
updatedExp: updatedUser.exp, // Added
|
||||
updatedExp: updatedUser.exp,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { REWARDS } from '../../utils/economy';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface DailyVisitResponse {
|
||||
message: string;
|
||||
reward: {
|
||||
coins: number;
|
||||
streakBonus: boolean;
|
||||
};
|
||||
updatedCoins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Date object for the start of a given day in UTC.
|
||||
*/
|
||||
function getStartOfDay(date: Date): Date {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||
return startOfDay;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<DailyVisitResponse> => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
const today = getStartOfDay(new Date());
|
||||
|
||||
// 1. Check if the user has already claimed the reward today
|
||||
const existingVisit = await prisma.dailyVisit.findUnique({
|
||||
where: {
|
||||
userId_date: {
|
||||
userId,
|
||||
date: today,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingVisit) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Daily visit reward has already been claimed today.',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check for a 5-day consecutive streak (i.e., visits on the 4 previous days)
|
||||
const previousDates = Array.from({ length: 4 }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - (i + 1));
|
||||
return d;
|
||||
});
|
||||
|
||||
const priorVisitsCount = await prisma.dailyVisit.count({
|
||||
where: {
|
||||
userId,
|
||||
date: {
|
||||
in: previousDates,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasStreak = priorVisitsCount === 4;
|
||||
|
||||
// 3. Calculate rewards and update the database in a transaction
|
||||
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
|
||||
let message = 'Daily visit claimed!';
|
||||
if (hasStreak) {
|
||||
totalReward += REWARDS.QUESTS.DAILY_VISIT.STREAK_BONUS.coins;
|
||||
message = 'Daily visit and streak bonus claimed!';
|
||||
}
|
||||
|
||||
const village = await prisma.village.findUnique({ where: { userId } });
|
||||
|
||||
const [, updatedUser] = await prisma.$transaction([
|
||||
prisma.dailyVisit.create({
|
||||
data: {
|
||||
userId,
|
||||
date: today,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coins: {
|
||||
increment: totalReward,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(village ? [prisma.villageEvent.create({
|
||||
data: {
|
||||
villageId: village.id,
|
||||
type: 'QUEST_DAILY_VISIT',
|
||||
message,
|
||||
coins: totalReward,
|
||||
exp: 0,
|
||||
}
|
||||
})] : []),
|
||||
]);
|
||||
|
||||
// 4. Return the response
|
||||
return {
|
||||
message,
|
||||
reward: {
|
||||
coins: totalReward,
|
||||
streakBonus: hasStreak,
|
||||
},
|
||||
updatedCoins: updatedUser.coins,
|
||||
};
|
||||
});
|
||||
31
server/api/user/visit.post.ts
Normal file
31
server/api/user/visit.post.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { calculateDailyStreak } from '../../utils/streak';
|
||||
import prisma from '../../utils/prisma';
|
||||
|
||||
/**
|
||||
* Registers a user's daily visit and calculates their new streak.
|
||||
* This endpoint is idempotent. Calling it multiple times on the same day
|
||||
* will not increment the streak further.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getUserIdFromSession(event);
|
||||
|
||||
// Calculate the streak and create today's visit record
|
||||
const updatedUser = await calculateDailyStreak(prisma, userId);
|
||||
|
||||
// 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,6 +1,6 @@
|
|||
// server/api/village/action.post.ts
|
||||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { buildOnTile, clearTile, moveObject, removeObject } from '../../services/villageService';
|
||||
import { buildOnTile } from '../../services/villageService';
|
||||
import { getVillageState } from '../../services/villageService';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -21,21 +21,6 @@ export default defineEventHandler(async (event) => {
|
|||
await buildOnTile(userId, tileId, payload.buildingType);
|
||||
break;
|
||||
|
||||
case 'CLEAR':
|
||||
await clearTile(userId, tileId);
|
||||
break;
|
||||
|
||||
case 'MOVE':
|
||||
if (!payload?.toTileId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing toTileId for MOVE action' });
|
||||
}
|
||||
await moveObject(userId, tileId, payload.toTileId);
|
||||
break;
|
||||
|
||||
case 'REMOVE':
|
||||
await removeObject(userId, tileId);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ type FullVillage = Prisma.VillageGetPayload<{
|
|||
*/
|
||||
export async function getVillageState(userId: number): Promise<FullVillage> {
|
||||
const now = new Date();
|
||||
const { applyStreakMultiplier } = await import('../utils/streak');
|
||||
|
||||
// --- Step 1: Initial Snapshot ---
|
||||
let villageSnapshot = await prisma.village.findUnique({
|
||||
|
|
@ -104,14 +105,20 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
|
||||
}
|
||||
|
||||
const userForStreak = villageSnapshot.user;
|
||||
|
||||
// --- Step 2: Terrain Cleaning Completion ---
|
||||
const finishedClearingTiles = villageSnapshot.tiles.filter(
|
||||
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
|
||||
);
|
||||
|
||||
if (finishedClearingTiles.length > 0) {
|
||||
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
|
||||
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
|
||||
// Apply streak multiplier to clearing rewards
|
||||
const baseClearingReward = REWARDS.VILLAGE.CLEARING;
|
||||
const finalClearingReward = applyStreakMultiplier(baseClearingReward, userForStreak.dailyStreak);
|
||||
|
||||
const totalCoins = finishedClearingTiles.length * finalClearingReward.coins;
|
||||
const totalExp = finishedClearingTiles.length * finalClearingReward.exp;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Update user totals
|
||||
|
|
@ -129,17 +136,28 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
|
||||
});
|
||||
|
||||
// 3. Create an event for each completed tile
|
||||
// 3. Create an event for each completed tile with the final reward
|
||||
const multiplier = userForStreak.dailyStreak;
|
||||
let streakBonusText = '';
|
||||
if (multiplier === 2) {
|
||||
streakBonusText = ' Ваша серия визитов (x2) удвоила награду!';
|
||||
} else if (multiplier >= 3) {
|
||||
streakBonusText = ' Ваша серия визитов (x3) утроила награду!';
|
||||
}
|
||||
|
||||
for (const tile of finishedClearingTiles) {
|
||||
const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень';
|
||||
const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун';
|
||||
|
||||
await tx.villageEvent.create({
|
||||
data: {
|
||||
villageId: villageSnapshot.id,
|
||||
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
|
||||
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
|
||||
message: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`,
|
||||
tileX: tile.x,
|
||||
tileY: tile.y,
|
||||
coins: REWARDS.VILLAGE.CLEARING.coins,
|
||||
exp: REWARDS.VILLAGE.CLEARING.exp,
|
||||
coins: finalClearingReward.coins,
|
||||
exp: finalClearingReward.exp,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -161,19 +179,33 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
const eventsToCreate = [];
|
||||
|
||||
for (const field of fieldsForExp) {
|
||||
let fieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
|
||||
// First, calculate base EXP with existing game logic (well bonus)
|
||||
let baseFieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
|
||||
if (wellPositions.has(`${field.tile.x},${field.tile.y - 1}`) || wellPositions.has(`${field.tile.x},${field.tile.y + 1}`) || wellPositions.has(`${field.tile.x - 1},${field.tile.y}`) || wellPositions.has(`${field.tile.x + 1},${field.tile.y}`)) {
|
||||
fieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
|
||||
baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
|
||||
}
|
||||
totalExpFromFields += fieldExp;
|
||||
|
||||
// Now, apply the daily streak multiplier
|
||||
const finalFieldExp = applyStreakMultiplier({ coins: 0, exp: baseFieldExp }, userForStreak.dailyStreak).exp;
|
||||
|
||||
totalExpFromFields += finalFieldExp;
|
||||
|
||||
const multiplier = userForStreak.dailyStreak;
|
||||
let streakBonusText = '';
|
||||
if (multiplier === 2) {
|
||||
streakBonusText = ' Ваша серия визитов (x2) удвоила урожай опыта!';
|
||||
} else if (multiplier >= 3) {
|
||||
streakBonusText = ' Ваша серия визитов (x3) утроила урожай опыта!';
|
||||
}
|
||||
|
||||
eventsToCreate.push({
|
||||
villageId: villageSnapshot.id,
|
||||
type: 'FIELD_EXP',
|
||||
message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
|
||||
message: `Поле (${field.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`,
|
||||
tileX: field.tile.x,
|
||||
tileY: field.tile.y,
|
||||
coins: 0,
|
||||
exp: fieldExp,
|
||||
exp: finalFieldExp,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -251,17 +283,6 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
const tilesWithActions = finalVillageState.tiles.map(tile => {
|
||||
const availableActions: any[] = [];
|
||||
|
||||
// Action: CLEAR
|
||||
if (tile.terrainState === 'IDLE' && (tile.terrainType === 'BLOCKED_STONE' || tile.terrainType === 'BLOCKED_TREE')) {
|
||||
const canClearTree = tile.terrainType === 'BLOCKED_TREE' && hasLumberjack;
|
||||
const canClearStone = tile.terrainType === 'BLOCKED_STONE' && hasQuarry;
|
||||
availableActions.push({
|
||||
type: 'CLEAR',
|
||||
isEnabled: canClearTree || canClearStone,
|
||||
disabledReason: !(canClearTree || canClearStone) ? `Requires ${tile.terrainType === 'BLOCKED_TREE' ? 'Lumberjack' : 'Quarry'}` : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Action: BUILD
|
||||
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
||||
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
||||
|
|
@ -288,19 +309,7 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
}
|
||||
|
||||
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,
|
||||
});
|
||||
// MOVE and REMOVE actions have been removed as per the refactor request.
|
||||
}
|
||||
|
||||
return { ...tile, availableActions };
|
||||
|
|
@ -372,53 +381,7 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
|
|||
exp: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearTile(userId: number, tileId: number) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const tile = await tx.villageTile.findUniqueOrThrow({
|
||||
where: { id: tileId },
|
||||
include: { village: { include: { objects: true } } },
|
||||
});
|
||||
|
||||
if (tile.village.userId !== userId) {
|
||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||
}
|
||||
|
||||
if (tile.terrainState !== 'IDLE') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
|
||||
}
|
||||
|
||||
if (tile.terrainType === 'BLOCKED_TREE') {
|
||||
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
|
||||
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
|
||||
} else if (tile.terrainType === 'BLOCKED_STONE') {
|
||||
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
|
||||
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
|
||||
} else {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
|
||||
}
|
||||
|
||||
await tx.villageTile.update({
|
||||
where: { id: tileId },
|
||||
data: {
|
||||
terrainState: 'CLEARING',
|
||||
clearingStartedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeObject(userId: number, tileId: number) {
|
||||
// As requested, this is a stub for now.
|
||||
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
|
||||
}
|
||||
|
||||
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
|
||||
// As requested, this is a stub for now.
|
||||
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
108
server/utils/streak.ts
Normal file
108
server/utils/streak.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { PrismaClient, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the user's daily visit streak.
|
||||
* It checks for consecutive daily visits and updates the user's streak count.
|
||||
* This function is idempotent and creates a visit record for the current day.
|
||||
*
|
||||
* @param prisma The Prisma client instance.
|
||||
* @param userId The ID of the user.
|
||||
* @returns The updated User object with the new streak count.
|
||||
*/
|
||||
export async function calculateDailyStreak(prisma: PrismaClient, userId: number): Promise<User> {
|
||||
const today = getStartOfDay(new Date());
|
||||
const yesterday = getStartOfDay(new Date());
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
|
||||
// 1. Find the user and their most recent visit
|
||||
const [user, lastVisit] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.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) {
|
||||
const lastVisitDate = getStartOfDay(new Date(lastVisit.date));
|
||||
|
||||
if (lastVisitDate.getTime() === today.getTime()) {
|
||||
// Already visited today, streak doesn't change.
|
||||
newStreak = user.dailyStreak;
|
||||
} else if (lastVisitDate.getTime() === yesterday.getTime()) {
|
||||
// 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. Use upsert to create today's visit record and update the user's streak in a transaction
|
||||
const [, updatedUser] = await prisma.$transaction([
|
||||
prisma.dailyVisit.upsert({
|
||||
where: { userId_date: { userId, date: today } },
|
||||
update: {},
|
||||
create: { userId, date: today },
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { dailyStreak: newStreak },
|
||||
}),
|
||||
]);
|
||||
|
||||
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.
|
||||
*
|
||||
* @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 || 0;
|
||||
const multiplier = Math.max(1, Math.min(effectiveStreak, 3));
|
||||
|
||||
if (multiplier === 0) {
|
||||
return {
|
||||
coins: reward.coins * 1,
|
||||
exp: reward.exp * 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
coins: reward.coins * multiplier,
|
||||
exp: reward.exp * multiplier,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ export const OBSTACLE_CLEAR_COST: Record<string, number> = {
|
|||
};
|
||||
|
||||
export const PLANTING_COST = 2; // A small, flat cost for seeds
|
||||
export const MOVE_COST = 1; // Cost to move any player-built item
|
||||
|
||||
// --- Crop Timings (in milliseconds) ---
|
||||
export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
||||
|
|
@ -37,12 +36,6 @@ export const CROP_GROWTH_TIME: Record<CropKind, number> = {
|
|||
CORN: 4 * 60 * 60 * 1000, // 4 hours
|
||||
};
|
||||
|
||||
// --- Rewards ---
|
||||
export const CROP_HARVEST_REWARD: Record<CropKind, { exp: number, coins: number }> = {
|
||||
BLUEBERRIES: { exp: 5, coins: 0 },
|
||||
CORN: { exp: 10, coins: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a crop is grown based on when it was planted.
|
||||
* @param plantedAt The ISO string or Date object when the crop was planted.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user