diff --git a/src/commands/fight.ts b/src/commands/fight.ts new file mode 100644 index 00000000..f495c41a --- /dev/null +++ b/src/commands/fight.ts @@ -0,0 +1,228 @@ +import type { ApplicationCommand } from "@/commands/command.js"; +import { + type APIEmbed, + APIEmbedField, + type BooleanCache, + type CacheType, + type CommandInteraction, + type InteractionResponse, + SlashCommandBuilder, + type User, +} from "discord.js"; +import type { BotContext } from "@/context.js"; +import type { JSONEncodable } from "@discordjs/util"; +import { + type BaseEntity, + baseStats, + bossMap, + Entity, + type EquipableArmor, + type EquipableItem, + type EquipableWeapon, + type FightScene, +} from "@/service/fightData.js"; +import { setTimeout } from "node:timers/promises"; +import { getFightInventoryEnriched, removeItemsAfterFight } from "@/storage/fightInventory.js"; +import { getLastFight, insertResult } from "@/storage/fightHistory.js"; + +async function getFighter(user: User): Promise { + const userInventory = await getFightInventoryEnriched(user.id); + + return { + ...baseStats, + name: user.displayName, + weapon: { + name: userInventory.weapon?.itemInfo?.displayName ?? "Nichts", + ...(userInventory.weapon?.gameTemplate as EquipableWeapon), + }, + armor: { + name: userInventory.armor?.itemInfo?.displayName ?? "Nichts", + ...(userInventory.armor?.gameTemplate as EquipableArmor), + }, + items: userInventory.items.map(value => { + return { + name: value.itemInfo?.displayName ?? "Error", + ...(value.gameTemplate as EquipableItem), + }; + }), + }; +} + +export default class FightCommand implements ApplicationCommand { + readonly description = "TBD"; + readonly name = "fight"; + readonly applicationCommand = new SlashCommandBuilder() + .setName(this.name) + .setDescription(this.description) + .addStringOption(builder => + builder + .setRequired(true) + .setName("boss") + .setDescription("Boss") + //switch to autocomplete when we reach 25 + .addChoices( + Object.entries(bossMap) + .filter(boss => boss[1].enabled) + .map(boss => { + return { + name: boss[1].name, + value: boss[0], + }; + }), + ), + ); + + async handleInteraction(command: CommandInteraction, context: BotContext) { + const boss = command.options.get("boss", true).value as string; + + const lastFight = await getLastFight(command.user.id); + + // if (lastFight !== undefined && (new Date(lastFight?.createdAt).getTime() > new Date().getTime() - 1000 * 60 * 60 * 24 * 5)) { + // await command.reply({ + // embeds: + // [{ + // title: "Du bist noch nicht bereit", + // color: 0xe74c3c, + // description: `Dein Letzter Kampf gegen ${bossMap[lastFight.bossName]?.name} ist noch keine 5 Tage her. Gegen ${bossMap[boss].name} hast du sowieso Chance, aber noch weniger, wenn nicht die vorgeschriebenen Pausenzeiten einhältst ` + // } + // ] + // , + // ephemeral: true + // } + // ); + + // return; + // } + + const interactionResponse = await command.deferReply(); + + const playerstats = await getFighter(command.user); + + await fight(command.user, playerstats, boss, { ...bossMap[boss] }, interactionResponse); + } +} + +type result = "PLAYER" | "ENEMY" | undefined; + +function checkWin(fightscene: FightScene): result { + if (fightscene.player.stats.health < 0) { + return "ENEMY"; + } + if (fightscene.enemy.stats.health < 0) { + return "PLAYER"; + } +} + +function renderEndScreen(fightscene: FightScene): APIEmbed | JSONEncodable { + const result = checkWin(fightscene); + const fields = [ + renderStats(fightscene.player), + renderStats(fightscene.enemy), + { + name: "Verlauf", + value: " ", + }, + { + name: " Die Items sind dir leider beim Kampf kaputt gegangen: ", + value: fightscene.player.stats.items.map(value => value.name).join(" \n"), + }, + ]; + if (result === "PLAYER") { + return { + title: `Mit viel Glück konnte ${fightscene.player.stats.name} ${fightscene.enemy.stats.name} besiegen `, + color: 0x57f287, + description: fightscene.enemy.stats.description, + fields: fields, + }; + } + if (result === "ENEMY") { + return { + title: `${fightscene.enemy.stats.name} hat ${fightscene.player.stats.name} gnadenlos vernichtet`, + color: 0xed4245, + description: fightscene.enemy.stats.description, + fields: fields, + }; + } + return { + title: `Kampf zwischen ${fightscene.player.stats.name} und ${fightscene.enemy.stats.name}`, + description: fightscene.enemy.stats.description, + fields: fields, + }; +} + +export async function fight( + user: User, + playerstats: BaseEntity, + boss: string, + enemystats: BaseEntity, + interactionResponse: InteractionResponse>, +) { + const enemy = new Entity(enemystats); + const player = new Entity(playerstats); + + const scene: FightScene = { + player: player, + enemy: enemy, + }; + while (checkWin(scene) === undefined) { + player.itemText = []; + enemy.itemText = []; + //playerhit first + player.attack(enemy); + // then enemny hit + enemy.attack(player); + //special effects from items + for (const value of player.stats.items) { + if (!value.afterFight) { + continue; + } + value.afterFight(scene); + } + for (const value of enemy.stats.items) { + if (!value.afterFight) { + continue; + } + value.afterFight({ player: enemy, enemy: player }); + } + await interactionResponse.edit({ embeds: [renderFightEmbedded(scene)] }); + await setTimeout(200); + } + + await interactionResponse.edit({ embeds: [renderEndScreen(scene)] }); + //delete items + await removeItemsAfterFight(user.id); + // + await insertResult(user.id, boss, checkWin(scene) === "PLAYER"); +} + +function renderStats(player: Entity) { + while (player.itemText.length < 5) { + player.itemText.push("-"); + } + return { + name: player.stats.name, + value: `❤️ HP${player.stats.health}/${player.maxHealth} + ❤️ ${"=".repeat(Math.max(0, (player.stats.health / player.maxHealth) * 10))} + ⚔️ Waffe: ${player.stats.weapon?.name ?? "Schwengel"} ${player.lastAttack} + 🛡️ Rüstung: ${player.stats.armor?.name ?? "Nackt"} ${player.lastDefense} + 📚 Items: + ${player.itemText.join("\n")} + `, + inline: true, + }; +} + +function renderFightEmbedded(fightscene: FightScene): JSONEncodable | APIEmbed { + return { + title: `Kampf zwischen ${fightscene.player.stats.name} und ${fightscene.enemy.stats.name}`, + description: fightscene.enemy.stats.description, + fields: [ + renderStats(fightscene.player), + renderStats(fightscene.enemy), + { + name: "Verlauf", + value: " ", + }, + ], + }; +} diff --git a/src/commands/gegenstand.ts b/src/commands/gegenstand.ts index ac2ca5b2..5d0e1004 100644 --- a/src/commands/gegenstand.ts +++ b/src/commands/gegenstand.ts @@ -24,6 +24,7 @@ import * as lootDataService from "@/service/lootData.js"; import { LootAttributeClassId, LootAttributeKindId, LootKindId } from "@/service/lootData.js"; import log from "@log"; +import { equipItembyLoot, getFightInventoryUnsorted } from "@/storage/fightInventory.js"; export default class GegenstandCommand implements ApplicationCommand { name = "gegenstand"; @@ -60,6 +61,18 @@ export default class GegenstandCommand implements ApplicationCommand { .setDescription("Die Sau, die du benutzen möchtest") .setAutocomplete(true), ), + ) + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("ausrüsten") + .setDescription("Rüste einen gegenstand aus") + .addStringOption( + new SlashCommandStringOption() + .setRequired(true) + .setName("item") + .setDescription("Rüste dich für deinen nächsten Kampf") + .setAutocomplete(true), + ), ); async handleInteraction(interaction: CommandInteraction, context: BotContext) { @@ -75,6 +88,9 @@ export default class GegenstandCommand implements ApplicationCommand { case "benutzen": await this.#useItem(interaction, context); break; + case "ausrüsten": + await this.#equipItem(interaction, context); + break; default: throw new Error(`Unknown subcommand: "${subCommand}"`); } @@ -309,7 +325,7 @@ export default class GegenstandCommand implements ApplicationCommand { async autocomplete(interaction: AutocompleteInteraction) { const subCommand = interaction.options.getSubcommand(true); - if (subCommand !== "info" && subCommand !== "benutzen") { + if (subCommand !== "info" && subCommand !== "benutzen" && subCommand !== "ausrüsten") { return; } @@ -337,6 +353,9 @@ export default class GegenstandCommand implements ApplicationCommand { if (subCommand === "benutzen" && template.onUse === undefined) { continue; } + if (subCommand === "ausrüsten" && template.gameEquip === undefined) { + continue; + } const emote = lootDataService.getEmote(interaction.guild, item); completions.push({ @@ -351,4 +370,41 @@ export default class GegenstandCommand implements ApplicationCommand { await interaction.respond(completions); } + + async #equipItem(interaction: CommandInteraction, context: BotContext) { + if (!interaction.isChatInputCommand()) { + throw new Error("Interaction is not a chat input command"); + } + if (!interaction.guild || !interaction.channel) { + return; + } + + const info = await this.#fetchItem(interaction); + if (!info) { + return; + } + const { item, template } = info; + log.info(item); + if (template.gameEquip === undefined) { + await interaction.reply({ + content: ` ${item.displayName} kann nicht ausgerüstet werden.`, + ephemeral: true, + }); + return; + } + const items = await getFightInventoryUnsorted(interaction.user.id); + if (items.filter(i => i.id === item.id).length !== 0) { + await interaction.reply({ + content: `Du hast ${item.displayName} schon ausgerüstet`, + ephemeral: true, + }); + return; + } + const result = await equipItembyLoot(interaction.user.id, item, template.gameEquip.type); + const message = + result.unequipped.length === 0 + ? `Du hast ${result.equipped?.displayName} ausgerüstet` + : `Du hast ${result.unequipped.join(", ")} abgelegt und dafür ${result.equipped?.displayName} ausgerüstet`; + await interaction.reply(message); + } } diff --git a/src/commands/inventar.ts b/src/commands/inventar.ts index 3225bdcf..6161d432 100644 --- a/src/commands/inventar.ts +++ b/src/commands/inventar.ts @@ -3,7 +3,9 @@ import { ButtonStyle, type CommandInteraction, ComponentType, + type InteractionReplyOptions, SlashCommandBuilder, + SlashCommandStringOption, SlashCommandUserOption, type User, } from "discord.js"; @@ -16,6 +18,7 @@ import * as lootDataService from "@/service/lootData.js"; import { LootAttributeKindId } from "@/service/lootData.js"; import log from "@log"; +import { getFightInventoryEnriched } from "@/storage/fightInventory.js"; export default class InventarCommand implements ApplicationCommand { name = "inventar"; @@ -29,12 +32,23 @@ export default class InventarCommand implements ApplicationCommand { .setRequired(false) .setName("user") .setDescription("Wem du tun willst"), + ) + .addStringOption( + new SlashCommandStringOption() + .setName("typ") + .setDescription("Anzeige") + .setRequired(false) + .addChoices( + { name: "Kampfausrüstung", value: "fightInventory" }, + { name: "Komplett", value: "all" }, + ), ); async handleInteraction(interaction: CommandInteraction, context: BotContext) { const cmd = ensureChatInputCommand(interaction); const user = cmd.options.getUser("user") ?? cmd.user; + const type = cmd.options.getString("typ") ?? "all"; const contents = await lootService.getInventoryContents(user); if (contents.length === 0) { @@ -44,7 +58,14 @@ export default class InventarCommand implements ApplicationCommand { return; } - await this.#createLongEmbed(context, interaction, user); + switch (type) { + case "fightInventory": + return await this.#createFightEmbed(context, interaction, user); + case "all": + return await this.#createLongEmbed(context, interaction, user); + default: + throw new Error(`Unhandled type: "${type}"`); + } } async #createLongEmbed(context: BotContext, interaction: CommandInteraction, user: User) { @@ -149,4 +170,39 @@ export default class InventarCommand implements ApplicationCommand { }); }); } + + async #createFightEmbed(context: BotContext, interaction: CommandInteraction, user: User) { + const fightInventory = await getFightInventoryEnriched(user.id); + const avatarURL = user.avatarURL(); + const display = { + title: `Kampfausrüstung von ${user.displayName}`, + description: + "Du kannst maximal eine Rüstung, eine Waffe und drei Items tragen. Wenn du kämpfst, setzt du die Items ein und verlierst diese, egal ob du gewinnst oder verlierst.", + thumbnail: avatarURL ? { url: avatarURL } : undefined, + fields: [ + { name: "Waffe", value: fightInventory.weapon?.itemInfo?.displayName ?? "Nix" }, + { name: "Rüstung", value: fightInventory.armor?.itemInfo?.displayName ?? "Nackt" }, + ...fightInventory.items.map(item => { + return { + name: item.itemInfo?.displayName ?? "", + value: "Hier sollten die buffs stehen", + inline: true, + }; + }), + { name: "Buffs", value: "Nix" }, + ], + footer: { + text: "Lol ist noch nicht fertig", + }, + } satisfies APIEmbed; + + const embed = { + components: [], + embeds: [display], + fetchReply: true, + tts: false, + } as const satisfies InteractionReplyOptions; + + const message = await interaction.reply(embed); + } } diff --git a/src/commands/stempelkarte.ts b/src/commands/stempelkarte.ts index 1efc0a45..8ea18d3e 100644 --- a/src/commands/stempelkarte.ts +++ b/src/commands/stempelkarte.ts @@ -43,7 +43,7 @@ const firmenstempelCenter = { y: 155, }; -const getAvatarUrlForMember = (member?: GuildMember, size: ImageSize = 32) => { +export const getAvatarUrlForMember = (member?: GuildMember, size: ImageSize = 32) => { return ( member?.user.avatarURL({ size, diff --git a/src/service/fight.ts b/src/service/fight.ts new file mode 100644 index 00000000..2b847dc9 --- /dev/null +++ b/src/service/fight.ts @@ -0,0 +1 @@ +import { type APIEmbed, APIEmbedField } from "discord.js"; diff --git a/src/service/fightData.ts b/src/service/fightData.ts new file mode 100644 index 00000000..67597177 --- /dev/null +++ b/src/service/fightData.ts @@ -0,0 +1,216 @@ +import { randomValue, type Range } from "@/service/random.js"; + +export const fightTemplates: { [name: string]: Equipable } = { + ayran: { + type: "item", + attackModifier: { min: 2, maxExclusive: 3 }, + }, + oettinger: { + type: "item", + attackModifier: { min: 1, maxExclusive: 5 }, + defenseModifier: { min: -3, maxExclusive: 0 }, + }, + thunfischshake: { + type: "item", + attackModifier: { min: 3, maxExclusive: 5 }, + }, + nachthemd: { + type: "armor", + health: 50, + defense: { min: 2, maxExclusive: 5 }, + }, + eierwaermer: { + type: "armor", + health: 30, + defense: { min: 3, maxExclusive: 5 }, + }, + dildo: { + type: "weapon", + attack: { min: 3, maxExclusive: 9 }, + }, + messerblock: { + type: "weapon", + attack: { min: 1, maxExclusive: 9 }, + }, +}; +export const bossMap: { [name: string]: Enemy } = { + gudrun: { + name: "Gudrun", + description: "", + health: 150, + baseDamage: 2, + baseDefense: 0, + enabled: true, + armor: { + name: "Nachthemd", + ...(fightTemplates.nachthemd as EquipableArmor), + }, + weapon: { + name: "Dildo", + ...(fightTemplates.dildo as EquipableWeapon), + }, + lossDescription: "", + winDescription: "", + items: [], + }, + + deinchef: { + name: "Deinen Chef", + description: "", + health: 120, + baseDamage: 1, + baseDefense: 1, + enabled: false, + lossDescription: "", + winDescription: "", + items: [], + }, + schutzheiliger: { + name: "Schutzheiliger der Matjesverkäufer", + description: "", + health: 120, + enabled: false, + baseDamage: 1, + baseDefense: 1, + lossDescription: "", + winDescription: "", + items: [], + }, + rentner: { + name: "Reeeeeeentner", + description: "Runter von meinem Rasen, dein Auto muss da weg", + lossDescription: "", + winDescription: "", + health: 200, + baseDamage: 3, + baseDefense: 5, + enabled: false, + items: [], + }, + barkeeper: { + name: "Barkeeper von Nürnia", + description: + "Nach deiner Reise durch den Schrank durch kommst du nach Nürnia, wo dich ein freundlicher Barkeeper dich anlächelt " + + "und dir ein Eimergroßes Fass Gettorade hinstellt. Deine nächste aufgabe ist es ihn im Wetttrinken zu besiegen", + lossDescription: "", + winDescription: "", + health: 350, + enabled: false, + baseDamage: 5, + baseDefense: 5, + items: [], + }, +}; + +export const baseStats = { + description: "", + health: 80, + baseDamage: 1, + baseDefense: 0, +}; + +export type FightItemType = "weapon" | "armor" | "item"; + +export type Equipable = EquipableWeapon | EquipableItem | EquipableArmor; + +export interface EquipableWeapon { + type: "weapon"; + attack: Range; +} + +export interface EquipableArmor { + type: "armor"; + defense: Range; + health: number; +} + +export interface FightScene { + player: Entity; + enemy: Entity; +} + +export interface BaseEntity { + health: number; + name: string; + description: string; + baseDamage: number; + baseDefense: number; + + items: (EquipableItem & { name: string })[]; + weapon?: EquipableWeapon & { name: string }; + armor?: EquipableArmor & { name: string }; + //TODO + permaBuffs?: undefined; +} + +export interface Enemy extends BaseEntity { + enabled: boolean; + winDescription: string; + lossDescription: string; +} + +export class Entity { + stats: BaseEntity; + maxHealth: number; + lastAttack?: number; + lastDefense?: number; + itemText: string[] = []; + + constructor(entity: BaseEntity) { + this.stats = entity; + if (this.stats.armor?.health) { + this.stats.health += this.stats.armor?.health; + } + this.maxHealth = this.stats.health; + } + + attack(enemy: Entity) { + let rawDamage: number; + rawDamage = this.stats.baseDamage; + if (this.stats.weapon?.attack) { + rawDamage += randomValue(this.stats.weapon.attack); + } + for (const value1 of this.stats.items) { + if (value1.attackModifier) { + rawDamage += randomValue(value1.attackModifier); + } + } + const defense = enemy.defend(); + const result = calcDamage(rawDamage, defense); + console.log( + `${this.stats.name} (${this.stats.health}) hits ${enemy.stats.name} (${enemy.stats.health}) for ${result.damage} mitigated ${result.mitigated}`, + ); + enemy.stats.health -= result.damage; + this.lastAttack = result.rawDamage; + return result; + } + + defend() { + let defense = this.stats.baseDefense; + if (this.stats.armor?.defense) { + defense += randomValue(this.stats.armor.defense); + } + for (const item of this.stats.items) { + if (item.defenseModifier) { + defense += randomValue(item.defenseModifier); + } + } + this.lastDefense = defense; + return defense; + } +} + +export interface EquipableItem { + type: "item"; + attackModifier?: Range; + defenseModifier?: Range; + afterFight?: (scene: FightScene) => void; + modifyAttack?: (scene: FightScene) => void; +} + +function calcDamage(rawDamage: number, defense: number) { + if (defense >= rawDamage) { + return { rawDamage: rawDamage, damage: 0, mitigated: rawDamage }; + } + return { rawDamage: rawDamage, damage: rawDamage - defense, mitigated: defense }; +} diff --git a/src/service/loot.ts b/src/service/loot.ts index 3b05c128..b9732d02 100644 --- a/src/service/loot.ts +++ b/src/service/loot.ts @@ -4,7 +4,7 @@ import type { LootId, LootInsertable, LootOrigin } from "@/storage/db/model.js"; import type { LootAttributeKindId, LootKindId } from "./lootData.js"; import * as loot from "@/storage/loot.js"; import * as lootDataService from "@/service/lootData.js"; -import db from "@/storage/db/db.js"; +import db from "@db"; export async function getInventoryContents(user: User) { const contents = await loot.findOfUserWithAttributes(user); @@ -25,8 +25,8 @@ export async function getUserLootsWithAttribute( return await loot.getUserLootsWithAttribute(userId, attributeKindId); } -export async function getUserLootById(userId: Snowflake, id: LootId) { - return await loot.getUserLootById(userId, id); +export async function getUserLootById(userId: Snowflake, id: LootId, ctx = db()) { + return await loot.getUserLootById(userId, id, ctx); } export async function getLootAttributes(id: LootId) { diff --git a/src/service/lootData.ts b/src/service/lootData.ts index af24a8b2..ddc7dbb6 100644 --- a/src/service/lootData.ts +++ b/src/service/lootData.ts @@ -6,6 +6,7 @@ import { GuildMember, type Guild } from "discord.js"; import type { Loot, LootAttribute } from "@/storage/db/model.js"; import log from "@log"; +import { fightTemplates } from "@/service/fightData.js"; const ACHTUNG_NICHT_DROPBAR_WEIGHT_KG = 0; @@ -95,6 +96,7 @@ export const lootTemplateMap: Record = { dropDescription: "🔪", emote: "🔪", asset: "assets/loot/02-messerblock.jpg", + gameEquip: fightTemplates.messerblock, }, [LootKindId.KUEHLSCHRANK]: { id: LootKindId.KUEHLSCHRANK, @@ -198,6 +200,7 @@ export const lootTemplateMap: Record = { dropDescription: "Der gute von Müller", emote: "🥛", asset: "assets/loot/09-ayran.jpg", + gameEquip: fightTemplates.ayran, }, [LootKindId.PKV]: { id: LootKindId.PKV, @@ -287,6 +290,7 @@ export const lootTemplateMap: Record = { dropDescription: "Ja dann Prost ne!", emote: "🍺", asset: "assets/loot/16-oettinger.jpg", + gameEquip: fightTemplates.oettinger, }, [LootKindId.ACHIEVEMENT]: { id: LootKindId.ACHIEVEMENT, @@ -455,6 +459,7 @@ export const lootTemplateMap: Record = { dropDescription: "Nach Rezept zubereitet, bestehend aus Thunfisch und Reiswaffeln", emote: ":baby_bottle:", asset: "assets/loot/33-thunfischshake.jpg", + gameEquip: fightTemplates.thunfischshake, }, [LootKindId.KAFFEEMUEHLE]: { id: LootKindId.KAFFEEMUEHLE, diff --git a/src/storage/db/model.ts b/src/storage/db/model.ts index 9d011731..625811ed 100644 --- a/src/storage/db/model.ts +++ b/src/storage/db/model.ts @@ -24,6 +24,8 @@ export interface Database { lootAttribute: LootAttributeTable; emote: EmoteTable; emoteUse: EmoteUseTable; + fightHistory: FightHistoryTable; + fightInventory: FightInventoryTable; } export type OneBasedMonth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; @@ -287,3 +289,18 @@ export interface EmoteUseTable extends AuditedTable { emoteId: ColumnType; isReaction: boolean; } + +interface FightHistoryTable extends AuditedTable { + id: GeneratedAlways; + userId: Snowflake; + result: boolean; + bossName: string; + firstTime: boolean; +} + +interface FightInventoryTable { + id: GeneratedAlways; + userId: Snowflake; + lootId: LootId; + equippedSlot: string; +} diff --git a/src/storage/fightHistory.ts b/src/storage/fightHistory.ts new file mode 100644 index 00000000..92815d24 --- /dev/null +++ b/src/storage/fightHistory.ts @@ -0,0 +1,35 @@ +import type { User } from "discord.js"; +import db from "@db"; +import { FightScene } from "@/service/fightData.js"; + +export async function insertResult(userId: User["id"], boss: string, win: boolean, ctx = db()) { + const lastWins = await getWinsForBoss(userId, boss); + return await ctx + .insertInto("fightHistory") + .values({ + userId: userId, + result: win, + bossName: boss, + firstTime: lastWins.length === 0 && win, + }) + .execute(); +} + +export async function getWinsForBoss(userId: User["id"], boss: string, ctx = db()) { + return await ctx + .selectFrom("fightHistory") + .where("userId", "=", userId) + .where("bossName", "=", boss) + .where("result", "=", true) + .selectAll() + .execute(); +} + +export async function getLastFight(userId: User["id"], ctx = db()) { + return await ctx + .selectFrom("fightHistory") + .where("userId", "=", userId) + .orderBy("createdAt desc") + .selectAll() + .executeTakeFirst(); +} diff --git a/src/storage/fightInventory.ts b/src/storage/fightInventory.ts new file mode 100644 index 00000000..5585a0e8 --- /dev/null +++ b/src/storage/fightInventory.ts @@ -0,0 +1,99 @@ +import type { User } from "discord.js"; +import db from "@db"; +import type { Equipable, FightItemType } from "@/service/fightData.js"; +import type { Loot, LootId } from "@/storage/db/model.js"; +import * as lootDataService from "@/service/lootData.js"; +import { type LootKindId, resolveLootTemplate } from "@/service/lootData.js"; +import * as lootService from "@/service/loot.js"; +import { deleteLoot } from "@/storage/loot.js"; + +export async function getFightInventoryUnsorted(userId: User["id"], ctx = db()) { + return await ctx + .selectFrom("fightInventory") + .where("userId", "=", userId) + .selectAll() + .execute(); +} + +export async function getFightInventoryEnriched(userId: User["id"], ctx = db()) { + const unsorted = await getFightInventoryUnsorted(userId, ctx); + const enriched = []; + for (const equip of unsorted) { + const itemInfo = await lootService.getUserLootById(userId, equip.lootId, ctx); + enriched.push({ + gameTemplate: await getGameTemplate(itemInfo?.lootKindId), + itemInfo: itemInfo, + }); + } + return { + weapon: enriched.filter(value => value.gameTemplate?.type === "weapon").shift(), + armor: enriched.filter(value => value.gameTemplate?.type === "armor").shift(), + items: enriched.filter(value => value.gameTemplate?.type === "item"), + }; +} + +export async function getGameTemplate( + lootKindId: LootKindId | undefined, +): Promise { + return lootKindId ? resolveLootTemplate(lootKindId)?.gameEquip : undefined; +} + +export async function getItemsByType(userId: User["id"], fightItemType: string, ctx = db()) { + return await ctx + .selectFrom("fightInventory") + .where("userId", "=", userId) + .where("equippedSlot", "=", fightItemType) + .selectAll() + .execute(); +} + +export async function removeItemsAfterFight(userId: User["id"], ctx = db()) { + await ctx.transaction().execute(async ctx => { + const items = await getItemsByType(userId, "item", ctx); + for (const item of items) { + await deleteLoot(item.lootId, ctx); + } + await ctx + .deleteFrom("fightInventory") + .where("userId", "=", userId) + .where("equippedSlot", "=", "item") + .execute(); + }); +} + +export async function equipItembyLoot( + userId: User["id"], + loot: Loot, + itemType: FightItemType, + ctx = db(), +) { + const maxItems = { + weapon: 1, + armor: 1, + item: 3, + }; + const unequippeditems: string[] = []; + + return await ctx.transaction().execute(async ctx => { + const equippedStuff = await getItemsByType(userId, itemType, ctx); + for (let i = 0; i <= equippedStuff.length - maxItems[itemType]; i++) { + const unequipitem = await lootService.getUserLootById( + userId, + equippedStuff[i].lootId, + ctx, + ); + unequippeditems.push(unequipitem?.displayName ?? String(equippedStuff[i].lootId)); + await ctx.deleteFrom("fightInventory").where("id", "=", equippedStuff[i].id).execute(); + } + + await ctx + .insertInto("fightInventory") + .values({ + userId: userId, + lootId: loot.id, + equippedSlot: itemType, + }) + .execute(); + return { unequipped: unequippeditems, equipped: loot }; + }); +} diff --git a/src/storage/loot.ts b/src/storage/loot.ts index 7b457eec..17d63417 100644 --- a/src/storage/loot.ts +++ b/src/storage/loot.ts @@ -20,7 +20,9 @@ import type { } from "./db/model.js"; import db from "@db"; + import { resolveLootAttributeTemplate, type LootAttributeKindId } from "@/service/lootData.js"; +import type { Equipable } from "@/service/fightData.js"; export type LootUseCommandInteraction = ChatInputCommandInteraction & { channel: GuildTextBasedChannel; @@ -36,8 +38,8 @@ export interface LootTemplate { emote: string; excludeFromInventory?: boolean; effects?: string[]; + gameEquip?: Equipable; initialAttributes?: LootAttributeKindId[]; - onDrop?: ( context: BotContext, winner: GuildMember, @@ -136,6 +138,7 @@ export async function findOfUser(user: User, ctx = db()) { } export type LootWithAttributes = Loot & { attributes: Readonly[] }; + export async function findOfUserWithAttributes( user: User, ctx = db(), diff --git a/src/storage/migrations/10-loot-attributes.ts b/src/storage/migrations/10-loot-attributes.ts index a8a872b5..16cd9b6e 100644 --- a/src/storage/migrations/10-loot-attributes.ts +++ b/src/storage/migrations/10-loot-attributes.ts @@ -10,7 +10,6 @@ export async function up(db: Kysely) { .addColumn("displayName", "text", c => c.notNull()) .addColumn("shortDisplay", "text", c => c.notNull()) .addColumn("color", "integer") - .addColumn("createdAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`)) .addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`)) .addColumn("deletedAt", "timestamp") diff --git a/src/storage/migrations/11-fightsystem.ts b/src/storage/migrations/11-fightsystem.ts new file mode 100644 index 00000000..2705e5ed --- /dev/null +++ b/src/storage/migrations/11-fightsystem.ts @@ -0,0 +1,44 @@ +import { sql, type Kysely } from "kysely"; + +export async function up(db: Kysely) { + await db.schema + .createTable("fightInventory") + .ifNotExists() + .addColumn("id", "integer", c => c.primaryKey().autoIncrement()) + .addColumn("userId", "text") + .addColumn("lootId", "integer", c => c.references("loot.id")) + .addColumn("equippedSlot", "text") + .execute(); + + await db.schema + .createTable("fightHistory") + .ifNotExists() + .addColumn("id", "integer", c => c.primaryKey().autoIncrement()) + .addColumn("userId", "text", c => c.notNull()) + .addColumn("bossName", "text", c => c.notNull()) + .addColumn("result", "boolean", c => c.notNull()) + .addColumn("firstTime", "boolean", c => c.notNull()) + .addColumn("createdAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`)) + .addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`)) + .execute(); + + await createUpdatedAtTrigger(db, "fightHistory"); +} + +function createUpdatedAtTrigger(db: Kysely, tableName: string) { + return sql + .raw(` + create trigger ${tableName}_updatedAt + after update on ${tableName} for each row + begin + update ${tableName} + set updatedAt = current_timestamp + where id = old.id; + end; + `) + .execute(db); +} + +export async function down(_db: Kysely) { + throw new Error("Not supported lol"); +}