diff --git a/.gitignore b/.gitignore index 917a1d8..69d455a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ data/usermap.json data/wrank.json data/bringer.json data/attendance.json +data/leaves.json data/sessionPreferences.json data/tg-history/ diff --git a/src/commands/tg.ts b/src/commands/tg.ts index 25973b6..99d1718 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -55,6 +55,7 @@ import { handleCharSetNation } from "@subcommands/char/setNation"; import { handleCharSetStats } from "@subcommands/char/setStats"; import { handleCharActive } from "@subcommands/char/active"; import { Nation } from "@types"; +import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left"; export function buildTgCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() @@ -127,6 +128,16 @@ export function buildTgCommand(): SlashCommandBuilder { ) .addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel")) .addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing")) + .addSubcommand((s) => s + .setName("mark-left") + .setDescription("Mark a character as having left TG") + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + ) + .addSubcommand((s) => s + .setName("unmark-left") + .setDescription("Remove left mark from a character") + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + ) ); // ── score group ──────────────────────────────────────────────────────────── @@ -314,6 +325,8 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction): if (sub === "remove-vote") return handleRemoveVote(interaction); if (sub === "purge") return handlePurge(interaction); if (sub === "seed") return handleSeed(interaction); + if (sub === "mark-left") return handleMarkLeft(interaction); + if (sub === "unmark-left") return handleUnmarkLeft(interaction); } if (group === "score") { if (sub === "set") return handleScoreSet(interaction); diff --git a/src/subcommands/poll/mark-left.ts b/src/subcommands/poll/mark-left.ts new file mode 100644 index 0000000..cf0272d --- /dev/null +++ b/src/subcommands/poll/mark-left.ts @@ -0,0 +1,69 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { Config } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { Leaves } from "@systems/leaves"; +import { polls, updatePollMessage } from "@systems/poll"; +import { CharacterRegistry } from "@registry/character-registry"; +import { replyAndDelete } from "@utils"; + +function getCurrentHistoryKey(slot: number): string { + const date = new Date().toISOString().slice(0, 10); + return `${date}-${slot}`; +} + +export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { + return void replyAndDelete(interaction, "❌ Only officers can mark characters as left.", true); + } + + const charName = interaction.options.getString("char_name", true); + const officer = await resolveUser(member); + + // Find character and its owner + const char = CharacterRegistry.find(charName); + if (!char) return void replyAndDelete(interaction, `❌ Character **${charName}** not found.`, true); + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); + + const historyKey = getCurrentHistoryKey(slot); + + Leaves.mark({ + characterName: char.name, + ownerKey: char.ownerKey, + historyKey, + markedBy: officer.userKey ?? "unknown", + }); + + const channel = interaction.channel as TextChannel; + await updatePollMessage(channel, slot); + + const count = Leaves.countForChar({ characterName: char.name }); + return void replyAndDelete(interaction, + `🪲 **${char.name}** marked as left (total leaves: ${count}).`, + true + ); +} + +export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { + return void replyAndDelete(interaction, "❌ Only officers can unmark characters.", true); + } + + const charName = interaction.options.getString("char_name", true); + const char = CharacterRegistry.find(charName); + if (!char) return void replyAndDelete(interaction, `❌ Character **${charName}** not found.`, true); + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); + + const historyKey = getCurrentHistoryKey(slot); + Leaves.unmark({ characterName: char.name, historyKey }); + + const channel = interaction.channel as TextChannel; + await updatePollMessage(channel, slot); + + return void replyAndDelete(interaction, `✅ Removed left mark from **${char.name}**.`, true); +} \ No newline at end of file diff --git a/src/systems/leaves.ts b/src/systems/leaves.ts new file mode 100644 index 0000000..939ff93 --- /dev/null +++ b/src/systems/leaves.ts @@ -0,0 +1,132 @@ +/** + * Leaves — tracks characters who left TG mid-game. + * + * Keyed by characterName — leaving is a character action, not a user action. + * User-level stats are derived by aggregating across all characters owned by a user. + * + * Usage: + * import { Leaves } from "@systems/leaves"; + * + * Leaves.mark({ characterName, historyKey, markedBy }) + * Leaves.unmark({ characterName, historyKey }) + * Leaves.hasLeft({ characterName, historyKey }) + * Leaves.countForChar({ characterName }) + * Leaves.countForUser({ userKey }) + * Leaves.formatIndicator({ characterName }) + */ + + import { UserKey, CharName, HistoryKey } from "@types"; + import { Store } from "@systems/store"; + import { Paths } from "@paths"; + import { Emoji } from "@systems/emojis"; + import { Runtime } from "@systems/runtime"; + + Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" }); + + // ─── Types ──────────────────────────────────────────────────────────────────── + + interface LeaveRecord { + historyKey: HistoryKey; + markedBy: UserKey; + markedAt: string; + } + + interface CharacterLeaves { + characterName: CharName; + ownerKey: UserKey; + count: number; + history: LeaveRecord[]; + } + + interface LeavesData { + [characterName: CharName]: CharacterLeaves; + } + + // ─── State ──────────────────────────────────────────────────────────────────── + + let _data: LeavesData = {}; + + // ─── Namespace ──────────────────────────────────────────────────────────────── + + export const Leaves = { + load(): void { + _data = Store.readOrDefault(Paths.data("leaves.json"), {}); + }, + + save(): void { + Store.write(Paths.data("leaves.json"), _data); + }, + + mark({ characterName, ownerKey, historyKey, markedBy }: { + characterName: CharName; + ownerKey: UserKey; + historyKey: HistoryKey; + markedBy: UserKey; + }): void { + if (!_data[characterName]) { + _data[characterName] = { characterName, ownerKey, count: 0, history: [] }; + } + const char = _data[characterName]; + if (char.history.some((r) => r.historyKey === historyKey)) return; // already marked + char.history.push({ historyKey, markedBy, markedAt: new Date().toISOString() }); + char.count = char.history.length; + Leaves.save(); + }, + + unmark({ characterName, historyKey }: { + characterName: CharName; + historyKey: HistoryKey; + }): void { + if (!_data[characterName]) return; + _data[characterName].history = _data[characterName].history.filter( + (r) => r.historyKey !== historyKey + ); + _data[characterName].count = _data[characterName].history.length; + Leaves.save(); + }, + + hasLeft({ characterName, historyKey }: { + characterName: CharName; + historyKey: HistoryKey; + }): boolean { + return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false; + }, + + /** Total leaves for a specific character */ + countForChar({ characterName }: { characterName: CharName }): number { + return _data[characterName]?.count ?? 0; + }, + + /** Total leaves across all characters owned by a user */ + countForUser({ userKey }: { userKey: UserKey }): number { + return Object.values(_data) + .filter((c) => c.ownerKey === userKey) + .reduce((sum, c) => sum + c.count, 0); + }, + + historyForChar({ characterName }: { characterName: CharName }): LeaveRecord[] { + return _data[characterName]?.history ?? []; + }, + + historyForUser({ userKey }: { userKey: UserKey }): { characterName: CharName; record: LeaveRecord }[] { + const result: { characterName: CharName; record: LeaveRecord }[] = []; + for (const [charName, data] of Object.entries(_data)) { + if (data.ownerKey !== userKey) continue; + for (const record of data.history) { + result.push({ characterName: charName, record }); + } + } + return result.sort((a, b) => a.record.markedAt.localeCompare(b.record.markedAt)); + }, + + /** + * Format the leave indicator for display in the poll. + * Output: <:cockroach:> <:wrank_3:> (count = total times this character left) + */ + formatIndicator({ characterName }: { characterName: CharName }): string { + const count = Leaves.countForChar({ characterName }); + const cockroach = Emoji.get("cockroach") || "🪳"; //"🪲"; + const countEmoji = Emoji.get(`wrank_${count}`) || `${count}`; + return `${cockroach}${countEmoji}`; + }, + }; \ No newline at end of file diff --git a/src/ui/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts index dc0b712..2fbabad 100644 --- a/src/ui/poll/layouts/default.ts +++ b/src/ui/poll/layouts/default.ts @@ -12,6 +12,7 @@ import { Emoji } from "@systems/emojis"; import { format } from "@format"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; + import { Leaves } from "@systems/leaves"; // ─── Row formatting ─────────────────────────────────────────────────────────── @@ -56,6 +57,12 @@ row += ` · ${format.bringer(nation)}`; } } + + if (entry.userKey && context.historyKey) { + if (Leaves.hasLeft({ userKey: entry.userKey, historyKey: context.historyKey })) { + row += ` ${Leaves.formatIndicator({ userKey: entry.userKey })}`; + } + } if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; @@ -66,7 +73,7 @@ function buildContext( entries: VoteEntry[], nation: Nation, - options?: { showNationEmoji?: boolean } + options?: { showNationEmoji?: boolean; historyKey?: string } ): PollRowContext { const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null @@ -76,10 +83,11 @@ return wr?.previousRank !== undefined; }); return { - nationHasRank, - nationHasDelta, - showNationEmoji: options?.showNationEmoji ?? false, - }; + nationHasRank, + nationHasDelta, + showNationEmoji: options?.showNationEmoji ?? false, + historyKey: options?.historyKey, + }; } // ─── Embed building ─────────────────────────────────────────────────────────── diff --git a/src/ui/poll/layouts/side-by-side.ts b/src/ui/poll/layouts/side-by-side.ts index e1febf2..ebb44f1 100644 --- a/src/ui/poll/layouts/side-by-side.ts +++ b/src/ui/poll/layouts/side-by-side.ts @@ -12,6 +12,7 @@ import { Emoji } from "@systems/emojis"; import { format } from "@format"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; + import { Leaves } from "@systems/leaves"; // ─── Row formatting (same as default) ──────────────────────────────────────── @@ -49,6 +50,12 @@ row += ` · ${format.bringer(nation)}`; } } + + if (entry.userKey && entry.characterName && context.historyKey) { + if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { + row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; + } + } if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; @@ -58,14 +65,19 @@ function buildContext( entries: VoteEntry[], nation: Nation, - options?: { showNationEmoji?: boolean } + options?: { showNationEmoji?: boolean; historyKey?: string } ): PollRowContext { const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null); const nationHasDelta = entries.some((e) => { const wr = e.characterName ? WRank.entry(e.characterName, nation) : null; return wr?.previousRank !== undefined; }); - return { nationHasRank, nationHasDelta, showNationEmoji: options?.showNationEmoji ?? false }; + return { + nationHasRank, + nationHasDelta, + showNationEmoji: options?.showNationEmoji ?? false, + historyKey: options?.historyKey, + }; } // ─── Embed building ─────────────────────────────────────────────────────────── @@ -74,9 +86,10 @@ nation: Nation, yesEntries: VoteEntry[], noVoters: VoteEntry[], - showNoInline: boolean + showNoInline: boolean, + historyKey?: string ): string { - const context = buildContext(yesEntries, nation); + const context = buildContext(yesEntries, nation, { historyKey }); const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; const lines = [ ...yesEntries.map((e) => formatRow(e, context)), @@ -119,6 +132,8 @@ const capellaEmoji = Emoji.get("capella"); const procyonEmoji = Emoji.get("procyon"); + const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`; + // Title const counts = !state.locked && state.confirmed === null ? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}` @@ -141,12 +156,12 @@ // ← inline: true makes them side by side { name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, - value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline), + value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey), inline: true, }, { name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, - value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline), + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey), inline: true, }, ) diff --git a/src/ui/types.ts b/src/ui/types.ts index 77eda5c..5ee58e2 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -8,9 +8,10 @@ // ─── Poll ───────────────────────────────────────────────────────────────────── export interface PollRowContext { - nationHasRank: boolean; - nationHasDelta: boolean; - showNationEmoji?: boolean; + nationHasRank: boolean; + nationHasDelta: boolean; + showNationEmoji?: boolean; + historyKey?: string } export interface PollEmbedOptions {