From 43f0bc31d6a0c2974891840f7ba10efa77c2e520 Mon Sep 17 00:00:00 2001 From: xangelo Date: Thu, 28 Sep 2023 15:04:20 -0400 Subject: [PATCH] feat: psql based event system 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 | 20 +++++++ package.json | 2 +- src/server/api.ts | 35 ++++++++++--- src/server/dungeon.ts | 17 +++++- src/server/events.ts | 81 +++++++++++++++++++++++++++++ src/server/fight.ts | 5 ++ src/server/lib/clickhouse.ts | 3 ++ src/server/locations/dungeon.ts | 15 ++++-- src/server/views/dungeons/room.ts | 3 +- src/shared/constants.ts | 3 ++ src/shared/event.ts | 23 ++++++++ 11 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 migrations/20230915162829_events.ts create mode 100644 src/server/events.ts create mode 100644 src/server/lib/clickhouse.ts create mode 100644 src/shared/event.ts diff --git a/migrations/20230915162829_events.ts b/migrations/20230915162829_events.ts new file mode 100644 index 0000000..b34437b --- /dev/null +++ b/migrations/20230915162829_events.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('events'); +} + diff --git a/package.json b/package.json index 7487106..6b395b4 100644 --- a/package.json +++ b/package.json @@ -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/", diff --git a/src/server/api.ts b/src/server/api.ts index b1ff042..9d1b641 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -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 diff --git a/src/server/dungeon.ts b/src/server/dungeon.ts index a58168d..33558a9 100644 --- a/src/server/dungeon.ts +++ b/src/server/dungeon.ts @@ -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 { + 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 index 0000000..f5a630d --- /dev/null +++ b/src/server/events.ts @@ -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>('events').where({ + player_id, + event_name + }); +} + +export async function getEventHistoryToday(player_id: string, event_name: EventName) { + return db.select('*').from>('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 { + return hasHappendXTimes(0, name, player_id, props); +} + +export async function hasHappendXTimes(times: number, event_name: EventName, player_id: string, props?: any): Promise { + const res: Event[] = await getEventHistory(player_id, event_name); + + if(props) { + return res.filter(row => isEqual(row.props, props)).length === times; + } + return res.length === times; +} diff --git a/src/server/fight.ts b/src/server/fight.ts index 011cc5c..83a8132 100644 --- a/src/server/fight.ts +++ b/src/server/fight.ts @@ -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 index 0000000..831f46d --- /dev/null +++ b/src/server/lib/clickhouse.ts @@ -0,0 +1,3 @@ +import { createClient } from '@clickhouse/client'; + +export const clickhouse = createClient(); diff --git a/src/server/locations/dungeon.ts b/src/server/locations/dungeon.ts index 5b2cc59..26f7460 100644 --- a/src/server/locations/dungeon.ts +++ b/src/server/locations/dungeon.ts @@ -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)); }); diff --git a/src/server/views/dungeons/room.ts b/src/server/views/dungeons/room.ts index cde75e5..a4e3552 100644 --- a/src/server/views/dungeons/room.ts +++ b/src/server/views/dungeons/room.ts @@ -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 `

Congratulations on completing the ${dungeon.name} Dungeon!

+

${completions <= 5 ? `${completions}/5 Runs` : `You've exhausted your runs!`}

Rewards:
Exp: ${rewards.exp.toLocaleString()}
Gold: ${rewards.gold.toLocaleString()}
diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 42e884c..7804b56 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 index 0000000..bb2bec2 --- /dev/null +++ b/src/shared/event.ts @@ -0,0 +1,23 @@ +type UUID = string; + +export type EventName = 'DUNGEON_COMPLETE' +| 'MONSTER_KILLED' +| 'PLAYER_KILLED' +| 'LEVEL_UP' +| 'LOGIN' +; + +export type Event = { + id: UUID; + event_name: EventName; + player_id: UUID; + app_version: string; + value: number; + props: T + created: Date; +} + +export type CreatedEvent = Omit, 'id'|'created'|'value'> & { + created?: Date; + value?: number; +} -- 2.25.1