большой фикс, касающией бизнес логики Деревни. Сейчас приложение стабильно

This commit is contained in:
Alexander Andreev 2026-01-03 23:56:29 +03:00
parent b8640802ae
commit de89a41926
23 changed files with 1247 additions and 988 deletions

View File

@ -1,17 +1,37 @@
<template> <template>
<div>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <div v-if="!initialized" class="loading-overlay">
<p>Loading session...</p>
</div>
<NuxtPage v-else />
</NuxtLayout> </NuxtLayout>
</div>
</template> </template>
<script setup lang="ts"> <script setup>
import { onMounted } from 'vue'; const { initialized, fetchMe } = useAuth();
const { fetchMe } = useAuth(); // Fetch the user state on initial client-side load.
// The middleware will wait for `initialized` to be true.
// Fetch the user state ONLY on the client-side after the app has mounted.
// This ensures the browser's cookies are sent, allowing the session to persist.
onMounted(() => { onMounted(() => {
fetchMe(); fetchMe();
}); });
</script> </script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f4f5;
}
.loading-overlay {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5em;
color: #555;
}
</style>

View File

@ -1,96 +1,115 @@
<template> <template>
<div class="default-layout"> <div class="app-container">
<header class="app-header" v-if="user"> <header v-if="isAuthenticated" class="top-bar">
<div class="stats"> <div class="user-info-top">
<span>SmurfCoins: {{ user.coins }}</span>
<span>EXP: {{ user.exp }}</span>
</div>
<div class="user-info">
<!-- Level can be calculated later -->
<span>{{ user.nickname }}</span> <span>{{ user.nickname }}</span>
<span>💰 {{ user.coins }}</span>
<span> {{ user.exp }}</span>
<button @click="handleLogout" class="logout-button">Logout</button>
</div> </div>
</header> </header>
<main class="app-content"> <main class="main-content">
<slot /> <slot />
</main> </main>
<footer class="app-footer"> <footer v-if="isAuthenticated" class="bottom-nav">
<nav class="bottom-nav"> <NuxtLink to="/" class="nav-item">
<NuxtLink to="/" class="nav-item">Главная</NuxtLink> <span class="icon">🏠</span>
<NuxtLink to="/habits" class="nav-item">Привычки</NuxtLink> <span class="label">Главная</span>
<NuxtLink to="/village" class="nav-item">Деревня</NuxtLink> </NuxtLink>
<NuxtLink to="/leaderboard" class="nav-item">Лидеры</NuxtLink> <NuxtLink to="/habits" class="nav-item">
</nav> <span class="icon">🎯</span>
<span class="label">Привычки</span>
</NuxtLink>
<NuxtLink to="/village" class="nav-item">
<span class="icon">🏞</span>
<span class="label">Деревня</span>
</NuxtLink>
<NuxtLink to="/leaderboard" class="nav-item">
<span class="icon">🏆</span>
<span class="label">Лидерборд</span>
</NuxtLink>
</footer> </footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
const { user } = useAuth(); const { user, isAuthenticated, logout } = useAuth();
const handleLogout = async () => {
await logout();
};
</script> </script>
<style scoped> <style scoped>
.default-layout { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; /* Use min-height to allow content to push height */ min-height: 100vh;
background-color: #eef5ff;
color: #333;
} }
.app-header { .top-bar {
display: flex; background-color: #f8f8f8;
justify-content: space-between;
align-items: center;
padding: 10px 15px; padding: 10px 15px;
background-color: #4a90e2; border-bottom: 1px solid #eee;
color: white;
flex-shrink: 0;
}
.stats, .user-info {
display: flex; display: flex;
justify-content: flex-end; /* Align user info to the right */
align-items: center;
}
.user-info-top {
display: flex;
align-items: center;
gap: 15px; gap: 15px;
font-size: 0.9em;
} }
.app-content { .logout-button {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 0.8em;
}
.main-content {
flex-grow: 1; flex-grow: 1;
overflow-y: auto; /* Allow content to scroll */ padding-bottom: 60px; /* Space for bottom nav */
padding: 15px;
/* Add padding-bottom to prevent content from being overlapped by fixed footer */
padding-bottom: 60px; /* Adjust based on footer height */
}
.app-footer {
flex-shrink: 0;
position: fixed; /* Changed to fixed as per request */
bottom: 0;
width: 100%;
background-color: #ffffff;
border-top: 1px solid #dcdcdc;
padding: 10px 0;
box-sizing: border-box; /* Include padding in width */
z-index: 1000; /* Ensure footer is on top */
} }
.bottom-nav { .bottom-nav {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #fff;
border-top: 1px solid #eee;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
width: 100%; padding: 5px 0;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
z-index: 1000;
} }
.nav-item { .nav-item {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none; text-decoration: none;
color: #4a90e2; color: #555;
padding: 5px 10px; font-size: 0.7em;
border-radius: 5px; padding: 5px;
text-align: center;
flex: 1; /* Distribute space evenly */
} }
.nav-item.router-link-exact-active { .nav-item .icon {
background-color: #eef5ff; font-size: 1.5em;
font-weight: bold; margin-bottom: 2px;
}
.nav-item.router-link-active {
color: #007bff;
} }
</style> </style>

View File

@ -1,15 +1,18 @@
<template> <template>
<div class="login-layout"> <div>
<slot /> <slot />
</div> </div>
</template> </template>
<script setup>
</script>
<style scoped> <style scoped>
.login-layout { div {
background-color: #f0f2f5;
min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh;
background-color: #f0f4f8;
} }
</style> </style>

View File

