diff --git a/assets/maps/CSZ Karte V1.png b/assets/maps/CSZ Karte V1.png new file mode 100644 index 000000000..bb5945ec6 Binary files /dev/null and b/assets/maps/CSZ Karte V1.png differ diff --git a/scripts/simulate-fight.ts b/scripts/simulate-fight.ts new file mode 100644 index 000000000..373815973 --- /dev/null +++ b/scripts/simulate-fight.ts @@ -0,0 +1,202 @@ +/** + * Fight simulator — run a boss fight N times and print statistics. + * + * Usage: + * node scripts/simulate-fight.ts --boss gudrun --n 1000 + * node scripts/simulate-fight.ts --boss gudrun --n 5000 --weapon dildo --armor nachthemd --items ayran,oettinger + * + * Available bosses: gudrun, deinchef, schutzheiliger, rentner, barkeeper + * Available weapons: dildo, messerblock + * Available armors: nachthemd, eierwaermer + * Available items: ayran, oettinger, thunfischshake + */ + +import { + baseStats, + bossMap, + Entity, + fightTemplates, + type BaseEntity, + type EquipableArmor, + type EquipableItem, + type EquipableWeapon, + type FightScene, +} from "../src/service/fightData.ts"; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv: string[]): Record { + const args: Record = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + const key = argv[i].slice(2); + args[key] = argv[i + 1] ?? "true"; + i++; + } + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +const bossKey = args["boss"]; +const iterations = Math.max(1, Number(args["n"] ?? 1000)); +const weaponKey = args["weapon"]; +const armorKey = args["armor"]; +const itemKeys = args["items"] ? args["items"].split(",") : []; + +if (!bossKey || !(bossKey in bossMap)) { + console.error(`Unknown boss "${bossKey}". Available: ${Object.keys(bossMap).join(", ")}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Build player stats from CLI args +// --------------------------------------------------------------------------- + +function buildPlayer(): BaseEntity { + const weapon = + weaponKey && weaponKey in fightTemplates && fightTemplates[weaponKey].type === "weapon" + ? { name: weaponKey, ...(fightTemplates[weaponKey] as EquipableWeapon) } + : undefined; + + const armor = + armorKey && armorKey in fightTemplates && fightTemplates[armorKey].type === "armor" + ? { name: armorKey, ...(fightTemplates[armorKey] as EquipableArmor) } + : undefined; + + const items = itemKeys + .filter(k => k in fightTemplates && fightTemplates[k].type === "item") + .map(k => ({ name: k, ...(fightTemplates[k] as EquipableItem) })); + + return { + ...baseStats, + name: "Player", + weapon, + armor, + items, + }; +} + +// --------------------------------------------------------------------------- +// Headless fight loop (no Discord, no delays) +// --------------------------------------------------------------------------- + +function checkWin(scene: FightScene): "PLAYER" | "ENEMY" | undefined { + if (scene.player.stats.health < 0) return "ENEMY"; + if (scene.enemy.stats.health < 0) return "PLAYER"; + return undefined; +} + +interface FightResult { + winner: "PLAYER" | "ENEMY"; + playerHp: number; + enemyHp: number; + rounds: number; +} + +const noop = () => {}; + +function simulateFight(playerStats: BaseEntity, enemyStats: BaseEntity): FightResult { + const player = new Entity(structuredClone(playerStats)); + const enemy = new Entity(structuredClone(enemyStats)); + const scene: FightScene = { player, enemy }; + + // Suppress the per-hit console.log inside Entity.attack + const origLog = console.log; + console.log = noop; + + let rounds = 0; + while (checkWin(scene) === undefined) { + player.attack(enemy); + enemy.attack(player); + for (const item of player.stats.items) item.afterFight?.(scene); + for (const item of enemy.stats.items) item.afterFight?.({ player: enemy, enemy: player }); + rounds++; + } + + console.log = origLog; + + return { + winner: checkWin(scene)!, + playerHp: player.stats.health, + enemyHp: enemy.stats.health, + rounds, + }; +} + +// --------------------------------------------------------------------------- +// Run simulations +// --------------------------------------------------------------------------- + +const playerStats = buildPlayer(); +const enemyStats = { ...bossMap[bossKey] }; + +const results: FightResult[] = []; +for (let i = 0; i < iterations; i++) { + results.push(simulateFight(playerStats, enemyStats)); +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +function stats(values: number[]) { + if (values.length === 0) return { avg: 0, min: 0, max: 0 }; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + return { avg, min: Math.min(...values), max: Math.max(...values) }; +} + +const wins = results.filter(r => r.winner === "PLAYER"); +const losses = results.filter(r => r.winner === "ENEMY"); + +const winRate = (wins.length / iterations) * 100; + +const playerHpOnWin = stats(wins.map(r => r.playerHp)); +const enemyHpOnLoss = stats(losses.map(r => r.enemyHp)); +const roundStats = stats(results.map(r => r.rounds)); + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +const playerDesc = [ + weaponKey ? `weapon=${weaponKey}` : "no weapon", + armorKey ? `armor=${armorKey}` : "no armor", + itemKeys.length ? `items=${itemKeys.join(",")}` : "no items", +].join(", "); + +console.log( + `\nFight Simulation: Player vs ${bossMap[bossKey].name} (${iterations.toLocaleString()} runs)`, +); +console.log(`Player: ${playerDesc}`); +console.log( + `Player HP: ${playerStats.health + (playerStats.armor?.health ?? 0)} | Enemy HP: ${enemyStats.health + (enemyStats.armor?.health ?? 0)}`, +); + +console.log("\n── Results ──────────────────────────────"); +console.log(` Wins: ${wins.length.toLocaleString().padStart(6)} (${winRate.toFixed(1)}%)`); +console.log( + ` Losses: ${losses.length.toLocaleString().padStart(6)} (${(100 - winRate).toFixed(1)}%)`, +); + +console.log("\n── Rounds per fight ─────────────────────"); +console.log(` Avg: ${roundStats.avg.toFixed(1)} Min: ${roundStats.min} Max: ${roundStats.max}`); + +if (wins.length > 0) { + console.log("\n── Player HP remaining on WIN ───────────"); + console.log( + ` Avg: ${playerHpOnWin.avg.toFixed(1)} Min: ${playerHpOnWin.min} Max: ${playerHpOnWin.max}`, + ); +} + +if (losses.length > 0) { + console.log("\n── Enemy HP remaining on LOSS ───────────"); + console.log( + ` Avg: ${enemyHpOnLoss.avg.toFixed(1)} Min: ${enemyHpOnLoss.min} Max: ${enemyHpOnLoss.max}`, + ); +} + +console.log(""); diff --git a/src/commands/fight.ts b/src/commands/fight.ts new file mode 100644 index 000000000..358b0b0c3 --- /dev/null +++ b/src/commands/fight.ts @@ -0,0 +1,232 @@ +import { setTimeout } from "node:timers/promises"; +import type { JSONEncodable } from "@discordjs/util"; + +import type { ApplicationCommand } from "#commands/command.ts"; +import { + type APIEmbed, + type BooleanCache, + type CacheType, + type CommandInteraction, + type InteractionResponse, + SlashCommandBuilder, + type User, +} from "discord.js"; +import type { BotContext } from "#context.ts"; +import { + type BaseEntity, + baseStats, + bossMap, + Entity, + type EquipableArmor, + type EquipableItem, + type EquipableWeapon, + type FightScene, +} from "#/service/fightData.ts"; +import { getFightInventoryEnriched, removeItemsAfterFight } from "#/storage/fightInventory.ts"; +import { getLastFight, insertResult } from "#/storage/fightHistory.ts"; + +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) { + if (!command.isChatInputCommand()) { + throw new Error("Invalid command type"); + } + + 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 63f6bcba3..12760f831 100644 --- a/src/commands/gegenstand.ts +++ b/src/commands/gegenstand.ts @@ -25,6 +25,7 @@ import * as imageService from "#/service/image.ts"; import * as lootDataService from "#/service/lootData.ts"; import { LootAttributeKind, LootKind } from "#/service/lootData.ts"; +import { equipItembyLoot, getFightInventoryUnsorted } from "#/storage/fightInventory.ts"; import log from "#log"; export default class GegenstandCommand implements ApplicationCommand { @@ -75,6 +76,18 @@ export default class GegenstandCommand implements ApplicationCommand { .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), + ), + ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("set-pet") @@ -134,6 +147,9 @@ export default class GegenstandCommand implements ApplicationCommand { case "set-pet": await this.#setPet(command, context); break; + case "ausrüsten": + await this.#equipItem(interaction, context); + break; default: throw new Error(`Unknown subcommand: "${subCommand}"`); } @@ -379,7 +395,12 @@ export default class GegenstandCommand implements ApplicationCommand { return; } - if (subCommand !== "info" && subCommand !== "use" && subCommand !== "set-pet") { + if ( + subCommand !== "info" && + subCommand !== "use" && + subCommand !== "set-pet" && + subCommand !== "ausrüsten" + ) { return; } @@ -406,6 +427,9 @@ export default class GegenstandCommand implements ApplicationCommand { if (subCommand === "use" && template.onUse === undefined) { continue; } + if (subCommand === "ausrüsten" && template.gameEquip === undefined) { + continue; + } const emote = lootDataService.getEmote(interaction.guild, item); completions.push({ @@ -421,6 +445,43 @@ export default class GegenstandCommand implements ApplicationCommand { await interaction.respond(completions.slice(0, 20)); } + 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); + } + async #setPet(interaction: ChatInputCommandInteraction, _context: BotContext) { const itemId = Number(interaction.options.getString("animal", true)); if (!Number.isSafeInteger(itemId)) { diff --git a/src/commands/inventar.ts b/src/commands/inventar.ts index 2d6f4efce..15746f9bf 100644 --- a/src/commands/inventar.ts +++ b/src/commands/inventar.ts @@ -8,7 +8,9 @@ import { ContainerBuilder, MessageFlags, SlashCommandBuilder, + SlashCommandStringOption, type User, + type APIEmbed, } from "discord.js"; import { createCanvas, loadImage } from "@napi-rs/canvas"; @@ -20,6 +22,14 @@ import * as lootDataService from "#/service/lootData.ts"; import { LootAttributeKind } from "#/service/lootData.ts"; import log from "#log"; +import { getFightInventoryEnriched } from "#/storage/fightInventory.ts"; +import { + type EquipableArmor, + type EquipableItem, + type EquipableWeapon, + baseStats, +} from "#/service/fightData.ts"; +import type { Range } from "#/service/random.ts"; import { extendContext } from "#/utils/ExtendedCanvasContext.ts"; import { Vec2 } from "#/utils/math.ts"; @@ -29,24 +39,41 @@ export default class InventarCommand implements ApplicationCommand { applicationCommand = new SlashCommandBuilder() .setName(this.name) - .setDescription(this.description); + .setDescription(this.description) + .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 contents = await lootService.getInventoryContents(cmd.user); + const type = cmd.options.getString("typ") ?? "all"; + const contents = await lootService.getInventoryContents(interaction.user); + if (contents.length === 0) { await interaction.reply({ content: "Dein Inventar ist ✨leer✨", }); return; } - - await this.#createLongEmbed(context, interaction, cmd.user); + switch (type) { + case "fightInventory": + return await this.#createFightEmbed(context, interaction, interaction.user); + case "all": + return await this.#createLongEmbed(context, interaction, interaction.user); + default: + throw new Error(`Unhandled type: "${type}"`); + } } async #createLongEmbed(context: BotContext, interaction: CommandInteraction, user: User) { const pageSize = 10; - const contentsUnsorted = await lootService.getInventoryContents(user); const contents = contentsUnsorted.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt), @@ -70,7 +97,6 @@ export default class InventarCommand implements ApplicationCommand { : ""; const shortAttributeList = item.attributes.map(a => a.shortDisplay).join(""); - return `${lootDataService.getEmote(context.guild, item)} ${item.displayName}${rarity} ${shortAttributeList}`.trim(); }); @@ -154,6 +180,69 @@ 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 armorHP = + (fightInventory.armor?.gameTemplate as EquipableArmor | undefined)?.health ?? 0; + const totalHP = baseStats.health + armorHP; + + const weaponEntry = fightInventory.weapon; + const weaponValue = weaponEntry?.itemInfo + ? `**${weaponEntry.itemInfo.displayName}**\n⚔️ Angriff: \`${formatRange((weaponEntry.gameTemplate as EquipableWeapon).attack)}\`` + : "_nicht ausgerüstet_"; + + const armorEntry = fightInventory.armor; + const armorValue = (() => { + if (!armorEntry?.itemInfo) return "_nicht ausgerüstet_"; + const armor = armorEntry.gameTemplate as EquipableArmor; + return `**${armorEntry.itemInfo.displayName}**\n🛡️ Verteidigung: \`${formatRange(armor.defense)}\`\n❤️ +${armor.health} HP`; + })(); + + const itemFields = fightInventory.items.map(entry => { + const equip = entry.gameTemplate as EquipableItem | undefined; + const lines: string[] = []; + if (equip?.attackModifier) + lines.push(`⚔️ +${formatRange(equip.attackModifier)} Angriff`); + if (equip?.defenseModifier) + lines.push(`🛡️ ${formatRange(equip.defenseModifier)} Verteidigung`); + return { + name: entry.itemInfo?.displayName ?? "Unbekannt", + value: lines.length > 0 ? lines.join("\n") : "_kein Effekt_", + inline: true, + }; + }); + + const display = { + title: `⚔️ Kampfausrüstung von ${user.displayName}`, + description: [ + `❤️ **${totalHP} HP**${armorHP > 0 ? ` *(Basis ${baseStats.health} + ${armorHP} durch Rüstung)*` : ""}`, + `🗡️ Basis-Schaden: **${baseStats.baseDamage}**`, + `-# Items werden nach dem Kampf verbraucht`, + ].join("\n"), + thumbnail: avatarURL ? { url: avatarURL } : undefined, + color: 0xc0392b, + fields: [ + { name: "⚔️ Waffe", value: weaponValue, inline: true }, + { name: "🛡️ Rüstung", value: armorValue, inline: true }, + ...(itemFields.length > 0 + ? [{ name: "​", value: "​", inline: false }, ...itemFields] + : []), + ], + } satisfies APIEmbed; + + await interaction.reply({ + embeds: [display], + tts: false, + }); + } +} + +function formatRange(range: Range): string { + const max = "maxInclusive" in range ? range.maxInclusive : range.maxExclusive - 1; + return range.min === max ? String(range.min) : `${range.min}–${max}`; } const size = new Vec2(80, 80); diff --git a/src/commands/modcommands/debugitems.ts b/src/commands/modcommands/debugitems.ts new file mode 100644 index 000000000..779fce88e --- /dev/null +++ b/src/commands/modcommands/debugitems.ts @@ -0,0 +1,105 @@ +import { + type CommandInteraction, + MessageFlags, + SlashCommandBuilder, + SlashCommandIntegerOption, + SlashCommandUserOption, +} from "discord.js"; + +import type { BotContext } from "#/context.ts"; +import type { ApplicationCommand } from "#/commands/command.ts"; +import { ensureChatInputCommand } from "#/utils/interactionUtils.ts"; +import * as lootService from "#/service/loot.ts"; +import { + LootKind, + LootAttributeKind, + lootAttributeTemplates, + resolveLootTemplate, +} from "#/service/lootData.ts"; + +const GIVEABLE_ITEMS = Object.values(LootKind).filter(id => id !== LootKind.NICHTS); +const RARITY_NORMAL = lootAttributeTemplates.find(a => a.id === LootAttributeKind.RARITY_NORMAL)!; + +export default class DebugItemsCommand implements ApplicationCommand { + modCommand = true; + name = "debugitems"; + description = "[DEBUG] Gibt einem Nutzer verschiedene Items"; + + applicationCommand = new SlashCommandBuilder() + .setName(this.name) + .setDescription(this.description) + .addUserOption( + new SlashCommandUserOption() + .setName("target") + .setDescription("Der Nutzer, der die Items erhalten soll") + .setRequired(false), + ) + .addIntegerOption( + new SlashCommandIntegerOption() + .setName("item_id") + .setDescription("Spezifische Item-ID (optional, sonst alle)") + .setRequired(false) + .setMinValue(1), + ); + + async handleInteraction(interaction: CommandInteraction, context: BotContext): Promise { + const command = ensureChatInputCommand(interaction); + + if (!interaction.guild) { + throw new Error("Interaction not in guild"); + } + + const member = interaction.guild.members.cache.get(interaction.user.id); + if (!member || !context.roleGuard.isMod(member)) { + await interaction.reply({ + content: "Nur Mods können diesen Befehl benutzen.", + flags: MessageFlags.Ephemeral, + }); + return; + } + + const targetUser = command.options.getUser("target") ?? interaction.user; + const specificItemId = command.options.getInteger("item_id"); + + const itemIds = specificItemId !== null ? [specificItemId] : GIVEABLE_ITEMS; + + const given: string[] = []; + const failed: number[] = []; + + for (const itemId of itemIds) { + const template = resolveLootTemplate(itemId as (typeof GIVEABLE_ITEMS)[number]); + if (!template) { + failed.push(itemId); + continue; + } + + try { + await lootService.createLoot( + template, + targetUser, + null, + "birthday", + null, + RARITY_NORMAL, + ); + given.push(`${template.emote ?? "📦"} ${template.displayName} (${itemId})`); + } catch { + failed.push(itemId); + } + } + + const lines: string[] = [ + `**${given.length}** Item(s) an <@${targetUser.id}> gegeben:`, + ...given.map(n => `- ${n}`), + ]; + + if (failed.length > 0) { + lines.push(`\nFehlgeschlagen (IDs): ${failed.join(", ")}`); + } + + await interaction.reply({ + content: lines.join("\n"), + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/commands/stempelkarte.ts b/src/commands/stempelkarte.ts index b738d905b..b3e073959 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/fightData.ts b/src/service/fightData.ts new file mode 100644 index 000000000..3b71377e5 --- /dev/null +++ b/src/service/fightData.ts @@ -0,0 +1,216 @@ +import { randomValue, type Range } from "#/service/random.ts"; + +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/lootData.ts b/src/service/lootData.ts index e3e5e1365..030b4539b 100644 --- a/src/service/lootData.ts +++ b/src/service/lootData.ts @@ -6,6 +6,7 @@ import * as emoteService from "#/service/emote.ts"; import * as bahnCardService from "#/service/bahncard.ts"; import { GuildMember, type Guild } from "discord.js"; import type { Loot, LootAttribute } from "#/storage/db/model.ts"; +import { fightTemplates } from "#/service/fightData.ts"; const ACHTUNG_NICHT_DROPBAR_WEIGHT_KG = 0; @@ -124,6 +125,7 @@ export const lootTemplateMap: Record = { dropDescription: "🔪", emote: "🔪", asset: "assets/loot/02-messerblock.jpg", + gameEquip: fightTemplates.messerblock, }, [LootKind.KUEHLSCHRANK]: { id: LootKind.KUEHLSCHRANK, @@ -239,6 +241,7 @@ export const lootTemplateMap: Record = { emote: "🥛", asset: "assets/loot/09-ayran.png", initialAttributes: [LootAttributeKind.NUTRI_SCORE_D], // Ref: https://de.openfoodfacts.org/produkt/4388860730685/ayran-ja + gameEquip: fightTemplates.ayran, }, [LootKind.PKV]: { id: LootKind.PKV, @@ -336,6 +339,7 @@ export const lootTemplateMap: Record = { emote: "🍺", asset: "assets/loot/16-oettinger.png", initialAttributes: [LootAttributeKind.NUTRI_SCORE_B], // Ref: https://archive.is/aonnZ + gameEquip: fightTemplates.oettinger, }, [LootKind.ACHIEVEMENT]: { id: LootKind.ACHIEVEMENT, @@ -515,6 +519,7 @@ export const lootTemplateMap: Record = { emote: "🍼", asset: "assets/loot/33-thunfischshake.jpg", initialAttributes: [LootAttributeKind.NUTRI_SCORE_A], + gameEquip: fightTemplates.thunfischshake, }, [LootKind.KAFFEEMUEHLE]: { id: LootKind.KAFFEEMUEHLE, diff --git a/src/storage/db/model.ts b/src/storage/db/model.ts index 44edc9660..9c2447b36 100644 --- a/src/storage/db/model.ts +++ b/src/storage/db/model.ts @@ -29,6 +29,10 @@ export interface Database { lootAttribute: LootAttributeTable; emote: EmoteTable; emoteUse: EmoteUseTable; + + fightHistory: FightHistoryTable; + fightInventory: FightInventoryTable; + lauscherRegistration: LauscherRegistrationTable; lauscherSpotifyLog: LauscherSpotifyLogTable; lauscherSpotifyLogView: LauscherSpotifyLogView; @@ -308,6 +312,28 @@ export interface EmoteUseTable extends AuditedTable { 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; +} + +export interface MapPositonTable { + id: GeneratedAlways; + userId: Snowflake; + x: number; + y: number; +} + export type LauscherRegistration = Selectable; export interface LauscherRegistrationTable extends AuditedTable { diff --git a/src/storage/fightHistory.ts b/src/storage/fightHistory.ts new file mode 100644 index 000000000..52c306153 --- /dev/null +++ b/src/storage/fightHistory.ts @@ -0,0 +1,34 @@ +import type { User } from "discord.js"; +import db from "#db"; + +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 000000000..cc208f892 --- /dev/null +++ b/src/storage/fightInventory.ts @@ -0,0 +1,98 @@ +import type { User } from "discord.js"; + +import db from "#db"; +import type { Equipable, FightItemType } from "#/service/fightData.ts"; +import type { Loot } from "#/storage/db/model.ts"; +import { type LootKindId, resolveLootTemplate } from "#/service/lootData.ts"; +import * as lootStorage from "#/storage/loot.ts"; + +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 lootStorage.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 lootStorage.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 lootStorage.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 c39f6daeb..1739e050f 100644 --- a/src/storage/loot.ts +++ b/src/storage/loot.ts @@ -21,6 +21,8 @@ import type { } from "./db/model.ts"; import db from "#db"; + +import type { Equipable } from "#/service/fightData.ts"; import { type LootKindId, resolveLootAttributeTemplate, @@ -48,6 +50,7 @@ export interface LootTemplate { emote: string; excludeFromInventory?: boolean; effects?: string[]; + gameEquip?: Equipable; initialAttributes?: LootAttributeKindId[]; excludeFromDoubleDrops?: boolean; @@ -181,6 +184,7 @@ export async function findById(id: LootId, 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 7007919af..25a7dcb57 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/19-map.ts b/src/storage/migrations/19-map.ts index 2ded54afa..a9422b45d 100644 --- a/src/storage/migrations/19-map.ts +++ b/src/storage/migrations/19-map.ts @@ -14,7 +14,6 @@ export async function up(db: Kysely) { .addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`)) .execute(); - // TODO: Check if we need this await db.schema .createIndex("locationHistory_userId_successor") .on("locationHistory") diff --git a/src/storage/migrations/xx-fightsystem.ts b/src/storage/migrations/xx-fightsystem.ts new file mode 100644 index 000000000..2705e5edf --- /dev/null +++ b/src/storage/migrations/xx-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"); +}