feat: hangman and such

This commit is contained in:
2025-07-08 11:26:33 +02:00
parent 08c1ac0c19
commit e4e14eef65
11 changed files with 313 additions and 9 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
*giveaway*
.sern
/generated/generated/prisma
src/utils/db/dict.db

View File

@@ -10,7 +10,7 @@
- [x] /a - Custom command with user autocomplete
## Miscellaneous Commands
- [ ] /rolemenu - Role selection menu (owner only)
- [x] /rolemenu - Role selection menu (owner only)
- [ ] /creditos - Bot credits and acknowledgments
- [x] ~~/infinitecraft - InfiniteCraft recipe solver~~
- [ ] /letra - Song lyrics search via Genius API

View File

@@ -17,6 +17,7 @@
},
"devDependencies": {
"@sern/cli": "^1.4.0",
"@types/bun": "^1.2.18",
"@types/mongodb": "^4.0.7",
"@types/node": "^17.0.25",
"prisma": "^6.10.1",
@@ -189,12 +190,16 @@
"@sern/publisher": ["@sern/publisher@1.1.2", "", {}, "sha512-1zh99JZykKUhqHhE75ZXfiLsBtf1WI+NnDCojv8UlpnGBEyzO8xyI1X7PNf6cPKRs4W9XqY3PqTJ+hrqzIsMkg=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/luxon": ["@types/luxon@3.4.2", "", {}, "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="],
"@types/mongodb": ["@types/mongodb@4.0.7", "", { "dependencies": { "mongodb": "*" } }, "sha512-lPUYPpzA43baXqnd36cZ9xxorprybxXDzteVKCPAdp14ppHtFJHnXYvNpmBvtMUTb5fKXVv6sVbzo1LHkWhJlw=="],
"@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
"@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="],
@@ -223,6 +228,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
@@ -255,6 +262,8 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],

View File

