diff --git a/assets/loot/56-geschenkpapier.png b/assets/loot/56-geschenkpapier.png new file mode 100644 index 00000000..65e215b5 Binary files /dev/null and b/assets/loot/56-geschenkpapier.png differ diff --git a/src/commands/devcommands/loot-drop.ts b/src/commands/devcommands/loot-drop.ts new file mode 100644 index 00000000..4d80f32c --- /dev/null +++ b/src/commands/devcommands/loot-drop.ts @@ -0,0 +1,75 @@ +import { + ChannelType, + type CommandInteraction, + SlashCommandBuilder, + SlashCommandNumberOption, +} from "discord.js"; + +import type { BotContext } from "#/context.ts"; +import type { ApplicationCommand } from "#/commands/command.ts"; +import { ensureChatInputCommand } from "#/utils/interactionUtils.ts"; + +import * as lootDataService from "#/service/lootData.ts"; +import * as lootService from "#/service/loot.ts"; + +import { postLootDrop, type LootClaimCallback } from "#/service/lootDrop.ts"; + +export default class LootDropCommand implements ApplicationCommand { + name = "loot-drop"; + description = "Drops dir 1 Loot"; + + applicationCommand = new SlashCommandBuilder() + .setName(this.name) + .setDescription(this.description) + .addNumberOption( + new SlashCommandNumberOption() + .setName("loot-kind-id") + .setDescription("Loot ID die gedroppt werden soll") + .setMinValue(0), + ); + + async handleInteraction(interaction: CommandInteraction, context: BotContext) { + const command = ensureChatInputCommand(interaction); + + if (command.guild === null) { + throw new Error("Interaction not in guild"); + } + if (command.channel?.type !== ChannelType.GuildText) { + throw new Error("Interaction not in text channel"); + } + + const lootKindId = command.options.getNumber("loot-kind-id", false); + if (lootKindId === null) { + await command.reply({ + content: "Es muss eine Loot ID angegeben werden.", + ephemeral: true, + }); + return; + } + const lootTemplate = lootDataService.resolveLootTemplate(lootKindId); + if (lootTemplate === undefined) { + await command.reply({ + content: `Es konnte kein Loot mit der ID ${lootKindId} gefunden werden.`, + ephemeral: true, + }); + return; + } + const predefinedLootClaim: LootClaimCallback = async (winner, message) => { + const loot = await lootService.createLoot( + lootTemplate, + winner, + message, + "drop", + null, + null, + ); + if (!loot) return undefined; + return { loot, template: lootTemplate, rarity: undefined, messages: [] }; + }; + await postLootDrop(context, command.channel, command.user, predefinedLootClaim); + await command.reply({ + content: `Es wurde ${lootTemplate.id} gedroppt!`, + ephemeral: true, + }); + } +} diff --git a/src/service/lootData.ts b/src/service/lootData.ts index 881f4994..f14dffac 100644 --- a/src/service/lootData.ts +++ b/src/service/lootData.ts @@ -6,6 +6,8 @@ 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 { randomEntry } from "./random.ts"; +import log from "#log"; const ACHTUNG_NICHT_DROPBAR_WEIGHT_KG = 0; @@ -66,6 +68,7 @@ export const LootKind = Object.freeze({ MAGERQUARK: 53, NAS: 54, USB_KABEL: 55, + GESCHENKPAPIER: 56, } as const); export type LootKindId = (typeof LootKind)[keyof typeof LootKind]; @@ -228,7 +231,7 @@ export const lootTemplateMap: Record = { context, interaction.channel, interaction.user, - loot.id, + lootDropService.randomizedLootClaim(loot.id), ); return false; }, @@ -872,6 +875,71 @@ export const lootTemplateMap: Record = { asset: "assets/loot/55-usb-kabel.png", wrapable: true, }, + [LootKind.GESCHENKPAPIER]: { + id: LootKind.GESCHENKPAPIER, + weight: 12, + displayName: "Geschenkpapier", + titleText: "Das sieht ja super cringe aus, wer würde sich denn darüber freuen wollen?", + dropDescription: + "Mit diesem Geschenkpapier sollte man Geschenke einpacken können, aber irgendwie sieht es auch nicht so aus, als wäre das jemals jemandem gelungen.", + emote: "🎁", + asset: "assets/loot/56-geschenkpapier.png", + onUse: async (interaction, context, _wrappingPaper) => { + const inventory = await lootService.getInventoryContents(interaction.user); + const wrapables = inventory.filter( + l => resolveLootTemplate(l.lootKindId)?.wrapable ?? false, + ); + + if (wrapables.length === 0) { + await interaction.reply({ + content: "Du hast nichts, was du einpacken könntest! 😢", + }); + return false; + } + + const randomItem = randomEntry(wrapables); + const rarity = + randomItem.attributes.find(a => a.attributeClassId === LootAttributeClass.RARITY) ?? + undefined; + const rarityAttributeTemplate = rarity + ? resolveLootAttributeTemplate(rarity.attributeKindId) + : undefined; + const lootTemplate = resolveLootTemplate(randomItem.lootKindId); + if (!lootTemplate) { + log.error( + "Failed to resolve loot template for wrapped item: " + randomItem.lootKindId, + ); + await interaction.reply({ + content: "Ein Fehler ist aufgetreten, versuch es später nochmal! 😢", + }); + return false; + } + + const transferWrappedItemLoot: lootDropService.LootClaimCallback = async winner => { + const claimedLoot = await lootService.transferLootToUser( + randomItem.id, + winner, + true, + ); + return { + loot: claimedLoot, + template: lootTemplate, + rarity: rarityAttributeTemplate, + messages: [], + }; + }; + const claimed = await lootDropService.postLootDrop( + context, + interaction.channel, + interaction.user, + transferWrappedItemLoot, + ); + if (!claimed) { + await lootService.deleteLoot(randomItem.id); + } + return false; + }, + }, } as const; export const lootTemplates: LootTemplate[] = Object.values(lootTemplateMap); diff --git a/src/service/lootDrop.ts b/src/service/lootDrop.ts index f3cfa582..61065faa 100644 --- a/src/service/lootDrop.ts +++ b/src/service/lootDrop.ts @@ -5,6 +5,7 @@ import { ButtonStyle, ChannelType, ComponentType, + type Message, type TextChannel, type User, type Interaction, @@ -21,7 +22,7 @@ import * as sentry from "@sentry/node"; import type { BotContext } from "#/context.ts"; import type { Loot, LootId } from "#/storage/db/model.ts"; -import type { LootTemplate, TimeBasedWeightConfig } from "#/storage/loot.ts"; +import type { LootAttributeTemplate, LootTemplate, TimeBasedWeightConfig } from "#/storage/loot.ts"; import { randomBoolean, randomEntry, randomEntryWeighted } from "#/service/random.ts"; import * as timeUtils from "#/utils/time.ts"; import { zonedNow } from "#/utils/dateUtils.ts"; @@ -81,14 +82,64 @@ export async function runDropAttempt(context: BotContext) { log.info( `Randomization hit threshold (${lootConfig.dropChance}). Dropping loot to ${targetChannel.name}!`, ); - await postLootDrop(context, targetChannel, undefined, undefined); + await postLootDrop(context, targetChannel, undefined, randomizedLootClaim()); +} + +export type ClaimedLootDrop = { + loot: Loot; + template: LootTemplate; + rarity: LootAttributeTemplate | undefined; + messages: readonly string[]; +}; + +export type LootClaimCallback = ( + winner: User, + message: Message, +) => Promise; + +export function randomizedLootClaim(predecessorLootId: LootId | null = null): LootClaimCallback { + return async (winner, message) => { + const drop = await randomizedLootDrop(winner); + const loot = await lootService.createLoot( + drop.template, + winner, + message, + "drop", + predecessorLootId, + drop.rarity ?? null, + ); + if (!loot) return undefined; + return { loot, ...drop }; + }; +} + +export async function randomizedLootDrop(winner: User): Promise> { + const timeBasedWeightKey = getCurrentTimeBasedKey(); + + const defaultWeights = timeBasedWeightKey + ? lootTemplates.map(t => t.timeBasedWeight?.[timeBasedWeightKey] ?? t.weight) + : lootTemplates.map(t => t.weight); + + const { messages, weights } = await getDropWeightAdjustments(winner, defaultWeights); + + const template = randomEntryWeighted(lootTemplates, weights); + + const rarities = lootAttributeTemplates.filter(a => a.classId === LootAttributeClass.RARITY); + const rarityWeights = rarities.map(a => a.initialDropWeight ?? 0); + + const rarity = + template.id === LootKind.NICHTS + ? undefined + : (randomEntryWeighted(rarities, rarityWeights) ?? undefined); + + return { template, rarity, messages }; } export async function postLootDrop( context: BotContext, channel: GuildBasedChannel & TextBasedChannel, donor: User | undefined, - predecessorLootId: LootId | undefined, + onClaim: LootClaimCallback, ): Promise { const takeLootButton = new ButtonBuilder() .setCustomId("take-loot") @@ -154,34 +205,11 @@ export async function postLootDrop( return; } - const timeBasedWeightKey = getCurrentTimeBasedKey(); - - const defaultWeights = timeBasedWeightKey - ? lootTemplates.map(t => t.timeBasedWeight?.[timeBasedWeightKey] ?? t.weight) - : lootTemplates.map(t => t.weight); - - const { messages, weights } = await getDropWeightAdjustments(interaction.user, defaultWeights); - - const template = randomEntryWeighted(lootTemplates, weights); - - const rarities = lootAttributeTemplates.filter(a => a.classId === LootAttributeClass.RARITY); - const rarityWeights = rarities.map(a => a.initialDropWeight ?? 0); - - const rarityAttribute = - template.id === LootKind.NICHTS ? null : randomEntryWeighted(rarities, rarityWeights); - - const claimedLoot = await lootService.createLoot( - template, - interaction.user, - message, - "drop", - predecessorLootId ?? null, - rarityAttribute, - ); + const claimed = await onClaim(interaction.user, message); const reply = await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - if (!claimedLoot) { + if (!claimed) { await reply.edit({ content: `Upsi, da ist was schief gelaufi oder jemand anderes war schnelli ${context.emoji.sadHamster}`, }); @@ -190,6 +218,8 @@ export async function postLootDrop( await reply.delete(); + const { loot: claimedLoot, template, rarity: rarityAttribute, messages } = claimed; + log.info( `User ${interaction.user.username} claimed loot ${claimedLoot.id} (template: ${template.id})`, ); @@ -299,7 +329,7 @@ export async function postLootDrop( message, "double-or-nothing", claimedLoot.id, - rarityAttribute, + rarityAttribute ?? null, ); if (!extraLoot) { await channel.send(`${winner}, ups, da ist was schief gelaufi ${context.emoji.sadHamster}`);