From 1c5c7d2470b76eb504da9d377772d4e2d8e2d3c8 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 10:04:16 +0200 Subject: [PATCH] Playtester werden jetzt beworben, nicht angeklickt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Knopf unter dem Playtester-Aufruf hat jeden aufgenommen, der ihn gedrueckt hat — ohne Frage, ohne Pruefung. Fuer ein Programm, dessen Teilnehmer Zugang und Alpha-Keys bekommen, ist das die falsche Tuer. Statt einen zweiten Pruef-Ablauf danebenzustellen, laeuft die Aufnahme jetzt ueber die Bewerbungs-Formulare, die es laengst gibt: Modal ausfuellen, Review-Embed im Staff-Kanal, ✅ oder ❌, Rolle und Antwort-DM bei Zusage. Das ist dieselbe Maschinerie, nur ein anderer Aufhaenger — und damit auch nur eine Stelle, an der spaeter etwas kaputtgehen kann. Ein Formular laesst sich als Playtester-Formular markieren ("Annahme traegt als Playtester ein"). Wer dort angenommen wird, landet zusaetzlich in der Playtester-Liste, damit die Schluessel-Verteilung ihn kennt. Nur eines kann es sein — sonst wuesste /playtester-setup nicht, welches es posten soll. /playtester-setup postet jetzt den Knopf dieses Formulars. Fehlt das Formular, fehlen Fragen oder fehlt der Review-Kanal, sagt der Befehl das, statt einen Aufruf zu posten, der ins Leere fuehrt. Der alte Knopf in bereits geposteten Nachrichten nimmt niemanden mehr auf: er verweist auf den Aufruf. Austreten bleibt Selbstbedienung — dafuer braucht es keine Freigabe. Im Panel steht im Playtester-Bereich, ueber welches Formular die Aufnahme laeuft, ob es schon gepostet ist, und ein Weg dorthin. Ohne markiertes Formular steht dort, dass gerade niemand hereinkommt — das ist sonst der Grund, warum sich tagelang niemand bewirbt. Geprueft: Routen, SQL gegen das Schema, und im Browser beide Zustaende des Hinweises sowie der neue Schalter — beim Wechsel zwischen zwei Formularen folgt er dem richtigen. Co-Authored-By: Claude Opus 5 --- README.md | 4 +-- frontend/src/pages/Settings.jsx | 48 +++++++++++++++++++++++++--- src/bot/app-forms.js | 7 +++- src/bot/client.js | 26 +++++++++------ src/bot/commands/playtester-setup.js | 47 ++++++++++++++++++++++++--- src/db.js | 20 ++++++++++-- src/templates.js | 4 +-- src/web/api.js | 14 ++++++-- 8 files changed, 139 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9b52866..33296eb 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | | 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite | -| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; eigener Panel-Bereich mit Liste, Rolle und dem Schlüssel-Vorrat — Playtester lassen sich dort auch wieder entfernen (die Rolle geht mit) | +| 🧪 **Playtester-Programm** | Aufnahme läuft über ein **Bewerbungs-Formular**: ausfüllen, das Team nimmt an oder lehnt ab. Wer angenommen wird, bekommt Rolle und Listeneintrag. Eigener Panel-Bereich mit Liste, Rolle und Schlüssel-Vorrat — Playtester lassen sich dort auch wieder entfernen (die Rolle geht mit) | | 🎟️ **Alpha-Keys** | Schlüssel-Vorrat im Panel: nachlegen, gezielt an eine Person schicken, an alle Wartenden verteilen, zurückziehen oder löschen. Zustellung per Direktnachricht — kommt sie nicht an, bleibt der Schlüssel frei | | 🎭 **Rollen-Menüs** | Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Posts jederzeit editierbar | -| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist | +| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist. Ein Formular lässt sich als **Playtester-Formular** markieren; Angenommene landen dann auch im Playtester-Programm | | 🪪 **Verknüpfte Rollen** | Discords *Linked Roles*: Level, Playtester-Status und Dabei-seit werden als geprüfte Kennzahlen an Discord gemeldet — Rollen vergibt dann Discord selbst | ### Moderation & Support diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index a99ab19..e58c6b2 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -322,7 +322,7 @@ export default function Settings({ me }) { // Bewerbungs-Formulare const emptyForm = { id: null, title: '', description: '', review_channel_id: '', - approve_role_id: '', post_channel_id: '', questions: [''], + approve_role_id: '', post_channel_id: '', questions: [''], playtester: false, }; const [appForms, setAppForms] = useState([]); const [formDraft, setFormDraft] = useState(emptyForm); @@ -2195,10 +2195,29 @@ export default function Settings({ me }) {

