feat(crafting): add monster drops
authorxangelo <me@xangelo.ca>
Tue, 3 Dec 2024 05:53:33 +0000 (00:53 -0500)
committerxangelo <me@xangelo.ca>
Tue, 3 Dec 2024 05:53:33 +0000 (00:53 -0500)
We now have per-monster drop tables that link back to items (Airtable).

It allows us to select an item + monster, and set a drop rate for that item,
as well as a min/max quantity. When the monster is killed, we use the drop table
to determine how many of that item to drop.

That item gets added to the players Items inventory.

In order to support the fact that crafting isn't in play yet, if an item
doesn't have an effect_name, we disable the "use" button.

Eventually these crafting items will be only usable through the crafting
system to improve your existing weapons/armour

17 files changed:
migrations/20241202190606_drop_tables.ts [new file with mode: 0644]
public/assets/css/inventory.css
public/assets/css/tabs.css
public/assets/img/icons/items/Light_Hide.png [new file with mode: 0644]
seeds/drop-tables.ts [new file with mode: 0644]
src/server/chat-commands.ts
src/server/chat-commands/index.ts
src/server/chat-commands/refresh-droptables.ts [new file with mode: 0644]
src/server/fight.ts
src/server/items.ts
src/server/monster.ts
src/server/routes/chat.ts
src/server/routes/inventory.ts
src/server/views/fight.ts
src/shared/fight.ts
src/shared/items/index.ts
src/shared/monsters.ts

diff --git a/migrations/20241202190606_drop_tables.ts b/migrations/20241202190606_drop_tables.ts
new file mode 100644 (file)
index 0000000..bc16516
--- /dev/null
@@ -0,0 +1,19 @@
+import { Knex } from "knex";
+
+
+export async function up(knex: Knex): Promise<void> {
+    return knex.schema.createTable("item_drop_tables", t => {
+        t.integer('item_id').references('id').inTable('items');
+        t.integer('monster_id').references('id').inTable('monsters');
+        t.double('drop_rate');
+        t.integer('min_amount');
+        t.integer('max_amount');
+        t.primary(['item_id', 'monster_id']);
+    });
+}
+
+
+export async function down(knex: Knex): Promise<void> {
+    return knex.schema.dropTable("item_drop_tables");
+}
+
index 50d348fffea6eaa5a944ddb8fb4e7f431184ba0d..e59a3fffae16768b21b8d46a20854ead46ea76bd 100644 (file)
@@ -54,6 +54,7 @@
 
     img {
         filter: grayscale(40%);
+        width: 100%;
 
         &:hover {
             filter: none;
         transition: opacity 0.2s;
         z-index: 100;
     }
+}
 
+#filter_ITEMS {
+    display: flex;
+    flex-wrap: wrap;
 }
 
 .icon-durability-container {
index 7a7ba44a93a6ffa27b23defb0e8f7389537e9527..0cc38bf14f1efdb5b6bd3877a3ce26fbcab8b196 100644 (file)
@@ -63,7 +63,7 @@ nav {
       display: none;
 
       &.active {
-        display: block !important;
+        display: block
       }
     }
   }
diff --git a/public/assets/img/icons/items/Light_Hide.png b/public/assets/img/icons/items/Light_Hide.png
new file mode 100644 (file)
index 0000000..8d350e3
Binary files /dev/null and b/public/assets/img/icons/items/Light_Hide.png differ
diff --git a/seeds/drop-tables.ts b/seeds/drop-tables.ts
new file mode 100644 (file)
index 0000000..a12e1c8
--- /dev/null
@@ -0,0 +1,64 @@
+import { config as dotenv } from 'dotenv';
+import Airtable from 'airtable';
+import { db } from '@server/lib/db';
+import { logger } from '@server/lib/logger'
+
+dotenv();
+
+Airtable.configure({
+  apiKey: process.env.AIRTABLE_API_KEY
+});
+
+const base = Airtable.base('appDfPLPajPNog5Iw');
+
+export async function createDropTables(): Promise<void> {
+  return new Promise(async (resolve, reject) => {
+    base('Drops').select().eachPage(async (records, next) => {
+        records.map(async record => {
+        logger.debug(record);
+
+        const monsterId = record.fields.id_from_monster[0];
+        const itemId = record.fields.id_from_item[0];
+
+        const [monsterExists, itemExists] = await Promise.all([
+          db('monsters').where('id', monsterId).first(),
+          db('items').where('id', itemId).first()
+        ]);
+
+        if (!monsterExists || !itemExists) {
+          logger.warn(`Skip invalid drop: Monster ${monsterId} or Item ${itemId} not found`);
+          return;
+        }
+
+        // Insert/Update drop table
+        await db('item_drop_tables')
+          .insert({
+            monster_id: monsterId,
+            item_id: itemId,
+            drop_rate: Number(record.fields.drop_rate),
+            min_amount: Number(record.fields.min_amount) || 1,
+            max_amount: Number(record.fields.max_amount) || 1
+          })
+          .onConflict(['monster_id', 'item_id'])
+          .merge(['drop_rate', 'min_amount', 'max_amount']);
+
+        });
+      next();
+    }).finally(() => {
+      resolve();
+    });
+  });
+}
+
+// run this script manually
+if(!module.parent) {
+  createDropTables()
+    .then(() => {
+      logger.info('Drop tables updated');
+      process.exit(0);
+    })
+    .catch(e => {
+      logger.error(e);
+      process.exit(1);
+    });
+}
index da43c1c42bf88e2a73f6814d3f33cb03d3bf3189..6d428a0f6012705fb1e72fb9cd69f8edbc8cd63f 100644 (file)
@@ -3,11 +3,13 @@ import { Player } from '@shared/player';
 import { broadcastMessage } from '@shared/message';
 import { renderChatMessage } from './views/chat';
 import { Commands } from './chat-commands/';
