diff --git a/app/pages/village.vue b/app/pages/village.vue
index ec0787c..747b20e 100644
--- a/app/pages/village.vue
+++ b/app/pages/village.vue
@@ -54,6 +54,29 @@
+
+
+
+
Activity Log
+
+
+
+ | Date |
+ Event |
+ Coins |
+ EXP |
+
+
+
+
+ | {{ new Date(event.createdAt).toLocaleString() }} |
+ {{ event.message }} |
+ {{ event.coins }} |
+ {{ event.exp }} |
+
+
+
+
@@ -65,6 +88,11 @@ const { data: villageData, pending, error, refresh: refreshVillageData } = await
server: false, // Ensure this runs on the client-side
});
+const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/village/events', {
+ lazy: true,
+ server: false,
+});
+
const selectedTile = ref(null);
const getTileEmoji = (tile) => {
@@ -122,6 +150,7 @@ const handleActionClick = async (action) => {
} else {
villageData.value = response.data.value;
selectedTile.value = null;
+ await refreshEvents(); // Refresh the event log
}
} catch (e) {
console.error('Failed to perform action:', e);
@@ -133,7 +162,7 @@ const handleActionClick = async (action) => {
const isSubmittingAdminAction = ref(false);
-async function handleAdminAction(url: string) {
+async function handleAdminAction(url) {
if (isSubmittingAdminAction.value) return;
isSubmittingAdminAction.value = true;
@@ -142,7 +171,8 @@ async function handleAdminAction(url: string) {
if (error.value) {
alert(error.value.data?.statusMessage || 'An admin action failed.');
} else {
- await refreshVillageData();
+ // Refresh both data sources in parallel
+ await Promise.all([refreshVillageData(), refreshEvents()]);
}
} catch (e) {
console.error('Failed to perform admin action:', e);
@@ -355,4 +385,31 @@ const handleCompleteClearing = () => handleAdminAction('/api/admin/village/compl
background-color: #e9ecef;
cursor: not-allowed;
}
+
+.event-log-container {
+ margin-top: 20px;
+ width: 100%;
+ max-width: 350px;
+}
+
+.event-log-container h4 {
+ text-align: center;
+ margin-bottom: 10px;
+}
+
+.event-log-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8em;
+}
+
+.event-log-table th, .event-log-table td {
+ border: 1px solid #ccc;
+ padding: 6px;
+ text-align: left;
+}
+
+.event-log-table th {
+ background-color: #f0f0f0;
+}
\ No newline at end of file
diff --git a/prisma/migrations/20260104143245_add_village_events/migration.sql b/prisma/migrations/20260104143245_add_village_events/migration.sql
new file mode 100644
index 0000000..7f8911d
--- /dev/null
+++ b/prisma/migrations/20260104143245_add_village_events/migration.sql
@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "VillageEvent" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "villageId" INTEGER NOT NULL,
+ "type" TEXT NOT NULL,
+ "message" TEXT NOT NULL,
+ "tileX" INTEGER,
+ "tileY" INTEGER,
+ "coins" INTEGER NOT NULL,
+ "exp" INTEGER NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "VillageEvent_villageId_fkey" FOREIGN KEY ("villageId") REFERENCES "Village" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0b6e8e1..d5d16a5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -113,6 +113,7 @@ model Village {
userId Int @unique // Each user has only one village
objects VillageObject[]
tiles VillageTile[]
+ events VillageEvent[]
}
// VillageObject: An object (e.g., house, field) placed on a village tile.
@@ -148,3 +149,21 @@ model VillageTile {
@@unique([villageId, x, y])
}
+
+model VillageEvent {
+ id Int @id @default(autoincrement())
+ villageId Int
+
+ type String
+ message String
+
+ tileX Int?
+ tileY Int?
+
+ coins Int
+ exp Int
+
+ createdAt DateTime @default(now())
+
+ village Village @relation(fields: [villageId], references: [id], onDelete: Cascade)
+}
diff --git a/server/api/admin/village/complete-clearing.post.ts b/server/api/admin/village/complete-clearing.post.ts
index a8e7f1a..09bc3fe 100644
--- a/server/api/admin/village/complete-clearing.post.ts
+++ b/server/api/admin/village/complete-clearing.post.ts
@@ -1,6 +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';
const prisma = new PrismaClient();
@@ -25,27 +26,40 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'No clearing tasks to complete.' };
}
- await prisma.$transaction([
- // Complete the tiles
- prisma.villageTile.updateMany({
- where: {
- id: { in: tilesToComplete.map(t => t.id) },
- },
- data: {
- terrainState: 'IDLE',
- terrainType: 'EMPTY',
- clearingStartedAt: null,
- },
- }),
- // Give the user the rewards (1 coin and 1 exp per tile)
- prisma.user.update({
+ const totalCoins = tilesToComplete.length * REWARDS.CLEARING.coins;
+ const totalExp = tilesToComplete.length * REWARDS.CLEARING.exp;
+
+ await prisma.$transaction(async (tx) => {
+ // 1. Update user totals
+ await tx.user.update({
where: { id: userId },
data: {
- coins: { increment: tilesToComplete.length },
- exp: { increment: tilesToComplete.length },
+ coins: { increment: totalCoins },
+ exp: { increment: totalExp },
},
- }),
- ]);
+ });
+
+ // 2. Update all the tiles
+ await tx.villageTile.updateMany({
+ where: { id: { in: tilesToComplete.map(t => t.id) } },
+ data: { terrainState: 'IDLE', terrainType: 'EMPTY', clearingStartedAt: null },
+ });
+
+ // 3. Create an event for each completed tile
+ for (const tile of tilesToComplete) {
+ await tx.villageEvent.create({
+ data: {
+ villageId: village.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})`,
+ tileX: tile.x,
+ tileY: tile.y,
+ coins: REWARDS.CLEARING.coins,
+ exp: REWARDS.CLEARING.exp,
+ }
+ });
+ }
+ });
return { success: true, message: `Completed ${tilesToComplete.length} clearing tasks.` };
});
diff --git a/server/api/village/events.get.ts b/server/api/village/events.get.ts
new file mode 100644
index 0000000..4682601
--- /dev/null
+++ b/server/api/village/events.get.ts
@@ -0,0 +1,30 @@
+// server/api/village/events.get.ts
+import { getUserIdFromSession } from '../../utils/auth';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+export default defineEventHandler(async (event) => {
+ const userId = await getUserIdFromSession(event);
+
+ const village = await prisma.village.findUnique({
+ where: { userId },
+ });
+
+ if (!village) {
+ // Or return empty array, depending on desired behavior for users without villages yet
+ return [];
+ }
+
+ const events = await prisma.villageEvent.findMany({
+ where: {
+ villageId: village.id,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ take: 50, // Limit to the last 50 events to avoid overloading the client
+ });
+
+ return events;
+});
diff --git a/server/services/villageService.ts b/server/services/villageService.ts
index 8e27781..8397bbe 100644
--- a/server/services/villageService.ts
+++ b/server/services/villageService.ts
@@ -21,6 +21,10 @@ export const PRODUCING_BUILDINGS: string[] = [
'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);
@@ -118,21 +122,40 @@ export async function getVillageState(userId: number): Promise {
);
if (finishedClearingTiles.length > 0) {
- await prisma.$transaction([
- prisma.user.update({
+ const totalCoins = finishedClearingTiles.length * REWARDS.CLEARING.coins;
+ const totalExp = finishedClearingTiles.length * REWARDS.CLEARING.exp;
+
+ await prisma.$transaction(async (tx) => {
+ // 1. Update user totals
+ await tx.user.update({
where: { id: userId },
data: {
- coins: { increment: finishedClearingTiles.length },
- exp: { increment: finishedClearingTiles.length },
+ coins: { increment: totalCoins },
+ exp: { increment: totalExp },
},
- }),
- ...finishedClearingTiles.map(t =>
- prisma.villageTile.update({
- where: { id: t.id },
+ });
+
+ // 2. Update all the tiles
+ await tx.villageTile.updateMany({
+ where: { id: { in: finishedClearingTiles.map(t => t.id) } },
data: { terrainType: 'EMPTY', terrainState: 'IDLE', clearingStartedAt: null },
- })
- ),
- ]);
+ });
+
+ // 3. Create an event for each completed tile
+ for (const tile of finishedClearingTiles) {
+ 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})`,
+ tileX: tile.x,
+ tileY: tile.y,
+ coins: REWARDS.CLEARING.coins,
+ exp: REWARDS.CLEARING.exp,
+ }
+ });
+ }
+ });
}
// --- Step 3: Refetch for next logic step ---
@@ -378,23 +401,87 @@ export async function getVillageState(userId: number): Promise {
- await tx.villageObject.create({
+ await tx.villageObject.create({
- data: {
+
- type: buildingType as keyof typeof VillageObjectType,
+ data: {
- villageId: tile.villageId,
+
- tileId: tileId,
+ 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,
+
+
+
+ },
+
+
+
+ });
+
+
+
+ });
+
+
+
+ }