// Playtester

- Wer sich über den Knopf aus /playtester-setup meldet, landet - hier — mit Rolle. Schlüssel gehen per Direktnachricht raus; kommt sie nicht - an, bleibt der Schlüssel im Vorrat. + Beworben wird sich über ein Formular, angenommen vom Team — wer angenommen + wird, landet hier mit Rolle. Schlüssel gehen per Direktnachricht raus; kommt + sie nicht an, bleibt der Schlüssel im Vorrat.

+ {(() => { + // Ohne markiertes Formular gibt es keinen Weg herein — das + // ist der haeufigste Grund, warum sich niemand bewirbt + const ptForm = appForms.find((f) => f.playtester); + return ptForm ? ( +

+ Aufnahme läuft über {ptForm.title} + {ptForm.message_id ? '' : ' — das Formular ist noch nicht gepostet'}. + {' '} +

+ ) : ( +

+ Noch kein Playtester-Formular. Ohne eines kommt niemand herein — + unter Bewerbungen ein Formular anlegen und dort + „Annahme trägt als Playtester ein" ankreuzen. + {' '} +

+ ); + })()}
(
{f.title} - {f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'} + + {f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'} + {f.playtester ? ' · Playtester' : ''} +
@@ -2411,6 +2434,21 @@ export default function Settings({ me }) { />
+
+ +
{formDraft.questions.map((q, i) => ( diff --git a/src/bot/app-forms.js b/src/bot/app-forms.js index 30fb33c..7056aec 100644 --- a/src/bot/app-forms.js +++ b/src/bot/app-forms.js @@ -5,7 +5,7 @@ import { ModalBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js'; import { - getAppForm, setAppFormMessage, + getAppForm, setAppFormMessage, addPlaytester, createApplication, getApplication, openApplicationOf, setApplicationStatus, } from '../db.js'; import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js'; @@ -172,6 +172,11 @@ export async function handleReviewButton(interaction) { console.error(`[bewerbung] Rolle fehlgeschlagen: ${e.message}`) ); } + // Playtester-Formular: Annahme trägt zusätzlich in die Liste ein, damit die + // Schlüssel-Verteilung denjenigen kennt + if (approved && form?.playtester) { + addPlaytester(application.user_id, application.username ?? application.user_id); + } // DM an den Bewerber const user = await interaction.client.users.fetch(application.user_id).catch(() => null); await user?.send(renderTemplate(approved ? 'application.approved' : 'application.denied', { diff --git a/src/bot/client.js b/src/bot/client.js index a006441..3cb01dd 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -22,7 +22,7 @@ import { import { handleRoleMenuButton } from './role-menus.js'; import { handleGiveawayAdminButton } from './giveaways.js'; import { - addPlaytester, removePlaytester, isWish, bumpWish, + removePlaytester, playtesterForm, isWish, bumpWish, getGiveaway, toggleGiveawayEntry, giveawayEntries, } from '../db.js'; import * as ping from './commands/ping.js'; @@ -309,22 +309,28 @@ export async function startBot() { return; } - // 🧪-Button: Playtester-Rolle + Liste togglen + // 🧪-Button aus älteren Posts. Aufgenommen wird hier niemand mehr — + // das läuft über das Playtester-Formular mit Prüfung durch das Team. + // Austreten bleibt Selbstbedienung: dafür braucht es keine Freigabe. if (interaction.isButton() && interaction.customId === 'playtester_toggle') { const roleId = playtesterRoleId(); try { if (!roleId || !interaction.inGuild()) throw new Error('Keine Playtester-Rolle konfiguriert'); const member = interaction.member; - const hasRole = member.roles.cache.has(roleId); - if (hasRole) { - await member.roles.remove(roleId); - removePlaytester(interaction.user.id); - } else { - await member.roles.add(roleId); - addPlaytester(interaction.user.id, interaction.user.username); + if (!member.roles.cache.has(roleId)) { + const form = playtesterForm(); + await interaction.reply({ + content: form + ? '❕ Dieser Knopf ist veraltet. Bewirb dich über den Playtester-Aufruf — dort füllst du ein kurzes Formular aus, das Team meldet sich.' + : '❕ Bewerbungen laufen gerade nicht über diesen Knopf. Melde dich beim Team.', + flags: MessageFlags.Ephemeral, + }); + return; } + await member.roles.remove(roleId); + removePlaytester(interaction.user.id); await interaction.reply({ - content: renderTemplate(hasRole ? 'playtester.left' : 'playtester.joined', { + content: renderTemplate('playtester.left', { user: member.displayName ?? interaction.user.username, mention: `<@${interaction.user.id}>`, }), diff --git a/src/bot/commands/playtester-setup.js b/src/bot/commands/playtester-setup.js index eff2421..8e32bf4 100644 --- a/src/bot/commands/playtester-setup.js +++ b/src/bot/commands/playtester-setup.js @@ -1,20 +1,52 @@ -// /playtester-setup — postet das Bewerbungs-Embed mit Button in den aktuellen Kanal (Admin) +// /playtester-setup — postet den Playtester-Aufruf in den aktuellen Kanal (Admin) +// +// Ist ein Bewerbungs-Formular als Playtester-Formular markiert, führt der Knopf +// dorthin: ausfüllen, das Team entscheidet. Ohne Formular gäbe es nur den alten +// Selbstbedienungs-Knopf — und der macht jeden zum Playtester, der draufdrückt. +// Deshalb sagt der Befehl dann, was zu tun ist, statt ihn zu posten. import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, } from 'discord.js'; import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js'; import { renderTemplate } from '../../templates.js'; +import { playtesterForm } from '../../db.js'; export const data = new SlashCommandBuilder() .setName('playtester-setup') - .setDescription('Postet das Playtester-Bewerbungs-Embed in diesen Kanal') + .setDescription('Postet den Playtester-Aufruf in diesen Kanal') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); export async function execute(interaction) { if (!playtesterRoleId()) { await interaction.reply({ - content: '❌ Erst auf der Config-Seite eine Playtester-Rolle wählen.', + content: '❌ Erst in der Config eine Playtester-Rolle wählen.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const form = playtesterForm(); + if (!form) { + await interaction.reply({ + content: '❌ Kein Playtester-Formular gesetzt.\n' + + 'Config → **Bewerbungen**: ein Formular anlegen und dort ' + + '„Annahme trägt als Playtester ein" ankreuzen. Dann kann sich ' + + 'niemand mehr per Knopfdruck selbst aufnehmen.', + flags: MessageFlags.Ephemeral, + }); + return; + } + if (JSON.parse(form.questions || '[]').length === 0) { + await interaction.reply({ + content: `❌ Das Formular „${form.title}" hat noch keine Fragen.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + if (!form.review_channel_id) { + await interaction.reply({ + content: `❌ Für „${form.title}" fehlt der Review-Kanal — sonst sieht niemand die Bewerbungen.`, flags: MessageFlags.Ephemeral, }); return; @@ -26,14 +58,19 @@ export async function execute(interaction) { .setDescription(renderTemplate('playtester.panel_text')) .setFooter({ text: brandFooter('PLAYTEST') }); + // Derselbe Knopf wie beim Bewerbungs-Formular — das Formular, die Prüfung + // und die Antwort-DMs sind dieselbe Maschinerie, nur ein anderer Aufhänger. const row = new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId('playtester_toggle') + .setCustomId(`apply:${form.id}`) .setStyle(ButtonStyle.Success) .setLabel('Als Playtester bewerben') .setEmoji('🧪') ); await interaction.channel.send({ embeds: [embed], components: [row] }); - await interaction.reply({ content: '✅ Bewerbungs-Post erstellt.', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: `✅ Aufruf gepostet — Bewerbungen laufen über „${form.title}".`, + flags: MessageFlags.Ephemeral, + }); } diff --git a/src/db.js b/src/db.js index 7f6685f..39a9323 100644 --- a/src/db.js +++ b/src/db.js @@ -340,24 +340,38 @@ db.exec(` created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); +// Migration: Annahme kann zusaetzlich ins Playtester-Programm eintragen +{ + const cols = db.prepare('PRAGMA table_info(app_forms)').all().map((c) => c.name); + if (!cols.includes('playtester')) { + db.exec('ALTER TABLE app_forms ADD COLUMN playtester INTEGER NOT NULL DEFAULT 0'); + } +} const insertAppForm = db.prepare(` - INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions) - VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions) + INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions, playtester) + VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions, @playtester) `); const updateAppFormStmt = db.prepare(` UPDATE app_forms SET title = @title, description = @description, review_channel_id = @review_channel_id, - approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions + approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions, + playtester = @playtester WHERE id = @id `); const setAppFormMessageStmt = db.prepare('UPDATE app_forms SET message_id = ? WHERE id = ?'); const getAppFormStmt = db.prepare('SELECT * FROM app_forms WHERE id = ?'); const listAppFormsStmt = db.prepare('SELECT * FROM app_forms ORDER BY id'); const deleteAppFormStmt = db.prepare('DELETE FROM app_forms WHERE id = ?'); +// Nur eines kann das Playtester-Formular sein — sonst weiss /playtester-setup +// nicht, welches es posten soll +const clearPlaytesterFormStmt = db.prepare('UPDATE app_forms SET playtester = 0 WHERE id != ?'); +const playtesterFormStmt = db.prepare('SELECT * FROM app_forms WHERE playtester = 1 LIMIT 1'); export const createAppForm = (f) => insertAppForm.run(f).lastInsertRowid; export const updateAppForm = (f) => updateAppFormStmt.run(f).changes > 0; export const setAppFormMessage = (id, messageId) => setAppFormMessageStmt.run(messageId, id); export const getAppForm = (id) => getAppFormStmt.get(id) ?? null; export const listAppForms = () => listAppFormsStmt.all(); +export const playtesterForm = () => playtesterFormStmt.get() ?? null; +export const clearOtherPlaytesterForms = (id) => clearPlaytesterFormStmt.run(id); export const deleteAppForm = (id) => deleteAppFormStmt.run(id).changes > 0; const insertApplication = db.prepare(` diff --git a/src/templates.js b/src/templates.js index 2d5f61a..5de5b3d 100644 --- a/src/templates.js +++ b/src/templates.js @@ -213,8 +213,8 @@ export const TEMPLATES = [ vars: [], default: 'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback die Entwicklung mitgestalten?\n\n' + - 'Ein Klick auf den Button setzt dich auf die Playtester-Liste — sobald die Alpha startet, ' + - 'bekommst du deinen Zugang direkt per DM. Abmelden geht jederzeit mit einem zweiten Klick.', + 'Klick auf den Button und füll das kurze Formular aus. Wir schauen es uns an und ' + + 'melden uns per DM — bei einer Zusage bekommst du Rolle und Zugang.', }, ]; diff --git a/src/web/api.js b/src/web/api.js index 3466f5f..1babbe8 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -9,7 +9,7 @@ import { topLevels, saveTag, listTags, deleteTag, createScheduledPost, listScheduledPosts, deleteScheduledPost, addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey, - createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm, + createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm, clearOtherPlaytesterForms, saveTrigger, listTriggers, deleteTrigger, activityRange, createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver, saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, @@ -1446,9 +1446,15 @@ ${rssItems} approve_role_id: roleId || null, post_channel_id: String(body.post_channel_id ?? '') || null, questions: JSON.stringify(questions), + playtester: body.playtester ? 1 : 0, }; } + /** Nur ein Formular kann das Playtester-Formular sein */ + function playtesterExklusiv(form, id) { + if (form.playtester) clearOtherPlaytesterForms(id); + } + app.get('/api/appforms', async (request, reply) => { if (requireScope(request, reply, 'bewerbungen')) return; return { forms: listAppForms().map(formToJson) }; @@ -1459,7 +1465,8 @@ ${rssItems} const form = parseFormBody(request, reply); if (!form) return; const id = createAppForm(form); - return { ok: true, form: formToJson(getAppForm(id)) }; + playtesterExklusiv(form, id); + return { ok: true, form: formToJson(getAppForm(id)), forms: listAppForms().map(formToJson) }; }); app.put('/api/appforms/:id', async (request, reply) => { @@ -1469,12 +1476,13 @@ ${rssItems} const form = parseFormBody(request, reply); if (!form) return; updateAppForm({ ...form, id }); + playtesterExklusiv(form, id); if (getAppForm(id).message_id) { await publishAppForm(client, id).catch((e) => request.log.warn(`Formular ${id}: Discord-Update fehlgeschlagen: ${e.message}`) ); } - return { ok: true, form: formToJson(getAppForm(id)) }; + return { ok: true, form: formToJson(getAppForm(id)), forms: listAppForms().map(formToJson) }; }); app.post('/api/appforms/:id/publish', async (request, reply) => {