@@ -30,6 +30,7 @@
},
"devDependencies": {
"@sern/cli": "^1.4.0",
"@types/bun": "^1.2.18",
"@types/mongodb": "^4.0.7",
"@types/node": "^17.0.25",
"prisma": "^6.10.1",

View File

@@ -23,7 +23,8 @@ export default commandModule({
? 'EL CHISTE DEL PINGUINO OMAIGAD'
: null
)
.setDescription(joke.text);
// regex matches both '-' and '- ' and escapes it because of discord markdown
.setDescription(joke.text.replace(/^-\s?/gm, '\\- '));
await ctx.reply({ embeds: [embed] });
},

View File

@@ -0,0 +1,104 @@
import { Palabra } from '#/db/dict.types';
import { WordController } from '#/wordController';
import { commandModule, CommandType } from '@sern/handler';
import { ActionRowBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
const helpText = `
## whar
se tiene en cuenta la cabeza, el cuerpo, pierna izquierda, pierna derecha, brazo izquierdo y brazo derecho.
## quiero palabras que no sean 6 letras
talk is cheap, send patches
## porque la palabra es tan rara
Las palabras son como espejos del alma colectiva - lo que nos parece extraño revela más sobre nosotros que sobre la palabra misma. En el juego del ahorcado, como en la vida, enfrentamos lo desconocido con la esperanza de que nuestras conjeturas nos acerquen a la verdad. Cada palabra "rara" es una invitación a expandir los límites de nuestro entendimiento.
`;
export default commandModule({
type: CommandType.Slash,
plugins: [],
description: 'hang, man',
//alias : [],
execute: async (ctx, sdt) => {
const dict = sdt.deps.dict;
const { palabra } = (await dict
.query('select palabra from palabra where length(palabra) = 6 order by random() limit 1')
.get()) as Record<'palabra', Palabra['palabra']>;
const wordcon = new WordController(palabra, ctx);
let lastSubmitUserId = '';
const collector = (await wordcon.getMessage())!.createMessageComponentCollector({
time: 180_000, // 3 minutes
});
collector.on('collect', async (interaction) => {
if (interaction.customId === 'hangman-answer') {
if (interaction.user.id !== lastSubmitUserId) {
lastSubmitUserId = interaction.user.id;
} else {
if (process.env.NODE_ENV === 'development') return;
const i = await interaction.reply({
content: 'Ya has respondido, espera a que se acabe el tiempo.\nEsto se elimina en 2 segundos, no te preocupes por cerrarlo.',
ephemeral: true,
});
setTimeout(() => i.delete().catch(() => { }), 2000);
return;
}
await interaction.showModal({
title: 'Responde',
customId: 'hangman-answer-modal',
components: [
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('hangman-answer-input')
.setLabel('Tu respuesta')
.setStyle(TextInputStyle.Short)
),
],
});
const submitted = await interaction.awaitModalSubmit({
time: 30_000,
filter: (i) => i.user.id === interaction.user.id,
});
wordcon.submitLetter(
submitted.fields.getTextInputValue('hangman-answer-input').toLowerCase()
);
await submitted.deferUpdate();
} else if (interaction.customId === 'hangman-help') {
await interaction.reply({ content: helpText, ephemeral: true });
}
});
const gameOver = async (won = false) => {
(await wordcon.getMessage())!.edit({
content: `la palabra era \`${palabra}\`. ${won ? 'enhorabuena!' : 'vaya hombre...'}`,
components: [],
});
collector.stop();
};
const editMsgContent = async () => {
const msg = await wordcon.getMessage();
if (msg) {
await msg.edit({
content: `**Incorrectas:** ${wordcon.data.incorrect.join(', ')}\n**Correctas:** ${wordcon.data.correct.join(', ')}\nÚltima letra enviada por ${lastSubmitUserId ? `<@${lastSubmitUserId}>` : 'nadie'}`,
allowedMentions: { users: [] },
});
}
};
collector.on('end', async (_, reason) => {
await gameOver(reason === 'time' ? false : true);
});
wordcon.on('correct', async () => {
if (wordcon.data.correct.length === palabra.length) {
return await gameOver(true);
}
await editMsgContent();
});
wordcon.on('incorrect', async () => {
await editMsgContent();
});
wordcon.on('gameOver', async () => {
await gameOver();
});
},
});

View File

@@ -0,0 +1,16 @@
import { Palabra } from '#/db/dict.types';
import { commandModule, CommandType } from '@sern/handler';
export default commandModule({
type: CommandType.Slash,
plugins: [],
description: 'palabra.ts',
//alias : [],
execute: async (ctx, sdt) => {
const dict = sdt.deps.dict;
const { palabra } = (await dict
.query('select palabra from palabra order by random() limit 1')
.get()) as Record<'palabra', Palabra['palabra']>;
await ctx.reply(palabra);
},
});

15
src/dependencies.d.ts vendored
View File

@@ -8,17 +8,18 @@ import type { CoreDependencies } from '@sern/handler';
import type { Client } from 'discord.js';
import type { Publisher } from '@sern/publisher';
import type prisma from './utils/db/index.js';
import type { Database } from 'bun:sqlite';
/**
* Note: You usually would not need to modify this unless there is an urgent need to break the contracts provided.
* You would need to modify this to add your custom Services, however.
*/
declare global {
interface Dependencies extends CoreDependencies {
'@sern/client': Client;
'publisher': Publisher;
'prisma': prisma;
}
interface Dependencies extends CoreDependencies {
'@sern/client': Client;
publisher: Publisher;
prisma: prisma;
dict: Database;
}
}
export {}
export {};

View File

@@ -3,6 +3,7 @@ import * as config from './config.js'
import { Client, GatewayIntentBits } from 'discord.js';
import { Sern, makeDependencies } from '@sern/handler';
import { Publisher } from '@sern/publisher'
import { Database } from 'bun:sqlite';
import prisma from './utils/db/index.js';
const client = new Client({
intents: [
@@ -27,6 +28,7 @@ await makeDependencies(({ add }) => {
deps['@sern/logger']!
));
add('prisma', prisma);
add('dict', new Database('src/utils/db/dict.db'))
});
//View docs for all options

View File

@@ -0,0 +1,13 @@
export interface Palabra {
palabra: string;
tipo: string;
'género': string;
'número': string;
'raíz': string;
afijo: string;
'tónica': string;
'sílabas': string;
locale: string;
origen: string;
'sinónimos': string[];
}

156
src/utils/wordController.ts Normal file
View File

@@ -0,0 +1,156 @@
// hangman word controller
// gnu gpl 3 by @SrIzan10
// coords for most stuff in canvas by claude 7 sonnet because i do not like canvas
import { createCanvas, type SKRSContext2D } from "@napi-rs/canvas";
import type { Context } from "@sern/handler";
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, type Message } from "discord.js";
import { EventEmitter } from "node:events";
export class WordController extends EventEmitter {
private word: string;
private canvasCtx = createCanvas(500, 500).getContext('2d');
private ctx: Context;
private msg?: Message;
private incorrectLetters: string[] = [];
private correctLetters: string[] = [];
constructor(word: string, ctx: Context) {
super();
this.word = word.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); // bye accents
this.ctx = ctx;
}
public get data() {
return {
incorrect: this.incorrectLetters,
correct: this.correctLetters,
word: this.word,
};
}
public async getMessage() {
if (!this.msg) {
await this.updateMsg();
}
return this.msg;
}
public async submitLetter(letter: string) {
if (letter.length !== 1 || !/^[a-z]$/i.test(letter)) {
letter = letter.toLowerCase().trim()[0];
}
if (this.incorrectLetters.includes(letter) || this.correctLetters.includes(letter)) {
return;
}
if (!this.word.includes(letter)) {
this.incorrectLetters.push(letter);
this.emit('incorrect', letter);
} else {
this.correctLetters.push(letter);
this.emit('correct', letter);
}
await this.updateMsg();
}
public async updateMsg() {
this.canvasUpdate();
const file = await this.canvasCtx.canvas.encode('webp');
const attachment = new AttachmentBuilder(file, { name: 'hangman.webp' });
if (!this.msg) {
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('hangman-answer').setLabel('Responde').setEmoji('✅').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('hangman-help').setLabel('Ayuda').setEmoji('❓').setStyle(ButtonStyle.Secondary),
);
this.msg = await this.ctx.reply({ files: [attachment], components: [buttons] });
return this.msg;
}
return this.msg.edit({ files: [attachment] });
}
private canvasUpdate() {
this.canvasCtx.fillStyle = '#1e1e2e';
this.canvasCtx.fillRect(0, 0, this.canvasCtx.canvas.width, this.canvasCtx.canvas.height);
this.canvasCtx.strokeStyle = '#cdd6f4';
this.canvasCtx.lineWidth = 2;
this.canvasCtx.beginPath();
this.canvasCtx.moveTo(150, 320);
this.canvasCtx.lineTo(150, 70);
this.canvasCtx.lineTo(250, 70);
this.canvasCtx.lineTo(250, 100);
this.canvasCtx.stroke();
if (this.incorrectLetters.length >= 1) this.writeHead();
if (this.incorrectLetters.length >= 2) this.writeBody();
if (this.incorrectLetters.length >= 3) this.writeLeg('left');
if (this.incorrectLetters.length >= 4) this.writeLeg('right');
if (this.incorrectLetters.length >= 5) this.writeArm('left');
if (this.incorrectLetters.length >= 6) {
this.writeArm('right');
this.emit('gameOver', this.word);
};
const wordLength = this.word.length;
const startX = 250 - (wordLength * 20);
const baseY = 450;
this.canvasCtx.font = '20px Arial';
this.canvasCtx.textAlign = 'center';
this.canvasCtx.fillStyle = '#cdd6f4';
this.canvasCtx.strokeStyle = '#cdd6f4';
this.canvasCtx.lineWidth = 2;
for (let i = 0; i < wordLength; i++) {
const letter = this.word[i];
const letterX = startX + i * 40;
this.canvasCtx.beginPath();
this.canvasCtx.moveTo(letterX - 15, baseY + 5);
this.canvasCtx.lineTo(letterX + 15, baseY + 5);
this.canvasCtx.stroke();
if (this.correctLetters.includes(letter.toLowerCase())) {
this.canvasCtx.fillText(letter.toUpperCase(), letterX, baseY - 10);
}
}
if (process.env.NODE_ENV === 'development') {
this.canvasCtx.font = '20px';
this.canvasCtx.fillStyle = '#a6adc8';
this.canvasCtx.fillText(`answer is: ${this.word}`, 250, 30);
}
}
private writeHead() {
this.canvasCtx.beginPath();
this.canvasCtx.arc(250, 115, 15, 0, 2 * Math.PI);
this.canvasCtx.stroke();
}
private writeBody() {
this.canvasCtx.beginPath();
this.canvasCtx.moveTo(250, 130);
this.canvasCtx.lineTo(250, 200);
this.canvasCtx.stroke();
}
private writeLeg(type: 'left' | 'right') {
this.canvasCtx.beginPath();
this.canvasCtx.moveTo(250, 200);
this.canvasCtx.lineTo(type === 'left' ? 225 : 275, 240);
this.canvasCtx.stroke();
}
private writeArm(type: 'left' | 'right') {
this.canvasCtx.beginPath();
this.canvasCtx.moveTo(250, 155);
this.canvasCtx.lineTo(type === 'left' ? 225 : 275, 180);
this.canvasCtx.stroke();
}
}