poll.ts
· 10 KiB · TypeScript
Raw
import {
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
TextChannel,
GuildMember,
} from "discord.js";
import { PollState, VoteEntry, Nation, TGSlot } from "../types";
import { cfg } from "./config";
import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
import { getActiveCharacter, getCharacterByName } from "./characters";
import { resolveNation } from "./nations";
import { getEntry, getBringer } from "./wrank";
import { nowFormatted } from "./messages";
// ─── Poll state ───────────────────────────────────────────────────────────────
export const polls: Map<number, PollState> = new Map();
const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void {
const e = publicOverrides.get(userId) ?? {};
e[voteType] = message;
publicOverrides.set(userId, e);
}
export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void {
if (!voteType) { publicOverrides.delete(userId); return; }
const e = publicOverrides.get(userId);
if (e) delete e[voteType];
}
export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void {
const e = ephemeralOverrides.get(userId) ?? {};
e[voteType] = message;
ephemeralOverrides.set(userId, e);
}
export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void {
if (!voteType) { ephemeralOverrides.delete(userId); return; }
const e = ephemeralOverrides.get(userId);
if (e) delete e[voteType];
}
export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
return publicOverrides.get(userId)?.[voteType];
}
export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
return ephemeralOverrides.get(userId)?.[voteType];
}
export function resetPollOverrides(): void {
publicOverrides.clear();
ephemeralOverrides.clear();
}
// ─── Character display ────────────────────────────────────────────────────────
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
const format = cfg("charDisplayFormat");
const nation = entry.characterNation;
const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
let wrank = "";
if (wRankEntry) {
const goal = cfg("wRankGoal");
const isDone = wRankEntry.tgCount >= goal;
const rank = wRankEntry.currentRank;
const prev = wRankEntry.previousRank;
const delta = prev !== undefined ? rank - prev : 0;
// W.Rank emoji with text fallback
const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`;
const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`);
// Delta arrows with text fallback
let deltaStr = "";
if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`;
else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`;
else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`;
wrank = `${rankStr}${deltaStr}`;
}
const classStr = entry.characterClass
? (getClassEmoji(entry.characterClass) || entry.characterClass)
: "";
const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
? `${entry.characterLevel}`
: "";
let row = format
.replace("{wrank}", wrank)
.replace("{class}", classStr)
.replace("{level}", levelStr)
.replace("{name}", entry.characterName ?? entry.displayName)
.replace(/\s+/g, " ")
.trim();
// Bringer title — independent of W.Rank so override always shows
if (nation && entry.userKey) {
const bringer = getBringer(nation);
if (bringer && bringer === entry.characterName) {
const emoji = nation === "Capella"
? (getEmoji("luminous_bringer") || "🔆")
: (getEmoji("storm_bringer") || "⚡");
const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
row += ` · ${emoji} **${title}**`;
}
}
if (entry.borrowedFrom) {
row += ` ${getEmoji("borrowed") || "🔗"}`;
}
if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`;
return row;
}
// ─── Embed building ───────────────────────────────────────────────────────────
export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
const noVoters: VoteEntry[] = [];
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
const showNoInline = (cfg as any)("showNoInNationField") ?? false;
for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? "Capella";
yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" });
}
for (const entry of state.no.values()) {
noVoters.push(entry);
allMessages.push({ entry, voteType: "no" });
}
const capellaEmoji = getEmoji("capella");
const procyonEmoji = getEmoji("procyon");
const formatNationField = (nation: Nation): string => {
const yesEntries = yesByNation[nation];
const noEntries = showNoInline
? noVoters.filter((e) => e.characterNation === nation)
: [];
const lines = [
...yesEntries.map((e) => formatCharRow(e)),
...noEntries.map((e) => `❌ ${formatCharRow(e)}`),
];
return lines.length > 0 ? lines.join("\n") : "—";
};
const formatMessages = (): string => {
if (allMessages.length === 0) return "";
return allMessages
.map((m) => {
const name = m.entry.characterName ?? m.entry.displayName;
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : "";
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
})
.join("\n");
};
const locked = state.locked;
const confirmed = state.confirmed;
const color =
confirmed === "yes" ? 0x57f287 :
confirmed === "no" ? 0xed4245 :
locked ? 0x888888 :
0xe8a317;
// Title with nation + no counts (hidden when confirmed or locked)
const counts = !locked && confirmed === null
? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
: "";
const statusSuffix =
locked ? " 🔒" :
confirmed === "yes" ? " ✅" :
confirmed === "no" ? " ❌" : "";
const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(color)
.addFields(
{ name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false },
{ name: "\u200b", value: "\u200b", inline: false },
{ name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false },
)
.setTimestamp();
const msgSection = formatMessages();
if (msgSection) {
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
}
let footer: string;
if (confirmed === "yes") footer = cfg("confirmYesMessage");
else if (confirmed === "no") footer = cfg("confirmNoMessage");
else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
embed.setFooter({ text: footer });
return embed;
}
export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
const yesBtn = new ButtonBuilder()
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
const noBtn = new ButtonBuilder()
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
}
export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
const state = polls.get(slot);
if (!state?.messageId) return;
try {
const msg = await channel.messages.fetch(state.messageId);
const disabled = state.locked || state.confirmed !== null;
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
} catch (err) {
console.error("Failed to update poll message:", err);
}
}
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
resetPollOverrides();
const { clearSessionBorrows } = require("@systems/borrow");
const { clearAllImpersonations } = require("@systems/impersonate");
clearSessionBorrows();
clearAllImpersonations();
const state: PollState = {
messageId: null, slot: slot.tgHour,
yes: new Map(), no: new Map(),
locked: false, confirmed: null,
};
polls.set(slot.tgHour, state);
const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
state.messageId = msg.id;
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
}
export function createVoteEntry(
userId: string,
member: GuildMember,
userKey: string | null,
discordUsername: string
): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
const serverNickname = member.nickname ?? null;
const globalNickname = member.user.globalName ?? null;
const displayName = serverNickname ?? globalNickname ?? discordUsername;
const { getEffectiveCharacter } = require("./borrow");
const { char, borrowedFrom: bf } = userKey
? getEffectiveCharacter(userKey)
: { char: null, borrowedFrom: null };
console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
return {
userKey: userKey ?? (undefined as any),
displayName,
characterName: char?.name,
characterClass: char?.class,
characterLevel: char?.level,
characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined),
borrowedFrom: bf ?? undefined,
};
}
| 1 | import { |
| 2 | EmbedBuilder, |
| 3 | ButtonBuilder, |
| 4 | ButtonStyle, |
| 5 | ActionRowBuilder, |
| 6 | TextChannel, |
| 7 | GuildMember, |
| 8 | } from "discord.js"; |
| 9 | import { PollState, VoteEntry, Nation, TGSlot } from "../types"; |
| 10 | import { cfg } from "./config"; |
| 11 | import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis"; |
| 12 | import { getActiveCharacter, getCharacterByName } from "./characters"; |
| 13 | import { resolveNation } from "./nations"; |
| 14 | import { getEntry, getBringer } from "./wrank"; |
| 15 | import { nowFormatted } from "./messages"; |
| 16 | |
| 17 | // ─── Poll state ─────────────────────────────────────────────────────────────── |
| 18 | export const polls: Map<number, PollState> = new Map(); |
| 19 | |
| 20 | const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map(); |
| 21 | const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map(); |
| 22 | |
| 23 | export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void { |
| 24 | const e = publicOverrides.get(userId) ?? {}; |
| 25 | e[voteType] = message; |
| 26 | publicOverrides.set(userId, e); |
| 27 | } |
| 28 | export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void { |
| 29 | if (!voteType) { publicOverrides.delete(userId); return; } |
| 30 | const e = publicOverrides.get(userId); |
| 31 | if (e) delete e[voteType]; |
| 32 | } |
| 33 | export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void { |
| 34 | const e = ephemeralOverrides.get(userId) ?? {}; |
| 35 | e[voteType] = message; |
| 36 | ephemeralOverrides.set(userId, e); |
| 37 | } |
| 38 | export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void { |
| 39 | if (!voteType) { ephemeralOverrides.delete(userId); return; } |
| 40 | const e = ephemeralOverrides.get(userId); |
| 41 | if (e) delete e[voteType]; |
| 42 | } |
| 43 | export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined { |
| 44 | return publicOverrides.get(userId)?.[voteType]; |
| 45 | } |
| 46 | export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined { |
| 47 | return ephemeralOverrides.get(userId)?.[voteType]; |
| 48 | } |
| 49 | export function resetPollOverrides(): void { |
| 50 | publicOverrides.clear(); |
| 51 | ephemeralOverrides.clear(); |
| 52 | } |
| 53 | |
| 54 | // ─── Character display ──────────────────────────────────────────────────────── |
| 55 | function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { |
| 56 | const format = cfg("charDisplayFormat"); |
| 57 | const nation = entry.characterNation; |
| 58 | const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; |
| 59 | |
| 60 | let wrank = ""; |
| 61 | if (wRankEntry) { |
| 62 | const goal = cfg("wRankGoal"); |
| 63 | const isDone = wRankEntry.tgCount >= goal; |
| 64 | const rank = wRankEntry.currentRank; |
| 65 | const prev = wRankEntry.previousRank; |
| 66 | const delta = prev !== undefined ? rank - prev : 0; |
| 67 | // W.Rank emoji with text fallback |
| 68 | const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`; |
| 69 | const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`); |
| 70 | |
| 71 | // Delta arrows with text fallback |
| 72 | let deltaStr = ""; |
| 73 | if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`; |
| 74 | else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`; |
| 75 | else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`; |
| 76 | |
| 77 | wrank = `${rankStr}${deltaStr}`; |
| 78 | } |
| 79 | |
| 80 | const classStr = entry.characterClass |
| 81 | ? (getClassEmoji(entry.characterClass) || entry.characterClass) |
| 82 | : ""; |
| 83 | |
| 84 | const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) |
| 85 | ? `${entry.characterLevel}` |
| 86 | : ""; |
| 87 | |
| 88 | let row = format |
| 89 | .replace("{wrank}", wrank) |
| 90 | .replace("{class}", classStr) |
| 91 | .replace("{level}", levelStr) |
| 92 | .replace("{name}", entry.characterName ?? entry.displayName) |
| 93 | .replace(/\s+/g, " ") |
| 94 | .trim(); |
| 95 | |
| 96 | // Bringer title — independent of W.Rank so override always shows |
| 97 | if (nation && entry.userKey) { |
| 98 | const bringer = getBringer(nation); |
| 99 | if (bringer && bringer === entry.characterName) { |
| 100 | const emoji = nation === "Capella" |
| 101 | ? (getEmoji("luminous_bringer") || "🔆") |
| 102 | : (getEmoji("storm_bringer") || "⚡"); |
| 103 | const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; |
| 104 | row += ` · ${emoji} **${title}**`; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | if (entry.borrowedFrom) { |
| 109 | row += ` ${getEmoji("borrowed") || "🔗"}`; |
| 110 | } |
| 111 | |
| 112 | if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`; |
| 113 | |
| 114 | return row; |
| 115 | } |
| 116 | |
| 117 | // ─── Embed building ─────────────────────────────────────────────────────────── |
| 118 | export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder { |
| 119 | const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] }; |
| 120 | const noVoters: VoteEntry[] = []; |
| 121 | const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; |
| 122 | const showNoInline = (cfg as any)("showNoInNationField") ?? false; |
| 123 | |
| 124 | for (const entry of state.yes.values()) { |
| 125 | const nation = entry.characterNation ?? "Capella"; |
| 126 | yesByNation[nation].push(entry); |
| 127 | allMessages.push({ entry, voteType: "yes" }); |
| 128 | } |
| 129 | for (const entry of state.no.values()) { |
| 130 | noVoters.push(entry); |
| 131 | allMessages.push({ entry, voteType: "no" }); |
| 132 | } |
| 133 | |
| 134 | const capellaEmoji = getEmoji("capella"); |
| 135 | const procyonEmoji = getEmoji("procyon"); |
| 136 | |
| 137 | const formatNationField = (nation: Nation): string => { |
| 138 | const yesEntries = yesByNation[nation]; |
| 139 | const noEntries = showNoInline |
| 140 | ? noVoters.filter((e) => e.characterNation === nation) |
| 141 | : []; |
| 142 | const lines = [ |
| 143 | ...yesEntries.map((e) => formatCharRow(e)), |
| 144 | ...noEntries.map((e) => `❌ ${formatCharRow(e)}`), |
| 145 | ]; |
| 146 | return lines.length > 0 ? lines.join("\n") : "—"; |
| 147 | }; |
| 148 | |
| 149 | const formatMessages = (): string => { |
| 150 | if (allMessages.length === 0) return ""; |
| 151 | return allMessages |
| 152 | .map((m) => { |
| 153 | const name = m.entry.characterName ?? m.entry.displayName; |
| 154 | const prefix = m.voteType === "no" ? "✗ " : "✓ "; |
| 155 | const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; |
| 156 | return `${prefix}${name} · ${m.entry.votedAt}${msg}`; |
| 157 | }) |
| 158 | .join("\n"); |
| 159 | }; |
| 160 | |
| 161 | const locked = state.locked; |
| 162 | const confirmed = state.confirmed; |
| 163 | |
| 164 | const color = |
| 165 | confirmed === "yes" ? 0x57f287 : |
| 166 | confirmed === "no" ? 0xed4245 : |
| 167 | locked ? 0x888888 : |
| 168 | 0xe8a317; |
| 169 | |
| 170 | // Title with nation + no counts (hidden when confirmed or locked) |
| 171 | const counts = !locked && confirmed === null |
| 172 | ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}` |
| 173 | : ""; |
| 174 | const statusSuffix = |
| 175 | locked ? " 🔒" : |
| 176 | confirmed === "yes" ? " ✅" : |
| 177 | confirmed === "no" ? " ❌" : ""; |
| 178 | |
| 179 | const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; |
| 180 | |
| 181 | const embed = new EmbedBuilder() |
| 182 | .setTitle(title) |
| 183 | .setColor(color) |
| 184 | .addFields( |
| 185 | { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false }, |
| 186 | { name: "\u200b", value: "\u200b", inline: false }, |
| 187 | { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false }, |
| 188 | ) |
| 189 | .setTimestamp(); |
| 190 | |
| 191 | const msgSection = formatMessages(); |
| 192 | if (msgSection) { |
| 193 | embed.addFields({ name: "\u200b", value: msgSection, inline: false }); |
| 194 | } |
| 195 | |
| 196 | let footer: string; |
| 197 | if (confirmed === "yes") footer = cfg("confirmYesMessage"); |
| 198 | else if (confirmed === "no") footer = cfg("confirmNoMessage"); |
| 199 | else if (locked) footer = overrideLockMsg ?? cfg("lockMessage"); |
| 200 | else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; |
| 201 | embed.setFooter({ text: footer }); |
| 202 | |
| 203 | return embed; |
| 204 | } |
| 205 | |
| 206 | export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> { |
| 207 | const yesBtn = new ButtonBuilder() |
| 208 | .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled); |
| 209 | const noBtn = new ButtonBuilder() |
| 210 | .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled); |
| 211 | return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn); |
| 212 | } |
| 213 | |
| 214 | export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> { |
| 215 | const state = polls.get(slot); |
| 216 | if (!state?.messageId) return; |
| 217 | try { |
| 218 | const msg = await channel.messages.fetch(state.messageId); |
| 219 | const disabled = state.locked || state.confirmed !== null; |
| 220 | await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] }); |
| 221 | } catch (err) { |
| 222 | console.error("Failed to update poll message:", err); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> { |
| 227 | resetPollOverrides(); |
| 228 | const { clearSessionBorrows } = require("@systems/borrow"); |
| 229 | const { clearAllImpersonations } = require("@systems/impersonate"); |
| 230 | |
| 231 | clearSessionBorrows(); |
| 232 | clearAllImpersonations(); |
| 233 | |
| 234 | const state: PollState = { |
| 235 | messageId: null, slot: slot.tgHour, |
| 236 | yes: new Map(), no: new Map(), |
| 237 | locked: false, confirmed: null, |
| 238 | }; |
| 239 | polls.set(slot.tgHour, state); |
| 240 | const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] }); |
| 241 | state.messageId = msg.id; |
| 242 | console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`); |
| 243 | } |
| 244 | |
| 245 | export function createVoteEntry( |
| 246 | userId: string, |
| 247 | member: GuildMember, |
| 248 | userKey: string | null, |
| 249 | discordUsername: string |
| 250 | ): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> { |
| 251 | const serverNickname = member.nickname ?? null; |
| 252 | const globalNickname = member.user.globalName ?? null; |
| 253 | const displayName = serverNickname ?? globalNickname ?? discordUsername; |
| 254 | |
| 255 | const { getEffectiveCharacter } = require("./borrow"); |
| 256 | const { char, borrowedFrom: bf } = userKey |
| 257 | ? getEffectiveCharacter(userKey) |
| 258 | : { char: null, borrowedFrom: null }; |
| 259 | console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); |
| 260 | |
| 261 | return { |
| 262 | userKey: userKey ?? (undefined as any), |
| 263 | displayName, |
| 264 | characterName: char?.name, |
| 265 | characterClass: char?.class, |
| 266 | characterLevel: char?.level, |
| 267 | characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), |
| 268 | borrowedFrom: bf ?? undefined, |
| 269 | }; |
| 270 | } |