работа с главной по визуалу. приложение работоспособно

This commit is contained in:
Alexander Andreev 2026-01-05 14:43:12 +03:00
parent a8241d93c6
commit 9838471871
10 changed files with 860 additions and 319 deletions

View File

@ -64,6 +64,12 @@ export function useAuth() {
}
};
const updateUser = (partialUser: Partial<User>) => {
if (user.value) {
user.value = { ...user.value, ...partialUser };
}
};
// Expose the state and methods.
return {
user,
@ -72,5 +78,6 @@ export function useAuth() {
fetchMe,
login,
logout,
updateUser,
};
}

View File

@ -3,8 +3,8 @@
<header v-if="isAuthenticated" class="top-bar">
<div class="user-info-top">
<span>{{ user.nickname }}</span>
<span>💰 {{ user.coins }}</span>
<span> {{ user.exp }}</span>
<span>💰 {{ displayedCoins }}</span>
<span> {{ displayedExp }}</span>
<button @click="handleLogout" class="logout-button">Logout</button>
</div>
</header>
@ -35,11 +35,58 @@
</template>
<script setup>
import { ref, watch } from 'vue';
const { user, isAuthenticated, logout } = useAuth();
const handleLogout = async () => {
await logout();
};
// Refs for the displayed values
const displayedCoins = ref(user.value?.coins ?? 0);
const displayedExp = ref(user.value?.exp ?? 0);
// Animation function
const animateValue = (start, end, onUpdate) => {
if (start === end) return;
const range = end - start;
const increment = range > 0 ? 1 : -1;
const duration = 500; // Total animation time
const stepTime = Math.abs(Math.floor(duration / range));
let current = start;
const timer = setInterval(() => {
current += increment;
onUpdate(current);
if (current === end) {
clearInterval(timer);
}
}, stepTime > 0 ? stepTime : 1);
};
// Watch for changes in user's coins and animate
watch(() => user.value?.coins, (newCoins, oldCoins) => {
if (newCoins !== undefined && oldCoins !== undefined) {
animateValue(oldCoins, newCoins, (value) => {
displayedCoins.value = value;
});
} else if (newCoins !== undefined) {
displayedCoins.value = newCoins;
}
});
// Watch for changes in user's exp and animate
watch(() => user.value?.exp, (newExp, oldExp) => {
if (newExp !== undefined && oldExp !== undefined) {
animateValue(oldExp, newExp, (value) => {
displayedExp.value = value;
});
} else if (newExp !== undefined) {
displayedExp.value = newExp;
}
});
</script>
<style scoped>

View File

