работа с главной по визуалу. приложение работоспособно
This commit is contained in:
parent
a8241d93c6
commit
9838471871
|
|
@ -64,6 +64,12 @@ export function useAuth() {
|
|||
}
|
||||
};
|
||||
|
||||
const updateUser = (partialUser: Partial<User>) => {
|
||||
if (user.value) {
|
||||
user.value = { ...user.value, ...partialUser };
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the state and methods.
|
||||
return {
|
||||
user,
|
||||
|
|
@ -72,5 +78,6 @@ export function useAuth() {
|
|||
fetchMe,
|
||||
login,
|
||||
logout,
|
||||
updateUser,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
<header v-if="isAuthenticated" class="top-bar">
|
||||
<div class="user-info-top">
|
||||
<span>{{ user.nickname }}</span>
|
||||
<span>💰 {{ user.coins }}</span>
|
||||
<span>✨ {{ user.exp }}</span>
|
||||
<span>💰 {{ displayedCoins }}</span>
|
||||
<span>✨ {{ displayedExp }}</span>
|
||||
<button @click="handleLogout" class="logout-button">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -35,11 +35,58 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Refs for the displayed values
|
||||
const displayedCoins = ref(user.value?.coins ?? 0);
|
||||
const displayedExp = ref(user.value?.exp ?? 0);
|
||||
|
||||
// Animation function
|
||||
const animateValue = (start, end, onUpdate) => {
|
||||
if (start === end) return;
|
||||
|
||||
const range = end - start;
|
||||
const increment = range > 0 ? 1 : -1;
|
||||
const duration = 500; // Total animation time
|
||||
const stepTime = Math.abs(Math.floor(duration / range));
|
||||
|
||||
let current = start;
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
onUpdate(current);
|
||||
if (current === end) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, stepTime > 0 ? stepTime : 1);
|
||||
};
|
||||
|
||||
// Watch for changes in user's coins and animate
|
||||
watch(() => user.value?.coins, (newCoins, oldCoins) => {
|
||||
if (newCoins !== undefined && oldCoins !== undefined) {
|
||||
animateValue(oldCoins, newCoins, (value) => {
|
||||
displayedCoins.value = value;
|
||||
});
|
||||
} else if (newCoins !== undefined) {
|
||||
displayedCoins.value = newCoins;
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for changes in user's exp and animate
|
||||
watch(() => user.value?.exp, (newExp, oldExp) => {
|
||||
if (newExp !== undefined && oldExp !== undefined) {
|
||||
animateValue(oldExp, newExp, (value) => {
|
||||
displayedExp.value = value;
|
||||
});
|
||||
} else if (newExp !== undefined) {
|
||||
displayedExp.value = newExp;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="isAuthenticated && user" class="dashboard-content">
|
||||
<h1>Welcome, {{ user.nickname }}!</h1>
|
||||
<p>This is your dashboard. Let's get those habits done!</p>
|
||||
<h1>Ваши цели на сегодня</h1>
|
||||
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
|
||||
|
||||
<div class="habits-section">
|
||||
<h2>My Habits</h2>
|
||||
<div v-if="habitsPending">Loading habits...</div>
|
||||
<div v-else-if="habitsError">Could not load habits.</div>
|
||||
<div v-else-if="habits && habits.length > 0">
|
||||
<div v-for="habit in habits" :key="habit.id" class="habit-card">
|
||||
<div class="habit-header">
|
||||
<div class="habit-details">
|
||||
<h3>{{ habit.name }}</h3>
|
||||
<div class="history-grid">
|
||||
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="{ 'completed': isCompleted(habit, day) }">
|
||||
<span class="day-label">{{ day.getDate() }}</span>
|
||||
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="completeHabit(habit.id)" :disabled="isCompleted(habit, today)">
|
||||
{{ isCompleted(habit, today) ? 'Completed Today' : 'Complete for Today' }}
|
||||
<div class="habit-action">
|
||||
<div v-if="isScheduledForToday(habit)">
|
||||
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
|
||||
<button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit">
|
||||
Выполнить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-grid">
|
||||
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
|
||||
<span class="day-label">{{ formatDayLabel(day) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="explodingHabitId === habit.id" class="confetti-container">
|
||||
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>You have no habits yet. Go to the <NuxtLink to="/habits">My Habits</NuxtLink> page to create one.</p>
|
||||
</div>
|
||||
|
|
@ -44,49 +58,425 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
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 ---
|
||||
|
||||
|
||||
// --- 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 = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
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));
|
||||
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
const completeHabit = async (habitId) => {
|
||||
try {
|
||||
await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
|
||||
await refreshHabits(); // Refresh the habits data to show the new completion
|
||||
} catch (err) {
|
||||
alert(err.data?.message || 'Failed to complete habit.');
|
||||
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Is the habit completed on this day?
|
||||
|
||||
if (isCompleted(habit, dayNormalized)) {
|
||||
|
||||
classes['completed'] = true;
|
||||
|
||||
return classes; // Completion state overrides all others
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
|
||||
habitId: habitId,
|
||||
|
||||
|
||||
|
||||
date: new Date().toISOString(),
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Trigger confetti
|
||||
|
||||
|
||||
|
||||
explodingHabitId.value = habitId;
|
||||
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
|
||||
|
||||
explodingHabitId.value = null;
|
||||
|
||||
|
||||
|
||||
}, 1000); // Animation duration
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (err) {
|
||||
|
||||
|
||||
|
||||
alert(err.data?.message || 'Failed to complete habit.');
|
||||
|
||||
|
||||
|
||||
} finally {
|
||||
|
||||
|
||||
|
||||
isSubmittingHabit.value = false;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
</script>
|
||||
|
|
@ -113,14 +503,14 @@ const completeHabit = async (habitId) => {
|
|||
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, 1fr);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
/* Removed fixed width/height for responsiveness */
|
||||
aspect-ratio: 1 / 1; /* Keep cells square */
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
|
@ -130,12 +520,31 @@ const completeHabit = async (habitId) => {
|
|||
}
|
||||
|
||||
.day-cell.completed {
|
||||
background-color: #4ade80;
|
||||
background-color: #4ade80; /* Green for completed */
|
||||
color: white;
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
.day-cell.missed-day {
|
||||
background-color: #feecf0; /* Light red for missed */
|
||||
}
|
||||
|
||||
.day-cell.scheduled-day {
|
||||
border-width: 2px;
|
||||
border-color: #81a1c1; /* Blueish accent for scheduled */
|
||||
}
|
||||
|
||||
.future-day .day-label {
|
||||
color: #adb5bd; /* Muted color for future days */
|
||||
}
|
||||
|
||||
.day-cell.today-highlight .day-label {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 0.8em;
|
||||
font-size: 0.9em; /* Make font size relative to parent for responsiveness */
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
|
|
@ -202,4 +611,101 @@ const completeHabit = async (habitId) => {
|
|||
.links a.button:hover {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
|
||||
/* New Habit Card Styles */
|
||||
.habit-card {
|
||||
position: relative; /* For confetti positioning */
|
||||
overflow: hidden; /* Hide confetti that flies out of bounds */
|
||||
}
|
||||
|
||||
.habit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.habit-details {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.habit-details h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.habit-schedule {
|
||||
margin: 5px 0 0 0;
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.habit-action button {
|
||||
padding: 8px 16px;
|
||||
background-color: #5e81ac;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.habit-action button:hover {
|
||||
background-color: #4c566a;
|
||||
}
|
||||
|
||||
.completed-text {
|
||||
font-weight: bold;
|
||||
color: #28a745; /* Joyful green */
|
||||
}
|
||||
|
||||
/* Confetti Animation */
|
||||
.confetti-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.confetti-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
|
||||
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
|
||||
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
|
||||
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
|
||||
|
||||
</style>
|
||||
|
|
@ -53,8 +53,8 @@
|
|||
<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>
|
||||
|
|
@ -184,6 +184,7 @@ async function handleAdminAction(url) {
|
|||
|
||||
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
|
||||
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
|
||||
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// server/api/admin/village/complete-clearing.post.ts
|
||||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { REWARDS } from '../../../services/villageService';
|
||||
import { REWARDS } from '../../../utils/economy';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -26,8 +26,8 @@ export default defineEventHandler(async (event) => {
|
|||
return { success: true, message: 'No clearing tasks to complete.' };
|
||||
}
|
||||
|
||||
const totalCoins = tilesToComplete.length * REWARDS.CLEARING.coins;
|
||||
const totalExp = tilesToComplete.length * REWARDS.CLEARING.exp;
|
||||
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
|
||||
|
|
@ -54,8 +54,8 @@ export default defineEventHandler(async (event) => {
|
|||
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
|
||||
tileX: tile.x,
|
||||
tileY: tile.y,
|
||||
coins: REWARDS.CLEARING.coins,
|
||||
exp: REWARDS.CLEARING.exp,
|
||||
coins: REWARDS.VILLAGE.CLEARING.coins,
|
||||
exp: REWARDS.VILLAGE.CLEARING.exp,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
49
server/api/admin/village/trigger-tick.post.ts
Normal file
49
server/api/admin/village/trigger-tick.post.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// server/api/admin/village/trigger-tick.post.ts
|
||||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// This is a simplified constant. In a real scenario, this might be shared from a single source.
|
||||
const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
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 now = Date.now();
|
||||
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
|
||||
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS + 5000); // 5 seconds past completion
|
||||
|
||||
await prisma.$transaction([
|
||||
// 1. Fast-forward any tiles that are currently being cleared
|
||||
prisma.villageTile.updateMany({
|
||||
where: {
|
||||
villageId: village.id,
|
||||
terrainState: 'CLEARING',
|
||||
},
|
||||
data: {
|
||||
clearingStartedAt: clearingFastForwardDate,
|
||||
},
|
||||
}),
|
||||
|
||||
// 2. Fast-forward any fields to be ready for EXP gain
|
||||
prisma.villageObject.updateMany({
|
||||
where: {
|
||||
villageId: village.id,
|
||||
type: 'FIELD',
|
||||
},
|
||||
data: {
|
||||
lastExpAt: yesterday,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' };
|
||||
});
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { getUserIdFromSession } from '../../../utils/auth';
|
||||
import { REWARDS } from '../../../utils/economy';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface CompletionResponse {
|
||||
message: string;
|
||||
reward: {
|
||||
coins: number;
|
||||
exp: number; // Added
|
||||
};
|
||||
updatedCoins: number;
|
||||
updatedExp: number; // Added
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,8 +41,12 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
}
|
||||
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
|
||||
if (!(habit.daysOfWeek as number[]).includes(dayOfWeek)) {
|
||||
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;
|
||||
|
||||
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +64,10 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
|
||||
}
|
||||
|
||||
const rewardCoins = 3;
|
||||
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
|
||||
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
|
||||
const village = await prisma.village.findUnique({ where: { userId } });
|
||||
|
||||
const [, updatedUser] = await prisma.$transaction([
|
||||
prisma.habitCompletion.create({
|
||||
data: {
|
||||
|
|
@ -68,15 +81,29 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
|
|||
coins: {
|
||||
increment: rewardCoins,
|
||||
},
|
||||
exp: { // Added
|
||||
increment: rewardExp, // Added
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(village ? [prisma.villageEvent.create({
|
||||
data: {
|
||||
villageId: village.id,
|
||||
type: 'HABIT_COMPLETION',
|
||||
message: `Completed habit: "${habit.name}"`,
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Changed from 0 to rewardExp
|
||||
}
|
||||
})] : []),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Habit completed successfully!',
|
||||
reward: {
|
||||
coins: rewardCoins,
|
||||
exp: rewardExp, // Added
|
||||
},
|
||||
updatedCoins: updatedUser.coins,
|
||||
updatedExp: updatedUser.exp, // Added
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { getUserIdFromSession } from '../../utils/auth';
|
||||
import { REWARDS } from '../../utils/economy';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface DailyVisitResponse {
|
||||
message: string;
|
||||
|
|
@ -58,11 +62,15 @@ export default defineEventHandler(async (event): Promise<DailyVisitResponse> =>
|
|||
const hasStreak = priorVisitsCount === 4;
|
||||
|
||||
// 3. Calculate rewards and update the database in a transaction
|
||||
let totalReward = 1; // Base reward
|
||||
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
|
||||
let message = 'Daily visit claimed!';
|
||||
if (hasStreak) {
|
||||
totalReward += 10; // Streak bonus
|
||||
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: {
|
||||
|
|
@ -78,11 +86,20 @@ export default defineEventHandler(async (event): Promise<DailyVisitResponse> =>
|
|||
},
|
||||
},
|
||||
}),
|
||||
...(village ? [prisma.villageEvent.create({
|
||||
data: {
|
||||
villageId: village.id,
|
||||
type: 'QUEST_DAILY_VISIT',
|
||||
message,
|
||||
coins: totalReward,
|
||||
exp: 0,
|
||||
}
|
||||
})] : []),
|
||||
]);
|
||||
|
||||
// 4. Return the response
|
||||
return {
|
||||
message: hasStreak ? 'Daily visit and streak bonus claimed!' : 'Daily visit claimed!',
|
||||
message,
|
||||
reward: {
|
||||
coins: totalReward,
|
||||
streakBonus: hasStreak,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// server/services/villageService.ts
|
||||
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
|
||||
import { COSTS, REWARDS } from '../utils/economy';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -7,24 +7,12 @@ export const VILLAGE_WIDTH = 5;
|
|||
export const VILLAGE_HEIGHT = 7;
|
||||
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export const BUILDING_COSTS: Record<string, number> = {
|
||||
HOUSE: 50,
|
||||
FIELD: 15,
|
||||
LUMBERJACK: 30,
|
||||
QUARRY: 30,
|
||||
WELL: 20,
|
||||
};
|
||||
|
||||
export const PRODUCING_BUILDINGS: string[] = [
|
||||
'FIELD',
|
||||
'LUMBERJACK',
|
||||
'QUARRY',
|
||||
];
|
||||
|
||||
export const REWARDS = {
|
||||
CLEARING: { coins: 1, exp: 1 },
|
||||
};
|
||||
|
||||
// Helper to get the start of a given date for daily EXP checks
|
||||
const getStartOfDay = (date: Date) => {
|
||||
const d = new Date(date);
|
||||
|
|
@ -122,8 +110,8 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
);
|
||||
|
||||
if (finishedClearingTiles.length > 0) {
|
||||
const totalCoins = finishedClearingTiles.length * REWARDS.CLEARING.coins;
|
||||
const totalExp = finishedClearingTiles.length * REWARDS.CLEARING.exp;
|
||||
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
|
||||
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Update user totals
|
||||
|
|
@ -150,8 +138,8 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
|
||||
tileX: tile.x,
|
||||
tileY: tile.y,
|
||||
coins: REWARDS.CLEARING.coins,
|
||||
exp: REWARDS.CLEARING.exp,
|
||||
coins: REWARDS.VILLAGE.CLEARING.coins,
|
||||
exp: REWARDS.VILLAGE.CLEARING.exp,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -170,18 +158,29 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
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 = 1;
|
||||
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 *= 2;
|
||||
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 } } }),
|
||||
...fieldsForExp.map(f => prisma.villageObject.update({ where: { id: f.id }, data: { lastExpAt: today } })),
|
||||
prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
|
||||
prisma.villageEvent.createMany({ data: eventsToCreate }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +266,7 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
if (tile.terrainType === 'EMPTY' && !tile.object) {
|
||||
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
|
||||
const buildActions = buildableObjectTypes.map(buildingType => {
|
||||
const cost = BUILDING_COSTS[buildingType];
|
||||
const cost = COSTS.BUILD[buildingType];
|
||||
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
|
||||
let isEnabled = user.coins >= cost;
|
||||
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
|
||||
|
|
@ -308,269 +307,118 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
|
|||
});
|
||||
|
||||
return { ...finalVillageState, tiles: tilesWithActions } as any;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Action Service Functions ---
|
||||
|
||||
|
||||
|
||||
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
|
||||
|
||||
const { VillageObjectType } = await import('@prisma/client');
|
||||
|
||||
const validBuildingTypes = Object.keys(VillageObjectType);
|
||||
|
||||
if (!validBuildingTypes.includes(buildingType)) {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
|
||||
// 1. Fetch all necessary data
|
||||
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
|
||||
|
||||
|
||||
|
||||
// Ownership check
|
||||
|
||||
if (tile.village.userId !== userId) {
|
||||
|
||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Business logic validation
|
||||
|
||||
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const cost = BUILDING_COSTS[buildingType];
|
||||
|
||||
const cost = COSTS.BUILD[buildingType];
|
||||
if (user.coins < cost) {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (PRODUCING_BUILDINGS.includes(buildingType)) {
|
||||
|
||||
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } });
|
||||
|
||||
const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length;
|
||||
|
||||
const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
|
||||
|
||||
if (producingCount >= housesCount) {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' });
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 2. Perform mutations
|
||||
|
||||
await tx.user.update({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
data: { coins: { decrement: cost } },
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await tx.villageObject.create({
|
||||
|
||||
|
||||
|
||||
data: {
|
||||
|
||||
|
||||
|
||||
type: buildingType as keyof typeof VillageObjectType,
|
||||
|
||||
|
||||
|
||||
villageId: tile.villageId,
|
||||
|
||||
|
||||
|
||||
tileId: tileId,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await tx.villageEvent.create({
|
||||
|
||||
|
||||
|
||||
data: {
|
||||
|
||||
|
||||
|
||||
villageId: tile.villageId,
|
||||
|
||||
|
||||
|
||||
type: `BUILD_${buildingType}`,
|
||||
|
||||
|
||||
|
||||
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`,
|
||||
|
||||
|
||||
|
||||
tileX: tile.x,
|
||||
|
||||
|
||||
|
||||
tileY: tile.y,
|
||||
|
||||
|
||||
|
||||
coins: -cost,
|
||||
|
||||
|
||||
|
||||
exp: 0,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function clearTile(userId: number, tileId: number) {
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
|
||||
const tile = await tx.villageTile.findUniqueOrThrow({
|
||||
|
||||
where: { id: tileId },
|
||||
|
||||
include: { village: { include: { objects: true } } },
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (tile.village.userId !== userId) {
|
||||
|
||||
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (tile.terrainState !== 'IDLE') {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (tile.terrainType === 'BLOCKED_TREE') {
|
||||
|
||||
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
|
||||
|
||||
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
|
||||
|
||||
} else if (tile.terrainType === 'BLOCKED_STONE') {
|
||||
|
||||
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
|
||||
|
||||
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
|
||||
|
||||
} else {
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await tx.villageTile.update({
|
||||
|
||||
where: { id: tileId },
|
||||
|
||||
data: {
|
||||
|
||||
terrainState: 'CLEARING',
|
||||
|
||||
clearingStartedAt: new Date(),
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function removeObject(userId: number, tileId: number) {
|
||||
|
||||
// As requested, this is a stub for now.
|
||||
|
||||
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
|
||||
|
||||
// As requested, this is a stub for now.
|
||||
|
||||
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
39
server/utils/economy.ts
Normal file
39
server/utils/economy.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// server/utils/economy.ts
|
||||
|
||||
/**
|
||||
* All costs for actions that the player can take.
|
||||
*/
|
||||
export const COSTS = {
|
||||
BUILD: {
|
||||
HOUSE: 50,
|
||||
FIELD: 15,
|
||||
LUMBERJACK: 30,
|
||||
QUARRY: 30,
|
||||
WELL: 20,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* All rewards the player can receive from various actions and events.
|
||||
*/
|
||||
export const REWARDS = {
|
||||
// Village-related rewards
|
||||
VILLAGE: {
|
||||
CLEARING: { coins: 1, exp: 1 },
|
||||
FIELD_EXP: {
|
||||
BASE: 1,
|
||||
WELL_MULTIPLIER: 2,
|
||||
},
|
||||
},
|
||||
// Quest-related rewards
|
||||
QUESTS: {
|
||||
DAILY_VISIT: {
|
||||
BASE: { coins: 1 },
|
||||
STREAK_BONUS: { coins: 10 },
|
||||
}
|
||||
},
|
||||
// Habit-related rewards
|
||||
HABITS: {
|
||||
COMPLETION: { coins: 3, exp: 1 },
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user