работа с деревней по визуалу. Сейчас в рабочм состоянии всё

This commit is contained in:
Alexander Andreev 2026-01-05 15:43:29 +03:00
parent 9838471871
commit da2d69960d
18 changed files with 615 additions and 863 deletions

View File

@ -7,6 +7,7 @@ interface User {
avatar: string | null;
coins: number;
exp: number;
dailyStreak: number;
soundOn: boolean;
confettiOn: boolean;
createdAt: string;

View File

@ -0,0 +1,12 @@
// app/composables/useVisitTracker.ts
import { ref } from 'vue';
// This is a simple, client-side, non-persisted state to ensure the
// daily visit API call is only made once per application lifecycle.
const visitCalled = ref(false);
export function useVisitTracker() {
return {
visitCalled,
};
}

View File

@ -1,9 +1,25 @@
<template>
<div class="home-page">
<div v-if="isAuthenticated && user" class="dashboard-content">
<h1>Ваши цели на сегодня</h1>
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
<div class="streak-section">
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">
<h2>x1</h2>
<p>Базовые</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 2 }">
<h2>x2</h2>
<p>Двойные</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak >= 3 }">
<h2>x3</h2>
<p>Тройные</p>
</div>
</div>
<div class="habits-section">
<div v-if="habitsPending">Loading habits...</div>
<div v-else-if="habitsError">Could not load habits.</div>
@ -40,11 +56,6 @@
</div>
</div>
<div class="links">
<NuxtLink to="/habits" class="button">Manage Habits</NuxtLink>
<NuxtLink to="/village" class="button">My Village</NuxtLink>
<NuxtLink to="/leaderboard" class="button">Leaderboard</NuxtLink>
</div>
</div>
<div v-else class="welcome-content">
<h1>Добро пожаловать в SmurfHabits!</h1>
@ -58,427 +69,153 @@
</template>
<script setup>
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 & 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 = [];
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));
};
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
return classes;
}
// 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
id: Math.random(),
habitId: habitId,
date: new Date().toISOString(),
});
}
// Trigger confetti
explodingHabitId.value = habitId;
setTimeout(() => {
explodingHabitId.value = null;
}, 1000); // Animation duration
}, 1000);
} catch (err) {
alert(err.data?.message || 'Failed to complete habit.');
} finally {
isSubmittingHabit.value = false;
}
};
</script>
<style scoped>
@ -487,6 +224,53 @@ const completeHabit = async (habitId, event) => {
text-align: center;
}
.streak-section {
display: flex;
justify-content: center;
gap: 10px; /* Reduced gap */
margin-top: 20px; /* Added margin-top to separate from paragraph */
margin-bottom: 30px; /* Slightly reduced margin-bottom */
}
.streak-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px; /* Slightly reduced border-radius */
padding: 10px 15px; /* Reduced padding */
width: 100px; /* Reduced width */
transition: all 0.3s ease;
font-size: 0.9em; /* Reduced base font size */
}
.streak-card h2 {
margin: 0 0 5px 0; /* Reduced margin */
font-size: 1.8em; /* Reduced font size */
color: #adb5bd;
}
.streak-card p {
margin: 0;
font-size: 0.8em; /* Reduced font size */
color: #6c757d;
}
.active-streak {
border-color: #81a1c1;
background-color: #eceff4;
box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Reduced shadow */
transform: translateY(-3px); /* Reduced transform */
}
.active-streak h2 {
color: #4c566a;
}
.active-streak p {
color: #3b4252;
font-weight: bold;
}
.habits-section {
margin-top: 40px;
margin-bottom: 40px;
@ -509,8 +293,7 @@ const completeHabit = async (habitId, event) => {
}
.day-cell {
/* Removed fixed width/height for responsiveness */
aspect-ratio: 1 / 1; /* Keep cells square */
aspect-ratio: 1 / 1;
border: 1px solid #e2e8f0;
border-radius: 4px;
display: flex;
@ -520,22 +303,22 @@ const completeHabit = async (habitId, event) => {
}
.day-cell.completed {
background-color: #4ade80; /* Green for completed */
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #feecf0; /* Light red for missed */
background-color: #feecf0;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: #81a1c1; /* Blueish accent for scheduled */
border-color: #81a1c1;
}
.future-day .day-label {
color: #adb5bd; /* Muted color for future days */
color: #adb5bd;
}
.day-cell.today-highlight .day-label {
@ -544,7 +327,7 @@ const completeHabit = async (habitId, event) => {
}
.day-label {
font-size: 0.9em; /* Make font size relative to parent for responsiveness */
font-size: 0.9em;
}
.welcome-content {
@ -615,8 +398,8 @@ const completeHabit = async (habitId, event) => {
/* New Habit Card Styles */
.habit-card {
position: relative; /* For confetti positioning */
overflow: hidden; /* Hide confetti that flies out of bounds */
position: relative;
overflow: hidden;
}
.habit-header {
@ -656,7 +439,7 @@ const completeHabit = async (habitId, event) => {
.completed-text {
font-weight: bold;
color: #28a745; /* Joyful green */
color: #28a745;
}
/* Confetti Animation */
@ -703,7 +486,7 @@ const completeHabit = async (habitId, event) => {
.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; }
.confetto-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; }

View File

@ -27,23 +27,43 @@
<!-- Tile Info Overlay -->
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
<div class="tile-overlay-panel" @click.stop>
<h2>Tile ({{ selectedTile.x }}, {{ selectedTile.y }})</h2>
<p>Terrain: {{ selectedTile.terrainType }}</p>
<p v-if="selectedTile.object">Object: {{ selectedTile.object.type }}</p>
<h3>Available Actions</h3>
<h2>{{ getTileTitle(selectedTile) }}</h2>
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
<h3 class="actions-header">Что здесь можно сделать?</h3>
<div class="actions-list">
<div v-for="(action, index) in selectedTile.availableActions" :key="index" class="action-item">
<button
:disabled="!action.isEnabled || isSubmitting"
@click="handleActionClick(action)"
<!-- Build Actions -->
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
<div class="build-card-grid">
<div
v-for="action in selectedTile.availableActions.filter(a => a.type === 'BUILD')"
:key="action.buildingType"
class="building-card"
:class="{ disabled: !action.isEnabled }"
>
<div class="building-icon">{{ getBuildingEmoji(action.buildingType) }}</div>
<h5>{{ getBuildingName(action.buildingType) }}</h5>
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
<div class="building-footer">
<div class="cost">
{{ action.cost }} монет
</div>
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)">
{{ getActionLabel(action) }}
</button>
<span v-if="!action.isEnabled" class="disabled-reason">{{ action.disabledReason }}</span>
</div>
<div v-if="!action.isEnabled" class="disabled-overlay">
<span>{{ getDisabledReasonText(action) }}</span>
</div>
</div>
<button @click="selectedTile = null" class="close-overlay-button">Close</button>
</div>
</div>
</div>
</div>
<button @click="selectedTile = null" class="close-overlay-button">Закрыть</button>
</div>
</div>
</div>
@ -121,11 +141,86 @@ const selectTile = (tile) => {
const getActionLabel = (action) => {
if (action.type === 'BUILD') {
return `${action.type} ${action.buildingType} (${action.cost} coins)`;
return `Построить`;
}
return action.type;
};
const getBuildingName = (buildingType) => {
const buildingNameMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return buildingNameMap[buildingType] || buildingType;
};
const getBuildingEmoji = (buildingType) => {
const emojiMap = {
'HOUSE': '🏠',
'FIELD': '🌱',
'LUMBERJACK': '🪓',
'QUARRY': '⛏️',
'WELL': '💧',
};
return emojiMap[buildingType] || '❓';
};
const getTileTitle = (tile) => {
if (tile.object) {
const buildingMap = {
'HOUSE': 'Дом',
'FIELD': 'Поле',
'LUMBERJACK': 'Домик лесоруба',
'QUARRY': 'Каменоломня',
'WELL': 'Колодец',
};
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} (${tile.x}, ${tile.y})`;
}
const terrainMap = {
'BLOCKED_TREE': 'Лесной участок',
'BLOCKED_STONE': 'Каменистый участок',
'EMPTY': 'Пустырь',
};
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} (${tile.x}, ${tile.y})`;
};
const getTileDescription = (tile) => {
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
const descriptionMap = {
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
'EMPTY': 'Свободное место, готовое к застройке.',
};
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
};
const getBuildingDescription = (buildingType) => {
const descriptions = {
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
'WELL': 'Увеличивает производство опыта на соседних полях.',
};
return descriptions[buildingType] || '';
};
const getDisabledReasonText = (action) => {
const reasons = {
'Not enough coins': 'Не хватает монет.',
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
};
return reasons[action.disabledReason] || action.disabledReason;
};
const isSubmitting = ref(false);
const handleActionClick = async (action) => {
@ -140,7 +235,6 @@ const handleActionClick = async (action) => {
actionType: action.type,
payload: {
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
...(action.type === 'MOVE' && { toTileId: action.toTileId }), // Assuming action.toTileId will be present for MOVE
},
},
});
@ -171,7 +265,6 @@ async function handleAdminAction(url) {
if (error.value) {
alert(error.value.data?.statusMessage || 'An admin action failed.');
} else {
// Refresh both data sources in parallel
await Promise.all([refreshVillageData(), refreshEvents()]);
}
} catch (e) {
@ -194,8 +287,8 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
align-items: center;
padding: 20px;
font-family: sans-serif;
min-height: calc(100vh - 120px); /* Adjust for top/bottom bars */
box-sizing: border-box; /* Include padding in element's total width and height */
min-height: calc(100vh - 120px);
box-sizing: border-box;
}
.loading, .error-container {
@ -206,14 +299,14 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
.village-container {
display: flex;
justify-content: center; /* Center grid */
justify-content: center;
width: 100%;
max-width: 350px; /* Adjust max-width for mobile view of grid (5*60px + 4*4px gap + 2*4px padding)*/
max-width: 350px;
margin-top: 20px;
}
.village-grid-wrapper {
overflow-x: auto; /* In case grid ever exceeds viewport (though it shouldn't with max-width) */
overflow-x: auto;
}
.village-grid {
@ -224,8 +317,8 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
border: 2px solid #333;
padding: 4px;
background-color: #f0f0f0;
width: fit-content; /* Ensure grid does not expand unnecessarily */
margin: 0 auto; /* Center grid within its wrapper */
width: fit-content;
margin: 0 auto;
}
.tile {
@ -253,7 +346,6 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
font-size: 2em;
}
/* Overlay Styles */
.tile-overlay-backdrop {
position: fixed;
top: 0;
@ -263,32 +355,32 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: flex-end; /* Start from bottom for mobile-first feel */
align-items: flex-end;
z-index: 1000;
}
.tile-overlay-panel {
background-color: #fff;
width: 100%;
max-width: 500px; /* Limit width for larger screens */
max-width: 500px;
padding: 20px;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
transform: translateY(0); /* For potential slide-in animation */
transform: translateY(0);
transition: transform 0.3s ease-out;
display: flex;
flex-direction: column;
gap: 15px;
}
@media (min-width: 768px) { /* Center for desktop, less "bottom sheet" */
@media (min-width: 768px) {
.tile-overlay-backdrop {
align-items: center;
}
.tile-overlay-panel {
border-radius: 15px;
max-height: 80vh; /* Don't cover entire screen */
max-height: 80vh;
}
}
@ -298,9 +390,22 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
color: #333;
}
.tile-overlay-panel p {
.tile-description {
text-align: center;
margin-top: -10px;
color: #666;
margin-bottom: 5px;
font-style: italic;
}
.actions-container {
margin-top: 15px;
}
.actions-header {
text-align: center;
font-size: 1.2em;
color: #444;
margin-bottom: 15px;
}
.actions-list {
@ -309,6 +414,10 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
gap: 10px;
}
.action-item {
margin-bottom: 10px;
}
.action-item button {
width: 100%;
padding: 10px 15px;
@ -332,6 +441,25 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
border-color: #e9ecef;
}
.build-section {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.build-section h4 {
text-align: center;
color: #555;
margin-bottom: 10px;
}
.building-description {
font-size: 0.85em;
color: #555;
margin-top: 5px;
text-align: center;
}
.disabled-reason {
font-size: 0.8em;
color: #dc3545;
@ -413,4 +541,92 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
.event-log-table th {
background-color: #f0f0f0;
}
/* New Build Card Styles */
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.building-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 130px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f9f9f9;
text-align: center;
transition: box-shadow 0.2s;
}
.building-card:not(.disabled):hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.building-card.disabled {
background-color: #e9ecef;
}
.building-icon {
font-size: 2em;
margin-bottom: 5px;
}
.building-card h5 {
margin: 0 0 5px 0;
font-size: 1em;
color: #333;
}
.building-card .building-description {
font-size: 0.75em;
color: #666;
flex-grow: 1; /* Pushes footer down */
margin-bottom: 10px;
}
.building-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: auto; /* Pushes footer to bottom */
}
.building-footer .cost {
font-weight: bold;
font-size: 0.9em;
}
.building-footer button {
padding: 5px 10px;
font-size: 0.9em;
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(233, 236, 239, 0.8);
color: #dc3545;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 5px;
border-radius: 8px;
cursor: not-allowed;
}
.disabled-overlay span {
font-size: 0.9em;
}
</style>

View File

@ -1,12 +1,28 @@
// /middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const { isAuthenticated, initialized } = useAuth();
// /middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { isAuthenticated, initialized, updateUser } = useAuth();
const { visitCalled } = useVisitTracker();
const api = useApi();
// Do not run middleware until auth state is initialized on client-side
if (!initialized.value) {
return;
}
// --- Daily Visit Registration ---
// This logic runs once per application load on the client-side for authenticated users.
if (process.client && isAuthenticated.value && !visitCalled.value) {
visitCalled.value = true; // Set flag immediately to prevent race conditions
try {
const updatedUser = await api('/api/user/visit', { method: 'POST' });
if (updatedUser) {
updateUser(updatedUser);
}
} catch (e) {
console.error("Failed to register daily visit from middleware:", e);
}
}
// if the user is authenticated and tries to access /login, redirect to home
if (isAuthenticated.value && to.path === '/login') {
return navigateTo('/', { replace: true });

View File

@ -1,12 +0,0 @@
<!-- /pages/index.vue -->
<template>
<div>
<h1>Dashboard</h1>
<p v-if="user">Welcome, {{ user.nickname }}!</p>
<button @click="logout">Logout</button>
</div>
</template>
<script setup lang="ts">
const { user, logout } = useAuth();
</script>

View File

@ -1,62 +0,0 @@
<!-- /pages/login.vue -->
<template>
<div>
<h1>Login or Register</h1>
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()">
<div>
<label for="email">Email</label>
<input type="email" id="email" v-model="email" required />
</div>
<div v-if="isRegistering">
<label for="nickname">Nickname</label>
<input type="text" id="nickname" v-model="nickname" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" v-model="password" required />
</div>
<div v-if="error">{{ error }}</div>
<button type="submit" :disabled="loading">
{{ isRegistering ? 'Register' : 'Login' }}
</button>
</form>
<button @click="isRegistering = !isRegistering">
{{ isRegistering ? 'Switch to Login' : 'Switch to Register' }}
</button>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default', // Using the default layout
});
const { login, register, loading } = useAuth();
const isRegistering = ref(false);
const email = ref('');
const password = ref('');
const nickname = ref('');
const error = ref<string | null>(null);
const handleLogin = async () => {
error.value = null;
try {
await login(email.value, password.value);
} catch (e: any) {
error.value = e.data?.message || 'Login failed.';
}
};
const handleRegister = async () => {
error.value = null;
try {
await register(email.value, password.value, nickname.value);
// On successful registration, switch to the login view
isRegistering.value = false;
// You might want to auto-login or show a success message instead
} catch (e: any) {
error.value = e.data?.message || 'Registration failed.';
}
};
</script>

View File

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

View File

@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_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,
"dailyStreak" 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
);
INSERT INTO "new_User" ("avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt") SELECT "avatar", "coins", "confettiOn", "createdAt", "email", "exp", "id", "nickname", "password", "soundOn", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -47,6 +47,7 @@ model User {
coins Int @default(0)
exp Int @default(0)
dailyStreak Int @default(0)
// User settings
soundOn Boolean @default(true)

View File

@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => {
avatar: user.avatar,
coins: user.coins,
exp: user.exp,
dailyStreak: user.dailyStreak,
soundOn: user.soundOn,
confettiOn: user.confettiOn,
createdAt: user.createdAt,

View File

@ -1,27 +1,23 @@
import { getUserIdFromSession } from '../../../utils/auth';
import { REWARDS } from '../../../utils/economy';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from '../../../utils/prisma';
import { applyStreakMultiplier } from '../../../utils/streak';
interface CompletionResponse {
message: string;
reward: {
coins: number;
exp: number; // Added
exp: number;
};
updatedCoins: number;
updatedExp: number; // Added
updatedExp: number;
}
/**
* Creates a Date object for the start of a given day in UTC.
* This is duplicated here as per the instruction not to create new shared utilities.
*/
// Helper to get the start of the day in UTC
function getStartOfDay(date: Date): Date {
const startOfDay = new Date(date);
startOfDay.setUTCHours(0, 0, 0, 0);
return startOfDay;
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}
export default defineEventHandler(async (event): Promise<CompletionResponse> => {
@ -32,10 +28,15 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
const habit = await prisma.habit.findFirst({
where: { id: habitId, userId },
});
// Fetch user and habit in parallel
const [user, habit] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.habit.findFirst({ where: { id: habitId, userId } })
]);
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'User not found.' });
}
if (!habit) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found.' });
}
@ -50,13 +51,12 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
throw createError({ statusCode: 400, statusMessage: 'Habit is not active today.' });
}
// Normalize date to the beginning of the day for consistent checks
const startOfToday = getStartOfDay(new Date()); // Correctly get a Date object
const startOfToday = getStartOfDay(today);
const existingCompletion = await prisma.habitCompletion.findFirst({
where: {
habitId: habitId,
date: startOfToday, // Use precise equality check
date: startOfToday,
},
});
@ -64,25 +64,27 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
throw createError({ statusCode: 409, statusMessage: 'Habit already completed today.' });
}
const rewardCoins = REWARDS.HABITS.COMPLETION.coins;
const rewardExp = REWARDS.HABITS.COMPLETION.exp; // Added
// Apply the streak multiplier to the base reward
const baseReward = REWARDS.HABITS.COMPLETION;
const finalReward = applyStreakMultiplier(baseReward, user.dailyStreak);
const village = await prisma.village.findUnique({ where: { userId } });
const [, updatedUser] = await prisma.$transaction([
prisma.habitCompletion.create({
data: {
habitId: habitId,
date: startOfToday, // Save the normalized date
date: startOfToday,
},
}),
prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: rewardCoins,
increment: finalReward.coins,
},
exp: { // Added
increment: rewardExp, // Added
exp: {
increment: finalReward.exp,
},
},
}),
@ -90,20 +92,17 @@ export default defineEventHandler(async (event): Promise<CompletionResponse> =>
data: {
villageId: village.id,
type: 'HABIT_COMPLETION',
message: `Completed habit: "${habit.name}"`,
coins: rewardCoins,
exp: rewardExp, // Changed from 0 to rewardExp
message: `Привычка "${habit.name}" выполнена, принеся вам ${finalReward.coins} монет и ${finalReward.exp} опыта.${user.dailyStreak > 1 ? ` Ваша серия визитов (x${user.dailyStreak}) ${user.dailyStreak === 2 ? 'удвоила' : 'утроила'} награду!` : ''}`,
coins: finalReward.coins,
exp: finalReward.exp,
}
})] : []),
]);
return {
message: 'Habit completed successfully!',
reward: {
coins: rewardCoins,
exp: rewardExp, // Added
},
reward: finalReward,
updatedCoins: updatedUser.coins,
updatedExp: updatedUser.exp, // Added
updatedExp: updatedUser.exp,
};
});