@ -1,323 +1,205 @@
<template> <template>
<div class="dashboard"> <div class="home-page">
<div v-if="isAuthenticated && user"> <div v-if="isAuthenticated && user" class="dashboard-content">
<h2>My Habits for {{ user.nickname }}</h2> <h1>Welcome, {{ user.nickname }}!</h1>
<p>This is your dashboard. Let's get those habits done!</p>
<div v-if="loading" class="loading-message"> <div class="habits-section">
<p>Loading habits...</p> <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">
<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>
</div> </div>
<div v-else-if="processedHabits.length > 0" class="habits-list">
<div v-for="habit in processedHabits" :key="habit.id" class="habit-card">
<div class="habit-details">
<div class="habit-header">
<div class="habit-title-area">
<span class="habit-name">{{ habit.name }}</span>
<span class="habit-schedule">{{ formatDaysOfWeek(habit.daysOfWeek) }}</span>
</div> </div>
<button <button @click="completeHabit(habit.id)" :disabled="isCompleted(habit, today)">
v-if="isActionableToday(habit) && !isCompleteToday(habit)" {{ isCompleted(habit, today) ? 'Completed Today' : 'Complete for Today' }}
@click="completeHabit(habit.id)"
:disabled="completing === habit.id"
class="complete-btn"
>
{{ completing === habit.id ? '...' : 'Complete' }}
</button> </button>
<span v-else-if="isCompleteToday(habit)" class="completed-text"> Done!</span>
</div> </div>
<div class="calendar-grid">
<div
v-for="day in calendarDays"
:key="day.getTime()"
:class="['calendar-cell', getDayStatus(habit, day), { 'is-scheduled': isScheduledDay(habit, day) }]"
>
<span class="date-label">{{ formatDate(day) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>You haven't created any habits yet.</p>
<NuxtLink to="/habits">Manage Habits</NuxtLink>
</div>
</div> </div>
<div v-else> <div v-else>
<p>Loading session...</p> <p>You have no habits yet. Go to the <NuxtLink to="/habits">My Habits</NuxtLink> page to create one.</p>
</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>
<p>Отслеживайте свои привычки и развивайте свою деревню.</p>
<div class="auth-buttons">
<NuxtLink to="/login" class="button primary">Войти</NuxtLink>
<NuxtLink to="/register" class="button secondary">Зарегистрироваться</NuxtLink>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { computed } from 'vue';
// --- Type Definitions ---
interface Habit {
id: number;
name: string;
daysOfWeek: number[]; // Backend: 0 = Monday, 6 = Sunday
completions: { id: number; date: string }[];
createdAt: string;
}
// Internal type for easier lookup
interface HabitWithCompletionSet extends Habit {
completionDates: Set<number>; // Set of timestamps for O(1) lookup
createdAtTimestamp: number;
}
// --- Composables ---
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const api = useApi(); const api = useApi();
// --- State --- // --- Habits Data ---
const allHabits = ref<Habit[]>([]); const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
const loading = ref(true); lazy: true,
const completing = ref<number | null>(null); server: false,
// --- Helpers: Date Normalization & Formatting ---
/**
* Normalizes a date to the start of the day in UTC.
*/
const normalizeDateUTC = (date: Date): Date => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
};
/**
* Normalizes a JS Date's getDay() result (0=Sun) to the backend's format (0=Mon).
* @param jsDay The result of date.getUTCDay()
* @returns A number where 0 = Monday, ..., 6 = Sunday.
*/
const normalizeJsDay = (jsDay: number): number => {
return jsDay === 0 ? 6 : jsDay - 1;
};
const today = normalizeDateUTC(new Date());
const formatDate = (date: Date): string => {
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
};
// --- Computed Properties ---
const processedHabits = computed((): HabitWithCompletionSet[] => {
return allHabits.value
.map(habit => ({
...habit,
completionDates: new Set(habit.completions.map(c => normalizeDateUTC(new Date(c.date)).getTime())),
createdAtTimestamp: normalizeDateUTC(new Date(habit.createdAt)).getTime(),
}));
}); });
const calendarDays = computed(() => { // --- Date Logic ---
const today = new Date();
const last14Days = computed(() => {
const dates = []; const dates = [];
// JS day is 0=Sun, so we normalize it to backend's 0=Mon format. for (let i = 13; i >= 0; i--) {
const todayBackendDay = normalizeJsDay(today.getUTCDay()); const date = new Date();
date.setDate(date.getDate() - i);
// Start date is the Monday of the previous week. dates.push(date);
const startDate = normalizeDateUTC(new Date(today));
startDate.setUTCDate(startDate.getUTCDate() - todayBackendDay - 7);
for (let i = 0; i < 14; i++) {
const d = new Date(startDate);
d.setUTCDate(d.getUTCDate() + i);
dates.push(d);
} }
return dates; return dates;
}); });
const isSameDay = (d1, d2) => {
// --- Methods: Status and Actions --- d1 = new Date(d1);
d2 = new Date(d2);
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
const formatDaysOfWeek = (days: number[]): string => { d1.getDate() === d2.getDate();
if (days.length === 7) {
return 'Каждый день';
}
return [...days]
.sort((a, b) => a - b) // Sort numerically (0=Mon, 1=Tue, etc.)
.map(dayIndex => dayLabels[dayIndex])
.join(', ');
}; };
const isScheduledDay = (habit: HabitWithCompletionSet, date: Date): boolean => { const isCompleted = (habit, date) => {
const backendDay = normalizeJsDay(normalizeDateUTC(date).getUTCDay()); return habit.completions.some(c => isSameDay(c.date, date));
return habit.daysOfWeek.includes(backendDay);
}; };
const isActionableToday = (habit: HabitWithCompletionSet): boolean => { // --- Actions ---
const todayBackendDay = normalizeJsDay(today.getUTCDay()); const completeHabit = async (habitId) => {
return habit.daysOfWeek.includes(todayBackendDay);
};
const getDayStatus = (habit: HabitWithCompletionSet, date: Date): string => {
const normalizedDate = normalizeDateUTC(date);
const normalizedTimestamp = normalizedDate.getTime();
if (normalizedTimestamp > today.getTime()) {
return 'NEUTRAL';
}
if (normalizedTimestamp < habit.createdAtTimestamp) {
return 'NEUTRAL';
}
if (!isScheduledDay(habit, date)) {
return 'NEUTRAL';
}
if (habit.completionDates.has(normalizedTimestamp)) {
return 'COMPLETED';
}
return 'MISSED';
};
const isCompleteToday = (habit: HabitWithCompletionSet) => {
return habit.completionDates.has(today.getTime());
};
const fetchHabits = async () => {
loading.value = true;
try { try {
allHabits.value = await api<Habit[]>('/habits'); await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
} catch (error) { await refreshHabits(); // Refresh the habits data to show the new completion
console.error("Failed to fetch habits:", error); } catch (err) {
allHabits.value = []; alert(err.data?.message || 'Failed to complete habit.');
} finally {
loading.value = false;
} }
}; };
const completeHabit = async (habitId: number) => {
completing.value = habitId;
try {
await api(`/habits/${habitId}/complete`, { method: 'POST' });
await fetchHabits(); // Re-fetch the list to update UI
} catch (error) {
console.error(`Failed to complete habit ${habitId}:`, error);
} finally {
completing.value = null;
}
};
// --- Lifecycle ---
onMounted(() => {
if (isAuthenticated.value) {
fetchHabits();
}
watch(isAuthenticated, (isAuth) => {
if (isAuth && allHabits.value.length === 0) {
fetchHabits();
}
});
});
</script> </script>
<style scoped> <style scoped>
.dashboard { .home-page {
max-width: 600px; padding: 40px;
margin: 0 auto;
text-align: center; text-align: center;
} }
.habits-list {
display: flex; .habits-section {
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.habit-card {
padding: 15px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: left;
}
.habit-details {
width: 100%;
}
.habit-header {
display: flex;
justify-content: space-between;
align-items: flex-start; /* Align items to the top */
margin-bottom: 15px;
gap: 10px; /* Add gap between title area and button */
}
.habit-title-area {
display: flex;
flex-direction: column;
gap: 4px;
}
.habit-name {
font-size: 1.2em;
font-weight: bold;
}
.habit-schedule {
font-size: 0.85em;
color: #666;
font-style: italic;
}
.complete-btn {
padding: 8px 12px;
border: none;
border-radius: 5px;
background-color: #88c0d0;
color: #2e3440;
cursor: pointer;
flex-shrink: 0;
}
.completed-text {
color: #a3be8c;
font-weight: bold;
flex-shrink: 0;
}
.empty-state {
margin-top: 40px; margin-top: 40px;
color: #666; margin-bottom: 40px;
}
.empty-state a {
margin-top: 10px;
display: inline-block;
} }
/* Calendar Grid Styles */ .habit-card {
.calendar-grid { background: #fff;
display: grid; border-radius: 8px;
grid-template-columns: repeat(7, 1fr); padding: 20px;
gap: 5px; /* Slightly more gap */ margin: 20px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.calendar-cell {
width: 100%; .history-grid {
aspect-ratio: 1 / 1; display: grid;
grid-template-columns: repeat(14, 1fr);
gap: 5px;
margin: 20px 0;
}
.day-cell {
width: 40px;
height: 40px;
border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border: 1px solid transparent; /* Default transparent border */ background-color: #f8fafc;
} }
.calendar-cell.is-scheduled {
border-color: #d8dee9; /* Neutral border for scheduled days */ .day-cell.completed {
} background-color: #4ade80;
.date-label {
font-size: 0.75em;
color: #4c566a;
}
.calendar-cell.COMPLETED .date-label,
.calendar-cell.MISSED .date-label {
color: white; color: white;
} }
.calendar-cell.NEUTRAL {
background-color: #eceff4; /* Gray */ .day-label {
font-size: 0.8em;
} }
.calendar-cell.COMPLETED {
background-color: #a3be8c; /* Green */ .welcome-content {
border-color: #a3be8c; margin-top: 50px;
} }
.calendar-cell.MISSED {
background-color: #bf616a; /* Red */ .welcome-content h1 {
border-color: #bf616a; font-size: 2.5em;
margin-bottom: 20px;
color: #333;
}
.welcome-content p {
font-size: 1.2em;
color: #555;
margin-bottom: 40px;
}
.auth-buttons {
display: flex;
justify-content: center;
gap: 20px;
}
.button {
display: inline-block;
padding: 12px 25px;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
transition: background-color 0.3s ease;
border: 1px solid transparent;
}
.button.primary {
background-color: #007bff;
color: white;
}
.button.primary:hover {
background-color: #0056b3;
}
.button.secondary {
background-color: #6c757d;
color: white;
}
.button.secondary:hover {
background-color: #5a6268;
}
.links {
display: flex;
justify-content: center;
gap: 20px;
margin: 40px 0;
}
.links a.button {
background-color: #e9ecef;
color: #333;
}
.links a.button:hover {
background-color: #dee2e6;
} }
</style> </style>