@ -1,26 +1,40 @@
<template>
<div class="home-page">
<div v-if="isAuthenticated && user" class="dashboard-content">
<h1>Welcome, {{ user.nickname }}!</h1>
<p>This is your dashboard. Let's get those habits done!</p>
<h1>Ваши цели на сегодня</h1>
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
<div class="habits-section">
<h2>My Habits</h2>
<div v-if="habitsPending">Loading habits...</div>
<div v-else-if="habitsError">Could not load habits.</div>
<div v-else-if="habits && habits.length > 0">
<div v-for="habit in habits" :key="habit.id" class="habit-card">
<div class="habit-header">
<div class="habit-details">
<h3>{{ habit.name }}</h3>
<div class="history-grid">
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="{ 'completed': isCompleted(habit, day) }">
<span class="day-label">{{ day.getDate() }}</span>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</div>
</div>
<button @click="completeHabit(habit.id)" :disabled="isCompleted(habit, today)">
{{ isCompleted(habit, today) ? 'Completed Today' : 'Complete for Today' }}
<div class="habit-action">
<div v-if="isScheduledForToday(habit)">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit">
Выполнить
</button>
</div>
</div>
</div>
<div class="history-grid">
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
<span class="day-label">{{ formatDayLabel(day) }}</span>
</div>
</div>
<div v-if="explodingHabitId === habit.id" class="confetti-container">
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
</div>
</div>
</div>
<div v-else>
<p>You have no habits yet. Go to the <NuxtLink to="/habits">My Habits</NuxtLink> page to create one.</p>
</div>
@ -44,49 +58,425 @@
</template>
<script setup>
import { computed } from 'vue';
const { user, isAuthenticated } = useAuth();
import { ref, computed } from 'vue';
const { user, isAuthenticated, updateUser } = useAuth();
const api = useApi();
// --- Habits Data ---
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
lazy: true,
server: false,
});
// --- Date Logic ---
// --- Date Logic & Helpers ---
const today = new Date();
const todayNormalized = new Date();
todayNormalized.setHours(0, 0, 0, 0);
const russianDayMap = { 0: 'Вс', 1: 'Пн', 2: 'Вт', 3: 'Ср', 4: 'Чт', 5: 'Пт', 6: 'Сб' };
const last14Days = computed(() => {
const dates = [];
for (let i = 13; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const today = new Date();
const todayDay = today.getDay(); // 0 for Sunday, 1 for Monday, etc.
// Adjust so that Monday is 1 and Sunday is 7
const dayOfWeek = todayDay === 0 ? 7 : todayDay;
// Calculate days to subtract to get to the Monday of LAST week
// (dayOfWeek - 1) gets us to this week's Monday. +7 gets us to last week's Monday.
const daysToSubtract = (dayOfWeek - 1) + 7;
const startDate = new Date();
startDate.setDate(today.getDate() - daysToSubtract);
for (let i = 0; i < 14; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
dates.push(date);
}
return dates;
});
const formatDayLabel = (date) => {
// Use Intl for robust localization. 'янв' needs a specific format.
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
// The format might include a " г.", remove it.
return formatted.replace(' г.', '');
};
const isSameDay = (d1, d2) => {
d1 = new Date(d1);
d2 = new Date(d2);
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
};
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
return habit.completions.some(c => isSameDay(c.date, date));
};
// --- Actions ---
const completeHabit = async (habitId) => {
try {
await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
await refreshHabits(); // Refresh the habits data to show the new completion
} catch (err) {
alert(err.data?.message || 'Failed to complete habit.');
const getCellClasses = (habit, day) => {
const classes = {};
const dayNormalized = new Date(day);
dayNormalized.setHours(0, 0, 0, 0);
const habitCreatedAt = new Date(habit.createdAt);
habitCreatedAt.setHours(0, 0, 0, 0);
// Is the day in the future?
if (dayNormalized > todayNormalized) {
classes['future-day'] = true;
}
// Is the day today?
if (isSameDay(dayNormalized, todayNormalized)) {
classes['today-highlight'] = true;
}
// Is the habit scheduled for this day?
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
// Is the habit completed on this day?
if (isCompleted(habit, dayNormalized)) {
classes['completed'] = true;
return classes; // Completion state overrides all others
}
// Is it a missed day?
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = today.getDay(); // Sunday is 0
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
const getScheduleText = (habit) => {
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
};
// --- Actions & UI State ---
const isSubmittingHabit = ref(false);
const explodingHabitId = ref(null);
const completeHabit = async (habitId, event) => {
if (event) {
event.preventDefault();
}
if (isSubmittingHabit.value) return;
isSubmittingHabit.value = true;
try {
// The API returns the updated user stats and reward info
const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
// Update the global user state for coins and exp
if (updateUser && response) {
updateUser({
coins: response.updatedCoins,
exp: response.updatedExp,
});
}
// Optimistically update the local habit state to show completion
const habit = habits.value.find(h => h.id === habitId);
if (habit) {
habit.completions.push({
id: Math.random(), // Temporary ID for the key
habitId: habitId,
date: new Date().toISOString(),
});
}
// Trigger confetti
explodingHabitId.value = habitId;
setTimeout(() => {
explodingHabitId.value = null;
}, 1000); // Animation duration
} catch (err) {
alert(err.data?.message || 'Failed to complete habit.');
} finally {
isSubmittingHabit.value = false;
}
};
</script>
@ -113,14 +503,14 @@ const completeHabit = async (habitId) => {
.history-grid {
display: grid;
grid-template-columns: repeat(14, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin: 20px 0;
}
.day-cell {
width: 40px;
height: 40px;
/* Removed fixed width/height for responsiveness */
aspect-ratio: 1 / 1; /* Keep cells square */
border: 1px solid #e2e8f0;
border-radius: 4px;
display: flex;
@ -130,12 +520,31 @@ const completeHabit = async (habitId) => {
}
.day-cell.completed {
background-color: #4ade80;
background-color: #4ade80; /* Green for completed */
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #feecf0; /* Light red for missed */
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: #81a1c1; /* Blueish accent for scheduled */
}
.future-day .day-label {
color: #adb5bd; /* Muted color for future days */
}
.day-cell.today-highlight .day-label {
text-decoration: underline;
font-weight: bold;
}
.day-label {
font-size: 0.8em;
font-size: 0.9em; /* Make font size relative to parent for responsiveness */
}
.welcome-content {
@ -202,4 +611,101 @@ const completeHabit = async (habitId) => {
.links a.button:hover {
background-color: #dee2e6;
}
/* New Habit Card Styles */
.habit-card {
position: relative; /* For confetti positioning */
overflow: hidden; /* Hide confetti that flies out of bounds */
}
.habit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.habit-details {
text-align: left;
}
.habit-details h3 {
margin: 0;
}
.habit-schedule {
margin: 5px 0 0 0;
font-size: 0.9em;
color: #6c757d;
}
.habit-action button {
padding: 8px 16px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.habit-action button:hover {
background-color: #4c566a;
}
.completed-text {
font-weight: bold;
color: #28a745; /* Joyful green */
}
/* Confetti Animation */
.confetti-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.confetti-particle {
position: absolute;
left: 50%;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 50%;
animation: confetti-fall 1s ease-out forwards;
}
@keyframes confetti-fall {
from {
transform: translateY(0) translateX(0);
opacity: 1;
}
to {
transform: translateY(-200px) translateX(var(--x-end)) rotate(360deg);
opacity: 0;
}
}
/* Particle Colors & random-ish trajectories */
.confetti-particle:nth-child(1) { background-color: #d88e8e; --x-end: -150px; animation-delay: 0s; }
.confetti-particle:nth-child(2) { background-color: #a3be8c; --x-end: 150px; animation-delay: 0.1s; }
.confetti-particle:nth-child(3) { background-color: #ebcb8b; --x-end: 100px; animation-delay: 0.05s; }
.confetti-particle:nth-child(4) { background-color: #81a1c1; --x-end: -100px; animation-delay: 0.2s; }
.confetti-particle:nth-child(5) { background-color: #b48ead; --x-end: 50px; animation-delay: 0.15s; }
.confetti-particle:nth-child(6) { background-color: #d88e8e; --x-end: -50px; animation-delay: 0.3s; }
.confetti-particle:nth-child(7) { background-color: #a3be8c; --x-end: -80px; animation-delay: 0.25s; }
.confetti-particle:nth-child(8) { background-color: #ebcb8b; --x-end: 80px; animation-delay: 0.4s; }
.confetti-particle:nth-child(9) { background-color: #81a1c1; --x-end: 120px; animation-delay: 0.35s; }
.confetti-particle:nth-child(10) { background-color: #b48ead; --x-end: -120px; animation-delay: 0.45s; }
.confetti-particle:nth-child(11) { background-color: #d88e8e; --x-end: -180px; animation-delay: 0.08s; }
.confetti-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
</style>

View File

@ -53,8 +53,8 @@
<h3>Admin Tools</h3>
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h4>Activity Log</h4>
@ -184,6 +184,7 @@ async function handleAdminAction(url) {
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
</script>
<style scoped>

View File

@ -1,7 +1,7 @@
// server/api/admin/village/complete-clearing.post.ts
import { getUserIdFromSession } from '../../../utils/auth';
import { PrismaClient } from '@prisma/client';
import { REWARDS } from '../../../services/villageService';
import { REWARDS } from '../../../utils/economy';
const prisma = new PrismaClient();
@ -26,8 +26,8 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'No clearing tasks to complete.' };
}
const totalCoins = tilesToComplete.length * REWARDS.CLEARING.coins;
const totalExp = tilesToComplete.length * REWARDS.CLEARING.exp;
const totalCoins = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.exp;
await prisma.$transaction(async (tx) => {
// 1. Update user totals
@ -54,8 +54,8 @@ export default defineEventHandler(async (event) => {
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.CLEARING.coins,
exp: REWARDS.CLEARING.exp,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
}
});
}

View File

@ -0,0 +1,49 @@
// server/api/admin/village/trigger-tick.post.ts
import { getUserIdFromSession } from '../../../utils/auth';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// This is a simplified constant. In a real scenario, this might be shared from a single source.
const CLEANING_TIME_MS = 24 * 60 * 60 * 1000; // 24 hours
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
// Simple admin check
if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
}
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
const now = Date.now();
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS + 5000); // 5 seconds past completion
await prisma.$transaction([
// 1. Fast-forward any tiles that are currently being cleared
prisma.villageTile.updateMany({
where: {
villageId: village.id,
terrainState: 'CLEARING',
},
data: {
clearingStartedAt: clearingFastForwardDate,
},
}),
// 2. Fast-forward any fields to be ready for EXP gain
prisma.villageObject.updateMany({
where: {
villageId: village.id,
type: 'FIELD',
},
data: {
lastExpAt: yesterday,
},
}),
]);
return { success: true, message: 'Clearing and Field timers have been fast-forwarded.' };
});

View File

@ -1,11 +1,17 @@
import { getUserIdFromSession } from '../../../utils/auth';
import { REWARDS } from '../../../utils/economy';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface CompletionResponse {
message: string;
reward: {
coins: number;
exp: number; // Added
};
updatedCoins: number;
updatedExp: number; // Added
}
/**
@ -35,8 +41,12 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
}
const today = new Date();
const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
if (!(habit.daysOfWeek as number[]).includes(dayOfWeek)) {
const jsDayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
const appDayOfWeek = (jsDayOfWeek === 0) ? 6 : jsDayOfWeek - 1;
if (!(habit.daysOfWeek as number[]).includes(appDayOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
}
@ -54,7 +64,10 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
}
const rewardCoins = 3;
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
const village = await prisma.village.findUnique({ where: { userId } });
const [, updatedUser] = await prisma.$transaction([
prisma.habitCompletion.create({
data: {
@ -68,15 +81,29 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
coins: {
increment: rewardCoins,
},
exp: { // Added
increment: rewardExp, // Added
},
},
}),
...(village ? [prisma.villageEvent.create({
data: {
villageId: village.id,
type: 'HABIT_COMPLETION',
message: `Completed habit: "${habit.name}"`,
coins: rewardCoins,
exp: rewardExp, // Changed from 0 to rewardExp
}
})] : []),
]);
return {
message: 'Habit completed successfully!',
reward: {
coins: rewardCoins,
exp: rewardExp, // Added
},
updatedCoins: updatedUser.coins,
updatedExp: updatedUser.exp, // Added
};
});

View File

@ -1,4 +1,8 @@
import { getUserIdFromSession } from '../../utils/auth';
import { REWARDS } from '../../utils/economy';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface DailyVisitResponse {
message: string;
@ -58,11 +62,15 @@ export default defineEventHandler(async (event): Promise<DailyVisitResponse> =>
const hasStreak = priorVisitsCount === 4;
// 3. Calculate rewards and update the database in a transaction
let totalReward = 1; // Base reward
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
let message = 'Daily visit claimed!';
if (hasStreak) {
totalReward += 10; // Streak bonus
totalReward += REWARDS.QUESTS.DAILY_VISIT.STREAK_BONUS.coins;
message = 'Daily visit and streak bonus claimed!';
}
const village = await prisma.village.findUnique({ where: { userId } });
const [, updatedUser] = await prisma.$transaction([
prisma.dailyVisit.create({
data: {
@ -78,11 +86,20 @@ export default defineEventHandler(async (event): Promise<DailyVisitResponse> =>
},
},
}),
...(village ? [prisma.villageEvent.create({
data: {
villageId: village.id,
type: 'QUEST_DAILY_VISIT',
message,
coins: totalReward,
exp: 0,
}
})] : []),
]);
// 4. Return the response
return {
message: hasStreak ? 'Daily visit and streak bonus claimed!' : 'Daily visit claimed!',
message,
reward: {
coins: totalReward,
streakBonus: hasStreak,

View File

@ -1,5 +1,5 @@
// server/services/villageService.ts
import { PrismaClient, User, Village, VillageTile, VillageObject, Prisma } from '@prisma/client';
import { COSTS, REWARDS } from '../utils/economy';
const prisma = new PrismaClient();
@ -7,24 +7,12 @@ export const VILLAGE_WIDTH = 5;
export const VILLAGE_HEIGHT = 7;
const CLEANING_TIME = 24 * 60 * 60 * 1000; // 24 hours
export const BUILDING_COSTS: Record<string, number> = {
HOUSE: 50,
FIELD: 15,
LUMBERJACK: 30,
QUARRY: 30,
WELL: 20,
};
export const PRODUCING_BUILDINGS: string[] = [
'FIELD',
'LUMBERJACK',
'QUARRY',
];
export const REWARDS = {
CLEARING: { coins: 1, exp: 1 },
};
// Helper to get the start of a given date for daily EXP checks
const getStartOfDay = (date: Date) => {
const d = new Date(date);
@ -122,8 +110,8 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
);
if (finishedClearingTiles.length > 0) {
const totalCoins = finishedClearingTiles.length * REWARDS.CLEARING.coins;
const totalExp = finishedClearingTiles.length * REWARDS.CLEARING.exp;
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
await prisma.$transaction(async (tx) => {
// 1. Update user totals
@ -150,8 +138,8 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.CLEARING.coins,
exp: REWARDS.CLEARING.exp,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
}
});
}
@ -170,18 +158,29 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
if (fieldsForExp.length > 0) {
const wellPositions = new Set(villageSnapshot.objects.filter(obj => obj.type === 'WELL').map(w => `${w.tile.x},${w.tile.y}`));
let totalExpFromFields = 0;
const eventsToCreate = [];
for (const field of fieldsForExp) {
let fieldExp = 1;
let fieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
if (wellPositions.has(`${field.tile.x},${field.tile.y - 1}`) || wellPositions.has(`${field.tile.x},${field.tile.y + 1}`) || wellPositions.has(`${field.tile.x - 1},${field.tile.y}`) || wellPositions.has(`${field.tile.x + 1},${field.tile.y}`)) {
fieldExp *= 2;
fieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
}
totalExpFromFields += fieldExp;
eventsToCreate.push({
villageId: villageSnapshot.id,
type: 'FIELD_EXP',
message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
tileX: field.tile.x,
tileY: field.tile.y,
coins: 0,
exp: fieldExp,
});
}
await prisma.$transaction([
prisma.user.update({ where: { id: userId }, data: { exp: { increment: totalExpFromFields } } }),
...fieldsForExp.map(f => prisma.villageObject.update({ where: { id: f.id }, data: { lastExpAt: today } })),
prisma.villageObject.updateMany({ where: { id: { in: fieldsForExp.map(f => f.id) } }, data: { lastExpAt: today } }),
prisma.villageEvent.createMany({ data: eventsToCreate }),
]);
}
@ -267,7 +266,7 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
if (tile.terrainType === 'EMPTY' && !tile.object) {
const buildableObjectTypes = ['HOUSE', 'FIELD', 'LUMBERJACK', 'QUARRY', 'WELL'];
const buildActions = buildableObjectTypes.map(buildingType => {
const cost = BUILDING_COSTS[buildingType];
const cost = COSTS.BUILD[buildingType];
const isProducing = PRODUCING_BUILDINGS.includes(buildingType);
let isEnabled = user.coins >= cost;
let disabledReason = user.coins < cost ? 'Not enough coins' : undefined;
@ -308,269 +307,118 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
});
return { ...finalVillageState, tiles: tilesWithActions } as any;
}
}
// --- Action Service Functions ---
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
// --- Action Service Functions ---
export async function buildOnTile(userId: number, tileId: number, buildingType: string) {
const { VillageObjectType } = await import('@prisma/client');
const validBuildingTypes = Object.keys(VillageObjectType);
if (!validBuildingTypes.includes(buildingType)) {
throw createError({ statusCode: 400, statusMessage: `Invalid building type: ${buildingType}` });
}
return prisma.$transaction(async (tx) => {
// 1. Fetch all necessary data
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
const tile = await tx.villageTile.findUniqueOrThrow({ where: { id: tileId }, include: { village: true } });
// Ownership check
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
}
// Business logic validation
if (tile.terrainType !== 'EMPTY' || tile.object) {
throw createError({ statusCode: 400, statusMessage: 'Tile is not empty' });
}
const cost = BUILDING_COSTS[buildingType];
const cost = COSTS.BUILD[buildingType];
if (user.coins < cost) {
throw createError({ statusCode: 400, statusMessage: 'Not enough coins' });
}
if (PRODUCING_BUILDINGS.includes(buildingType)) {
const villageObjects = await tx.villageObject.findMany({ where: { villageId: tile.villageId } });
const housesCount = villageObjects.filter(o => o.type === 'HOUSE').length;
const producingCount = villageObjects.filter(o => PRODUCING_BUILDINGS.includes(o.type)).length;
if (producingCount >= housesCount) {
throw createError({ statusCode: 400, statusMessage: 'Not enough workers (houses)' });
}
}
// 2. Perform mutations
await tx.user.update({
where: { id: userId },
data: { coins: { decrement: cost } },
});
await tx.villageObject.create({
data: {
type: buildingType as keyof typeof VillageObjectType,
villageId: tile.villageId,
tileId: tileId,
},
});
await tx.villageEvent.create({
data: {
villageId: tile.villageId,
type: `BUILD_${buildingType}`,
message: `Built a ${buildingType} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: -cost,
exp: 0,
},
});
});
}
}
export async function clearTile(userId: number, tileId: number) {
export async function clearTile(userId: number, tileId: number) {
return prisma.$transaction(async (tx) => {
const tile = await tx.villageTile.findUniqueOrThrow({
where: { id: tileId },
include: { village: { include: { objects: true } } },
});
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
}
if (tile.terrainState !== 'IDLE') {
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
}
if (tile.terrainType === 'BLOCKED_TREE') {
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
} else if (tile.terrainType === 'BLOCKED_STONE') {
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
} else {
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
}
await tx.villageTile.update({
where: { id: tileId },
data: {
terrainState: 'CLEARING',
clearingStartedAt: new Date(),
},
});
});
}
}
export async function removeObject(userId: number, tileId: number) {
export async function removeObject(userId: number, tileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
}
}
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
}
}

39
server/utils/economy.ts Normal file
View File

@ -0,0 +1,39 @@
// server/utils/economy.ts
/**
* All costs for actions that the player can take.
*/
export const COSTS = {
BUILD: {
HOUSE: 50,
FIELD: 15,
LUMBERJACK: 30,
QUARRY: 30,
WELL: 20,
}
};
/**
* All rewards the player can receive from various actions and events.
*/
export const REWARDS = {
// Village-related rewards
VILLAGE: {
CLEARING: { coins: 1, exp: 1 },
FIELD_EXP: {
BASE: 1,
WELL_MULTIPLIER: 2,
},
},
// Quest-related rewards
QUESTS: {
DAILY_VISIT: {
BASE: { coins: 1 },
STREAK_BONUS: { coins: 10 },
}
},
// Habit-related rewards
HABITS: {
COMPLETION: { coins: 3, exp: 1 },
}
};