View File

@ -1,109 +0,0 @@
import { getUserIdFromSession } from '../../utils/auth';
import { REWARDS } from '../../utils/economy';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface DailyVisitResponse {
message: string;
reward: {
coins: number;
streakBonus: boolean;
};
updatedCoins: number;
}
/**
* Creates a Date object for the start of a given day in UTC.
*/
function getStartOfDay(date: Date): Date {
const startOfDay = new Date(date);
startOfDay.setUTCHours(0, 0, 0, 0);
return startOfDay;
}
export default defineEventHandler(async (event): Promise<DailyVisitResponse> => {
const userId = await getUserIdFromSession(event);
const today = getStartOfDay(new Date());
// 1. Check if the user has already claimed the reward today
const existingVisit = await prisma.dailyVisit.findUnique({
where: {
userId_date: {
userId,
date: today,
},
},
});
if (existingVisit) {
throw createError({
statusCode: 409,
statusMessage: 'Daily visit reward has already been claimed today.',
});
}
// 2. Check for a 5-day consecutive streak (i.e., visits on the 4 previous days)
const previousDates = Array.from({ length: 4 }, (_, i) => {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - (i + 1));
return d;
});
const priorVisitsCount = await prisma.dailyVisit.count({
where: {
userId,
date: {
in: previousDates,
},
},
});
const hasStreak = priorVisitsCount === 4;
// 3. Calculate rewards and update the database in a transaction
let totalReward = REWARDS.QUESTS.DAILY_VISIT.BASE.coins;
let message = 'Daily visit claimed!';
if (hasStreak) {
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: {
userId,
date: today,
},
}),
prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: totalReward,
},
},
}),
...(village ? [prisma.villageEvent.create({
data: {
villageId: village.id,
type: 'QUEST_DAILY_VISIT',
message,
coins: totalReward,
exp: 0,
}
})] : []),
]);
// 4. Return the response
return {
message,
reward: {
coins: totalReward,
streakBonus: hasStreak,
},
updatedCoins: updatedUser.coins,
};
});