View File

@ -1,109 +1,116 @@
<template> <template>
<div class="login-container"> <div class="auth-page">
<h2>Smurf Habits</h2> <div class="auth-container">
<h1>Login</h1>
<form @submit.prevent="handleLogin"> <form @submit.prevent="handleLogin">
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input v-model="email" type="email" id="email" placeholder="papa@smurf.village" required /> <input type="email" id="email" v-model="email" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input v-model="password" type="password" id="password" required /> <input type="password" id="password" v-model="password" required />
</div> </div>
<button type="submit" :disabled="loading">{{ loading ? 'Logging in...' : 'Login' }}</button> <div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form> </form>
<div class="register-link"> <div class="switch-link">
<p>No account? <NuxtLink to="/register">Register</NuxtLink></p> <p>
Don't have an account?
<NuxtLink to="/register">Register here</NuxtLink>
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
definePageMeta({ import { ref } from 'vue';
layout: 'login',
});
const { login } = useAuth(); const { login } = useAuth();
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const error = ref<string | null>(null); const error = ref(null);
const loading = ref(false); // This is the local loading state const loading = ref(false);
const handleLogin = async () => { const handleLogin = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
await login(email.value, password.value); await login(email.value, password.value);
await navigateTo('/'); // Explicitly navigate on success await navigateTo('/');
} catch (err: any) { } catch (err) {
console.error(err); error.value = err.data?.message || 'An error occurred during login.';
error.value = err.data?.message || 'Login failed. Please check your credentials.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
definePageMeta({
layout: 'login',
});
</script> </script>
<style scoped> <style scoped>
.login-container { .auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f2f5;
}
.auth-container {
width: 100%; width: 100%;
max-width: 350px; max-width: 400px;
padding: 20px; padding: 40px;
background-color: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center; text-align: center;
margin-bottom: 24px;
} }
h2 {
color: #4a90e2;
margin-bottom: 20px;
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 16px;
text-align: left;
} }
label { label {
display: block; display: block;
margin-bottom: 5px; margin-bottom: 8px;
font-weight: bold;
} }
input { input {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
} }
button { button {
width: 100%; width: 100%;
padding: 10px; padding: 12px;
background-color: #4a90e2;
color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; background-color: #007bff;
color: white;
font-size: 16px; font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
} }
button:disabled { button:disabled {
background-color: #a3bde3; background-color: #ccc;
cursor: not-allowed; cursor: not-allowed;
} }
button:hover:not(:disabled) {
.register-link { background-color: #0056b3;
margin-top: 20px;
} }
.error-message { .error-message {
color: #bf616a; color: red;
background-color: #fbe2e5; margin-bottom: 16px;
padding: 10px; text-align: center;
border-radius: 4px; }
margin-bottom: 15px; .switch-link {
margin-top: 20px;
text-align: center;
} }
</style> </style>

View File

@ -1,138 +1,139 @@
<template> <template>
<div class="register-container"> <div class="auth-page">
<h2>Create Account</h2> <div class="auth-container">
<h1>Register</h1>
<form @submit.prevent="handleRegister"> <form @submit.prevent="handleRegister">
<div v-if="error" class="error-message">{{ error }}</div> <div class="form-group">
<label for="nickname">Nickname</label>
<input type="text" id="nickname" v-model="nickname" required />
</div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input v-model="email" type="email" id="email" required /> <input type="email" id="email" v-model="email" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="nickname">Nickname (optional)</label> <label for="password">Password (min 8 characters)</label>
<input v-model="nickname" type="text" id="nickname" /> <input type="password" id="password" v-model="password" required />
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group"> <div v-if="successMessage" class="success-message">{{ successMessage }}</div>
<label for="password">Password</label>
<input v-model="password" type="password" id="password" required />
</div>
<button type="submit" :disabled="loading"> <button type="submit" :disabled="loading">
{{ loading ? 'Registering...' : 'Register' }} {{ loading ? 'Registering...' : 'Register' }}
</button> </button>
</form> </form>
<div class="switch-link">
<div class="login-link"> <p>
<p>Already have an account? <NuxtLink to="/login">Log In</NuxtLink></p> Already have an account?
<NuxtLink to="/login">Login here</NuxtLink>
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
definePageMeta({ import { ref } from 'vue';
layout: 'login', // Using the same simple layout as the login page
});
const api = useApi(); const api = useApi();
const nickname = ref('');
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const nickname = ref(''); const error = ref(null);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const successMessage = ref('');
const handleRegister = async () => { const handleRegister = async () => {
if (!email.value || !password.value) {
error.value = 'Email and Password are required.';
return;
}
loading.value = true; loading.value = true;
error.value = null; error.value = null;
successMessage.value = '';
try { try {
await api('/auth/register', { await api('/auth/register', {
method: 'POST', method: 'POST',
body: { body: {
nickname: nickname.value,
email: email.value, email: email.value,
password: password.value, password: password.value,
nickname: nickname.value || undefined, // Send undefined if empty
}, },
}); });
successMessage.value = 'Registration successful! Please log in.';
// On success, redirect to login page setTimeout(() => {
await navigateTo('/login'); navigateTo('/login');
}, 2000);
} catch (err: any) { } catch (err) {
console.error('Registration failed:', err); error.value = err.data?.message || 'An error occurred during registration.';
error.value = err.data?.message || 'An unexpected error occurred.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
definePageMeta({
layout: 'login',
});
</script> </script>
<style scoped> <style scoped>
.register-container { .auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f2f5;
}
.auth-container {
width: 100%; width: 100%;
max-width: 350px; max-width: 400px;
padding: 20px; padding: 40px;
background-color: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center; text-align: center;
margin-bottom: 24px;
} }
h2 {
color: #4a90e2;
margin-bottom: 20px;
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 16px;
text-align: left;
} }
label { label {
display: block; display: block;
margin-bottom: 5px; margin-bottom: 8px;
font-weight: bold;
} }
input { input {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
} }
button { button {
width: 100%; width: 100%;
padding: 10px; padding: 12px;
background-color: #4a90e2;
color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; background-color: #28a745;
color: white;
font-size: 16px; font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
} }
button:disabled { button:disabled {
background-color: #a3bde3; background-color: #ccc;
cursor: not-allowed; cursor: not-allowed;
} }
button:hover:not(:disabled) {
.login-link { background-color: #218838;
margin-top: 20px;
} }
.error-message { .error-message {
color: #bf616a; color: red;
background-color: #fbe2e5; margin-bottom: 16px;
padding: 10px; text-align: center;
border-radius: 4px; }
margin-bottom: 15px; .success-message {
color: green;
margin-bottom: 16px;
text-align: center;
}
.switch-link {
margin-top: 20px;
text-align: center;
} }
</style> </style>

View File

@ -1,59 +1,197 @@
<template> <template>
<div class="village-container"> <div class="village-page">
<h3>My Village</h3> <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 class="village-grid">
<div v-for="n in 64" :key="n" class="grid-cell"> <div
<!-- Placeholder for village objects --> v-for="tile in villageData.tiles"
:key="tile.id"
class="tile"
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
@click="selectTile(tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div> </div>
</div> </div>
<div class="village-actions">
<button>Build Mode</button> <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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
// No logic for now, just visual placeholders 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> </script>
<style scoped> <style scoped>
.village-container { .village-page {
text-align: center; display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
font-family: sans-serif;
} }
h3 { .loading, .error-container {
margin-bottom: 20px; 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 { .village-grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(5, 60px);
grid-template-rows: repeat(8, 1fr); grid-template-rows: repeat(7, 60px);
width: 100%; gap: 4px;
max-width: 500px; /* Or other size that fits your design */ border: 2px solid #333;
margin: 0 auto; padding: 4px;
background-color: #f0f0f0;
}
.tile {
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #ccc; border: 1px solid #ccc;
aspect-ratio: 1 / 1; background-color: #fff;
cursor: pointer;
transition: background-color 0.2s;
} }
.grid-cell { .tile:hover {
border: 1px dotted #e0e0e0; background-color: #e9e9e9;
background-color: #a3be8c; /* Grassy color */
} }
.grid-cell:nth-child(5) { background-color: #bf616a; } /* Fake house */ .tile.selected {
.grid-cell:nth-child(10) { background-color: #ebcb8b; } /* Fake field */ border: 2px solid #007bff;
.grid-cell:nth-child(11) { background-color: #ebcb8b; } /* Fake field */ box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
}
.village-actions { .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; margin-top: 20px;
} width: 100%;
padding: 10px;
button {
padding: 10px 20px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 5px;
} }
</style> </style>

197
pages/village.vue Normal file
View File

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

View File

@ -1,9 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@ -1,10 +1,18 @@
/* -- CreateTable
Warnings: CREATE TABLE "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,
"soundOn" BOOLEAN NOT NULL DEFAULT true,
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
- Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable -- CreateTable
CREATE TABLE "Habit" ( CREATE TABLE "Habit" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@ -43,37 +51,30 @@ CREATE TABLE "Village" (
CREATE TABLE "VillageObject" ( CREATE TABLE "VillageObject" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"type" TEXT NOT NULL, "type" TEXT NOT NULL,
"x" INTEGER NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"y" INTEGER NOT NULL, "lastExpAt" DATETIME,
"obstacleMetadata" TEXT,
"cropType" TEXT, "cropType" TEXT,
"plantedAt" DATETIME, "plantedAt" DATETIME,
"villageId" INTEGER NOT NULL, "villageId" INTEGER NOT NULL,
CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE "tileId" INTEGER NOT NULL,
CONSTRAINT "VillageObject_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "VillageObject_tileId_fkey" FOREIGN KEY ("tileId") REFERENCES "VillageTile" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
-- RedefineTables -- CreateTable
PRAGMA defer_foreign_keys=ON; CREATE TABLE "VillageTile" (
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL, "x" INTEGER NOT NULL,
"password" TEXT NOT NULL, "y" INTEGER NOT NULL,
"nickname" TEXT, "terrainType" TEXT NOT NULL,
"avatar" TEXT DEFAULT '/avatars/default.png', "terrainState" TEXT NOT NULL DEFAULT 'IDLE',
"coins" INTEGER NOT NULL DEFAULT 0, "clearingStartedAt" DATETIME,
"exp" INTEGER NOT NULL DEFAULT 0, "villageId" INTEGER NOT NULL,
"soundOn" BOOLEAN NOT NULL DEFAULT true, CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
"confettiOn" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
); );
INSERT INTO "new_User" ("createdAt", "email", "id") SELECT "createdAt", "email", "id" FROM "User";
DROP TABLE "User"; -- CreateIndex
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date"); CREATE UNIQUE INDEX "HabitCompletion_habitId_date_key" ON "HabitCompletion"("habitId", "date");
@ -85,4 +86,7 @@ CREATE UNIQUE INDEX "DailyVisit_userId_date_key" ON "DailyVisit"("userId", "date
CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId"); CREATE UNIQUE INDEX "Village_userId_key" ON "Village"("userId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "VillageObject_villageId_x_y_key" ON "VillageObject"("villageId", "x", "y"); CREATE UNIQUE INDEX "VillageObject_tileId_key" ON "VillageObject"("tileId");
-- CreateIndex
CREATE UNIQUE INDEX "VillageTile_villageId_x_y_key" ON "VillageTile"("villageId", "x", "y");

View File

@ -14,9 +14,17 @@ datasource db {
enum VillageObjectType { enum VillageObjectType {
HOUSE HOUSE
FIELD FIELD
ROAD }
FENCE
OBSTACLE enum TerrainType {
EMPTY
BLOCKED_TREE
BLOCKED_STONE
}
enum TerrainState {
IDLE
CLEARING
} }
// CropType: Defines the types of crops that can be planted. // CropType: Defines the types of crops that can be planted.
@ -101,17 +109,15 @@ model Village {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int @unique // Each user has only one village userId Int @unique // Each user has only one village
objects VillageObject[] objects VillageObject[]
tiles VillageTile[]
} }
// VillageObject: An object (e.g., house, field, obstacle) placed on the // VillageObject: An object (e.g., house, field) placed on a village tile.
// village grid. It stores the object's type, its coordinates, and optionally
// details if it's an obstacle or a planted crop.
model VillageObject { model VillageObject {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type VillageObjectType type VillageObjectType
x Int createdAt DateTime @default(now())
y Int lastExpAt DateTime?
obstacleMetadata String? // Stores metadata for obstacles (e.g., "rock", "bush").
// Crop details (only if type is FIELD) // Crop details (only if type is FIELD)
cropType CropType? cropType CropType?
@ -120,6 +126,22 @@ model VillageObject {
// Relations // Relations
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade) village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
villageId Int villageId Int
tile VillageTile @relation(fields: [tileId], references: [id])
@@unique([villageId, x, y]) // Ensure only one object per grid cell per village tileId Int @unique
}
model VillageTile {
id Int @id @default(autoincrement())
x Int
y Int
terrainType TerrainType
terrainState TerrainState @default(IDLE)
clearingStartedAt DateTime?
// Relations
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
villageId Int
object VillageObject?
@@unique([villageId, x, y])
} }

View File

@ -0,0 +1,2 @@
DELETE FROM _prisma_migrations
WHERE migration_name = '20260103181802_refactor_village_schema';

View File

@ -1,4 +1,5 @@
import { hashPassword } from '../../utils/password'; import { hashPassword } from '../../utils/password';
import { generateVillageForUser } from '../../services/villageService';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event); const body = await readBody(event);
@ -43,10 +44,13 @@ export default defineEventHandler(async (event) => {
}, },
}); });
// 4. Generate the user's village
await generateVillageForUser(user);
// NOTE: Registration does not automatically log in the user. // NOTE: Registration does not automatically log in the user.
// The user needs to explicitly call the login endpoint after registration. // The user needs to explicitly call the login endpoint after registration.
// 4. Return the new user, excluding sensitive fields and shortening DTO // 5. Return the new user, excluding sensitive fields and shortening DTO
return { return {
user: { user: {
id: user.id, id: user.id,

View File

@ -1,72 +0,0 @@
import { getUserIdFromSession } from '../../utils/auth';
import { CROP_HARVEST_REWARD, isCropGrown } from '../../utils/village';
import { CropType } from '@prisma/client';
// --- Handler ---
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
const { fieldId } = await readBody(event);
// 1. --- Validation ---
if (typeof fieldId !== 'number') {
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" is required.' });
}
// 2. --- Find the target field and validate its state ---
const field = await prisma.villageObject.findFirst({
where: {
id: fieldId,
type: 'FIELD',
village: { userId: userId }, // Ensures ownership
},
});
if (!field) {
throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' });
}
if (!field.cropType || !field.plantedAt) {
throw createError({ statusCode: 400, statusMessage: 'Nothing is planted in this field.' });
}
if (!isCropGrown(field.plantedAt, field.cropType)) {
throw createError({ statusCode: 400, statusMessage: 'Crop is not yet fully grown.' });
}
// 3. --- Grant rewards and clear field in a transaction ---
const reward = CROP_HARVEST_REWARD[field.cropType];
const [, , updatedField] = await prisma.$transaction([
// Grant EXP and Coins
prisma.user.update({
where: { id: userId },
data: {
exp: { increment: reward.exp },
coins: { increment: reward.coins },
},
}),
// Clear the crop from the field
prisma.villageObject.update({
where: { id: fieldId },
data: {
cropType: null,
plantedAt: null,
},
}),
// Re-fetch the field to return its cleared state
prisma.villageObject.findUniqueOrThrow({ where: { id: fieldId } }),
]);
return {
message: `${field.cropType} harvested successfully!`,
reward: reward,
updatedField: {
id: updatedField.id,
type: updatedField.type,
x: updatedField.x,
y: updatedField.y,
cropType: updatedField.cropType,
isGrown: false,
}
};
});

View File

@ -1,50 +1,28 @@
import { getUserIdFromSession } from '../../utils/auth'; // server/api/village/index.get.ts
import { isCropGrown } from '../../utils/village'; import { getVillageState, generateVillageForUser } from '../../services/villageService';
import { CropType, VillageObjectType } from '@prisma/client'; import { defineEventHandler } from 'h3';
// --- DTOs --- export default defineEventHandler(async (event) => {
interface VillageObjectDto { const user = event.context.user;
id: number;
type: VillageObjectType;
x: number;
y: number;
cropType: CropType | null;
isGrown: boolean | null;
}
interface VillageDto { if (!user) {
objects: VillageObjectDto[]; throw createError({
} statusCode: 401,
statusMessage: 'Unauthorized',
// --- Handler ---
export default defineEventHandler(async (event): Promise<VillageDto> => {
const userId = await getUserIdFromSession(event);
let village = await prisma.village.findUnique({
where: { userId },
include: { objects: true },
});
// If the user has no village yet, create one automatically.
if (!village) {
village = await prisma.village.create({
data: { userId },
include: { objects: true },
}); });
} }
// Map Prisma objects to clean DTOs, computing `isGrown`. // Ensure the user has a village generated. This function is idempotent.
const objectDtos: VillageObjectDto[] = village.objects.map(obj => ({ await generateVillageForUser(user);
id: obj.id,
type: obj.type,
x: obj.x,
y: obj.y,
cropType: obj.cropType,
isGrown: obj.type === 'FIELD' ? isCropGrown(obj.plantedAt, obj.cropType) : null,
}));
return { try {
objects: objectDtos, const villageState = await getVillageState(user.id);
}; return villageState;
} catch (error: any) {
// Catch errors from the service and re-throw them as H3 errors
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'An error occurred while fetching village state.',
});
}
}); });

View File

@ -1,78 +0,0 @@
import { getUserIdFromSession } from '../../utils/auth';
import { VILLAGE_GRID_SIZE, ITEM_COSTS } from '../../utils/village';
import { VillageObjectType } from '@prisma/client';
// --- Handler ---
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
const { type, x, y } = await readBody(event);
// 1. --- Validation ---
if (!type || typeof x !== 'number' || typeof y !== 'number') {
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "type", "x", and "y" are required.' });
}
if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) {
throw createError({ statusCode: 400, statusMessage: 'Object placed outside of village bounds.' });
}
const cost = ITEM_COSTS[type as VillageObjectType];
if (cost === undefined) {
throw createError({ statusCode: 400, statusMessage: 'Cannot place objects of this type.' });
}
// 2. --- Fetch current state and enforce rules ---
const village = await prisma.village.findUnique({
where: { userId },
include: { objects: true },
});
if (!village) {
// This should not happen if GET /village is called first, but as a safeguard:
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
}
// Rule: Cell must be empty
if (village.objects.some(obj => obj.x === x && obj.y === y)) {
throw createError({ statusCode: 409, statusMessage: 'A building already exists on this cell.' });
}
// Rule: Fields require available workers
if (type === 'FIELD') {
const houseCount = village.objects.filter(obj => obj.type === 'HOUSE').length;
const fieldCount = village.objects.filter(obj => obj.type === 'FIELD').length;
if (fieldCount >= houseCount) {
throw createError({ statusCode: 400, statusMessage: 'Not enough available workers to build a new field. Build more houses first.' });
}
}
// 3. --- Perform atomic transaction ---
try {
const [, newObject] = await prisma.$transaction([
prisma.user.update({
where: { id: userId, coins: { gte: cost } },
data: { coins: { decrement: cost } },
}),
prisma.villageObject.create({
data: {
villageId: village.id,
type,
x,
y,
},
}),
]);
setResponseStatus(event, 201);
return {
id: newObject.id,
type: newObject.type,
x: newObject.x,
y: newObject.y,
cropType: null,
isGrown: null,
};
} catch (e) {
// Catches failed transactions, likely from the user.update 'where' clause (insufficient funds)
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to build.' });
}
});

View File

@ -1,74 +0,0 @@
import { getUserIdFromSession } from '../../../utils/auth';
import { OBSTACLE_CLEAR_COST } from '../../../utils/village';
export default defineEventHandler(async (event) => {
// 1. Get objectId from params
const objectId = parseInt(event.context.params?.id || '', 10);
if (isNaN(objectId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' });
}
const userId = await getUserIdFromSession(event);
// 2. Find village + objects
const village = await prisma.village.findUnique({
where: { userId },
include: { objects: true }
});
if (!village) {
// This case is unlikely if user has ever fetched their village, but is a good safeguard.
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
}
// 3. Find the object
const objectToDelete = village.objects.find(o => o.id === objectId);
// 4. If not found -> 404
if (!objectToDelete) {
throw createError({ statusCode: 404, statusMessage: 'Object not found.' });
}
// 5. If OBSTACLE
if (objectToDelete.type === 'OBSTACLE') {
const cost = OBSTACLE_CLEAR_COST[objectToDelete.obstacleMetadata || 'DEFAULT'] ?? OBSTACLE_CLEAR_COST.DEFAULT;
try {
// Atomically check coins, deduct, and delete
await prisma.$transaction([
prisma.user.update({
where: { id: userId, coins: { gte: cost } },
data: { coins: { decrement: cost } },
}),
prisma.villageObject.delete({ where: { id: objectId } }),
]);
} catch (e) {
// The transaction fails if the user update fails due to insufficient coins
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to clear obstacle.' });
}
// 6. If HOUSE
} else if (objectToDelete.type === 'HOUSE') {
const houseCount = village.objects.filter(o => o.type === 'HOUSE').length;
const fieldCount = village.objects.filter(o => o.type === 'FIELD').length;
// Check if removing this house violates the worker rule
if (fieldCount > (houseCount - 1)) {
throw createError({
statusCode: 400,
statusMessage: `Cannot remove house. You have ${fieldCount} fields and need at least ${fieldCount} workers.`
});
}
// Delete the house (no cost)
await prisma.villageObject.delete({ where: { id: objectId } });
// 7. Otherwise (FIELD, ROAD, FENCE)
} else {
// Delete the object (no cost)
await prisma.villageObject.delete({ where: { id: objectId } });
}
// 8. Return success message
return { message: "Object removed successfully" };
});

View File

@ -1,73 +0,0 @@
import { getUserIdFromSession } from '../../../utils/auth';
import { VILLAGE_GRID_SIZE, MOVE_COST, isCropGrown } from '../../../utils/village';
// --- Handler ---
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
const objectId = parseInt(event.context.params?.id || '', 10);
const { x, y } = await readBody(event);
// 1. --- Validation ---
if (isNaN(objectId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid object ID.' });
}
if (typeof x !== 'number' || typeof y !== 'number') {
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "x" and "y" are required.' });
}
if (x < 0 || x >= VILLAGE_GRID_SIZE.width || y < 0 || y >= VILLAGE_GRID_SIZE.height) {
throw createError({ statusCode: 400, statusMessage: 'Cannot move object outside of village bounds.' });
}
// 2. --- Fetch state and enforce rules ---
const village = await prisma.village.findUnique({
where: { userId },
include: { objects: true },
});
if (!village) {
throw createError({ statusCode: 404, statusMessage: 'Village not found.' });
}
const objectToMove = village.objects.find(obj => obj.id === objectId);
// Rule: Object must exist and belong to the user (implicit via village)
if (!objectToMove) {
throw createError({ statusCode: 404, statusMessage: 'Object not found.' });
}
// Rule: Cannot move obstacles
if (objectToMove.type === 'OBSTACLE') {
throw createError({ statusCode: 400, statusMessage: 'Cannot move obstacles. They must be cleared.' });
}
// Rule: Target cell must be empty (and not the same cell)
if (village.objects.some(obj => obj.x === x && obj.y === y)) {
throw createError({ statusCode: 409, statusMessage: 'Target cell is already occupied.' });
}
// 3. --- Perform atomic transaction ---
try {
const [, updatedObject] = await prisma.$transaction([
prisma.user.update({
where: { id: userId, coins: { gte: MOVE_COST } },
data: { coins: { decrement: MOVE_COST } },
}),
prisma.villageObject.update({
where: { id: objectId },
data: { x, y },
}),
]);
return {
id: updatedObject.id,
type: updatedObject.type,
x: updatedObject.x,
y: updatedObject.y,
cropType: updatedObject.cropType,
isGrown: isCropGrown(updatedObject.plantedAt, updatedObject.cropType),
};
} catch (e) {
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to move object.' });
}
});

View File

@ -1,63 +0,0 @@
import { getUserIdFromSession } from '../../utils/auth';
import { PLANTING_COST, isCropGrown } from '../../utils/village';
import { CropType } from '@prisma/client';
// --- Handler ---
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
const { fieldId, cropType } = await readBody(event);
// 1. --- Validation ---
if (typeof fieldId !== 'number' || !cropType) {
throw createError({ statusCode: 400, statusMessage: 'Invalid request body. "fieldId" and "cropType" are required.' });
}
if (!Object.values(CropType).includes(cropType)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid crop type.' });
}
// 2. --- Find the target field and validate its state ---
const field = await prisma.villageObject.findFirst({
where: {
id: fieldId,
type: 'FIELD',
village: { userId: userId }, // Ensures ownership
},
});
if (!field) {
throw createError({ statusCode: 404, statusMessage: 'Field not found or you do not own it.' });
}
if (field.cropType !== null) {
throw createError({ statusCode: 409, statusMessage: 'A crop is already planted in this field.' });
}
// 3. --- Perform atomic transaction ---
try {
const [, updatedField] = await prisma.$transaction([
prisma.user.update({
where: { id: userId, coins: { gte: PLANTING_COST } },
data: { coins: { decrement: PLANTING_COST } },
}),
prisma.villageObject.update({
where: { id: fieldId },
data: {
cropType: cropType,
plantedAt: new Date(),
},
}),
]);
return {
id: updatedField.id,
type: updatedField.type,
x: updatedField.x,
y: updatedField.y,
cropType: updatedField.cropType,
isGrown: isCropGrown(updatedField.plantedAt, updatedField.cropType), // will be false
};
} catch (e) {
throw createError({ statusCode: 402, statusMessage: 'Insufficient coins to plant seeds.' });
}
});

42
server/middleware/auth.ts Normal file
View File

@ -0,0 +1,42 @@
// server/middleware/auth.ts
import { defineEventHandler, useSession } from 'h3';
import prisma from '../utils/prisma';
/**
* Global server middleware to populate `event.context.user` for every incoming request.
*
* It safely checks for a session and fetches the user from the database if a
* valid session ID is found. It does NOT block requests or throw errors if the
* user is not authenticated, as authorization is handled within API endpoints themselves.
*/
export default defineEventHandler(async (event) => {
// This middleware should not run on static assets or internal requests.
const path = event.path || '';
if (path.startsWith('/_nuxt') || path.startsWith('/__nuxt_error')) {
return;
}
// Safely get the session
const session = await useSession(event, {
password: process.env.SESSION_PASSWORD!,
});
const userId = session.data?.user?.id;
// If a userId is found in the session, fetch the user and attach it to the context.
if (userId) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (user) {
event.context.user = user;
}
} catch (error) {
// If there's an error fetching the user (e.g., DB connection issue),
// we log it but don't block the request. The user will be treated as unauthenticated.
console.error('Error fetching user in auth middleware:', error);
}
}
});

View File

@ -0,0 +1,299 @@
// server/services/villageService.ts
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
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',
];
// Helper to get the start of a given date for daily EXP checks
const getStartOfDay = (date: Date) => {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0); // Use UTC for calendar day consistency
return d;
};
/**
* Generates the initial village for a new user atomically.
*/
export async function generateVillageForUser(user: User) {
const existingVillage = await prisma.village.findUnique({
where: { userId: user.id },
});
if (existingVillage) {
return;
}
const tilesToCreate: Omit<VillageTile, 'id' | 'clearingStartedAt' | 'villageId'>[] = [];
const central_x_start = 1;
const central_x_end = 4;
const central_y_start = 2;
const central_y_end = 5;
for (let y = 0; y < VILLAGE_HEIGHT; y++) {
for (let x = 0; x < VILLAGE_WIDTH; x++) {
const isCentral = x >= central_x_start && x < central_x_end && y >= central_y_start && y < central_y_end;
const terrainType = isCentral
? 'EMPTY'
: Math.random() < 0.5 ? 'BLOCKED_TREE' : 'BLOCKED_STONE';
tilesToCreate.push({
x,
y,
terrainType,
terrainState: 'IDLE',
});
}
}
// FIX: Wrap village generation in a single transaction for atomicity.
await prisma.$transaction(async (tx) => {
const village = await tx.village.create({
data: {
userId: user.id,
},
});
await tx.user.update({
where: { id: user.id },
data: {
coins: 10,
exp: 0,
},
});
await tx.villageTile.createMany({
data: tilesToCreate.map(tile => ({ ...tile, villageId: village.id })),
});
});
}
type FullVillage = Prisma.VillageGetPayload<{
include: {
user: true;
tiles: { include: { object: true } };
objects: { include: { tile: true } };
};
}>;
/**
* Gets the full, updated state of a user's village, calculating all time-based progression.
*/
export async function getVillageState(userId: number): Promise<FullVillage> {
const now = new Date();
// --- Step 1: Initial Snapshot ---
let villageSnapshot = await prisma.village.findUnique({
where: { userId },
include: {
user: true,
tiles: { include: { object: true } },
objects: { include: { tile: true } },
},
});
if (!villageSnapshot) {
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
// --- Step 2: Terrain Cleaning Completion ---
const finishedClearingTiles = villageSnapshot.tiles.filter(
t => t.terrainState === 'CLEARING' && t.clearingStartedAt && now.getTime() - t.clearingStartedAt.getTime() >= CLEANING_TIME
);
if (finishedClearingTiles.length > 0) {
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
coins: { increment: finishedClearingTiles.length },
exp: { increment: finishedClearingTiles.length },
},
}),
...finishedClearingTiles.map(t =>
prisma.villageTile.update({
where: { id: t.id },
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
})
),
]);
}
// --- Step 3: Refetch for next logic step ---
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
// --- Step 4: Field EXP Processing ---
const today = getStartOfDay(now);
const fieldsForExp = villageSnapshot.objects.filter(
obj => obj.type === 'FIELD' && (!obj.lastExpAt || getStartOfDay(obj.lastExpAt) < today)
);
if (fieldsForExp.length > 0) {
const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`));
let totalExpFromFields = 0;
for (const field of fieldsForExp) {
let fieldExp = 1;
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;
}
totalExpFromFields += 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 } })),
]);
}
// --- Step 5: Refetch for next logic step ---
villageSnapshot = await prisma.village.findUnique({ where: { userId }, include: { user: true, tiles: { include: { object: true } }, objects: { include: { tile: true } } } })!;
// --- Step 6: Auto-start Terrain Cleaning ---
const housesCount = villageSnapshot.objects.filter(o => o.type === 'HOUSE').length;
const producingCount = villageSnapshot.objects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
const freeWorkers = housesCount - producingCount;
if (producingCount <= housesCount) {
const manhattanDistance = (p1: {x: number, y: number}, p2: {x: number, y: number}) => Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
const getDirectionalPriority = (worker: VillageTile, target: VillageTile): number => {
const dy = target.y - worker.y;
const dx = target.x - worker.x;
if (dy < 0) return 1; // Up
if (dx < 0) return 2; // Left
if (dx > 0) return 3; // Right
if (dy > 0) return 4; // Down
return 5;
};
const assignTasks = (workers: (VillageObject & { tile: VillageTile })[], targets: VillageTile[], newlyTargeted: Set<number>) => {
workers.forEach(worker => {
const potentialTargets = targets
.filter(t => !newlyTargeted.has(t.id))
.map(target => ({ target, distance: manhattanDistance(worker.tile, target) }))
.sort((a, b) => a.distance - b.distance);
if (!potentialTargets.length) return;
const minDistance = potentialTargets[0].distance;
const tiedTargets = potentialTargets.filter(t => t.distance === minDistance).map(t => t.target);
tiedTargets.sort((a, b) => getDirectionalPriority(worker.tile, a) - getDirectionalPriority(worker.tile, b));
const bestTarget = tiedTargets[0];
if (bestTarget) newlyTargeted.add(bestTarget.id);
});
};
const lumberjacks = villageSnapshot.objects.filter(obj => obj.type === 'LUMBERJACK') as (VillageObject & { tile: VillageTile })[];
const quarries = villageSnapshot.objects.filter(obj => obj.type === 'QUARRY') as (VillageObject & { tile: VillageTile })[];
const idleTrees = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_TREE' && t.terrainState === 'IDLE');
const idleStones = villageSnapshot.tiles.filter(t => t.terrainType === 'BLOCKED_STONE' && t.terrainState === 'IDLE');
const tileIdsToClear = new Set<number>();
assignTasks(lumberjacks, idleTrees, tileIdsToClear);
assignTasks(quarries, idleStones, tileIdsToClear);
if (tileIdsToClear.size > 0) {
await prisma.villageTile.updateMany({
where: { id: { in: Array.from(tileIdsToClear) } },
data: { terrainState: 'CLEARING', clearingStartedAt: now },
});
}
}
// --- Step 7: Final Fetch & Action Enrichment ---
const finalVillageState = await prisma.village.findUnique({
where: { userId },
include: {
user: true,
tiles: { include: { object: true }, orderBy: [{ y: 'asc' }, { x: 'asc' }] },
objects: { include: { tile: true } },
},
});
if (!finalVillageState) {
throw createError({ statusCode: 404, statusMessage: 'Village not found post-update' });
}
// --- Step 8: Enrich tiles with available actions ---
const { user } = finalVillageState;
const hasLumberjack = finalVillageState.objects.some(o => o.type === 'LUMBERJACK');
const hasQuarry = finalVillageState.objects.some(o => o.type === 'QUARRY');
const tilesWithActions = finalVillageState.tiles.map(tile => {
const availableActions: any[] = [];
// Action: CLEAR
if (tile.terrainState === 'IDLE' && (tile.terrainType === 'BLOCKED_STONE' || tile.terrainType === 'BLOCKED_TREE')) {
const canClearTree = tile.terrainType === 'BLOCKED_TREE' && hasLumberjack;
const canClearStone = tile.terrainType === 'BLOCKED_STONE' && hasQuarry;
availableActions.push({
type: 'CLEAR',
isEnabled: canClearTree || canClearStone,
disabledReason: !(canClearTree || canClearStone) ? `Requires ${tile.terrainType === 'BLOCKED_TREE' ? 'Lumberjack' : 'Quarry'}` : undefined,
});
}
// Action: BUILD
if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = BUILDING_COSTS[buildingType];
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
let isEnabled = user.coins >= cost;
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
if (isEnabled && isProducing && freeWorkers <= 0) {
isEnabled = false;
disabledReason = 'Not enough workers';
}
return {
type: 'BUILD',
buildingType,
cost,
isEnabled,
disabledReason,
};
});
availableActions.push(...buildActions);
}
if (tile.object) {
const isHouse = tile.object.type === 'HOUSE';
// Action: MOVE
availableActions.push({
type: 'MOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be moved' : undefined,
});
// Action: REMOVE
availableActions.push({
type: 'REMOVE',
isEnabled: !isHouse,
disabledReason: isHouse ? 'House cannot be removed' : undefined,
});
}
return { ...tile, availableActions };
});
return { ...finalVillageState, tiles: tilesWithActions } as any;
}

View File

@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient() const prisma = new PrismaClient()

View File

@ -1,10 +1,20 @@
// server/utils/village.ts // server/utils/village.ts
import { CropType, VillageObjectType } from '@prisma/client';
export type VillageObjectKind =
| 'HOUSE'
| 'FIELD'
| 'LUMBERJACK'
| 'QUARRY'
| 'WELL'
| 'ROAD'
| 'FENCE';
export type CropKind = 'BLUEBERRIES' | 'CORN';
// --- Game Economy & Rules --- // --- Game Economy & Rules ---
export const VILLAGE_GRID_SIZE = { width: 15, height: 15 }; export const VILLAGE_GRID_SIZE = { width: 15, height: 15 };
export const ITEM_COSTS: Partial<Record<VillageObjectType, number>> = { export const ITEM_COSTS: Partial<Record<VillageObjectKind, number>> = {
HOUSE: 50, HOUSE: 50,
FIELD: 15, FIELD: 15,
ROAD: 5, ROAD: 5,
@ -22,13 +32,13 @@ export const PLANTING_COST = 2; // A small, flat cost for seeds
export const MOVE_COST = 1; // Cost to move any player-built item export const MOVE_COST = 1; // Cost to move any player-built item
// --- Crop Timings (in milliseconds) --- // --- Crop Timings (in milliseconds) ---
export const CROP_GROWTH_TIME: Record<CropType, number> = { export const CROP_GROWTH_TIME: Record<CropKind, number> = {
BLUEBERRIES: 60 * 60 * 1000, // 1 hour BLUEBERRIES: 60 * 60 * 1000, // 1 hour
CORN: 4 * 60 * 60 * 1000, // 4 hours CORN: 4 * 60 * 60 * 1000, // 4 hours
}; };
// --- Rewards --- // --- Rewards ---
export const CROP_HARVEST_REWARD: Record<CropType, { exp: number, coins: number }> = { export const CROP_HARVEST_REWARD: Record<CropKind, { exp: number, coins: number }> = {
BLUEBERRIES: { exp: 5, coins: 0 }, BLUEBERRIES: { exp: 5, coins: 0 },
CORN: { exp: 10, coins: 1 }, CORN: { exp: 10, coins: 1 },
}; };
@ -39,7 +49,7 @@ export const CROP_HARVEST_REWARD: Record<CropType, { exp: number, coins: number
* @param cropType The type of crop. * @param cropType The type of crop.
* @returns True if the crop has finished growing. * @returns True if the crop has finished growing.
*/ */
export function isCropGrown(plantedAt: Date | null, cropType: CropType | null): boolean { export function isCropGrown(plantedAt: Date | null, cropType: CropKind | null): boolean {
if (!plantedAt || !cropType) { if (!plantedAt || !cropType) {
return false; return false;
} }