feat: psql based event system
authorxangelo <me@xangelo.ca>
Thu, 28 Sep 2023 19:04:20 +0000 (15:04 -0400)
committerxangelo <me@xangelo.ca>
Thu, 28 Sep 2023 19:04:20 +0000 (15:04 -0400)
you can now track arbitrary events that get flushed to postgres so that
you can track things. To start we're tracking dungeon completions so
that we can give users 20% rewards after 5 daily completions.

migrations/20230915162829_events.ts [new file with mode: 0644]
package.json
src/server/api.ts
src/server/dungeon.ts
src/server/events.ts [new file with mode: 0644]
src/server/fight.ts
src/server/lib/clickhouse.ts [new file with mode: 0644]
src/server/locations/dungeon.ts
src/server/views/dungeons/room.ts
src/shared/constants.ts
src/shared/event.ts [new file with mode: 0644]

diff --git a/migrations/20230915162829_events.ts b/migrations/20230915162829_events.ts
new file mode 100644 (file)
index 0000000..b34437b
--- /dev/null
@@ -0,0 +1,20 @@
+import { Knex } from "knex";
+
+
+export async function up(knex: Knex): Promise<void> {
+  return knex.schema.createTable('events', function(table) {
+    table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
+    table.string('event_name');
+    table.uuid('player_id');
+    table.integer('value').defaultTo(1);
+    table.string('app_version');
+    table.json('props').defaultTo('{}');
+    table.timestamp('created').defaultTo(knex.raw('NOW()'))
+  });
+}
+
+
+export async function down(knex: Knex): Promise<void> {
+  return knex.schema.dropTable('events');
+}
+
index 7487106d285e1dbca0037ac8775788e2c6202e42..6b395b40ea11d140d5b3665206ce57afd4929f61 100644 (file)
@@ -12,7 +12,7 @@
     "seed": "npx ts-node ./node_modules/knex/bin/cli.js seed:run",
     "seed:prod": "NODE_ENV=production npm run seed",
     "dev:client": "npx webpack -w",
-    "dev:server": "npx nodemon src/server/api.ts",
+    "dev": "npx nodemon src/server/api.ts",
     "prepare": "husky install",
     "release": "npx standard-version && npm run copy-changelog",
     "copy-changelog": "cp ./CHANGELOG.md ~/repos/xangelo.ca/static/",
index b1ff042b03771c8b19803ae3ac42afcbdd2110b5..9d1b6413a286ee8f7277c50747569b51682e8382 100644 (file)
@@ -12,13 +12,13 @@ import { Server, Socket } from 'socket.io';
 import * as CONSTANT from '../shared/constants';
 import { logger } from './lib/logger';
 import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
-import { random, round, sample } from 'lodash';
+import { random, sample } from 'lodash';
 import {broadcastMessage, Message} from '../shared/message';
 import {maxHp, maxVigor, Player} from '../shared/player';
 import {createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction} from './monster';
 import {addInventoryItem, getEquippedItems, getInventory, getInventoryItem} from './inventory';
 import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
-import {FightTrigger, Monster, MonsterForFight} from '../shared/monsters';
+import {FightTrigger, Monster} from '../shared/monsters';
 import {getShopEquipment, listShopItems } from './shopEquipment';
 import {EquipmentSlot} from '../shared/inventory';
 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel, getDungeon } from './map';
@@ -53,11 +53,13 @@ import { equip, unequip } from './equipment';
 import { HealthPotionSmall } from '../shared/items/health_potion';
 import { completeDungeonFight, getActiveDungeon, getRoomVists, loadRoom, blockPlayerInDungeon } from './dungeon';
 import { renderDungeon, renderDungeonRoom } from './views/dungeons/room';
+import { flushBuffer, addEvent } from './events';
 
 dotenv();
 
 otel.s();
 
+flushBuffer();
 const app = express();
 const server = http.createServer(app);
 
@@ -144,6 +146,8 @@ io.on('connection', async socket => {
   // this is a special event to let the client know it can start 
   // requesting data
   socket.emit('ready');
+
+  addEvent('LOGIN', player.id);
 });
 
 