+import { logger } from './lib/logger';
 
 
 export async function handleChatCommands(msg: string, player: Player, io: Server, sender: Socket): Promise<void> {
   const rawCommand = msg.split('/server ')[1];
 
+  logger.debug(`${player.username} running command: [${rawCommand}]`);
   let matched = false;
   Commands.forEach(async command => {
     if(command.regex.test(rawCommand)) {
index 2ea13bcec71915658133f22305548e5525f297d2..d8f638b047233ff9f9fabfc1a68d545d6c7a3b65 100644 (file)
@@ -1,4 +1,5 @@
 import { ChatCommand } from "./base";
+import { refreshDropTables } from './refresh-droptables';
 import { refreshMonsters } from "./refresh-monsters";
 import { refreshCities } from './refresh-cities';
 import { refreshShops } from './refresh-shops';
@@ -11,6 +12,7 @@ export const Commands = new Set<ChatCommand>();
 Commands.add(refreshMonsters);
 Commands.add(refreshCities);
 Commands.add(refreshShops);
+Commands.add(refreshDropTables);
 Commands.add(setLevel);
 Commands.add(say);
 Commands.add(setPermission);
diff --git a/src/server/chat-commands/refresh-droptables.ts b/src/server/chat-commands/refresh-droptables.ts
new file mode 100644 (file)
index 0000000..9b10bc2
--- /dev/null
@@ -0,0 +1,16 @@
+import { ChatCommand } from './base';
+import { Socket } from 'socket.io';
+import { createDropTables } from '../../../seeds/drop-tables';
+import { broadcastMessage } from '../../shared/message';
+import { renderChatMessage } from '../views/chat';
+import { Player } from '../../shared/player';
+
+async function handler(rawCommand: string, sender: Socket, player: Player) {
+  if(player.permissions.includes('admin')) {
+    await createDropTables();
+    const message = broadcastMessage('server', 'Drop tables refreshed');
+    sender.emit('chat', renderChatMessage(message));
+  }
+}
+
+export const refreshDropTables = new ChatCommand('refresh-droptables', new RegExp(/^refresh-droptables/), handler);
index 34425f6f092b4effcac18f81989f602f1d2bed97..e27a87d4deece23de44a646c48c74b47f8274fb1 100644 (file)
@@ -5,6 +5,7 @@ import {
   getMonsterList,
   saveFightState,
   loadMonsterFromFight,
+  getMonsterDrop,
 } from "./monster";
 import {
   Player,
@@ -32,6 +33,8 @@ import { Request, Response } from "express";
 import * as Alert from "./views/alert";
 import { addEvent } from "./events";
 import { Professions } from "../shared/profession";
+import { getShopItem } from "./shopEquipment";
+import { getItem, givePlayerItem } from "./items";
 
 export async function blockPlayerInFight(
   req: Request,
@@ -115,6 +118,7 @@ export async function fightRound(
       exp: 0,
       gold: 0,
       levelIncrease: false,
+      items: []
     },
   };
   const equippedItems = await getEquippedItems(player.id);
@@ -261,6 +265,13 @@ export async function fightRound(
       fightTrigger: roundData.monster.fight_trigger,
     });
 
+    // lets check out drop rates for this monster!
+    const drop = await getMonsterDrop(monster.ref_id);
+    if (drop) {
+      await givePlayerItem(player.id, drop.id, drop.amount);
+      roundData.rewards.items.push(drop);
+    }
+
     const expGained = exponentialExp(monster.exp, monster.level, player.level);
 
     roundData.rewards.exp = expGained;
index fc39bbca7bb5792c256440e966ef22bf1a7b8e25..1eac0be4b7e521b9afb3b28248452f818c938d70 100644 (file)
@@ -48,3 +48,9 @@ export async function updateItemCount(player_id: string, item_id: number, delta:
 
   return rows[0].amount;
 }
+
+export async function getItem(item_id: number): Promise<Item> {
+  return db.select('*').from<Item>('items').where({ 
+    id: item_id
+  }).first();
+}
index c0e66518f4f1dae47b5b99339a7c231598608e9f..3e7e813d690ea8f2362eec45d2506cc3b12faef6 100644 (file)
@@ -4,7 +4,9 @@ import { TimePeriod, TimeManager } from '@shared/time';
 import { LocationWithCity } from '@shared/map';
 import { random, sample } from 'lodash';
 import { CHANCE_TO_FIGHT_SPECIAL } from '@shared/constants';
-
+import { ItemDropTable } from '@shared/monsters';
+import { DroppedItem } from '@shared/items';
+import { getItem } from './items';
 const time = new TimeManager();
 
 /**
@@ -135,3 +137,32 @@ export async function clearFight(authToken: string) {
     player_id: authToken
   }).delete();
 }
+
+export async function getDropTable(monsterId: number): Promise<ItemDropTable[]> {
+  return db('item_drop_tables').where('monster_id', monsterId);
+}
+
+export async function getMonsterDrop(monsterId: number): Promise<DroppedItem | null> {
+  const drops = await getDropTable(monsterId);
+  const roll = Math.random();
+
+  let currentWeight = 0;
+  let droppedItem: ItemDropTable | null = null;
+  for (const drop of drops) {
+    currentWeight += drop.drop_rate;
+    if (roll <= currentWeight) {
+      droppedItem = drop;
+    }
+  }
+
+  if(droppedItem) {
+    const amount = random(droppedItem.min_amount, droppedItem.max_amount);
+    return {
+      ...(await getItem(droppedItem.item_id)),
+      monster_id: droppedItem.monster_id,
+      amount,
+    };
+  }
+
+  return null;
+}
index cc41b80a5a744dad3886be1b87356db5fc3b4a93..ff11e89519ef4e08a61603bfb8fd30038764d12c 100644 (file)
@@ -4,7 +4,7 @@ import { authEndpoint } from '../auth';
 import { renderChatMessage } from '../views/chat';
 import { handleChatCommands } from '../chat-commands';
 import xss from 'xss';
-
+import { logger } from '../lib/logger';
 export const chatRouter = Router();
 
 const chatHistory: Message[] = [];
@@ -23,6 +23,8 @@ chatRouter.post('/chat', authEndpoint, async (req: Request, res: Response) => {
     return;
   }
 
+  logger.debug(`${req.player.username} sending message: [${msg}] (${req.player.permissions.join(', ')})`);
+
   if(msg.startsWith('/server') && req.player.permissions.includes('admin')) {
     const sender = req.rl.io.sockets.sockets.get(req.rl.cache.get(`socket:${req.player.id}`));
     try {
index 841dbe1285bd3352832c33f6bd10e87bb7a77906..8af4557cc505fe583c862184f160afa468834dcf 100644 (file)
@@ -155,7 +155,7 @@ inventoryRouter.get('/modal/items/:item_id', authEndpoint, async (req: Request,
     </div>
   </div>
   <div class="actions">
-    <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
+    ${item.effect_name ? `<button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>` : ''}
     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
   </div>
 </dialog>
index b773fba01a766033da5335d1acec98b259c85f0f..9981ff765d47d2fbaf41b1dea808e8f691e343b7 100644 (file)
@@ -21,6 +21,10 @@ export function renderRoundDetails(roundData: FightRound): string {
       if(roundData.rewards.levelIncrease) {
         html.push(`<div>You gained a level! ${roundData.player.level}</div>`);
       }
+
+      roundData.rewards.items.forEach(item => {
+        html.push(`<div>${roundData.monster.name} dropped ${item.amount} ${item.name}</div>`);
+      });
     break;
     case 'monster':
       if(roundData.player.hp === 0) {
index 22f0ed9b04c81bd64af6b95fd999cfc85af4535b..63c071579447692e16326aa1b41bb075ad8caea0 100644 (file)
@@ -1,10 +1,12 @@
+import { DroppedItem, Item, ShopItem } from "./items"
 import {Fight, FightTrigger, MonsterWithFaction} from "./monsters"
 import {Player} from "./player"
 
 export type FightReward = {
   exp: number,
   gold: number,
-  levelIncrease: boolean 
+  levelIncrease: boolean,
+  items: DroppedItem[]
 }
 
 export type FightRound = {
index eeb0aef368edb18d3bf480ceb482e41cd9a66857..5a135f62500db6b81da6827eccbbf6fe38bf8cbc 100644 (file)
@@ -9,6 +9,11 @@ export type Item = {
   icon_name: string;
 }
 
+export type DroppedItem = Item & {
+  monster_id: number;
+  amount: number;
+}
+
 // this is the result of a join, the actual 
 // table only maps player_id/item_id + amount
 export type PlayerItem = {
index 854eee5c3993279e55ba962224d001b0bffebe3d..203d6e232e968def1427b77b805852846d34a923 100644 (file)
@@ -102,3 +102,11 @@ export const MonsterVariants: MonsterVariant[] = [
     defence: 0.8
   }
 ];
+
+export type ItemDropTable = {
+  monster_id: number;
+  item_id: number;
+  drop_rate: number;
+  min_amount: number;
+  max_amount: number;
+}