View File

@ -0,0 +1,31 @@
import { getUserIdFromSession } from '../../utils/auth';
import { calculateDailyStreak } from '../../utils/streak';
import prisma from '../../utils/prisma';
/**
* Registers a user's daily visit and calculates their new streak.
* This endpoint is idempotent. Calling it multiple times on the same day
* will not increment the streak further.
*/
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
// Calculate the streak and create today's visit record
const updatedUser = await calculateDailyStreak(prisma, userId);
// The consumer of this endpoint needs the most up-to-date user info,
// including the newly calculated streak.
return {
id: updatedUser.id,
email: updatedUser.email,
nickname: updatedUser.nickname,
avatar: updatedUser.avatar,
coins: updatedUser.coins,
exp: updatedUser.exp,
dailyStreak: updatedUser.dailyStreak,
soundOn: updatedUser.soundOn,
confettiOn: updatedUser.confettiOn,
createdAt: updatedUser.createdAt,
updatedAt: updatedUser.updatedAt,
}
});

View File

@ -1,6 +1,6 @@
// server/api/village/action.post.ts
import { getUserIdFromSession } from '../../utils/auth';
import { buildOnTile, clearTile, moveObject, removeObject } from '../../services/villageService';
import { buildOnTile } from '../../services/villageService';
import { getVillageState } from '../../services/villageService';
export default defineEventHandler(async (event) => {
@ -21,21 +21,6 @@ export default defineEventHandler(async (event) => {
await buildOnTile(userId, tileId, payload.buildingType);
break;
case 'CLEAR':
await clearTile(userId, tileId);
break;
case 'MOVE':
if (!payload?.toTileId) {
throw createError({ statusCode: 400, statusMessage: 'Missing toTileId for MOVE action' });
}
await moveObject(userId, tileId, payload.toTileId);
break;
case 'REMOVE':
await removeObject(userId, tileId);
break;
default:
throw createError({ statusCode: 400, statusMessage: 'Invalid actionType' });
}

View File

@ -89,6 +89,7 @@ type FullVillage = Prisma.VillageGetPayload<{
*/
export async function getVillageState(userId: number): Promise<FullVillage> {
const now = new Date();
const { applyStreakMultiplier } = await import('../utils/streak');
// --- Step 1: Initial Snapshot ---
let villageSnapshot = await prisma.village.findUnique({
@ -104,14 +105,20 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
throw createError({ statusCode: 404, statusMessage: 'Village not found' });
}
const userForStreak = villageSnapshot.user;
// --- 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) {
const totalCoins = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = finishedClearingTiles.length * REWARDS.VILLAGE.CLEARING.exp;
// Apply streak multiplier to clearing rewards
const baseClearingReward = REWARDS.VILLAGE.CLEARING;
const finalClearingReward = applyStreakMultiplier(baseClearingReward, userForStreak.dailyStreak);
const totalCoins = finishedClearingTiles.length * finalClearingReward.coins;
const totalExp = finishedClearingTiles.length * finalClearingReward.exp;
await prisma.$transaction(async (tx) => {
// 1. Update user totals
@ -129,17 +136,28 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
});
// 3. Create an event for each completed tile
// 3. Create an event for each completed tile with the final reward
const multiplier = userForStreak.dailyStreak;
let streakBonusText = '';
if (multiplier === 2) {
streakBonusText = ' Ваша серия визитов (x2) удвоила награду!';
} else if (multiplier >= 3) {
streakBonusText = ' Ваша серия визитов (x3) утроила награду!';
}
for (const tile of finishedClearingTiles) {
const resourceName = tile.terrainType === 'BLOCKED_TREE' ? 'дерево' : 'камень';
const actionText = tile.terrainType === 'BLOCKED_TREE' ? 'Лесоруб расчистил участок' : 'Каменотес раздробил валун';
await tx.villageEvent.create({
data: {
villageId: villageSnapshot.id,
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
message: `${actionText}, принеся вам ${finalClearingReward.coins} монет и ${finalClearingReward.exp} опыта.${streakBonusText}`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
coins: finalClearingReward.coins,
exp: finalClearingReward.exp,
}
});
}
@ -161,19 +179,33 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
const eventsToCreate = [];
for (const field of fieldsForExp) {
let fieldExp = REWARDS.VILLAGE.FIELD_EXP.BASE;
// First, calculate base EXP with existing game logic (well bonus)
let baseFieldExp = 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 *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
baseFieldExp *= REWARDS.VILLAGE.FIELD_EXP.WELL_MULTIPLIER;
}
totalExpFromFields += fieldExp;
// Now, apply the daily streak multiplier
const finalFieldExp = applyStreakMultiplier({ coins: 0, exp: baseFieldExp }, userForStreak.dailyStreak).exp;
totalExpFromFields += finalFieldExp;
const multiplier = userForStreak.dailyStreak;
let streakBonusText = '';
if (multiplier === 2) {
streakBonusText = ' Ваша серия визитов (x2) удвоила урожай опыта!';
} else if (multiplier >= 3) {
streakBonusText = ' Ваша серия визитов (x3) утроила урожай опыта!';
}
eventsToCreate.push({
villageId: villageSnapshot.id,
type: 'FIELD_EXP',
message: `Field at (${field.tile.x}, ${field.tile.y}) produced ${fieldExp} EXP.`,
message: `Поле (${field.tile.x}, ${field.tile.y}) плодоносит, принося вам ${finalFieldExp} опыта.${streakBonusText}`,
tileX: field.tile.x,
tileY: field.tile.y,
coins: 0,
exp: fieldExp,
exp: finalFieldExp,
});
}
@ -251,17 +283,6 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
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'];
@ -288,19 +309,7 @@ export async function getVillageState(userId: number): Promise<FullVillage> {
}
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,
});
// MOVE and REMOVE actions have been removed as per the refactor request.
}
return { ...tile, availableActions };
@ -375,50 +384,4 @@ export async function buildOnTile(userId: number, tileId: number, buildingType:
});
}
export async function clearTile(userId: number, tileId: number) {
return prisma.$transaction(async (tx) => {
const tile = await tx.villageTile.findUniqueOrThrow({
where: { id: tileId },
include: { village: { include: { objects: true } } },
});
if (tile.village.userId !== userId) {
throw createError({ statusCode: 403, statusMessage: "You don't own this tile" });
}
if (tile.terrainState !== 'IDLE') {
throw createError({ statusCode: 400, statusMessage: 'Tile is not idle' });
}
if (tile.terrainType === 'BLOCKED_TREE') {
const hasLumberjack = tile.village.objects.some(o => o.type === 'LUMBERJACK');
if (!hasLumberjack) throw createError({ statusCode: 400, statusMessage: 'Requires a Lumberjack to clear trees' });
} else if (tile.terrainType === 'BLOCKED_STONE') {
const hasQuarry = tile.village.objects.some(o => o.type === 'QUARRY');
if (!hasQuarry) throw createError({ statusCode: 400, statusMessage: 'Requires a Quarry to clear stones' });
} else {
throw createError({ statusCode: 400, statusMessage: 'Tile is not blocked by trees or stones' });
}
await tx.villageTile.update({
where: { id: tileId },
data: {
terrainState: 'CLEARING',
clearingStartedAt: new Date(),
},
});
});
}
export async function removeObject(userId: number, tileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Remove action not implemented yet' });
}
export async function moveObject(userId: number, fromTileId: number, toTileId: number) {
// As requested, this is a stub for now.
throw createError({ statusCode: 501, statusMessage: 'Move action not implemented yet' });
}

108
server/utils/streak.ts Normal file
View File

@ -0,0 +1,108 @@
import { PrismaClient, User } from '@prisma/client';
/**
* Creates a Date object for the start of a given day in UTC.
*/
function getStartOfDay(date: Date): Date {
const startOfDay = new Date(date);
startOfDay.setUTCHours(0, 0, 0, 0);
return startOfDay;
}
/**
* Calculates the user's daily visit streak.
* It checks for consecutive daily visits and updates the user's streak count.
* This function is idempotent and creates a visit record for the current day.
*
* @param prisma The Prisma client instance.
* @param userId The ID of the user.
* @returns The updated User object with the new streak count.
*/
export async function calculateDailyStreak(prisma: PrismaClient, userId: number): Promise<User> {
const today = getStartOfDay(new Date());
const yesterday = getStartOfDay(new Date());
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
// 1. Find the user and their most recent visit
const [user, lastVisit] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.dailyVisit.findFirst({
where: { userId },
orderBy: { date: 'desc' },
}),
]);
if (!user) {
throw new Error('User not found');
}
let newStreak = user.dailyStreak;
// 2. Determine the new streak count
if (lastVisit) {
const lastVisitDate = getStartOfDay(new Date(lastVisit.date));
if (lastVisitDate.getTime() === today.getTime()) {
// Already visited today, streak doesn't change.
newStreak = user.dailyStreak;
} else if (lastVisitDate.getTime() === yesterday.getTime()) {
// Visited yesterday, so increment the streak (capped at 3).
newStreak = Math.min(user.dailyStreak + 1, 3);
} else {
// Missed a day, reset streak to 1.
newStreak = 1;
}
} else {
// No previous visits, so this is the first day of the streak.
newStreak = 1;
}
if (newStreak === 0) {
newStreak = 1;
}
// 3. Use upsert to create today's visit record and update the user's streak in a transaction
const [, updatedUser] = await prisma.$transaction([
prisma.dailyVisit.upsert({
where: { userId_date: { userId, date: today } },
update: {},
create: { userId, date: today },
}),
prisma.user.update({
where: { id: userId },
data: { dailyStreak: newStreak },
}),
]);
return updatedUser;
}
interface Reward {
coins: number;
exp: number;
}
/**
* Applies a streak-based multiplier to a given reward.
* The multiplier is the streak count, capped at 3x.
*
* @param reward The base reward object { coins, exp }.
* @param streak The user's current daily streak.
* @returns The new reward object with the multiplier applied.
*/
export function applyStreakMultiplier(reward: Reward, streak: number | null | undefined): Reward {
const effectiveStreak = streak || 0;
const multiplier = Math.max(1, Math.min(effectiveStreak, 3));
if (multiplier === 0) {
return {
coins: reward.coins * 1,
exp: reward.exp * 1,
};
}
return {
coins: reward.coins * multiplier,
exp: reward.exp * multiplier,
};
}

View File

@ -29,7 +29,6 @@ export const OBSTACLE_CLEAR_COST: Record<string, number> = {
};
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<CropKind, number> = {
@ -37,12 +36,6 @@ export const CROP_GROWTH_TIME: Record<CropKind, number> = {
CORN: 4 * 60 * 60 * 1000, // 4 hours
};
// --- Rewards ---
export const CROP_HARVEST_REWARD: Record<CropKind, { exp: number, coins: number }> = {
BLUEBERRIES: { exp: 5, coins: 0 },
CORN: { exp: 10, coins: 1 },
};
/**
* Checks if a crop is grown based on when it was planted.
* @param plantedAt The ISO string or Date object when the crop was planted.