feat: TGKey branded type, PersistentMessage abstraction, createBuildEmbed factory, BaseLayout refactor
This commit is contained in:
parent
a4d772b81d
commit
b22602f431
16 changed files with 218 additions and 206 deletions
|
|
@ -27,7 +27,7 @@
|
|||
"userKey": "ayana",
|
||||
"displayName": "Ayana",
|
||||
"characterName": "«MonkeyHunter»",
|
||||
"characterClass": "GL",
|
||||
"characterClass": "DM",
|
||||
"characterLevel": 79,
|
||||
"characterNation": "Procyon",
|
||||
"votedAt": "19:46"
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"no": [],
|
||||
"leaves": [
|
||||
{
|
||||
"characterName": "»Flash«",
|
||||
"characterName": " «Deystroyer»",
|
||||
"historyKey": "2026-06-11-20"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@ 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}`;
|
||||
}
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
|
|
@ -27,7 +23,7 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
|
|||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
|
||||
|
||||
const historyKey = getCurrentHistoryKey(slot);
|
||||
const historyKey = TGKey.current({ slot });
|
||||
|
||||
Leaves.mark({
|
||||
characterName: char.name,
|
||||
|
|
@ -59,7 +55,7 @@ export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction)
|
|||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
|
||||
|
||||
const historyKey = getCurrentHistoryKey(slot);
|
||||
const historyKey = TGKey.current({ slot });
|
||||
Leaves.unmark({ characterName: char.name, historyKey });
|
||||
|
||||
const channel = interaction.channel as TextChannel;
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@
|
|||
|
||||
import fs from "fs";
|
||||
import { Paths } from "@paths";
|
||||
import { UserKey, HistoryKey } from "@types";
|
||||
import { UserKey } from "@types";
|
||||
import { Store } from "@systems/store";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
interface AttendanceData {
|
||||
[historyKey: HistoryKey]: UserKey[];
|
||||
[historyKey: TGKey]: UserKey[];
|
||||
}
|
||||
|
||||
let _data: AttendanceData = {};
|
||||
|
|
@ -34,8 +35,7 @@ function save(): void {
|
|||
* Snapshot attendance from poll state at lock time.
|
||||
*/
|
||||
snapshot(slot: number, lockedYesKeys: Set<UserKey>): void {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const historyKey = `${date}-${slot}` as HistoryKey;
|
||||
const historyKey = TGKey.current({ slot });
|
||||
_data[historyKey] = [...lockedYesKeys];
|
||||
save();
|
||||
},
|
||||
|
|
@ -43,21 +43,21 @@ function save(): void {
|
|||
/**
|
||||
* Get players who attended a specific TG.
|
||||
*/
|
||||
players(historyKey: HistoryKey): UserKey[] {
|
||||
players(historyKey: TGKey): UserKey[] {
|
||||
return _data[historyKey] ?? [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific player attended.
|
||||
*/
|
||||
includes(historyKey: HistoryKey, userKey: UserKey): boolean {
|
||||
includes(historyKey: TGKey, userKey: UserKey): boolean {
|
||||
return (_data[historyKey] ?? []).includes(userKey);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if all attendees have submitted scores.
|
||||
*/
|
||||
allSubmitted(historyKey: HistoryKey): boolean {
|
||||
allSubmitted(historyKey: TGKey): boolean {
|
||||
const players = _data[historyKey] ?? [];
|
||||
if (players.length === 0) return false;
|
||||
try {
|
||||
|
|
@ -74,7 +74,7 @@ function save(): void {
|
|||
/**
|
||||
* Get all history keys (for listing past TGs).
|
||||
*/
|
||||
all(): HistoryKey[] {
|
||||
return Object.keys(_data) as HistoryKey[];
|
||||
all(): TGKey[] {
|
||||
return Object.keys(_data) as TGKey[];
|
||||
},
|
||||
};
|
||||
|
|
@ -4,23 +4,22 @@ import { TGResult, TGScore, Nation } from "../types";
|
|||
import { Nations } from "@systems/nations";
|
||||
import { Store } from "@systems/store";
|
||||
import { Paths } from "@helpers/paths";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
|
||||
|
||||
function historyKey(date: string, slot: number): string {
|
||||
return `${date}-${String(slot).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function historyPath(key: string): string {
|
||||
return path.join(HISTORY_DIR, `${key}.json`);
|
||||
}
|
||||
|
||||
export function loadResult(date: string, slot: number): TGResult | null {
|
||||
return Store.read(historyPath(historyKey(date, slot)));
|
||||
const path = historyPath(TGKey.from({ date, slot }));
|
||||
return Store.read(path);
|
||||
}
|
||||
export function saveResult(result: TGResult): void {
|
||||
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||||
Store.write(historyPath(historyKey(result.date, result.slot)), result);
|
||||
const path = historyPath(TGKey.from({ date: result.date, slot: result.slot }));
|
||||
Store.write(path, result);
|
||||
}
|
||||
|
||||
export function upsertScore(score: TGScore): void {
|
||||
|
|
|
|||
|
|
@ -15,18 +15,19 @@
|
|||
* Leaves.formatIndicator({ characterName })
|
||||
*/
|
||||
|
||||
import { UserKey, CharName, HistoryKey } from "@types";
|
||||
import { UserKey, CharName } from "@types";
|
||||
import { Store } from "@systems/store";
|
||||
import { Paths } from "@paths";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { Runtime } from "@systems/runtime";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" });
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LeaveRecord {
|
||||
historyKey: HistoryKey;
|
||||
historyKey: TGKey;
|
||||
markedBy: UserKey;
|
||||
markedAt: string;
|
||||
}
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
mark({ characterName, ownerKey, historyKey, markedBy }: {
|
||||
characterName: CharName;
|
||||
ownerKey: UserKey;
|
||||
historyKey: HistoryKey;
|
||||
historyKey: TGKey;
|
||||
markedBy: UserKey;
|
||||
}): void {
|
||||
if (!_data[characterName]) {
|
||||
|
|
@ -75,7 +76,7 @@
|
|||
|
||||
unmark({ characterName, historyKey }: {
|
||||
characterName: CharName;
|
||||
historyKey: HistoryKey;
|
||||
historyKey: TGKey;
|
||||
}): void {
|
||||
if (!_data[characterName]) return;
|
||||
_data[characterName].history = _data[characterName].history.filter(
|
||||
|
|
@ -87,7 +88,7 @@
|
|||
|
||||
hasLeft({ characterName, historyKey }: {
|
||||
characterName: CharName;
|
||||
historyKey: HistoryKey;
|
||||
historyKey: TGKey;
|
||||
}): boolean {
|
||||
return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
|
||||
*/
|
||||
|
||||
import { Character, Nation, UserKey, HistoryKey, SlotHour } from "@types";
|
||||
import { Character, Nation, UserKey, SlotHour } from "@types";
|
||||
import { WRank } from "@systems/wrank";
|
||||
import { Store } from "@systems/store";
|
||||
import { Paths } from "@helpers/paths";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
export interface TGScore {
|
||||
userKey: UserKey;
|
||||
|
|
@ -48,14 +49,14 @@
|
|||
previousRank?: number;
|
||||
}
|
||||
|
||||
function getHistoryPath(historyKey: HistoryKey): string {
|
||||
function getHistoryPath(historyKey: TGKey): string {
|
||||
return Paths.data("tg-history", `${historyKey}.json`);
|
||||
}
|
||||
|
||||
function loadHistory(historyKey: HistoryKey): { scores: TGScore[] } {
|
||||
function loadHistory(historyKey: TGKey): { scores: TGScore[] } {
|
||||
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] });
|
||||
}
|
||||
function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void {
|
||||
function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||
Store.write(getHistoryPath(historyKey), data);
|
||||
}
|
||||
|
||||
|
|
@ -63,13 +64,11 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
|
|||
/**
|
||||
* Get a score for a character in a specific TG.
|
||||
*/
|
||||
get({ character, slot, date }: {
|
||||
get({ character, slot }: {
|
||||
character: Character;
|
||||
slot: SlotHour;
|
||||
date?: string;
|
||||
}): TGScore | null {
|
||||
const d = date ?? new Date().toISOString().slice(0, 10);
|
||||
const historyKey = `${d}-${slot}` as HistoryKey;
|
||||
const historyKey = TGKey.current({ slot });
|
||||
const history = loadHistory(historyKey);
|
||||
return history.scores.find(
|
||||
(s) => s.userKey === character.ownerKey && s.characterName === character.name
|
||||
|
|
@ -82,11 +81,10 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
|
|||
getWeeklySummary({ character }: { character: Character }): WeeklySummary {
|
||||
const week = WRank.currentWeek();
|
||||
const entry = WRank.entry(character.name, character.nation);
|
||||
const allKeys = Object.keys(week.scoreIndex[character.name] ?? {}) as HistoryKey[];
|
||||
|
||||
const scores: TGScore[] = [];
|
||||
for (const historyKey of (week.scoreIndex[character.name] ?? [])) {
|
||||
const history = loadHistory(historyKey as HistoryKey);
|
||||
const history = loadHistory(historyKey as TGKey);
|
||||
const score = history.scores.find(
|
||||
(s) => s.userKey === character.ownerKey && s.characterName === character.name
|
||||
);
|
||||
|
|
@ -127,7 +125,7 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
|
|||
submittedByOfficer?: boolean;
|
||||
}): void {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const historyKey = `${date}-${slot}` as HistoryKey;
|
||||
const historyKey = TGKey.current({ slot });
|
||||
const history = loadHistory(historyKey);
|
||||
|
||||
// Snapshot W.Rank before recording score
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { TGScore, Nation, ClassKey } from "../types";
|
|||
import { Config } from "./config";
|
||||
import { upsertScore, todayString } from "./history";
|
||||
import { WRank } from "./wrank";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
// Normalize a slot string to a 24h integer hour
|
||||
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
|
||||
|
|
@ -33,10 +34,10 @@ export function normalizeSlot(input: string): number | null {
|
|||
|
||||
// Detect which slot a submission belongs to based on current time
|
||||
export function detectSlot(): number | null {
|
||||
const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active);
|
||||
const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000;
|
||||
const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active);
|
||||
const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000;
|
||||
const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const slot of slots) {
|
||||
const today = new Date();
|
||||
|
|
@ -71,7 +72,7 @@ export interface ScoreSubmission {
|
|||
|
||||
export function submitScore(sub: ScoreSubmission): void {
|
||||
const date = sub.date ?? todayString();
|
||||
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
|
||||
const historyKey = TGKey.from({ date, slot: sub.slot });
|
||||
|
||||
const score: TGScore = {
|
||||
userKey: sub.userKey,
|
||||
|
|
|
|||
52
src/systems/tg-key.ts
Normal file
52
src/systems/tg-key.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* TGKey — branded type for TG session identifiers.
|
||||
* Format: "YYYY-MM-DD-SLOT" e.g. "2026-06-11-20"
|
||||
*
|
||||
* Usage:
|
||||
* import { TGKey } from "@systems/tg-key";
|
||||
*
|
||||
* const key = TGKey.current({ slot: 20 })
|
||||
* const key = TGKey.from({ date: "2026-06-11", slot: 20 })
|
||||
* TGKey.parse(key) // { date: "2026-06-11", slot: 20 }
|
||||
* TGKey.toHistoryPath(key) // ".../tg-history/2026-06-11-20.json"
|
||||
*/
|
||||
|
||||
import { Paths } from "@paths";
|
||||
|
||||
export type TGKey = string & { readonly __brand: "TGKey" };
|
||||
|
||||
export const TGKey = {
|
||||
from({ date, slot }: { date: Date | string; slot: number }): TGKey {
|
||||
const d = date instanceof Date ? date.toISOString().slice(0, 10) : date;
|
||||
const s = String(slot).padStart(2, "0");
|
||||
return `${d}-${s}` as TGKey;
|
||||
},
|
||||
|
||||
current({ slot }: { slot: number }): TGKey {
|
||||
return TGKey.from({ date: new Date(), slot });
|
||||
},
|
||||
|
||||
parse(key: TGKey): { date: string; slot: number } {
|
||||
const parts = key.split("-");
|
||||
return {
|
||||
date: parts.slice(0, 3).join("-"), // "YYYY-MM-DD"
|
||||
slot: parseInt(parts[3], 10),
|
||||
};
|
||||
},
|
||||
|
||||
toHistoryPath(key: TGKey): string {
|
||||
return Paths.data("tg-history", `${key}.json`);
|
||||
},
|
||||
|
||||
/** Format for display: "11/06/2026 · 20:00" */
|
||||
toDisplay(key: TGKey): string {
|
||||
const { date, slot } = TGKey.parse(key);
|
||||
const [year, month, day] = date.split("-");
|
||||
return `${day}/${month}/${year} · ${slot}:00`;
|
||||
},
|
||||
|
||||
/** Check if a string is a valid TGKey */
|
||||
isValid(key: string): key is TGKey {
|
||||
return /^\d{4}-\d{2}-\d{2}-\d{1,2}$/.test(key);
|
||||
},
|
||||
};
|
||||
|
|
@ -12,13 +12,13 @@
|
|||
* TG.getWeeklySummary({ character })
|
||||
*/
|
||||
|
||||
import { Nation, Character, UserKey, HistoryKey, SlotHour } from "@types";
|
||||
import { Nation, Character, UserKey, SlotHour } from "@types";
|
||||
import { WRank } from "@systems/wrank";
|
||||
import { Bringer } from "@systems/bringer";
|
||||
import { Score, TGScore, WeeklySummary } from "@systems/score";
|
||||
import { Attendance } from "@systems/attendance";
|
||||
import { Nations } from "@systems/nations";
|
||||
import { Config } from "@systems/config";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
export const TG = {
|
||||
// ── Week ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
// ── Attendance ────────────────────────────────────────────────────────────
|
||||
|
||||
getAttendance({ historyKey, nation }: {
|
||||
historyKey: HistoryKey;
|
||||
historyKey: TGKey;
|
||||
nation?: Nation;
|
||||
}): UserKey[] {
|
||||
const players = Attendance.players(historyKey);
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
return players;
|
||||
},
|
||||
|
||||
allSubmitted(historyKey: HistoryKey): boolean {
|
||||
allSubmitted(historyKey: TGKey): boolean {
|
||||
return Attendance.allSubmitted(historyKey);
|
||||
},
|
||||
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
slot: SlotHour;
|
||||
date?: string;
|
||||
}): TGScore | null {
|
||||
return Score.get({ character, slot, date });
|
||||
return Score.get({ character, slot });
|
||||
},
|
||||
|
||||
getWeeklySummary({ character }: { character: Character }): WeeklySummary {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
import { WRank } from "@systems/wrank";
|
||||
import { Leaves } from "@systems/leaves";
|
||||
import { PersistentMessage } from "@systems/persistent-message";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
const log = Logger.for("updates");
|
||||
|
||||
|
|
@ -79,7 +80,7 @@
|
|||
}[];
|
||||
leaves?: {
|
||||
characterName: string;
|
||||
historyKey: string;
|
||||
historyKey: TGKey;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +224,10 @@
|
|||
try {
|
||||
PollUI.setLayout(example.layout);
|
||||
const state = buildExamplePollState(exampleData);
|
||||
const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: `🪲 ${example.caption}` });
|
||||
const exampleEmbed = PollUI.buildEmbed(state, {
|
||||
overrideLockMsg: `🪲 ${example.caption}`,
|
||||
historyKey: exampleData.leaves?.[0]?.historyKey
|
||||
});
|
||||
exampleEmbed.setTitle(`📋 Example — ${example.caption}`);
|
||||
embeds.push(exampleEmbed);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { HistoryKey, UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
|
||||
import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
|
||||
import { Config } from "@systems/config";
|
||||
import { Bringer } from "@systems/bringer";
|
||||
import { Nations } from "@systems/nations";
|
||||
import { Store } from "@systems/store";
|
||||
import { Paths } from "@paths";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
import { Runtime } from "@systems/runtime";
|
||||
import { Logger } from "@systems/logger";
|
||||
import { CharacterRegistry } from "@registry/character-registry";
|
||||
|
|
@ -40,7 +41,7 @@ export interface WRankEntry {
|
|||
export interface WRankWeek {
|
||||
weekKey: string;
|
||||
entries: Record<Nation, SerializableWRankEntry[]>;
|
||||
scoreIndex: Record<CharName, HistoryKey[]>;
|
||||
scoreIndex: Record<CharName, TGKey[]>;
|
||||
bringer: {
|
||||
[Nation.Capella]: string | null;
|
||||
[Nation.Procyon]: string | null;
|
||||
|
|
@ -146,7 +147,7 @@ export const WRank = {
|
|||
cls: ClassKey,
|
||||
nation: Nation,
|
||||
pts: number,
|
||||
historyKey: HistoryKey
|
||||
historyKey: TGKey
|
||||
): void {
|
||||
const week = ensureWeek(WRank.weekKey());
|
||||
const list = week.entries[nation];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export type UserKey = string;
|
|||
export type DiscordId = string;
|
||||
export type CharName = string;
|
||||
export type SlotHour = number;
|
||||
export type HistoryKey = string;
|
||||
export type VoteType = "yes" | "no";
|
||||
export type ConfirmType = "yes" | "no";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,24 +2,18 @@
|
|||
* BaseLayout — shared poll layout functions.
|
||||
* All layouts inherit these via BaseLayout.methods() spread.
|
||||
* Override only what differs in each layout.
|
||||
*
|
||||
* Usage:
|
||||
* export const myLayout: PollLayout = {
|
||||
* ...BaseLayout.methods(),
|
||||
* name: "my-layout",
|
||||
* description: "...",
|
||||
* buildEmbed(state, options) { ... }, // override
|
||||
* };
|
||||
*/
|
||||
|
||||
import { VoteEntry, Nation } from "@types";
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { VoteEntry, Nation, PollState } from "@types";
|
||||
import { Config } from "@systems/config";
|
||||
import { WRank, WRankEntry } from "@systems/wrank";
|
||||
import { Bringer } from "@systems/bringer";
|
||||
import { Leaves } from "@systems/leaves";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
import { format } from "@format";
|
||||
import { PollRowContext } from "@ui/types";
|
||||
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
||||
|
||||
// ─── W.Rank formatting ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -41,8 +35,8 @@
|
|||
// ─── Row formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
export function formatRow(entry: VoteEntry, context: PollRowContext): string {
|
||||
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
|
||||
const nation = entry.characterNation;
|
||||
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
|
||||
const nation = entry.characterNation;
|
||||
|
||||
const wRankEntry = entry.characterName && entry.characterNation
|
||||
? WRank.entry(entry.characterName, entry.characterNation)
|
||||
|
|
@ -76,8 +70,8 @@
|
|||
// Nation emoji prefix
|
||||
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
|
||||
|
||||
// Cockroach indicator (left TG)
|
||||
if (entry.userKey && entry.characterName && context.historyKey) {
|
||||
// Cockroach indicator
|
||||
if (entry.characterName && context.historyKey) {
|
||||
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
|
||||
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
|
||||
}
|
||||
|
|
@ -91,7 +85,7 @@
|
|||
export function buildContext(
|
||||
entries: VoteEntry[],
|
||||
nation: Nation,
|
||||
options?: { showNationEmoji?: boolean; historyKey?: string }
|
||||
options?: { showNationEmoji?: boolean; historyKey?: TGKey }
|
||||
): PollRowContext {
|
||||
const nationHasRank = entries.some((e) => {
|
||||
if (!e.characterName) return false;
|
||||
|
|
@ -113,7 +107,25 @@
|
|||
};
|
||||
}
|
||||
|
||||
// ─── Message formatting ───────────────────────────────────────────────────────
|
||||
// ─── Shared embed helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export function formatNationField(
|
||||
nation: Nation,
|
||||
yesEntries: VoteEntry[],
|
||||
noVoters: VoteEntry[],
|
||||
showNoInline: boolean,
|
||||
historyKey?: TGKey
|
||||
): string {
|
||||
const context = buildContext(yesEntries, nation, { historyKey });
|
||||
const noEntries = showNoInline
|
||||
? noVoters.filter((e) => e.characterNation === nation)
|
||||
: [];
|
||||
const lines = [
|
||||
...yesEntries.map((e) => formatRow(e, context)),
|
||||
...noEntries.map((e) => `❌ ${formatRow(e, context)}`),
|
||||
];
|
||||
return lines.length > 0 ? lines.join("\n") : "—";
|
||||
}
|
||||
|
||||
export function formatMessages(
|
||||
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
|
||||
|
|
@ -129,34 +141,14 @@
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
// ─── Embed helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function formatNationField(
|
||||
nation: Nation,
|
||||
yesEntries: VoteEntry[],
|
||||
noVoters: VoteEntry[],
|
||||
showNoInline: boolean,
|
||||
historyKey?: string
|
||||
): string {
|
||||
const context = buildContext(yesEntries, nation, { historyKey });
|
||||
const noEntries = showNoInline
|
||||
? noVoters.filter((e) => e.characterNation === nation)
|
||||
: [];
|
||||
const lines = [
|
||||
...yesEntries.map((e) => formatRow(e, context)),
|
||||
...noEntries.map((e) => `❌ ${formatRow(e, context)}`),
|
||||
];
|
||||
return lines.length > 0 ? lines.join("\n") : "—";
|
||||
}
|
||||
|
||||
export function resolveColor(state: any): number {
|
||||
export function resolveColor(state: PollState): number {
|
||||
if (state.confirmed === "yes") return 0x57f287;
|
||||
if (state.confirmed === "no") return 0xed4245;
|
||||
if (state.locked) return 0x888888;
|
||||
return 0xe8a317;
|
||||
}
|
||||
|
||||
export function resolveTitle(state: any, yesByNation: Record<Nation, VoteEntry[]>): string {
|
||||
export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
|
||||
const capellaEmoji = Emoji.get("capella");
|
||||
const procyonEmoji = Emoji.get("procyon");
|
||||
const counts = !state.locked && state.confirmed === null
|
||||
|
|
@ -169,16 +161,16 @@
|
|||
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
||||
}
|
||||
|
||||
export function resolveFooter(state: any, noCount: number, overrideLockMsg?: string): string {
|
||||
export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
|
||||
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
|
||||
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
|
||||
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
||||
return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
||||
}
|
||||
|
||||
export function buildYesByNation(state: any): {
|
||||
export function buildYesByNation(state: PollState): {
|
||||
yesByNation: Record<Nation, VoteEntry[]>;
|
||||
noVoters: VoteEntry[];
|
||||
noVoters: VoteEntry[];
|
||||
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
|
||||
} {
|
||||
const yesByNation: Record<Nation, VoteEntry[]> = {
|
||||
|
|
@ -189,10 +181,7 @@
|
|||
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
||||
|
||||
for (const entry of state.yes.values()) {
|
||||
const nation: Nation = entry.characterNation === Nation.Procyon
|
||||
? Nation.Procyon
|
||||
: Nation.Capella;
|
||||
|
||||
const nation: Nation = (entry.characterNation as Nation) ?? Nation.Capella;
|
||||
yesByNation[nation].push(entry);
|
||||
allMessages.push({ entry, voteType: "yes" });
|
||||
}
|
||||
|
|
@ -204,13 +193,76 @@
|
|||
return { yesByNation, noVoters, allMessages };
|
||||
}
|
||||
|
||||
// ─── buildEmbed factory ───────────────────────────────────────────────────────
|
||||
|
||||
export function createBuildEmbed({ inline, maxInlinePlayers = 5 }: {
|
||||
inline: boolean;
|
||||
maxInlinePlayers?: number;
|
||||
}) {
|
||||
return function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
||||
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
|
||||
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
|
||||
const historyKey = options?.historyKey
|
||||
?? TGKey.current({ slot: state.slot });
|
||||
|
||||
const capellaEmoji = Emoji.get("capella");
|
||||
const procyonEmoji = Emoji.get("procyon");
|
||||
|
||||
// Auto-stack to vertical if too many players
|
||||
const maxPlayers = Math.max(
|
||||
yesByNation[Nation.Capella].length,
|
||||
yesByNation[Nation.Procyon].length
|
||||
);
|
||||
const useInline = inline && maxPlayers <= maxInlinePlayers;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(resolveTitle(state, yesByNation))
|
||||
.setColor(resolveColor(state))
|
||||
.setTimestamp();
|
||||
|
||||
if (useInline) {
|
||||
// Side-by-side — no spacer needed
|
||||
embed.addFields(
|
||||
{
|
||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||
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, historyKey),
|
||||
inline: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Vertical — spacer between nations
|
||||
embed.addFields(
|
||||
{
|
||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
||||
inline: false,
|
||||
},
|
||||
{ name: "\u200b", value: "\u200b", inline: false },
|
||||
{
|
||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
||||
inline: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const msgSection = formatMessages(allMessages);
|
||||
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||
|
||||
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
||||
return embed;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── BaseLayout factory ───────────────────────────────────────────────────────
|
||||
|
||||
export const BaseLayout = {
|
||||
methods() {
|
||||
return {
|
||||
formatRow,
|
||||
buildContext,
|
||||
};
|
||||
return { formatRow, buildContext };
|
||||
},
|
||||
};
|
||||
|
|
@ -1,53 +1,9 @@
|
|||
import { EmbedBuilder } from "discord.js";
|
||||
import { PollState, Nation, VoteEntry } from "@types";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { PollLayout, PollEmbedOptions } from "@ui/types";
|
||||
import {
|
||||
BaseLayout,
|
||||
buildYesByNation,
|
||||
formatNationField,
|
||||
formatMessages,
|
||||
resolveColor,
|
||||
resolveTitle,
|
||||
resolveFooter,
|
||||
} from "../base-layout";
|
||||
|
||||
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
||||
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
|
||||
const showNoInline = false; // default layout stacks no-voters
|
||||
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
|
||||
|
||||
const capellaEmoji = Emoji.get("capella");
|
||||
const procyonEmoji = Emoji.get("procyon");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(resolveTitle(state, yesByNation))
|
||||
.setColor(resolveColor(state))
|
||||
.addFields(
|
||||
{
|
||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
||||
inline: false,
|
||||
},
|
||||
{ name: "\u200b", value: "\u200b", inline: false },
|
||||
{
|
||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
||||
inline: false,
|
||||
},
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const msgSection = formatMessages(allMessages);
|
||||
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||
|
||||
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
||||
return embed;
|
||||
}
|
||||
import { PollLayout } from "@ui/types";
|
||||
import { BaseLayout, createBuildEmbed } from "../base-layout";
|
||||
|
||||
export const defaultLayout: PollLayout = {
|
||||
...BaseLayout.methods(),
|
||||
name: "default",
|
||||
description: "Standard vertical layout with nation-separated fields",
|
||||
buildEmbed,
|
||||
buildEmbed: createBuildEmbed({ inline: false }),
|
||||
};
|
||||
|
|
@ -1,58 +1,9 @@
|
|||
import { EmbedBuilder } from "discord.js";
|
||||
import { PollState, Nation } from "@types";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { PollLayout, PollEmbedOptions } from "@ui/types";
|
||||
import {
|
||||
BaseLayout,
|
||||
buildYesByNation,
|
||||
formatNationField,
|
||||
formatMessages,
|
||||
resolveColor,
|
||||
resolveTitle,
|
||||
resolveFooter,
|
||||
} from "../base-layout";
|
||||
|
||||
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
||||
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
|
||||
const showNoInline = false;
|
||||
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
|
||||
|
||||
const capellaEmoji = Emoji.get("capella");
|
||||
const procyonEmoji = Emoji.get("procyon");
|
||||
|
||||
const maxPlayers = Math.max(
|
||||
yesByNation[Nation.Capella].length,
|
||||
yesByNation[Nation.Procyon].length
|
||||
);
|
||||
const useInline = maxPlayers <= 5;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(resolveTitle(state, yesByNation))
|
||||
.setColor(resolveColor(state))
|
||||
.addFields(
|
||||
{
|
||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
||||
inline: useInline,
|
||||
},
|
||||
{
|
||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
||||
inline: useInline,
|
||||
},
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const msgSection = formatMessages(allMessages);
|
||||
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||
|
||||
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
||||
return embed;
|
||||
}
|
||||
import { PollLayout } from "@ui/types";
|
||||
import { BaseLayout, createBuildEmbed } from "../base-layout";
|
||||
|
||||
export const sideBySideLayout: PollLayout = {
|
||||
...BaseLayout.methods(),
|
||||
name: "side-by-side",
|
||||
description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)",
|
||||
buildEmbed,
|
||||
buildEmbed: createBuildEmbed({ inline: true, maxInlinePlayers: 5 }),
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { PollState, VoteEntry, Nation } from "@types";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
|
||||
// ─── Poll ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -11,12 +12,13 @@
|
|||
nationHasRank: boolean;
|
||||
nationHasDelta: boolean;
|
||||
showNationEmoji?: boolean;
|
||||
historyKey?: string
|
||||
historyKey?: TGKey;
|
||||
}
|
||||
|
||||
export interface PollEmbedOptions {
|
||||
overrideLockMsg?: string;
|
||||
showScoreButton?: boolean;
|
||||
historyKey?: TGKey;
|
||||
}
|
||||
|
||||
export interface PollLayout {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue