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
--- /dev/null
+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");
+}
+
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 {
display: none;
&.active {
- display: block !important;
+ display: block
}
}
}
--- /dev/null
+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);
+ });
+}
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)) {
import { ChatCommand } from "./base";
+import { refreshDropTables } from './refresh-droptables';
import { refreshMonsters } from "./refresh-monsters";
import { refreshCities } from './refresh-cities';
import { refreshShops } from './refresh-shops';
Commands.add(refreshMonsters);
Commands.add(refreshCities);
Commands.add(refreshShops);
+Commands.add(refreshDropTables);
Commands.add(setLevel);
Commands.add(say);
Commands.add(setPermission);
--- /dev/null
+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);
getMonsterList,
saveFightState,
loadMonsterFromFight,
+ getMonsterDrop,
} from "./monster";
import {
Player,
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,
exp: 0,
gold: 0,
levelIncrease: false,
+ items: []
},
};
const equippedItems = await getEquippedItems(player.id);
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;
return rows[0].amount;
}
+
+export async function getItem(item_id: number): Promise<Item> {
+ return db.select('*').from<Item>('items').where({
+ id: item_id
+ }).first();
+}
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();
/**
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;
+}
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[] = [];
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 {
</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>
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) {
+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 = {
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 = {
defence: 0.8
}
];
+
+export type ItemDropTable = {
+ monster_id: number;
+ item_id: number;
+ drop_rate: number;
+ min_amount: number;
+ max_amount: number;
+}