@@ -313,7 +317,7 @@ app.get('/player/explore', authEndpoint, async (req: Request, res: Response) =>
       closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
   }
 
-  if(fight) {
+  if(fight && req.player.hp > 0) {
     const location = await getMonsterLocation(fight.ref_id);
 
     res.send(renderPlayerBar(req.player) + renderFightPreRound(fight, true, location, closestTown));
@@ -321,7 +325,7 @@ app.get('/player/explore', authEndpoint, async (req: Request, res: Response) =>
   }
 
   const dungeon = await getActiveDungeon(req.player.id);
-  if(dungeon) {
+  if(dungeon && req.player.hp > 0) {
     const service = await getDungeon(dungeon.dungeon_id);
     const room = await loadRoom(dungeon.current_room_id);
     const visits = await getRoomVists(req.player.id, service.event_name);
@@ -330,8 +334,7 @@ app.get('/player/explore', authEndpoint, async (req: Request, res: Response) =>
     return;
   }
 
-  // are you in a dungeon?
-  if(travelPlan) {
+  if(travelPlan && req.player.hp > 0) {
     // traveling!
     const chanceToSeeMonster = random(0, 100);
     const things: any[] = [];
@@ -606,11 +609,29 @@ app.post('/fight/turn', authEndpoint, async (req: Request, res: Response) => {
   });
 
 
-
   if(fightData.roundData.winner !== 'in-progress') {
     delete cache[fightBlockKey];
   }
 
+  if(fightData.roundData.winner === 'player') {
+    //@TODO: Add equipped weapons
+    addEvent('MONSTER_KILLED', req.player.id, {
+      monster_id: monster.ref_id,
+      monster_name: monster.name,
+      monster_level: monster.level,
+      fight_trigger: monster.fight_trigger,
+    });
+  }
+  else if(fightData.roundData.winner === 'monster') {
+    addEvent('PLAYER_KILLED', req.player.id, {
+      monster_id: monster.ref_id,
+      monster_name: monster.name,
+      monster_level: monster.level,
+      fight_trigger: monster.fight_trigger
+    });
+  }
+
+
   if(monster.fight_trigger === 'dungeon-forced' && fightData.roundData.winner === 'player') {
     // ok the player was in a dungeon, lets make sure 
     // that they complete whatever dungeon room they are in
index a58168d9a29cf21d7f0f7c172ae35ed0099feea1..33558a94425433974e16ff0002affaded4bd6059 100644 (file)
@@ -3,6 +3,7 @@ import { Dungeon, DungeonRoom, DungeonPlayer, DungeonState, DungeonStateSummaryV
 import { db } from './lib/db';
 import { Request, Response } from 'express';
 import { ErrorAlert } from "./views/alert";
+import { addEvent } from './events';
 
 export async function blockPlayerInDungeon(req: Request, res: Response, next: any) {
   const state = await getActiveDungeon(req.player.id);
@@ -156,8 +157,22 @@ export async function loadRoom(room_id: string): Promise<DungeonRoom | undefined
 
 export async function completeDungeon(player_id: string) {
   const dungeonPlayer = await getActiveDungeon(player_id);
-  //const state = await getDungeonState(player_id, dungeonPlayer.dungeon_id);
+  const dungeonState = await getDungeonState(player_id, dungeonPlayer.dungeon_id);
 
   await db('dungeon_players').where({player_id}).delete();
   await db('dungeon_state').where({player_id, dungeon_id: dungeonPlayer.dungeon_id}).delete();
+
+  let startTime = Number.MAX_VALUE;
+  let endTime = 0;
+  dungeonState.forEach(s => {
+    startTime = Math.min(startTime, s.create_date);
+    endTime = Math.max(endTime, s.create_date);
+  });
+
+  addEvent('DUNGEON_COMPLETE', player_id, {
+    dungeon_id: dungeonPlayer.dungeon_id,
+    start: startTime,
+    end: endTime,
+    duration: endTime - startTime
+  });
 }
diff --git a/src/server/events.ts b/src/server/events.ts
new file mode 100644 (file)
index 0000000..f5a630d
--- /dev/null
@@ -0,0 +1,81 @@
+import { db } from './lib/db';
+import { version } from '../../package.json';
+import { CreatedEvent, Event, EventName } from '../shared/event';
+import { isEqual } from 'lodash';
+import { logger } from './lib/logger';
+import { EVENT_FLUSH_INTERVAL, EVENT_SECOND_BUCKET } from '../shared/constants';
+import { clickhouse } from './lib/clickhouse';
+
+const eventBuffer: CreatedEvent[] = [];
+const maxToAdd = 10;
+
+export async function flushBuffer() {
+  const events = eventBuffer.splice(0, maxToAdd);
+  if(events.length) {
+    await addEvents(events);
+    logger.log(`Flushed ${events.length} events`);
+  }
+  else {
+    logger.log('No events to flush');
+  }
+  setTimeout(flushBuffer, EVENT_FLUSH_INTERVAL);
+}
+
+function bucketTime(date: Date): Date {
+  const d = new Date();
+  d.setFullYear(date.getFullYear());
+  d.setMonth(date.getMonth());
+  d.setDate(date.getDate());
+  d.setHours(date.getHours());
+  d.setMinutes(date.getMinutes());
+  d.setMilliseconds(0);
+
+  const s = date.getSeconds();
+
+  // round down to closest 5 second interval
+  d.setSeconds(s - (s%EVENT_SECOND_BUCKET));
+  return d;
+}
+
+
+export async function addEvent(event_name: EventName, player_id: string, props?: any, created?: Date) {
+  eventBuffer.push({
+    event_name,
+    player_id,
+    app_version: version,
+    props: props,
+    created
+  });
+}
+
+export async function addEvents(events: CreatedEvent[]) {
+  return db('events').insert(events);
+}
+
+export async function getEventHistory(player_id: string, event_name: EventName) {
+  return db.select('*').from<Event<any>>('events').where({
+    player_id,
+    event_name
+  });
+}
+
+export async function getEventHistoryToday(player_id: string, event_name: EventName) {
+  return db.select('*').from<Event<any>>('events').where({
+    player_id,
+    event_name
+  }).andWhere('created', '>=', db.raw('current_date::timestamp'));
+
+}
+
+export async function hasNeverHappenedBefeore(name: EventName, player_id: string, props?: any): Promise<boolean> {
+  return hasHappendXTimes(0, name, player_id, props);
+}
+
+export async function hasHappendXTimes(times: number, event_name: EventName, player_id: string, props?: any): Promise<boolean> {
+  const res: Event<any>[] = await getEventHistory(player_id, event_name);
+
+  if(props) {
+    return res.filter(row => isEqual(row.props, props)).length === times;
+  }
+  return res.length === times;
+}
index 011cc5c667dfa27a4f4f90035b94aa545a675aa5..83a813225d6c5ebb178ae3b5cbd506bb6194872a 100644 (file)
@@ -11,6 +11,7 @@ import { getPlayerSkillsAsObject, updatePlayerSkills } from './skills';
 import { SkillID, Skills } from '../shared/skills';
 import { Request, Response } from 'express';
 import * as Alert from './views/alert';
+import { addEvent } from './events';
 
 export async function blockPlayerInFight(req: Request, res: Response, next: any) {
   const fight = await loadMonsterFromFight(req.player.id);
@@ -173,6 +174,10 @@ export async function fightRound(player: Player, monster: Fight,  data: {action:
     if(player.exp >= expToLevel(player.level + 1)) {
       player.exp -= expToLevel(player.level + 1)
       player.level++;
+      addEvent('LEVEL_UP', player.id, {
+        from_level: player.level-1,
+        to_level: player.level
+      });
       roundData.rewards.levelIncrease = true;
       let statPointsGained = 1;
 
diff --git a/src/server/lib/clickhouse.ts b/src/server/lib/clickhouse.ts
new file mode 100644 (file)
index 0000000..831f46d
--- /dev/null
@@ -0,0 +1,3 @@
+import { createClient } from '@clickhouse/client';
+
+export const clickhouse = createClient();
index 5b2cc59959059207f7f0d44c050769673dfe511f..26f74605c064696664983bde8f34e6ee1f525b40 100644 (file)
@@ -11,6 +11,7 @@ import { renderFightPreRoundDungeon } from "../views/fight";
 import { has, max, each } from 'lodash';
 import { expToLevel, maxHp, maxVigor } from "../../shared/player";
 import { updatePlayer } from "../player";
+import { getEventHistoryToday } from "../events";
 
 export const router = Router();
 
@@ -160,12 +161,14 @@ router.post('/city/dungeon/:dungeon_id/complete', authEndpoint, async (req, res)
     });
   }
 
-  // delete the tracking for this dungeon-run
-  await completeDungeon(req.player.id);
+
+  // if this is not the first completion, lets give them diminishing returns
+  const completionsToday = await getEventHistoryToday(req.player.id, 'DUNGEON_COMPLETE');
+  let factor = completionsToday.length <= 5 ? 1 : 0.2;
 
   // give the user these rewards!
-  req.player.gold += rewards.gold;
-  req.player.exp += rewards.exp;
+  req.player.gold += Math.ceil(rewards.gold * factor);
+  req.player.exp += Math.ceil(rewards.exp * factor);
 
   while(req.player.exp >=  expToLevel(req.player.level + 1)) {
     req.player.exp -= expToLevel(req.player.level + 1);
@@ -175,7 +178,9 @@ router.post('/city/dungeon/:dungeon_id/complete', authEndpoint, async (req, res)
     req.player.vigor = maxVigor(req.player.constitution, req.player.level);
   }
 
+  // delete the tracking for this dungeon-run
+  await completeDungeon(req.player.id);
   await updatePlayer(req.player);
 
-  res.send(dungeonRewards(dungeon, rewards));
+  res.send(dungeonRewards(dungeon, rewards, completionsToday.length));
 });
index cde75e544cea0e9668c8e566620c007d43eca2ff..a4e3552ff257d87bcceea1773c039e798b582227 100644 (file)
@@ -53,11 +53,12 @@ export function renderDungeon(city: string, dungeon_name: string, room: DungeonR
 `;
 }
 
-export function dungeonRewards(dungeon: Dungeon, rewards: DungeonRewards): string {
+export function dungeonRewards(dungeon: Dungeon, rewards: DungeonRewards, completions: number): string {
   return `
 <dialog>
   <div class="dungeon-rewards">
     <p>Congratulations on completing the ${dungeon.name} Dungeon!</p>
+    <p>${completions <= 5 ? `${completions}/5 Runs` : `You've exhausted your runs!`}</p>
     <b>Rewards:</b><br>
     <b>Exp:</b> ${rewards.exp.toLocaleString()}<br>
     <b>Gold:</b> ${rewards.gold.toLocaleString()}<br>
index 42e884ce79f71f571f2fc28946bd648982ed8ddb..7804b5662af372d567c54a93b6143648a1d02e5b 100644 (file)
@@ -5,3 +5,6 @@ export const ALERT_DISPLAY_LENGTH = 3000;
 export const CHANCE_TO_FIGHT_SPECIAL = 10;
 
 export const DUNGEON_TRAVEL_BLOCK = 3000;
+
+export const EVENT_FLUSH_INTERVAL = 10000;
+export const EVENT_SECOND_BUCKET = 3;
diff --git a/src/shared/event.ts b/src/shared/event.ts
new file mode 100644 (file)
index 0000000..bb2bec2
--- /dev/null
@@ -0,0 +1,23 @@
+type UUID = string;
+
+export type EventName = 'DUNGEON_COMPLETE'
+| 'MONSTER_KILLED'
+| 'PLAYER_KILLED'
+| 'LEVEL_UP'
+| 'LOGIN'
+;
+
+export type Event<T> = {
+  id: UUID;
+  event_name: EventName;
+  player_id: UUID;
+  app_version: string;
+  value: number;
+  props: T
+  created: Date;
+}
+
+export type CreatedEvent = Omit<Event<any>, 'id'|'created'|'value'> & {
+  created?: Date;
+  value?: number;
+}