add leave system, add cockroach on players that leave
This commit is contained in:
parent
347d1423fc
commit
fd1b8ed50c
7 changed files with 253 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
69
src/subcommands/poll/mark-left.ts
Normal file
69
src/subcommands/poll/mark-left.ts
Normal 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
132
src/systems/leaves.ts
Normal 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}`;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue