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

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>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<div>
<NuxtLayout>
<div v-if="!initialized" class="loading-overlay">
<p>Loading session...</p>
</div>
<NuxtPage v-else />
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
<script setup>
const { initialized, fetchMe } = useAuth();
const { fetchMe } = useAuth();
// 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.
// Fetch the user state on initial client-side load.
// The middleware will wait for `initialized` to be true.
onMounted(() => {
fetchMe();
});
</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>
<div class="default-layout">
<header class="app-header" v-if="user">
<div class="stats">
<span>SmurfCoins: {{ user.coins }}</span>
<span>EXP: {{ user.exp }}</span>
</div>
<div class="user-info">
<!-- Level can be calculated later -->
<div class="app-container">
<header v-if="isAuthenticated" class="top-bar">
<div class="user-info-top">
<span>{{ user.nickname }}</span>
<span>💰 {{ user.coins }}</span>
<span> {{ user.exp }}</span>
<button @click="handleLogout" class="logout-button">Logout</button>
</div>
</header>
<main class="app-content">
<main class="main-content">
<slot />
</main>
<footer class="app-footer">
<nav class="bottom-nav">
<NuxtLink to="/" class="nav-item">Главная</NuxtLink>
<NuxtLink to="/habits" class="nav-item">Привычки</NuxtLink>
<NuxtLink to="/village" class="nav-item">Деревня</NuxtLink>
<NuxtLink to="/leaderboard" class="nav-item">Лидеры</NuxtLink>
</nav>
<footer v-if="isAuthenticated" class="bottom-nav">
<NuxtLink to="/" class="nav-item">
<span class="icon">🏠</span>
<span class="label">Главная</span>
</NuxtLink>
<NuxtLink to="/habits" class="nav-item">
<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>
</div>
</template>
<script setup lang="ts">
const { user } = useAuth();
<script setup>
const { user, isAuthenticated, logout } = useAuth();
const handleLogout = async () => {
await logout();
};
</script>
<style scoped>
.default-layout {
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh; /* Use min-height to allow content to push height */
background-color: #eef5ff;
color: #333;
min-height: 100vh;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
.top-bar {
background-color: #f8f8f8;
padding: 10px 15px;
background-color: #4a90e2;
color: white;
flex-shrink: 0;
}
.stats, .user-info {
border-bottom: 1px solid #eee;
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;
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;
overflow-y: auto; /* Allow content to scroll */
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 */
padding-bottom: 60px; /* Space for bottom nav */
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #fff;
border-top: 1px solid #eee;
display: flex;
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 {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: #4a90e2;
padding: 5px 10px;
border-radius: 5px;
text-align: center;
flex: 1; /* Distribute space evenly */
color: #555;
font-size: 0.7em;
padding: 5px;
}
.nav-item.router-link-exact-active {
background-color: #eef5ff;
font-weight: bold;
.nav-item .icon {
font-size: 1.5em;
margin-bottom: 2px;
}
.nav-item.router-link-active {
color: #007bff;
}
</style>

View File

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

View File

@ -1,323 +1,205 @@
<template>
<div class="dashboard">
<div v-if="isAuthenticated && user">
<h2>My Habits for {{ user.nickname }}</h2>
<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>
<div v-if="loading" class="loading-message">
<p>Loading habits...</p>
</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>
<button
v-if="isActionableToday(habit) && !isCompleteToday(habit)"
@click="completeHabit(habit.id)"
:disabled="completing === habit.id"
class="complete-btn"
>
{{ completing === habit.id ? '...' : 'Complete' }}
</button>
<span v-else-if="isCompleteToday(habit)" class="completed-text"> Done!</span>
</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 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">
<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>
<button @click="completeHabit(habit.id)" :disabled="isCompleted(habit, today)">
{{ isCompleted(habit, today) ? 'Completed Today' : 'Complete for Today' }}
</button>
</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>
</div>
<div v-else class="empty-state">
<p>You haven't created any habits yet.</p>
<NuxtLink to="/habits">Manage Habits</NuxtLink>
<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>
<p>Loading session...</p>
<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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
<script setup>
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 api = useApi();
// --- State ---
const allHabits = ref<Habit[]>([]);
const loading = ref(true);
const completing = ref<number | null>(null);
// --- 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(),
}));
// --- Habits Data ---
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
lazy: true,
server: false,
});
const calendarDays = computed(() => {
// --- Date Logic ---
const today = new Date();
const last14Days = computed(() => {
const dates = [];
// JS day is 0=Sun, so we normalize it to backend's 0=Mon format.
const todayBackendDay = normalizeJsDay(today.getUTCDay());
// Start date is the Monday of the previous week.
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);
for (let i = 13; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
dates.push(date);
}
return dates;
});
// --- Methods: Status and Actions ---
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const formatDaysOfWeek = (days: number[]): string => {
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 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 isScheduledDay = (habit: HabitWithCompletionSet, date: Date): boolean => {
const backendDay = normalizeJsDay(normalizeDateUTC(date).getUTCDay());
return habit.daysOfWeek.includes(backendDay);
const isCompleted = (habit, date) => {
return habit.completions.some(c => isSameDay(c.date, date));
};
const isActionableToday = (habit: HabitWithCompletionSet): boolean => {
const todayBackendDay = normalizeJsDay(today.getUTCDay());
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 {
allHabits.value = await api<Habit[]>('/habits');
} catch (error) {
console.error("Failed to fetch habits:", error);
allHabits.value = [];
} 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();
// --- 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.');
}
});
});
};
</script>
<style scoped>
.dashboard {
max-width: 600px;
margin: 0 auto;
.home-page {
padding: 40px;
text-align: center;
}
.habits-list {
display: flex;
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 {
.habits-section {
margin-top: 40px;
color: #666;
}
.empty-state a {
margin-top: 10px;
display: inline-block;
margin-bottom: 40px;
}
/* Calendar Grid Styles */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px; /* Slightly more gap */
.habit-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin: 20px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.calendar-cell {
width: 100%;
aspect-ratio: 1 / 1;
.history-grid {
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;
display: flex;
justify-content: 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 */
}
.date-label {
font-size: 0.75em;
color: #4c566a;
}
.calendar-cell.COMPLETED .date-label,
.calendar-cell.MISSED .date-label {
.day-cell.completed {
background-color: #4ade80;
color: white;
}
.calendar-cell.NEUTRAL {
background-color: #eceff4; /* Gray */
.day-label {
font-size: 0.8em;
}
.calendar-cell.COMPLETED {
background-color: #a3be8c; /* Green */
border-color: #a3be8c;
.welcome-content {
margin-top: 50px;
}
.calendar-cell.MISSED {
background-color: #bf616a; /* Red */
border-color: #bf616a;
.welcome-content h1 {
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>

View File

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

View File

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

View File

@ -1,59 +1,197 @@
<template>
<div class="village-container">
<h3>My Village</h3>
<div class="village-grid">
<div v-for="n in 64" :key="n" class="grid-cell">
<!-- Placeholder for village objects -->
</div>
<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 class="village-actions">
<button>Build Mode</button>
<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 lang="ts">
// No logic for now, just visual placeholders
<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-container {
text-align: center;
.village-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
font-family: sans-serif;
}
h3 {
margin-bottom: 20px;
.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(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 100%;
max-width: 500px; /* Or other size that fits your design */
margin: 0 auto;
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;
aspect-ratio: 1 / 1;
background-color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.grid-cell {
border: 1px dotted #e0e0e0;
background-color: #a3be8c; /* Grassy color */
.tile:hover {
background-color: #e9e9e9;
}
.grid-cell:nth-child(5) { background-color: #bf616a; } /* Fake house */
.grid-cell:nth-child(10) { background-color: #ebcb8b; } /* Fake field */
.grid-cell:nth-child(11) { background-color: #ebcb8b; } /* Fake field */
.tile.selected {
border: 2px solid #007bff;
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;
}
button {
padding: 10px 20px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 5px;
width: 100%;
padding: 10px;
}
</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 @@
/*
Warnings:
-- CreateTable
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
CREATE TABLE "Habit" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@ -43,37 +51,30 @@ CREATE TABLE "Village" (
CREATE TABLE "VillageObject" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"type" TEXT NOT NULL,
"x" INTEGER NOT NULL,
"y" INTEGER NOT NULL,
"obstacleMetadata" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastExpAt" DATETIME,
"cropType" TEXT,
"plantedAt" DATETIME,
"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
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
-- CreateTable
CREATE TABLE "VillageTile" (
"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
"x" INTEGER NOT NULL,
"y" INTEGER NOT NULL,
"terrainType" TEXT NOT NULL,
"terrainState" TEXT NOT NULL DEFAULT 'IDLE',
"clearingStartedAt" DATETIME,
"villageId" INTEGER NOT NULL,
CONSTRAINT "VillageTile_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_User" ("createdAt", "email", "id") SELECT "createdAt", "email", "id" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
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");
-- 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 {
HOUSE
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.
@ -101,25 +109,39 @@ model Village {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int @unique // Each user has only one village
objects VillageObject[]
tiles VillageTile[]
}
// VillageObject: An object (e.g., house, field, obstacle) placed on the
// village grid. It stores the object's type, its coordinates, and optionally
// details if it's an obstacle or a planted crop.
// VillageObject: An object (e.g., house, field) placed on a village tile.
model VillageObject {
id Int @id @default(autoincrement())
type VillageObjectType
x Int
y Int
obstacleMetadata String? // Stores metadata for obstacles (e.g., "rock", "bush").
createdAt DateTime @default(now())
lastExpAt DateTime?
// Crop details (only if type is FIELD)
cropType CropType?
plantedAt DateTime?
// Relations
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
villageId Int
@@unique([villageId, x, y]) // Ensure only one object per grid cell per village
village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
villageId Int
tile VillageTile @relation(fields: [tileId], references: [id])
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 { generateVillageForUser } from '../../services/villageService';
export default defineEventHandler(async (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.
// 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 {
user: {
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';
import { isCropGrown } from '../../utils/village';
import { CropType, VillageObjectType } from '@prisma/client';
// server/api/village/index.get.ts
import { getVillageState, generateVillageForUser } from '../../services/villageService';
import { defineEventHandler } from 'h3';
// --- DTOs ---
interface VillageObjectDto {
id: number;
type: VillageObjectType;
x: number;
y: number;
cropType: CropType | null;
isGrown: boolean | null;
}
export default defineEventHandler(async (event) => {
const user = event.context.user;
interface VillageDto {
objects: VillageObjectDto[];
}
// --- 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 (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
// If the user has no village yet, create one automatically.
if (!village) {
village = await prisma.village.create({
data: { userId },
include: { objects: true },
});
}
// Ensure the user has a village generated. This function is idempotent.
await generateVillageForUser(user);
// Map Prisma objects to clean DTOs, computing `isGrown`.
const objectDtos: VillageObjectDto[] = village.objects.map(obj => ({
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 {
objects: objectDtos,
};
try {
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()

View File

@ -1,10 +1,20 @@
// 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 ---
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,
FIELD: 15,
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
// --- 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
CORN: 4 * 60 * 60 * 1000, // 4 hours
};
// --- 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 },
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.
* @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) {
return false;
}