最後活躍 1 month ago

switch.ts 原始檔案
1import { ChatInputCommandInteraction, TextChannel } from "discord.js";
2import { cfg } from "@systems/config";
3import { resolveUser, hasOfficerRole } from "@systems/users";
4import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
5import {
6 getEffectiveCharacter,
7 setSessionBorrow,
8 setPersistentPreference,
9 clearPersistentPreference,
10 clearSessionBorrowForUser,
11} from "@systems/borrow";
12import { polls, updatePollMessage } from "@systems/poll";
13import { getClassEmoji } from "@systems/emojis";
14import { replyAndDelete } from "@src/utils";
15import { format } from "@format";
16import fs from "fs";
17import path from "path";
18
19const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
20
21function findSharedChar(userKey: string, charName: string): { ownerKey: string; char: any } | null {
22 try {
23 const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
24 for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
25 if (ownerKey === userKey) continue;
26 const char = data.characters?.find(
27 (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey)
28 );
29 if (char) return { ownerKey, char };
30 }
31 } catch {}
32 return null;
33}
34
35function findVoteIdInPoll(state: any, userKey: string): string | null {
36 for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
37 if (entry.userKey === userKey) return id;
38 }
39 return null;
40}
41
42export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
43 const member = await interaction.guild!.members.fetch(interaction.user.id);
44 const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
45 const nameArg = interaction.options.getString("name");
46 const charName = interaction.options.getString("char_name", true);
47
48 let userKey: string | null;
49 if (nameArg) {
50 if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters.");
51 userKey = nameArg;
52 } else {
53 const user = await resolveUser(member);
54 userKey = user.userKey;
55 }
56
57 if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
58
59 // Resolve the target character without switching yet
60 let resolvedChar: any = null;
61 let borrowedFrom: string | null = null;
62
63 const ownChar = getCharacterByName(userKey, charName);
64 if (ownChar) {
65 resolvedChar = ownChar;
66 } else {
67 const shared = findSharedChar(userKey, charName);
68 if (shared) {
69 resolvedChar = shared.char;
70 borrowedFrom = shared.ownerKey;
71 }
72 }
73
74 if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
75
76 // If already active — just show current state without switching
77 const current = getEffectiveCharacter(userKey);
78 if (current.char?.name === resolvedChar.name) {
79 const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
80 const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : "";
81 return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
82 }
83
84 // Check if target character is already in the active poll by another player
85 const slot = [...polls.keys()][0];
86 if (slot !== undefined) {
87 const state = polls.get(slot)!;
88 for (const [id, entry] of state.yes.entries()) {
89 const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`;
90 if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) {
91 const slotHour = state.slot;
92 const charDisplay = format.char(resolvedChar);
93 const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
94 if (isOwner) {
95 return void replyAndDelete(interaction,
96 `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`,
97 true
98 );
99 }
100 return void replyAndDelete(interaction,
101 `${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
102 true
103 );
104 }
105 }
106 }
107
108 // Now actually switch
109 if (borrowedFrom) {
110 setSessionBorrow(userKey, borrowedFrom, resolvedChar.name);
111 setPersistentPreference(userKey, borrowedFrom, resolvedChar.name);
112 } else {
113 setActiveCharacter(userKey, charName);
114 clearPersistentPreference(userKey);
115 clearSessionBorrowForUser(userKey);
116 resolvedChar = getActiveCharacter(userKey);
117 }
118
119 // Update poll embed if user has already voted
120 if (slot !== undefined) {
121 const state = polls.get(slot)!;
122 const voteId = findVoteIdInPoll(state, userKey);
123
124 if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) {
125 const updateEntry = (map: Map<string, any>) => {
126 const entry = map.get(voteId);
127 if (entry) {
128 entry.characterName = resolvedChar.name;
129 entry.characterClass = resolvedChar.class;
130 entry.characterLevel = resolvedChar.level;
131 entry.characterNation = resolvedChar.nation;
132 entry.borrowedFrom = borrowedFrom ?? undefined;
133 }
134 };
135 updateEntry(state.yes);
136 updateEntry(state.no);
137
138 const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
139 await updatePollMessage(channel, slot);
140 }
141 }
142
143 const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
144 const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
145 return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
146}