add leave system, add cockroach on players that leave

This commit is contained in:
Nuno Duque Nunes 2026-06-11 05:17:29 +01:00
parent 347d1423fc
commit fd1b8ed50c
7 changed files with 253 additions and 14 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@ data/usermap.json
data/wrank.json data/wrank.json
data/bringer.json data/bringer.json
data/attendance.json data/attendance.json
data/leaves.json
data/sessionPreferences.json data/sessionPreferences.json
data/tg-history/ data/tg-history/

View file

@ -55,6 +55,7 @@ import { handleCharSetNation } from "@subcommands/char/setNation";
import { handleCharSetStats } from "@subcommands/char/setStats"; import { handleCharSetStats } from "@subcommands/char/setStats";
import { handleCharActive } from "@subcommands/char/active"; import { handleCharActive } from "@subcommands/char/active";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
export function buildTgCommand(): SlashCommandBuilder { export function buildTgCommand(): SlashCommandBuilder {
const cmd = new 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("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("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 ──────────────────────────────────────────────────────────── // ── score group ────────────────────────────────────────────────────────────
@ -314,6 +325,8 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
if (sub === "remove-vote") return handleRemoveVote(interaction); if (sub === "remove-vote") return handleRemoveVote(interaction);
if (sub === "purge") return handlePurge(interaction); if (sub === "purge") return handlePurge(interaction);
if (sub === "seed") return handleSeed(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 (group === "score") {
if (sub === "set") return handleScoreSet(interaction); if (sub === "set") return handleScoreSet(interaction);

View file

@ -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<void> {
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<void> {
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);
}

132
src/systems/leaves.ts Normal file
View file

@ -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<LeavesData>(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}`;
},
};

View file

@ -12,6 +12,7 @@
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { format } from "@format"; import { format } from "@format";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
import { Leaves } from "@systems/leaves";
// ─── Row formatting ─────────────────────────────────────────────────────────── // ─── Row formatting ───────────────────────────────────────────────────────────
@ -57,6 +58,12 @@
} }
} }
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 (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
@ -66,7 +73,7 @@
function buildContext( function buildContext(
entries: VoteEntry[], entries: VoteEntry[],
nation: Nation, nation: Nation,
options?: { showNationEmoji?: boolean } options?: { showNationEmoji?: boolean; historyKey?: string }
): PollRowContext { ): PollRowContext {
const nationHasRank = entries.some((e) => const nationHasRank = entries.some((e) =>
e.characterName && WRank.entry(e.characterName, nation) !== null e.characterName && WRank.entry(e.characterName, nation) !== null
@ -76,10 +83,11 @@
return wr?.previousRank !== undefined; return wr?.previousRank !== undefined;
}); });
return { return {
nationHasRank, nationHasRank,
nationHasDelta, nationHasDelta,
showNationEmoji: options?.showNationEmoji ?? false, showNationEmoji: options?.showNationEmoji ?? false,
}; historyKey: options?.historyKey,
};
} }
// ─── Embed building ─────────────────────────────────────────────────────────── // ─── Embed building ───────────────────────────────────────────────────────────

View file

@ -12,6 +12,7 @@
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { format } from "@format"; import { format } from "@format";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
import { Leaves } from "@systems/leaves";
// ─── Row formatting (same as default) ──────────────────────────────────────── // ─── Row formatting (same as default) ────────────────────────────────────────
@ -50,6 +51,12 @@
} }
} }
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 (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
return row; return row;
@ -58,14 +65,19 @@
function buildContext( function buildContext(
entries: VoteEntry[], entries: VoteEntry[],
nation: Nation, nation: Nation,
options?: { showNationEmoji?: boolean } options?: { showNationEmoji?: boolean; historyKey?: string }
): PollRowContext { ): PollRowContext {
const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null); const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null);
const nationHasDelta = entries.some((e) => { const nationHasDelta = entries.some((e) => {
const wr = e.characterName ? WRank.entry(e.characterName, nation) : null; const wr = e.characterName ? WRank.entry(e.characterName, nation) : null;
return wr?.previousRank !== undefined; return wr?.previousRank !== undefined;
}); });
return { nationHasRank, nationHasDelta, showNationEmoji: options?.showNationEmoji ?? false }; return {
nationHasRank,
nationHasDelta,
showNationEmoji: options?.showNationEmoji ?? false,
historyKey: options?.historyKey,
};
} }
// ─── Embed building ─────────────────────────────────────────────────────────── // ─── Embed building ───────────────────────────────────────────────────────────
@ -74,9 +86,10 @@
nation: Nation, nation: Nation,
yesEntries: VoteEntry[], yesEntries: VoteEntry[],
noVoters: VoteEntry[], noVoters: VoteEntry[],
showNoInline: boolean showNoInline: boolean,
historyKey?: string
): string { ): string {
const context = buildContext(yesEntries, nation); const context = buildContext(yesEntries, nation, { historyKey });
const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : [];
const lines = [ const lines = [
...yesEntries.map((e) => formatRow(e, context)), ...yesEntries.map((e) => formatRow(e, context)),
@ -119,6 +132,8 @@
const capellaEmoji = Emoji.get("capella"); const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon"); const procyonEmoji = Emoji.get("procyon");
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
// Title // Title
const counts = !state.locked && state.confirmed === null const counts = !state.locked && state.confirmed === null
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}` ? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
@ -141,12 +156,12 @@
// ← inline: true makes them side by side // ← inline: true makes them side by side
{ {
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, 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, inline: true,
}, },
{ {
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, 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, inline: true,
}, },
) )

View file

@ -8,9 +8,10 @@
// ─── Poll ───────────────────────────────────────────────────────────────────── // ─── Poll ─────────────────────────────────────────────────────────────────────
export interface PollRowContext { export interface PollRowContext {
nationHasRank: boolean; nationHasRank: boolean;
nationHasDelta: boolean; nationHasDelta: boolean;
showNationEmoji?: boolean; showNationEmoji?: boolean;
historyKey?: string
} }
export interface PollEmbedOptions { export interface PollEmbedOptions {