большой фикс, касающией бизнес логики Деревни. Сейчас приложение стабильно
This commit is contained in:
parent
b8640802ae
commit
de89a41926
40
app/app.vue
40
app/app.vue
|
|
@ -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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
.nav-item.router-link-active {
|
||||
color: #007bff;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
.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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
.switch-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
.success-message {
|
||||
color: green;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.switch-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
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
197
pages/village.vue
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<template>
|
||||
<div class="village-page">
|
||||
<h1>My Village</h1>
|
||||
|
||||
<div v-if="pending" class="loading">Loading your village...</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
|
||||
<p v-else>An error occurred while fetching your village data. Please try again.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="villageData" class="village-container">
|
||||
<div class="village-grid">
|
||||
<div
|
||||
v-for="tile in villageData.tiles"
|
||||
:key="tile.id"
|
||||
class="tile"
|
||||
:class="{ selected: selectedTile && selectedTile.id === tile.id }"
|
||||
@click="selectTile(tile)"
|
||||
>
|
||||
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTile" class="action-panel">
|
||||
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
|
||||
<div class="actions-list">
|
||||
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
|
||||
<button
|
||||
:disabled="!action.isEnabled"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
{{ getActionLabel(action) }}
|
||||
</button>
|
||||
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="selectedTile = null" class="close-panel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { data: villageData, pending, error } = await useFetch('/api/village', {
|
||||
lazy: true,
|
||||
server: false, // Ensure this runs on the client-side
|
||||
});
|
||||
|
||||
const selectedTile = ref(null);
|
||||
|
||||
const getTileEmoji = (tile) => {
|
||||
if (tile.terrainState === 'CLEARING') return '⏳';
|
||||
if (tile.object) {
|
||||
switch (tile.object.type) {
|
||||
case 'HOUSE': return '🏠';
|
||||
case 'FIELD': return '🌱';
|
||||
case 'LUMBERJACK': return '🪓';
|
||||
case 'QUARRY': return '⛏️';
|
||||
case 'WELL': return '💧';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
switch (tile.terrainType) {
|
||||
case 'BLOCKED_TREE': return '🌳';
|
||||
case 'BLOCKED_STONE': return '🪨';
|
||||
case 'EMPTY': return '⬜';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const selectTile = (tile) => {
|
||||
selectedTile.value = tile;
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
if (action.type === 'BUILD') {
|
||||
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
|
||||
}
|
||||
return action.type;
|
||||
};
|
||||
|
||||
const handleActionClick = (action) => {
|
||||
console.log('Action clicked:', action);
|
||||
// In a future task, this will dispatch the action to the backend.
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.village-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.loading, .error-container {
|
||||
margin-top: 50px;
|
||||
font-size: 1.2em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.village-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.village-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 60px);
|
||||
grid-template-rows: repeat(7, 60px);
|
||||
gap: 4px;
|
||||
border: 2px solid #333;
|
||||
padding: 4px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.tile.selected {
|
||||
border: 2px solid #007bff;
|
||||
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
.tile-content {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.action-panel {
|
||||
width: 300px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 20px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.action-panel h2 {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-item button {
|
||||
padding: 10px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.action-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: #eee;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.disabled-reason {
|
||||
font-size: 0.8em;
|
||||
color: #d9534f;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.close-panel {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
2
scripts/fix-migration.sql
Normal file
2
scripts/fix-migration.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DELETE FROM _prisma_migrations
|
||||
WHERE migration_name = '20260103181802_refactor_village_schema';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -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.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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.' });
|
||||
}
|
||||
});
|
||||
|
|
@ -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" };
|
||||
});
|
||||
|
|
@ -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.' });
|
||||
}
|
||||
});
|
||||
|
|
@ -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
42
server/middleware/auth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
299
server/services/villageService.ts
Normal file
299
server/services/villageService.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user