--- /dev/null
+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');
+}
+
"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/",
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';
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);
// this is a special event to let the client know it can start
// requesting data
socket.emit('ready');
+
+ addEvent('LOGIN', player.id);
});
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));
}
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);
return;
}
- // are you in a dungeon?
- if(travelPlan) {
+ if(travelPlan && req.player.hp > 0) {
// traveling!
const chanceToSeeMonster = random(0, 100);
const things: any[] = [];
});
-
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
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);
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
+ });
}
--- /dev/null
+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;
+}
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);
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;
--- /dev/null
+import { createClient } from '@clickhouse/client';
+
+export const clickhouse = createClient();
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();
});
}
- // 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);
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));
});
`;
}
-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>
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;
--- /dev/null
+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;
+}