diff --git a/.gitignore b/.gitignore index 81b036d..917a1d8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,12 +15,16 @@ data/poll-state.json data/usermap.json data/wrank.json data/bringer.json +data/attendance.json data/sessionPreferences.json data/tg-history/ # Emoji data emoji-uploads/ +# Scripts +scripts/ + # Tests tests/ diff --git a/data/emojis/classes.json b/data/emojis/classes.json new file mode 100644 index 0000000..521b918 --- /dev/null +++ b/data/emojis/classes.json @@ -0,0 +1,11 @@ +{ + "bl": "<:bl:1511906439516651561>", + "dm": "<:dm:1511906450866180126>", + "fa": "<:fa:1511906454506967242>", + "fb": "<:fb:1511906458231377950>", + "fg": "<:fg:1511906461977022605>", + "fs": "<:fs:1511906465798029423>", + "gl": "<:gl:1511906470684524594>", + "wa": "<:wa:1511906499889467492>", + "wi": "<:wi:1511906503647563807>" +} \ No newline at end of file diff --git a/data/emojis/misc.json b/data/emojis/misc.json new file mode 100644 index 0000000..fbcbd21 --- /dev/null +++ b/data/emojis/misc.json @@ -0,0 +1,14 @@ +{ + "borrowed": "<:borrowed:1511906443245391944>", + "capella": "<:capella:1511906447167062137>", + "kd": "<:kd:1511906474497146983>", + "luminous_bringer": "<:luminous_bringer:1511906480184492263>", + "procyon": "<:procyon:1511906483993055295>", + "rank": "<:rank:1511906488380293180>", + "score": "<:score:1511906491903250525>", + "storm_bringer": "<:storm_bringer:1511906496097554594>", + "wrank_down": "<:wrank_down:1511906547104616643>", + "wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>", + "wrank_up": "<:wrank_up:1512114414474756132>", + "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>" +} \ No newline at end of file diff --git a/data/emojis/wrank-down.json b/data/emojis/wrank-down.json new file mode 100644 index 0000000..80658bb --- /dev/null +++ b/data/emojis/wrank-down.json @@ -0,0 +1,102 @@ +{ + "wrank_down_1": "<:wrank_down_1:1512124970698801244>", + "wrank_down_2": "<:wrank_down_2:1512125016114729133>", + "wrank_down_3": "<:wrank_down_3:1512125023199166536>", + "wrank_down_4": "<:wrank_down_4:1512125027372237040>", + "wrank_down_5": "<:wrank_down_5:1512125030765691072>", + "wrank_down_10": "<:wrank_down_10:1512124974582989000>", + "wrank_down_11": "<:wrank_down_11:1512124978504536114>", + "wrank_down_12": "<:wrank_down_12:1512124982728069192>", + "wrank_down_13": "<:wrank_down_13:1512124987501314150>", + "wrank_down_14": "<:wrank_down_14:1512124991292837918>", + "wrank_down_15": "<:wrank_down_15:1512124995340468335>", + "wrank_down_16": "<:wrank_down_16:1512124999576850462>", + "wrank_down_17": "<:wrank_down_17:1512125004353896642>", + "wrank_down_18": "<:wrank_down_18:1512125008132964486>", + "wrank_down_19": "<:wrank_down_19:1512125011857510410>", + "wrank_down_20": "<:wrank_down_20:1512125019814101173>", + "wrank_down_6": "<:wrank_down_6:1512125035123576922>", + "wrank_down_7": "<:wrank_down_7:1512125039091126434>", + "wrank_down_8": "<:wrank_down_8:1512125042757210123>", + "wrank_down_9": "<:wrank_down_9:1512125047798759706>", + "wrank_down_100": "<:wrank_down_100:1513360157923475496>", + "wrank_down_21": "<:wrank_down_21:1513360161446559745>", + "wrank_down_22": "<:wrank_down_22:1513360165443993720>", + "wrank_down_23": "<:wrank_down_23:1513360169453752380>", + "wrank_down_24": "<:wrank_down_24:1513360173241077762>", + "wrank_down_25": "<:wrank_down_25:1513360177087254620>", + "wrank_down_26": "<:wrank_down_26:1513360181054935134>", + "wrank_down_27": "<:wrank_down_27:1513360184758505673>", + "wrank_down_28": "<:wrank_down_28:1513360188353282100>", + "wrank_down_29": "<:wrank_down_29:1513360191826038966>", + "wrank_down_30": "<:wrank_down_30:1513360195387129896>", + "wrank_down_31": "<:wrank_down_31:1513360198981648434>", + "wrank_down_32": "<:wrank_down_32:1513360202492149861>", + "wrank_down_33": "<:wrank_down_33:1513360206166360146>", + "wrank_down_34": "<:wrank_down_34:1513360209790242887>", + "wrank_down_35": "<:wrank_down_35:1513360214429007954>", + "wrank_down_36": "<:wrank_down_36:1513360218250022984>", + "wrank_down_37": "<:wrank_down_37:1513360223484510218>", + "wrank_down_38": "<:wrank_down_38:1513360226873512078>", + "wrank_down_39": "<:wrank_down_39:1513360230606442557>", + "wrank_down_40": "<:wrank_down_40:1513360234469523547>", + "wrank_down_41": "<:wrank_down_41:1513360238399586314>", + "wrank_down_42": "<:wrank_down_42:1513360241834594356>", + "wrank_down_43": "<:wrank_down_43:1513360245609730109>", + "wrank_down_44": "<:wrank_down_44:1513360248667246805>", + "wrank_down_45": "<:wrank_down_45:1513360253025124453>", + "wrank_down_46": "<:wrank_down_46:1513360256946929715>", + "wrank_down_47": "<:wrank_down_47:1513360261128654971>", + "wrank_down_48": "<:wrank_down_48:1513360265935192104>", + "wrank_down_49": "<:wrank_down_49:1513360269768654888>", + "wrank_down_50": "<:wrank_down_50:1513360273468297438>", + "wrank_down_51": "<:wrank_down_51:1513360277117337770>", + "wrank_down_52": "<:wrank_down_52:1513360280808067172>", + "wrank_down_53": "<:wrank_down_53:1513360284033618092>", + "wrank_down_54": "<:wrank_down_54:1513360288081117284>", + "wrank_down_55": "<:wrank_down_55:1513360291398946868>", + "wrank_down_56": "<:wrank_down_56:1513360295630868550>", + "wrank_down_57": "<:wrank_down_57:1513360299078713447>", + "wrank_down_58": "<:wrank_down_58:1513360303360970896>", + "wrank_down_59": "<:wrank_down_59:1513360306993369229>", + "wrank_down_60": "<:wrank_down_60:1513360310583427092>", + "wrank_down_61": "<:wrank_down_61:1513360314203246773>", + "wrank_down_62": "<:wrank_down_62:1513360318410002522>", + "wrank_down_63": "<:wrank_down_63:1513360322268893296>", + "wrank_down_64": "<:wrank_down_64:1513360325771137095>", + "wrank_down_65": "<:wrank_down_65:1513360329676034068>", + "wrank_down_66": "<:wrank_down_66:1513360333518143668>", + "wrank_down_67": "<:wrank_down_67:1513360337112662026>", + "wrank_down_68": "<:wrank_down_68:1513360340635877467>", + "wrank_down_69": "<:wrank_down_69:1513360344318349352>", + "wrank_down_70": "<:wrank_down_70:1513360347925319862>", + "wrank_down_71": "<:wrank_down_71:1513360352023285770>", + "wrank_down_72": "<:wrank_down_72:1513360356070785066>", + "wrank_down_73": "<:wrank_down_73:1513360359803846716>", + "wrank_down_74": "<:wrank_down_74:1513360363595239596>", + "wrank_down_75": "<:wrank_down_75:1513360367538016410>", + "wrank_down_76": "<:wrank_down_76:1513360372302745721>", + "wrank_down_77": "<:wrank_down_77:1513360376035545328>", + "wrank_down_78": "<:wrank_down_78:1513360379810545755>", + "wrank_down_79": "<:wrank_down_79:1513360383451201707>", + "wrank_down_80": "<:wrank_down_80:1513360387012296895>", + "wrank_down_81": "<:wrank_down_81:1513360390573002872>", + "wrank_down_82": "<:wrank_down_82:1513360393983234242>", + "wrank_down_83": "<:wrank_down_83:1513360398076743792>", + "wrank_down_84": "<:wrank_down_84:1513360401763405947>", + "wrank_down_85": "<:wrank_down_85:1513360405681016843>", + "wrank_down_86": "<:wrank_down_86:1513360409929973812>", + "wrank_down_87": "<:wrank_down_87:1513360413985865828>", + "wrank_down_88": "<:wrank_down_88:1513360417802420395>", + "wrank_down_89": "<:wrank_down_89:1513360421430497441>", + "wrank_down_90": "<:wrank_down_90:1513360424882667554>", + "wrank_down_91": "<:wrank_down_91:1513360428900548628>", + "wrank_down_92": "<:wrank_down_92:1513360432856043550>", + "wrank_down_93": "<:wrank_down_93:1513360436706283530>", + "wrank_down_94": "<:wrank_down_94:1513360440879747112>", + "wrank_down_95": "<:wrank_down_95:1513360445728096456>", + "wrank_down_96": "<:wrank_down_96:1513360449276477509>", + "wrank_down_97": "<:wrank_down_97:1513360453152280709>", + "wrank_down_98": "<:wrank_down_98:1513360459758174328>", + "wrank_down_99": "<:wrank_down_99:1513360463558082590>" +} \ No newline at end of file diff --git a/data/emojis/wrank-gold.json b/data/emojis/wrank-gold.json new file mode 100644 index 0000000..ba29ca5 --- /dev/null +++ b/data/emojis/wrank-gold.json @@ -0,0 +1,22 @@ +{ + "wrank_1_gold": "<:wrank_1_gold:1512125051728560278>", + "wrank_2_gold": "<:wrank_2_gold:1512125095974535271>", + "wrank_3_gold": "<:wrank_3_gold:1512125103964684390>", + "wrank_4_gold": "<:wrank_4_gold:1512125108154663133>", + "wrank_5_gold": "<:wrank_5_gold:1512125112084594818>", + "wrank_10_gold": "<:wrank_10_gold:1512125055432396910>", + "wrank_11_gold": "<:wrank_11_gold:1512125059123249242>", + "wrank_12_gold": "<:wrank_12_gold:1512125063304974438>", + "wrank_13_gold": "<:wrank_13_gold:1512125067201609908>", + "wrank_14_gold": "<:wrank_14_gold:1512125071043596520>", + "wrank_15_gold": "<:wrank_15_gold:1512125074893832443>", + "wrank_16_gold": "<:wrank_16_gold:1512125078966374420>", + "wrank_17_gold": "<:wrank_17_gold:1512125083605532715>", + "wrank_18_gold": "<:wrank_18_gold:1512125088378392718>", + "wrank_19_gold": "<:wrank_19_gold:1512125091960459508>", + "wrank_20_gold": "<:wrank_20_gold:1512125100265181204>", + "wrank_6_gold": "<:wrank_6_gold:1512125115956203601>", + "wrank_7_gold": "<:wrank_7_gold:1512125120204771338>", + "wrank_8_gold": "<:wrank_8_gold:1512125123874918661>", + "wrank_9_gold": "<:wrank_9_gold:1512125128299905104>" +} \ No newline at end of file diff --git a/data/emojis/wrank-neutral.json b/data/emojis/wrank-neutral.json new file mode 100644 index 0000000..e2933c8 --- /dev/null +++ b/data/emojis/wrank-neutral.json @@ -0,0 +1,5 @@ +{ + "wrank_neutral": "<:wrank_neutral:1511950713713070160>", + "wrank_no_dash": "<:wrank_no_dash:1511956379403943979>", + "wrank_no_rank": "<:wrank_no_rank:1512261782205628606>" +} \ No newline at end of file diff --git a/data/emojis/wrank-up.json b/data/emojis/wrank-up.json new file mode 100644 index 0000000..ea14a4c --- /dev/null +++ b/data/emojis/wrank-up.json @@ -0,0 +1,102 @@ +{ + "wrank_up_1": "<:wrank_up_1:1512125132242554890>", + "wrank_up_10": "<:wrank_up_10:1512125136445243503>", + "wrank_up_2": "<:wrank_up_2:1512127569259135139>", + "wrank_up_3": "<:wrank_up_3:1512127577051893843>", + "wrank_up_4": "<:wrank_up_4:1512127582068281455>", + "wrank_up_6": "<:wrank_up_6:1512127590062620723>", + "wrank_up_7": "<:wrank_up_7:1512127593883766978>", + "wrank_up_8": "<:wrank_up_8:1512127598044643428>", + "wrank_up_9": "<:wrank_up_9:1512127601811128501>", + "wrank_up_5": "<:wrank_up_5:1512127585826377928>", + "wrank_up_11": "<:wrank_up_11:1512125140454998018>", + "wrank_up_12": "<:wrank_up_12:1512125144984719630>", + "wrank_up_13": "<:wrank_up_13:1512125149057388667>", + "wrank_up_14": "<:wrank_up_14:1512125153671123124>", + "wrank_up_15": "<:wrank_up_15:1512127541995901100>", + "wrank_up_16": "<:wrank_up_16:1512127545753993439>", + "wrank_up_17": "<:wrank_up_17:1512127549956821002>", + "wrank_up_18": "<:wrank_up_18:1512127553995931959>", + "wrank_up_19": "<:wrank_up_19:1512127558143971534>", + "wrank_up_20": "<:wrank_up_20:1512127573092597840>", + "wrank_up_100": "<:wrank_up_100:1513360467228364852>", + "wrank_up_21": "<:wrank_up_21:1513360471611408494>", + "wrank_up_22": "<:wrank_up_22:1513360476015296603>", + "wrank_up_23": "<:wrank_up_23:1513360479433785436>", + "wrank_up_24": "<:wrank_up_24:1513360483351007355>", + "wrank_up_25": "<:wrank_up_25:1513360487096651876>", + "wrank_up_26": "<:wrank_up_26:1513360490334654555>", + "wrank_up_27": "<:wrank_up_27:1513360494264713409>", + "wrank_up_28": "<:wrank_up_28:1513360497972609175>", + "wrank_up_29": "<:wrank_up_29:1513360501730709746>", + "wrank_up_30": "<:wrank_up_30:1513360505530745004>", + "wrank_up_31": "<:wrank_up_31:1513360508819079350>", + "wrank_up_32": "<:wrank_up_32:1513360512749142086>", + "wrank_up_33": "<:wrank_up_33:1513360516695986206>", + "wrank_up_34": "<:wrank_up_34:1513360521850785993>", + "wrank_up_35": "<:wrank_up_35:1513360525508087900>", + "wrank_up_36": "<:wrank_up_36:1513360529278632009>", + "wrank_up_37": "<:wrank_up_37:1513360533745569882>", + "wrank_up_38": "<:wrank_up_38:1513360537235226804>", + "wrank_up_39": "<:wrank_up_39:1513360541228470474>", + "wrank_up_40": "<:wrank_up_40:1513360545078579290>", + "wrank_up_41": "<:wrank_up_41:1513360549411422288>", + "wrank_up_42": "<:wrank_up_42:1513360552896888904>", + "wrank_up_43": "<:wrank_up_43:1513360557485326397>", + "wrank_up_44": "<:wrank_up_44:1513360561348280450>", + "wrank_up_45": "<:wrank_up_45:1513360564905312286>", + "wrank_up_46": "<:wrank_up_46:1513360568445046914>", + "wrank_up_47": "<:wrank_up_47:1513360571964067940>", + "wrank_up_48": "<:wrank_up_48:1513360575776817353>", + "wrank_up_49": "<:wrank_up_49:1513360579501232189>", + "wrank_up_50": "<:wrank_up_50:1513360583519371364>", + "wrank_up_51": "<:wrank_up_51:1513360586732343471>", + "wrank_up_52": "<:wrank_up_52:1513360590641299568>", + "wrank_up_53": "<:wrank_up_53:1513360594051399731>", + "wrank_up_54": "<:wrank_up_54:1513360597981335572>", + "wrank_up_55": "<:wrank_up_55:1513360601567723641>", + "wrank_up_56": "<:wrank_up_56:1513360605560438825>", + "wrank_up_57": "<:wrank_up_57:1513360609725382798>", + "wrank_up_58": "<:wrank_up_58:1513360613517037669>", + "wrank_up_59": "<:wrank_up_59:1513360617397031084>", + "wrank_up_60": "<:wrank_up_60:1513360620865454122>", + "wrank_up_61": "<:wrank_up_61:1513360624720281630>", + "wrank_up_62": "<:wrank_up_62:1513360628528447592>", + "wrank_up_63": "<:wrank_up_63:1513360632517365870>", + "wrank_up_64": "<:wrank_up_64:1513360635713552466>", + "wrank_up_65": "<:wrank_up_65:1513360639404544094>", + "wrank_up_66": "<:wrank_up_66:1513360643867021464>", + "wrank_up_67": "<:wrank_up_67:1513360647809667092>", + "wrank_up_68": "<:wrank_up_68:1513360652750684200>", + "wrank_up_69": "<:wrank_up_69:1513360656626094140>", + "wrank_up_70": "<:wrank_up_70:1513360660531118330>", + "wrank_up_71": "<:wrank_up_71:1513360663987355710>", + "wrank_up_72": "<:wrank_up_72:1513360668462551100>", + "wrank_up_73": "<:wrank_up_73:1513360672740737104>", + "wrank_up_74": "<:wrank_up_74:1513360676666478723>", + "wrank_up_75": "<:wrank_up_75:1513360680399409293>", + "wrank_up_76": "<:wrank_up_76:1513360683914367116>", + "wrank_up_77": "<:wrank_up_77:1513360688045887672>", + "wrank_up_78": "<:wrank_up_78:1513360691443138684>", + "wrank_up_79": "<:wrank_up_79:1513360695972987061>", + "wrank_up_80": "<:wrank_up_80:1513360699793997875>", + "wrank_up_81": "<:wrank_up_81:1513360703560618136>", + "wrank_up_82": "<:wrank_up_82:1513360708899967116>", + "wrank_up_83": "<:wrank_up_83:1513360712393560214>", + "wrank_up_84": "<:wrank_up_84:1513360716101582910>", + "wrank_up_85": "<:wrank_up_85:1513360720094302329>", + "wrank_up_86": "<:wrank_up_86:1513360724091473950>", + "wrank_up_87": "<:wrank_up_87:1513360728390897896>", + "wrank_up_88": "<:wrank_up_88:1513360732249391104>", + "wrank_up_89": "<:wrank_up_89:1513360735923736606>", + "wrank_up_90": "<:wrank_up_90:1513360739530833950>", + "wrank_up_91": "<:wrank_up_91:1513360743402176604>", + "wrank_up_92": "<:wrank_up_92:1513360747600805918>", + "wrank_up_93": "<:wrank_up_93:1513360752197767329>", + "wrank_up_94": "<:wrank_up_94:1513360755494359161>", + "wrank_up_95": "<:wrank_up_95:1513360759197794385>", + "wrank_up_96": "<:wrank_up_96:1513360762666746008>", + "wrank_up_97": "<:wrank_up_97:1513360766588424192>", + "wrank_up_98": "<:wrank_up_98:1513360770895708303>", + "wrank_up_99": "<:wrank_up_99:1513360776071745576>" +} \ No newline at end of file diff --git a/data/emojis/wrank.json b/data/emojis/wrank.json new file mode 100644 index 0000000..5859f94 --- /dev/null +++ b/data/emojis/wrank.json @@ -0,0 +1,22 @@ +{ + "wrank_1": "<:wrank_1:1512124887592996995>", + "wrank_2": "<:wrank_2:1512124931075342376>", + "wrank_3": "<:wrank_3:1512124938453254334>", + "wrank_4": "<:wrank_4:1512124943465316433>", + "wrank_5": "<:wrank_5:1512124947852431513>", + "wrank_10": "<:wrank_10:1512124891250561096>", + "wrank_11": "<:wrank_11:1512124894694080576>", + "wrank_12": "<:wrank_12:1512124898611429387>", + "wrank_13": "<:wrank_13:1512124902831030282>", + "wrank_14": "<:wrank_14:1512124907511611537>", + "wrank_15": "<:wrank_15:1512124911550730452>", + "wrank_16": "<:wrank_16:1512124915367673886>", + "wrank_17": "<:wrank_17:1512124919029305434>", + "wrank_18": "<:wrank_18:1512124923018219721>", + "wrank_19": "<:wrank_19:1512124927262855239>", + "wrank_20": "<:wrank_20:1512124934762135684>", + "wrank_6": "<:wrank_6:1512124952738795581>", + "wrank_7": "<:wrank_7:1512124956622979143>", + "wrank_8": "<:wrank_8:1512124961450496020>", + "wrank_9": "<:wrank_9:1512124965363650631>" +} \ No newline at end of file diff --git a/messages/emojis.json b/messages/emojis.json index 426f173..bb13f27 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -102,5 +102,165 @@ "wrank_8_gold": "<:wrank_8_gold:1512125123874918661>", "wrank_9_gold": "<:wrank_9_gold:1512125128299905104>", "wrank_no_rank": "<:wrank_no_rank:1512261782205628606>", - "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>" + "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>", + "wrank_down_100": "<:wrank_down_100:1513360157923475496>", + "wrank_down_21": "<:wrank_down_21:1513360161446559745>", + "wrank_down_22": "<:wrank_down_22:1513360165443993720>", + "wrank_down_23": "<:wrank_down_23:1513360169453752380>", + "wrank_down_24": "<:wrank_down_24:1513360173241077762>", + "wrank_down_25": "<:wrank_down_25:1513360177087254620>", + "wrank_down_26": "<:wrank_down_26:1513360181054935134>", + "wrank_down_27": "<:wrank_down_27:1513360184758505673>", + "wrank_down_28": "<:wrank_down_28:1513360188353282100>", + "wrank_down_29": "<:wrank_down_29:1513360191826038966>", + "wrank_down_30": "<:wrank_down_30:1513360195387129896>", + "wrank_down_31": "<:wrank_down_31:1513360198981648434>", + "wrank_down_32": "<:wrank_down_32:1513360202492149861>", + "wrank_down_33": "<:wrank_down_33:1513360206166360146>", + "wrank_down_34": "<:wrank_down_34:1513360209790242887>", + "wrank_down_35": "<:wrank_down_35:1513360214429007954>", + "wrank_down_36": "<:wrank_down_36:1513360218250022984>", + "wrank_down_37": "<:wrank_down_37:1513360223484510218>", + "wrank_down_38": "<:wrank_down_38:1513360226873512078>", + "wrank_down_39": "<:wrank_down_39:1513360230606442557>", + "wrank_down_40": "<:wrank_down_40:1513360234469523547>", + "wrank_down_41": "<:wrank_down_41:1513360238399586314>", + "wrank_down_42": "<:wrank_down_42:1513360241834594356>", + "wrank_down_43": "<:wrank_down_43:1513360245609730109>", + "wrank_down_44": "<:wrank_down_44:1513360248667246805>", + "wrank_down_45": "<:wrank_down_45:1513360253025124453>", + "wrank_down_46": "<:wrank_down_46:1513360256946929715>", + "wrank_down_47": "<:wrank_down_47:1513360261128654971>", + "wrank_down_48": "<:wrank_down_48:1513360265935192104>", + "wrank_down_49": "<:wrank_down_49:1513360269768654888>", + "wrank_down_50": "<:wrank_down_50:1513360273468297438>", + "wrank_down_51": "<:wrank_down_51:1513360277117337770>", + "wrank_down_52": "<:wrank_down_52:1513360280808067172>", + "wrank_down_53": "<:wrank_down_53:1513360284033618092>", + "wrank_down_54": "<:wrank_down_54:1513360288081117284>", + "wrank_down_55": "<:wrank_down_55:1513360291398946868>", + "wrank_down_56": "<:wrank_down_56:1513360295630868550>", + "wrank_down_57": "<:wrank_down_57:1513360299078713447>", + "wrank_down_58": "<:wrank_down_58:1513360303360970896>", + "wrank_down_59": "<:wrank_down_59:1513360306993369229>", + "wrank_down_60": "<:wrank_down_60:1513360310583427092>", + "wrank_down_61": "<:wrank_down_61:1513360314203246773>", + "wrank_down_62": "<:wrank_down_62:1513360318410002522>", + "wrank_down_63": "<:wrank_down_63:1513360322268893296>", + "wrank_down_64": "<:wrank_down_64:1513360325771137095>", + "wrank_down_65": "<:wrank_down_65:1513360329676034068>", + "wrank_down_66": "<:wrank_down_66:1513360333518143668>", + "wrank_down_67": "<:wrank_down_67:1513360337112662026>", + "wrank_down_68": "<:wrank_down_68:1513360340635877467>", + "wrank_down_69": "<:wrank_down_69:1513360344318349352>", + "wrank_down_70": "<:wrank_down_70:1513360347925319862>", + "wrank_down_71": "<:wrank_down_71:1513360352023285770>", + "wrank_down_72": "<:wrank_down_72:1513360356070785066>", + "wrank_down_73": "<:wrank_down_73:1513360359803846716>", + "wrank_down_74": "<:wrank_down_74:1513360363595239596>", + "wrank_down_75": "<:wrank_down_75:1513360367538016410>", + "wrank_down_76": "<:wrank_down_76:1513360372302745721>", + "wrank_down_77": "<:wrank_down_77:1513360376035545328>", + "wrank_down_78": "<:wrank_down_78:1513360379810545755>", + "wrank_down_79": "<:wrank_down_79:1513360383451201707>", + "wrank_down_80": "<:wrank_down_80:1513360387012296895>", + "wrank_down_81": "<:wrank_down_81:1513360390573002872>", + "wrank_down_82": "<:wrank_down_82:1513360393983234242>", + "wrank_down_83": "<:wrank_down_83:1513360398076743792>", + "wrank_down_84": "<:wrank_down_84:1513360401763405947>", + "wrank_down_85": "<:wrank_down_85:1513360405681016843>", + "wrank_down_86": "<:wrank_down_86:1513360409929973812>", + "wrank_down_87": "<:wrank_down_87:1513360413985865828>", + "wrank_down_88": "<:wrank_down_88:1513360417802420395>", + "wrank_down_89": "<:wrank_down_89:1513360421430497441>", + "wrank_down_90": "<:wrank_down_90:1513360424882667554>", + "wrank_down_91": "<:wrank_down_91:1513360428900548628>", + "wrank_down_92": "<:wrank_down_92:1513360432856043550>", + "wrank_down_93": "<:wrank_down_93:1513360436706283530>", + "wrank_down_94": "<:wrank_down_94:1513360440879747112>", + "wrank_down_95": "<:wrank_down_95:1513360445728096456>", + "wrank_down_96": "<:wrank_down_96:1513360449276477509>", + "wrank_down_97": "<:wrank_down_97:1513360453152280709>", + "wrank_down_98": "<:wrank_down_98:1513360459758174328>", + "wrank_down_99": "<:wrank_down_99:1513360463558082590>", + "wrank_up_100": "<:wrank_up_100:1513360467228364852>", + "wrank_up_21": "<:wrank_up_21:1513360471611408494>", + "wrank_up_22": "<:wrank_up_22:1513360476015296603>", + "wrank_up_23": "<:wrank_up_23:1513360479433785436>", + "wrank_up_24": "<:wrank_up_24:1513360483351007355>", + "wrank_up_25": "<:wrank_up_25:1513360487096651876>", + "wrank_up_26": "<:wrank_up_26:1513360490334654555>", + "wrank_up_27": "<:wrank_up_27:1513360494264713409>", + "wrank_up_28": "<:wrank_up_28:1513360497972609175>", + "wrank_up_29": "<:wrank_up_29:1513360501730709746>", + "wrank_up_30": "<:wrank_up_30:1513360505530745004>", + "wrank_up_31": "<:wrank_up_31:1513360508819079350>", + "wrank_up_32": "<:wrank_up_32:1513360512749142086>", + "wrank_up_33": "<:wrank_up_33:1513360516695986206>", + "wrank_up_34": "<:wrank_up_34:1513360521850785993>", + "wrank_up_35": "<:wrank_up_35:1513360525508087900>", + "wrank_up_36": "<:wrank_up_36:1513360529278632009>", + "wrank_up_37": "<:wrank_up_37:1513360533745569882>", + "wrank_up_38": "<:wrank_up_38:1513360537235226804>", + "wrank_up_39": "<:wrank_up_39:1513360541228470474>", + "wrank_up_40": "<:wrank_up_40:1513360545078579290>", + "wrank_up_41": "<:wrank_up_41:1513360549411422288>", + "wrank_up_42": "<:wrank_up_42:1513360552896888904>", + "wrank_up_43": "<:wrank_up_43:1513360557485326397>", + "wrank_up_44": "<:wrank_up_44:1513360561348280450>", + "wrank_up_45": "<:wrank_up_45:1513360564905312286>", + "wrank_up_46": "<:wrank_up_46:1513360568445046914>", + "wrank_up_47": "<:wrank_up_47:1513360571964067940>", + "wrank_up_48": "<:wrank_up_48:1513360575776817353>", + "wrank_up_49": "<:wrank_up_49:1513360579501232189>", + "wrank_up_50": "<:wrank_up_50:1513360583519371364>", + "wrank_up_51": "<:wrank_up_51:1513360586732343471>", + "wrank_up_52": "<:wrank_up_52:1513360590641299568>", + "wrank_up_53": "<:wrank_up_53:1513360594051399731>", + "wrank_up_54": "<:wrank_up_54:1513360597981335572>", + "wrank_up_55": "<:wrank_up_55:1513360601567723641>", + "wrank_up_56": "<:wrank_up_56:1513360605560438825>", + "wrank_up_57": "<:wrank_up_57:1513360609725382798>", + "wrank_up_58": "<:wrank_up_58:1513360613517037669>", + "wrank_up_59": "<:wrank_up_59:1513360617397031084>", + "wrank_up_60": "<:wrank_up_60:1513360620865454122>", + "wrank_up_61": "<:wrank_up_61:1513360624720281630>", + "wrank_up_62": "<:wrank_up_62:1513360628528447592>", + "wrank_up_63": "<:wrank_up_63:1513360632517365870>", + "wrank_up_64": "<:wrank_up_64:1513360635713552466>", + "wrank_up_65": "<:wrank_up_65:1513360639404544094>", + "wrank_up_66": "<:wrank_up_66:1513360643867021464>", + "wrank_up_67": "<:wrank_up_67:1513360647809667092>", + "wrank_up_68": "<:wrank_up_68:1513360652750684200>", + "wrank_up_69": "<:wrank_up_69:1513360656626094140>", + "wrank_up_70": "<:wrank_up_70:1513360660531118330>", + "wrank_up_71": "<:wrank_up_71:1513360663987355710>", + "wrank_up_72": "<:wrank_up_72:1513360668462551100>", + "wrank_up_73": "<:wrank_up_73:1513360672740737104>", + "wrank_up_74": "<:wrank_up_74:1513360676666478723>", + "wrank_up_75": "<:wrank_up_75:1513360680399409293>", + "wrank_up_76": "<:wrank_up_76:1513360683914367116>", + "wrank_up_77": "<:wrank_up_77:1513360688045887672>", + "wrank_up_78": "<:wrank_up_78:1513360691443138684>", + "wrank_up_79": "<:wrank_up_79:1513360695972987061>", + "wrank_up_80": "<:wrank_up_80:1513360699793997875>", + "wrank_up_81": "<:wrank_up_81:1513360703560618136>", + "wrank_up_82": "<:wrank_up_82:1513360708899967116>", + "wrank_up_83": "<:wrank_up_83:1513360712393560214>", + "wrank_up_84": "<:wrank_up_84:1513360716101582910>", + "wrank_up_85": "<:wrank_up_85:1513360720094302329>", + "wrank_up_86": "<:wrank_up_86:1513360724091473950>", + "wrank_up_87": "<:wrank_up_87:1513360728390897896>", + "wrank_up_88": "<:wrank_up_88:1513360732249391104>", + "wrank_up_89": "<:wrank_up_89:1513360735923736606>", + "wrank_up_90": "<:wrank_up_90:1513360739530833950>", + "wrank_up_91": "<:wrank_up_91:1513360743402176604>", + "wrank_up_92": "<:wrank_up_92:1513360747600805918>", + "wrank_up_93": "<:wrank_up_93:1513360752197767329>", + "wrank_up_94": "<:wrank_up_94:1513360755494359161>", + "wrank_up_95": "<:wrank_up_95:1513360759197794385>", + "wrank_up_96": "<:wrank_up_96:1513360762666746008>", + "wrank_up_97": "<:wrank_up_97:1513360766588424192>", + "wrank_up_98": "<:wrank_up_98:1513360770895708303>", + "wrank_up_99": "<:wrank_up_99:1513360776071745576>" } \ No newline at end of file diff --git a/src/commands/tg.ts b/src/commands/tg.ts index b5f193b..4f532cb 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -8,52 +8,53 @@ import { cfg } from "../systems/config"; import { hasOfficerRole } from "../systems/users"; // Poll subcommands -import { handleStart } from "../subcommands/poll/start"; -import { handleLock } from "../subcommands/poll/lock"; -import { handleUnlock } from "../subcommands/poll/unlock"; -import { handleConfirm } from "../subcommands/poll/confirm"; -import { handleStatus } from "../subcommands/poll/status"; -import { handleReload } from "../subcommands/poll/reload"; -import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "../subcommands/poll/setMessage"; -import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; -import { handleSeed } from "../subcommands/poll/seed"; -import { handlePurge } from "../subcommands/poll/purge"; -import { handleImpersonate } from "../subcommands/impersonate"; +import { handleStart } from "@subcommands/poll/start"; +import { handleLock } from "@subcommands/poll/lock"; +import { handleUnlock } from "@subcommands/poll/unlock"; +import { handleConfirm } from "@subcommands/poll/confirm"; +import { handleStatus } from "@subcommands/poll/status"; +import { handleReload } from "@subcommands/poll/reload"; +import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "@subcommands/poll/setMessage"; +import { handleInject, handleRemoveVote } from "@subcommands/poll/inject"; +import { handleSeed } from "@subcommands/poll/seed"; +import { handlePurge } from "@subcommands/poll/purge"; +import { handleImpersonate } from "@subcommands/impersonate"; // Char subcommands (borrow / sharing system) -import { handleCharBorrow } from "../subcommands/char/borrow"; -import { handleCharAccept } from "../subcommands/char/accept"; -import { handleCharDecline } from "../subcommands/char/decline"; -import { handleCharShare, handleCharUnshare } from "../subcommands/char/share"; +import { handleCharBorrow } from "@subcommands/char/borrow"; +import { handleCharAccept } from "@subcommands/char/accept"; +import { handleCharDecline } from "@subcommands/char/decline"; +import { handleCharShare, handleCharUnshare } from "@subcommands/char/share"; // Score subcommands -import { handleScoreSet } from "../subcommands/score/set"; -import { handleScoreGet } from "../subcommands/score/get"; +import { handleScoreSet } from "@subcommands/score/set"; +import { handleScoreGet } from "@subcommands/score/get"; // Rank subcommands -import { handleRankGet } from "../subcommands/rank/get"; -import { handleRankPost } from "../subcommands/rank/post"; +import { handleRankGet } from "@subcommands/rank/get"; +import { handleRankPost } from "@subcommands/rank/post"; // Result subcommands -import { handleResultSet } from "../subcommands/result/set"; -import { handleResultView } from "../subcommands/result/view"; -import { handleResultPost } from "../subcommands/result/post"; +import { handleResultSet } from "@subcommands/result/set"; +import { handleResultView } from "@subcommands/result/view"; +import { handleResultPost } from "@subcommands/result/post"; // Bringer subcommands -import { handleBringerSet } from "../subcommands/bringer/set"; -import { handleBringerClear } from "../subcommands/bringer/clear"; +import { handleBringerSet } from "@subcommands/bringer/set"; +import { handleBringerClear } from "@subcommands/bringer/clear"; // Other -import { handleSwitch } from "../subcommands/switch"; -import { handleHistory } from "../subcommands/history"; +import { handleSwitch } from "@subcommands/switch"; +import { handleHistory } from "@subcommands/history"; // Import char handlers here to keep tg.ts clean -import { handleCharAdd } from "../subcommands/char/add"; -import { handleCharRemove } from "../subcommands/char/remove"; -import { handleCharSetActive } from "../subcommands/char/setActive"; -import { handleCharSetNation } from "../subcommands/char/setNation"; -import { handleCharSetStats } from "../subcommands/char/setStats"; -import { handleCharActive } from "../subcommands/char/active"; +import { handleCharAdd } from "@subcommands/char/add"; +import { handleCharRemove } from "@subcommands/char/remove"; +import { handleCharSetActive } from "@subcommands/char/setActive"; +import { handleCharSetNation } from "@subcommands/char/setNation"; +import { handleCharSetStats } from "@subcommands/char/setStats"; +import { handleCharActive } from "@subcommands/char/active"; +import { Nation } from "@types"; export function buildTgCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() @@ -164,7 +165,7 @@ export function buildTgCommand(): SlashCommandBuilder { .setDescription("TG result management") .addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)") .addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true)) .addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true)) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) @@ -180,12 +181,12 @@ export function buildTgCommand(): SlashCommandBuilder { .setDescription("Bringer management (officer only)") .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) + .addStringOption((o) => o.setName("char_name").setDescription("Character ").setRequired(true).setAutocomplete(true)) ) .addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))) + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))) ); // ── switch ───────────────────────────────────────────────────────────────── @@ -214,7 +215,7 @@ export function buildTgCommand(): SlashCommandBuilder { )) .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) ) .addSubcommand((s) => s.setName("remove").setDescription("Remove a character") @@ -227,7 +228,7 @@ export function buildTgCommand(): SlashCommandBuilder { ) .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) ) diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts new file mode 100644 index 0000000..76944d1 --- /dev/null +++ b/src/commands/tgAdmin.ts @@ -0,0 +1,88 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { + handleAdminUserMap, + handleAdminPollFixVoter, + handleAdminPollShowEntry, +} from "@subcommands/admin/userMap"; + +export function buildTgAdminCommand(): SlashCommandBuilder { + const cmd = new SlashCommandBuilder() + .setName("tg-admin") + .setDescription("Administrative commands for TG bot management"); + + // ── user group ──────────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("user") + .setDescription("Manage player registrations") + .addSubcommand((s) => s + .setName("map") + .setDescription("Register a Discord user to a userKey") + .addUserOption((o) => o + .setName("user") + .setDescription("Discord user to map") + .setRequired(true) + ) + .addStringOption((o) => o + .setName("userkey") + .setDescription("The userKey to map this player to (must exist in characters.json)") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand((s) => s + .setName("unmap") + .setDescription("Remove a player's registration") + .addUserOption((o) => o + .setName("user") + .setDescription("Discord user to unmap") + .setRequired(true) + ) + ) + .addSubcommand((s) => s + .setName("list") + .setDescription("List all registered player mappings") + ) + ); + + // ── poll group ───────────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("poll") + .setDescription("Manage active poll entries") + .addSubcommand((s) => s + .setName("fix-voter") + .setDescription("Re-resolve a voter's character in the active poll") + .addUserOption((o) => o + .setName("user") + .setDescription("Discord user whose entry needs fixing") + .setRequired(true) + ) + ) + .addSubcommand((s) => s + .setName("show-entry") + .setDescription("Show raw poll entry for a user") + .addUserOption((o) => o + .setName("user") + .setDescription("Discord user to inspect") + .setRequired(true) + ) + ) + ); + + return cmd; +} + +export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise { + const group = interaction.options.getSubcommandGroup(true); + const sub = interaction.options.getSubcommand(); + + if (group === "user") { + if (sub === "map" || sub === "unmap" || sub === "list") { + return handleAdminUserMap(interaction); + } + } + + if (group === "poll") { + if (sub === "fix-voter") return handleAdminPollFixVoter(interaction); + if (sub === "show-entry") return handleAdminPollShowEntry(interaction); + } +} \ No newline at end of file diff --git a/src/commands/tgConfig.ts b/src/commands/tgConfig.ts index 017a301..9aa6dfe 100644 --- a/src/commands/tgConfig.ts +++ b/src/commands/tgConfig.ts @@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { cfg, setCfg, resetCfg } from "../systems/config"; import { hasOfficerRole } from "../systems/users"; import { replyAndDelete } from "../utils"; -import { Nation } from "../types"; +import { Nation } from "@types"; export function buildTgConfigCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() @@ -16,7 +16,7 @@ export function buildTgConfigCommand(): SlashCommandBuilder { .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }); const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true) - .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }); + .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }); const roleOpt = strOpt("role", "Role name"); const rolesOpt = strOpt("roles", "Comma-separated role names"); diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index 7e93bbf..972c386 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -2,46 +2,70 @@ import { AutocompleteInteraction } from "discord.js"; import { resolveUser } from "@systems/users"; import { getCharacters } from "@systems/characters"; import { cfg } from "@systems/config"; -import { getNationEmoji } from "@systems/emojis"; +import { Emoji } from "@systems/emojis"; +import { CharacterRegistry } from "@registry/character-registry"; +import { UserRegistry } from "@registry/user-registry"; +import { Paths } from "@helpers/paths"; +import { Nation } from "@types"; +import { NATION_UNICODE } from "@systems/nations"; import fs from "fs"; -import path from "path"; + +// ─── Usermap cache ──────────────────────────────────────────────────────────── +let _usermapCache: Record | null = null; + +function getUsermapCache(): Record { + if (!_usermapCache) { + try { _usermapCache = JSON.parse(fs.readFileSync(Paths.data("usermap.json"), "utf8")); } + catch { _usermapCache = {}; } + } + return _usermapCache!; +} + +export function invalidateUsermapCache(): void { _usermapCache = null; } // ─── Autocomplete subsets ───────────────────────────────────────────────────── async function autocompleteCharNames( interaction: AutocompleteInteraction, - focused: string + focused: string, + nation?: Nation | null // optional — if provided, filter by nation ): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); const user = await resolveUser(member); + + // For bringer set — scan all chars by nation, no user filter needed + if (nation !== undefined) { + const all = CharacterRegistry.all(); + const results = all + .filter((c) => !nation || c.nation === nation) + .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) + .map((c) => { + const nationEmoji = c.nation ? (NATION_UNICODE[c.nation] || c.nation) : ""; + return { name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), value: c.name }; + }) + .slice(0, 25); + return interaction.respond(results); + } + if (!user.userKey) return interaction.respond([]); + // Own chars const ownChars = getCharacters(user.userKey).map((c) => { - const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : ""; + const nationEmoji = c.nation ? (Emoji.nation(c.nation) || c.nation) : ""; return { name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), value: c.name, }; }); - const sharedChars: { name: string; value: string }[] = []; - try { - const chars = JSON.parse( - fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") - ); - for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - if (ownerKey === user.userKey) continue; - for (const char of data.characters ?? []) { - if (char.sharedWith?.includes(user.userKey)) { - const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : ""; - sharedChars.push({ - name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(), - value: char.name, - }); - } - } - } - } catch {} + // Shared chars + const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => { + const nationEmoji = char.nation ? (Emoji.nation(char.nation) || char.nation) : ""; + return { + name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(), + value: char.name, + }; + }); const all = [...ownChars, ...sharedChars] .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) @@ -55,9 +79,7 @@ async function autocompleteUserKeys( focused: string ): Promise { try { - const usermap = JSON.parse( - fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") - ); + const usermap = getUsermapCache(); const choices = Object.entries(usermap) .map(([, entry]: [string, any]) => { const fileKey = typeof entry === "string" ? entry : entry.file; @@ -90,11 +112,21 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction): const focused = interaction.options.getFocused(true); const optionName = focused.name; const focusedValue = focused.value as string; + const sub = interaction.options.getSubcommand(false); + const subGroup = interaction.options.getSubcommandGroup(false); - if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue); - if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); - if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); - if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); + if (optionName === "char_name") { + // Bringer set — filter by selected nation + if (sub === "set" && subGroup === "bringer") { + const nation = interaction.options.getString("nation") as Nation | null; + return await autocompleteCharNames(interaction, focusedValue, nation); + } + return await autocompleteCharNames(interaction, focusedValue); + } + + if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); + if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); + if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); await interaction.respond([]); } catch (err) { diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index dc384d4..59d29f5 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -9,19 +9,27 @@ import { cfg } from "@systems/config"; import { pollReplyAndDelete } from "../utils"; import { resolveUser } from "@systems/users"; import { resolveMessage, nowFormatted } from "@systems/messages"; -import { resolveNation } from "@systems/nations"; +import { Nations } from "@systems/nations"; import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; import { persist } from "@systems/pollPersistence" import { showConflictEmbed } from "@systems/conflict"; import { getCharacters } from "@systems/characters"; import { getImpersonation } from "@systems/impersonate"; import { format } from "@format"; +import { buildCharSelectButtons } from "@systems/charSelect"; +import { getEffectiveCharacter } from "@systems/borrow"; import { Character } from "@src/types"; import { modals } from "@handlers/modals"; +import { Ephemeral } from "@registry/ephemeral-registry"; +import { Nation, CLASSES } from "@types"; +import { InteractionLock } from "@helpers/interaction-lock"; +import { Benchmark } from "@systems/benchmark"; + const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); -const clickCounts = new Map(); +const clickCounts = new Map(); +const _processingVotes = new Set(); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -71,11 +79,6 @@ async function handleCharacterConflict( const slot = [...polls.keys()][0]; const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; - // await interaction.followUp({ - // content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - // // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, - // ephemeral: true - // }); const { buildCharSelectButtons } = require("@systems/charSelect"); const buttons = buildCharSelectButtons(userKey ?? "", { customIdPrefix: `switch_after_reclaim:${userKey}`, @@ -93,123 +96,175 @@ async function handleCharacterConflict( // ─── Main button handler ────────────────────────────────────────────────────── export async function handleButton(interaction: ButtonInteraction): Promise { + console.log(`[handleButton] ${interaction.customId} - start`); if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; - try { - await interaction.deferUpdate(); - } catch { - return; - } + await InteractionLock.with(interaction, async () => { + const bench = Benchmark.start("handleButton"); - const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; - if (slot === undefined) return; + const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; + if (slot === undefined) return; - const state = polls.get(slot)!; - if (state.locked || state.confirmed !== null) return; + const state = polls.get(slot)!; + if (state.locked || state.confirmed !== null) return; + + const userId = interaction.user.id; + const member = interaction.guild!.members.cache.get(userId) + ?? await interaction.guild!.members.fetch(userId); + bench.mark("fetchMember"); + const user = await resolveUser(member); + bench.mark("resolveUser"); + const votedYes = interaction.customId === "tg_yes"; + const now = nowFormatted(); + + const impersonating = getImpersonation(userId); + const voteId = impersonating ? `impersonated:${impersonating}` : userId; + const lookupUsername = user.lookupUsername ?? user.discordUsername; + + // Nation check + const nation = Nations.resolve(member, user.userKey); + if (!nation) { + const capella = format.nation(Nation.Capella); + const procyon = format.nation(Nation.Procyon); + await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true }); + return; + } + + // Click tracking + if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 }); + const clicks = clickCounts.get(voteId)!; + + if (votedYes && clicks.yes >= LOCK_AT) return; + if (!votedYes && clicks.no >= LOCK_AT) return; + + // Ignore same vote + if (votedYes && state.yes.has(voteId)) return; + if (!votedYes && state.no.has(voteId)) return; + + // Increment click (may be decremented in conflict handler) + if (votedYes) clicks.yes += 1; + else clicks.no += 1; + + const clickCount = votedYes ? clicks.yes : clicks.no; + + // Resolve messages + const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); + + const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); + + const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); + + // Character conflict check — applies to both Yes and No + if (baseEntry.characterName) { + const conflictChar = { + name: baseEntry.characterName!, + class: CLASSES[baseEntry.characterClass!] ?? { key: baseEntry.characterClass!, name: baseEntry.characterClass!, shortName: baseEntry.characterClass! }, + level: baseEntry.characterLevel!, + nation: baseEntry.characterNation!, + ownerKey: user.userKey ?? "", + active: false, // not needed for display + }; + + const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( + state, baseEntry.characterName, voteId, user.userKey ?? "" + ); + if (found) { + await handleCharacterConflict( + interaction, user.userKey, conflictChar, + entryUserKey, clicks, votedYes + ); + return; + } + } + + // Register vote + if (votedYes) { + const previousNo = state.no.get(voteId); + state.no.delete(voteId); + state.yes.set(voteId, { + ...baseEntry, + discordId: userId, + votedAt: now, + previousNoAt: previousNo?.votedAt, + publicMessage: publicMsg ?? undefined, + }); + } else { + const previousYes = state.yes.get(voteId); + state.yes.delete(voteId); + state.no.set(voteId, { + ...baseEntry, + votedAt: now, + discordId: userId, + previousYesAt: previousYes?.votedAt, + publicMessage: publicMsg ?? undefined, + }); + } + + const locked = clickCount >= LOCK_AT; + if (locked) state.locked = true; + persist.save(polls); + + const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; + const msgContent = ephemeralMsg + ? `${ephemeralMsg}${lockedSuffix}` + : locked ? "🔒 You've been locked in." : null; + await pollReplyAndDelete(interaction, msgContent); + + const channel = interaction.channel as TextChannel; + + await Promise.all([ + showActiveCharSwitching(interaction), + updatePollMessage(channel, slot), + ]); + bench.mark("updatePollMessage+companion"); + }); +} + +export async function showActiveCharSwitching(interaction: ButtonInteraction): Promise { + const bench = Benchmark.start("showActiveCharSwitching"); const userId = interaction.user.id; const member = interaction.guild!.members.cache.get(userId) ?? await interaction.guild!.members.fetch(userId); - const user = await resolveUser(member); - const votedYes = interaction.customId === "tg_yes"; - const now = nowFormatted(); - + const user = await resolveUser(member); + const impersonating = getImpersonation(userId); const voteId = impersonating ? `impersonated:${impersonating}` : userId; - const lookupUsername = user.lookupUsername ?? user.discordUsername; - - // Nation check - const nation = resolveNation(member, user.userKey); - if (!nation) { - const capella = format.nation("Capella"); - const procyon = format.nation("Procyon"); - await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true }); - return; - } - - // Click tracking - if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 }); const clicks = clickCounts.get(voteId)!; - if (votedYes && clicks.yes >= LOCK_AT) return; - if (!votedYes && clicks.no >= LOCK_AT) return; - - // Ignore same vote - if (votedYes && state.yes.has(voteId)) return; - if (!votedYes && state.no.has(voteId)) return; - - // Increment click (may be decremented in conflict handler) - if (votedYes) clicks.yes += 1; - else clicks.no += 1; - + const votedYes = interaction.customId === "tg_yes"; const clickCount = votedYes ? clicks.yes : clicks.no; - // Resolve messages - const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no") - ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); + const locked = clickCount >= LOCK_AT; - const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") - ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); - - const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); - - // Character conflict check — applies to both Yes and No - if (baseEntry.characterName) { - const conflictChar = { - name: baseEntry.characterName!, - class: baseEntry.characterClass!, - level: baseEntry.characterLevel!, - nation: baseEntry.characterNation!, - active: false, // not needed for display - }; - - const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( - state, baseEntry.characterName, voteId, user.userKey ?? "" - ); - if (found) { - await handleCharacterConflict( - interaction, user.userKey, conflictChar, - entryUserKey, clicks, votedYes - ); - return; + // Yes vote companion — show active char + switch buttons + if (votedYes && user.userKey && !locked) { + const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); + bench.mark("getEffectiveCharacter"); + if (char) { + const starEmoji = process.env.ACTIVE_CHAR_EMOJI || "⭐"; + const borrowNote = borrowedFrom ? ` 🔗` : ""; + const buttons = buildCharSelectButtons(user.userKey, { + customIdPrefix: `companion_switch:${user.userKey}`, + excludeCharName: char.name, + appendToCustomId: ":yes", + }); + if (buttons.length > 0) { + const companionMsg = await interaction.followUp({ + content: `${starEmoji} ${format.char(char)}${borrowNote}`, + components: buttons, + ephemeral: true, + fetchReply: true, + }); + bench.mark("companion"); + bench.end(); + + Ephemeral.store(voteId, "companion", interaction, companionMsg.id); + } } } - - // Register vote - if (votedYes) { - const previousNo = state.no.get(voteId); - state.no.delete(voteId); - state.yes.set(voteId, { - ...baseEntry, - discordId: userId, - votedAt: now, - previousNoAt: previousNo?.votedAt, - publicMessage: publicMsg ?? undefined, - }); - } else { - const previousYes = state.yes.get(voteId); - state.yes.delete(voteId); - state.no.set(voteId, { - ...baseEntry, - votedAt: now, - discordId: userId, - previousYesAt: previousYes?.votedAt, - publicMessage: publicMsg ?? undefined, - }); - } - - const locked = clickCount >= LOCK_AT; - if (locked) state.locked = true; - persist.save(polls); - - const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; - const msgContent = ephemeralMsg - ? `${ephemeralMsg}${lockedSuffix}` - : locked ? "🔒 You've been locked in." : null; - await pollReplyAndDelete(interaction, msgContent); - - const channel = interaction.channel as TextChannel; - await updatePollMessage(channel, slot); } export function resetClickCounts(): void { diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts index c71967c..a21eaa9 100644 --- a/src/handlers/interactions.ts +++ b/src/handlers/interactions.ts @@ -7,107 +7,73 @@ import { handleBorrowDeclineButton } from "@subcommands/char/decline"; import { handleConflictButton } from "@systems/conflict"; import { handleImpersonateButton } from "@subcommands/impersonate"; import { handleAutocomplete } from "@handlers/autocomplete"; -import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; -import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow"; -import { polls, updatePollMessage } from "@systems/poll"; -import { cfg } from "@systems/config"; -import { resolveMessage, nowFormatted } from "@systems/messages"; -import { format } from "@format"; +import { Ephemeral } from "@registry/ephemeral-registry"; +import { getImpersonation } from "@systems/impersonate"; import { modals } from "@handlers/modals"; -import fs from "fs"; -import path from "path"; +import { Character } from "@systems/character"; +import { handleTgAdminCommand } from "@commands/tgAdmin"; +import { InteractionLock } from "@helpers/interaction-lock"; +import { CharacterRegistry } from "@registry/character-registry"; + async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise { - const parts = btn.customId.split(":"); - const userKey = parts[1]; - const charName = parts[2]; - const prevVoteType = (parts[3] ?? "yes") as "yes" | "no"; + const prefix = btn.customId.startsWith("companion_switch:") ? "companion_switch:" : "switch_after_reclaim:"; + const withoutPrefix = btn.customId.slice(prefix.length); + const firstColon = withoutPrefix.indexOf(":"); + const userKey = withoutPrefix.slice(0, firstColon); + const rest = withoutPrefix.slice(firstColon + 1); + const lastColon = rest.lastIndexOf(":"); + const charName = rest.slice(0, lastColon); + const prevVoteType = (rest.slice(lastColon + 1) || "yes") as "yes" | "no"; - const chars = JSON.parse( - fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") - ); + const impersonating = getImpersonation(btn.user.id); + const voteId = impersonating ? `impersonated:${impersonating}` : btn.user.id; - let resolvedChar: any = null; + // Resolve char without switching + let resolvedChar: any = null; let borrowedFrom: string | null = null; - // Try own char first - const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName); - if (ownEntry) { - setActiveCharacter(userKey, charName); - clearSessionBorrowForUser(userKey); - resolvedChar = ownEntry; - } else { - // Try shared char - for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - const char = data.characters?.find( - (c: any) => c.name === charName && c.sharedWith?.includes(userKey) - ); - if (char) { - setPersistentPreference(userKey, ownerKey, charName); - clearSessionBorrowForUser(userKey); - resolvedChar = char; - borrowedFrom = ownerKey; - break; - } - } - } + await InteractionLock.with(btn, async () => { + const ownEntry = CharacterRegistry.findForUser(userKey, charName); - if (!resolvedChar) { - await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); - return; - } - - // Re-add to poll with previous vote type - const slot = [...polls.keys()][0]; - const state = slot !== undefined ? polls.get(slot) : null; - - if (state && !state.locked && state.confirmed === null) { - const { char } = getEffectiveCharacter(userKey); - const now = nowFormatted(); - const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null); - const voteEntry = { - userKey, - displayName: charName, - characterName: char?.name ?? charName, - characterClass: char?.class ?? resolvedChar.class, - characterLevel: char?.level ?? resolvedChar.level, - characterNation: char?.nation ?? resolvedChar.nation, - borrowedFrom: borrowedFrom ?? undefined, - discordId: btn.user.id, - votedAt: now, - publicMessage: publicMsg ?? undefined, - }; - - // Find and reuse existing vote ID — avoids duplicate entries - let existingVoteId: string | null = null; - for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.userKey === userKey) { - if (!existingVoteId) existingVoteId = id; - state.yes.delete(id); - state.no.delete(id); - } - } - const voteId = existingVoteId ?? btn.user.id; - - if (prevVoteType === "yes") { - state.yes.set(voteId, voteEntry); + if (ownEntry) { + resolvedChar = ownEntry; } else { - state.no.set(voteId, voteEntry); + const shared = CharacterRegistry.sharedWith(userKey).find(({ char }) => char.name === charName); + if (shared) { + resolvedChar = shared.char; + borrowedFrom = shared.ownerKey; + } } - console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`); - console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`)); - console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`)); + if (!resolvedChar) { + await btn.followUp({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); + return; + } - const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; - await updatePollMessage(channel, slot!); - } + // Delegate to shared switch logic + const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, btn, prevVoteType); - const charDisplay = resolvedChar ? format.char(resolvedChar) : charName; - const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; - await btn.reply({ - content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`, - ephemeral: true, + if (result.replyData) { + const { content, components } = result.replyData; + console.log(`[switchAfterReclaim] replyData, isCompanion=${btn.customId.startsWith("companion_switch:")}`); + if (btn.customId.startsWith("companion_switch:")) { + await Ephemeral.update(voteId, "companion", content, components, { final: false }); + } else { + await btn.followUp(result.replyData); + } + return; + } + + if (result.success && result.message) { + const companionExists = !!Ephemeral.get(voteId, "companion"); + console.log(`[switchAfterReclaim] success, companionExists=${companionExists} voteId=${voteId}`); + await Ephemeral.update(voteId, "companion", result.message, []); + // Ephemeral.delete(voteId, "companion"); // clean up after final switch + if (!companionExists) { + await btn.followUp({ content: result.message, ephemeral: true }); + } + } }); } @@ -119,37 +85,7 @@ export async function handleInteraction(interaction: Interaction): Promise } if (interaction.isButton()) { - const btn = interaction as ButtonInteraction; - - console.log("[interactions] interaction btnId:", btn.customId); - if (btn.customId.startsWith("conflict_")) { - console.log("[interactions] routing to conflict handler:", btn.customId); - return await handleConflictButton(btn); - } - - if (btn.customId.startsWith("impersonate_")) { - return await handleImpersonateButton(btn); - } - - if (btn.customId.startsWith("switch_after_reclaim:")) { - return await handleSwitchAfterReclaim(btn); - } - - if (btn.customId.startsWith("borrow_accept:")) { - const [, ownerKey, requesterKey] = btn.customId.split(":"); - return await handleBorrowAcceptButton(btn, ownerKey, requesterKey); - } - - if (btn.customId.startsWith("borrow_decline:")) { - const [, ownerKey, requesterKey] = btn.customId.split(":"); - return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); - } - - if (btn.customId === "tg_score_submit") { - return await handleScoreSubmitButton(btn); - } - - return await handleButton(btn); + return await handleButtonInteraction(interaction as ButtonInteraction); } if (interaction.isModalSubmit()) { @@ -167,9 +103,7 @@ export async function handleInteraction(interaction: Interaction): Promise } if (interaction.isChatInputCommand()) { - const cmd = interaction as ChatInputCommandInteraction; - if (cmd.commandName === "tg") await handleTgCommand(cmd); - if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd); + await handleChatInputCommandInteraction(interaction as ChatInputCommandInteraction); } } catch (err) { console.error("Interaction error:", err); @@ -182,4 +116,46 @@ export async function handleInteraction(interaction: Interaction): Promise } } catch {} } +} + +async function handleChatInputCommandInteraction(cmd: ChatInputCommandInteraction): Promise { + if (cmd.commandName === "tg") await handleTgCommand(cmd); + if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd); + if (cmd.commandName === "tg-admin") await handleTgAdminCommand(cmd); +} + +async function handleButtonInteraction(btn: ButtonInteraction): Promise { + console.log("[interactions] interaction btnId:", btn.customId); + if (btn.customId.startsWith("conflict_")) { + console.log("[interactions] routing to conflict handler:", btn.customId); + return await handleConflictButton(btn); + } + + if (btn.customId.startsWith("impersonate_")) { + return await handleImpersonateButton(btn); + } + + if (btn.customId.startsWith("switch_after_reclaim:")) { + return await handleSwitchAfterReclaim(btn); + } + + if (btn.customId.startsWith("borrow_accept:")) { + const [, ownerKey, requesterKey] = btn.customId.split(":"); + return await handleBorrowAcceptButton(btn, ownerKey, requesterKey); + } + + if (btn.customId.startsWith("borrow_decline:")) { + const [, ownerKey, requesterKey] = btn.customId.split(":"); + return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); + } + + if (btn.customId === "tg_score_submit") { + return await handleScoreSubmitButton(btn); + } + + if (btn.customId.startsWith("companion_switch:")) { + return await handleSwitchAfterReclaim(btn); + } + + return await handleButton(btn); } \ No newline at end of file diff --git a/src/helpers/interaction-lock.ts b/src/helpers/interaction-lock.ts new file mode 100644 index 0000000..3264712 --- /dev/null +++ b/src/helpers/interaction-lock.ts @@ -0,0 +1,29 @@ +type LockableInteraction = { + user: { id: string }; + customId: string; + deferUpdate(): Promise; +}; + +const _processing = new Set(); + +export async function withInteractionLock( + interaction: LockableInteraction, + fn: () => Promise +): Promise { + const key = `${interaction.user.id}:${interaction.customId}`; + if (_processing.has(key)) { + return null; + } + _processing.add(key); + await interaction.deferUpdate(); + try { + return await fn(); + } finally { + _processing.delete(key); + } +} + +export const InteractionLock = { + with: withInteractionLock, + clear: () => _processing.clear(), // useful for testing +}; \ No newline at end of file diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts new file mode 100644 index 0000000..48d0e22 --- /dev/null +++ b/src/helpers/paths.ts @@ -0,0 +1,13 @@ +import path from "path"; + +// Resolves from project root regardless of which file imports this +const PROJECT_ROOT = path.resolve(__dirname, "../../"); + +export const Paths = { + data: (...segments: string[]) => path.join(PROJECT_ROOT, "data", ...segments), + messages: (...segments: string[]) => path.join(PROJECT_ROOT, "messages", ...segments), + emojis: (...segments: string[]) => path.join(PROJECT_ROOT, "data", "emojis", ...segments), + scripts: (...segments: string[]) => path.join(PROJECT_ROOT, "scripts", ...segments), + src: (...segments: string[]) => path.join(PROJECT_ROOT, "src", ...segments), + resolve: (...segments: string[]) => path.join(PROJECT_ROOT, ...segments), +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 745a1bf..40f32d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,17 @@ import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; import { loadConfig, cfg } from "@systems/config"; import { loadMessages } from "@systems/messages"; -import { loadEmojis } from "@systems/emojis"; -import { loadCharacters } from "@systems/characters"; -import { loadWRank } from "@systems/wrank"; -import { scheduleSlots } from "@systems/slots"; +import { Emoji } from "@systems/emojis"; +import { Char } from "@systems/characters"; +import { WRank } from "@systems/wrank"; import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; import { handleInteraction } from "@handlers/interactions"; import { buildTgCommand } from "@commands/tg"; import { buildTgConfigCommand } from "@commands/tgConfig"; import { TGSlot } from "@src/types"; import { persist } from "@systems/pollPersistence" +import { buildTgAdminCommand } from "@commands/tgAdmin"; +import { Scheduler } from "@systems/scheduler"; const TOKEN = process.env.DISCORD_TOKEN!; const CLIENT_ID = process.env.CLIENT_ID!; @@ -23,7 +24,11 @@ const client = new Client({ async function registerCommands(): Promise { const rest = new REST({ version: "10" }).setToken(TOKEN); await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { - body: [buildTgCommand().toJSON(), buildTgConfigCommand().toJSON()], + body: [ + buildTgCommand().toJSON(), + buildTgConfigCommand().toJSON(), + buildTgAdminCommand().toJSON(), + ], }); console.log("Slash commands registered."); } @@ -68,9 +73,9 @@ client.once("clientReady", async () => { loadConfig(); loadMessages(); - loadEmojis(); - loadCharacters(); - loadWRank(); + Emoji.load(); + Char.load(); + WRank.load(); const restored = persist.load(); if (restored) { @@ -93,7 +98,7 @@ client.once("clientReady", async () => { await registerCommands(); } - scheduleSlots(client, onPollOpen, onPollLock, onPollClose); + Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); console.log("Bot ready."); }); diff --git a/src/subcommands/admin/userMap.ts b/src/subcommands/admin/userMap.ts new file mode 100644 index 0000000..75da1c4 --- /dev/null +++ b/src/subcommands/admin/userMap.ts @@ -0,0 +1,173 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "@systems/config"; +import { hasOfficerRole } from "@systems/users"; +import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages"; +import { replyAndDelete } from "@utils"; +import { Nations } from "@systems/nations"; +import { polls, updatePollMessage } from "@systems/poll"; +import { getEffectiveCharacter } from "@systems/borrow"; + +export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("officerRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const sub = interaction.options.getSubcommand(); + + // ── map ────────────────────────────────────────────────────────────────────── + if (sub === "map") { + const target = interaction.options.getUser("user", true); + const userKey = interaction.options.getString("userkey", true); + + // Check if userKey exists in characters.json + const fs = require("fs"); + const path = require("path"); + const chars = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/characters.json"), "utf8")); + if (!chars[userKey]) { + return void replyAndDelete(interaction, `❌ No characters found for userKey **${userKey}**. Make sure it exists in characters.json first.`, true); + } + + // Check if this Discord ID is already mapped + const existing = getUsermapEntryById(target.id, target.username); + const entry = existing ?? { file: userKey, aliases: [target.globalName ?? target.username] }; + entry.file = userKey; + + setUsermapEntry(target.id, entry); + return void replyAndDelete(interaction, `✅ Mapped **${target.username}** (${target.id}) → **${userKey}**`, true); + } + + // ── unmap ──────────────────────────────────────────────────────────────────── + if (sub === "unmap") { + const target = interaction.options.getUser("user", true); + const entry = getUsermapEntryById(target.id, target.username); + if (!entry) { + return void replyAndDelete(interaction, `❌ No mapping found for **${target.username}**.`, true); + } + removeUsermapEntry(target.id); + // Also remove old username-based entry if it exists + removeUsermapEntry(target.username); + return void replyAndDelete(interaction, `✅ Removed mapping for **${target.username}** (was → **${entry.file}**)`, true); + } + + // ── list ───────────────────────────────────────────────────────────────────── + if (sub === "list") { + const fs = require("fs"); + const path = require("path"); + const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8")); + + const lines: string[] = []; + for (const [key, value] of Object.entries(raw)) { + const file = typeof value === "string" ? value : (value as any).file; + const aliases = typeof value === "object" ? ((value as any).aliases ?? []) : []; + const isId = /^\d{17,20}$/.test(key); + const type = isId ? "🆔" : "👤"; + lines.push(`${type} \`${key}\` → **${file}**${aliases.length ? ` *(${aliases.join(", ")})*` : ""}`); + } + + if (lines.length === 0) return void replyAndDelete(interaction, "No usermap entries found.", true); + + // Split into chunks to avoid Discord's 2000 char limit + const chunks: string[] = []; + let current = ""; + for (const line of lines) { + if (current.length + line.length + 1 > 1900) { + chunks.push(current); + current = line; + } else { + current += (current ? "\n" : "") + line; + } + } + if (current) chunks.push(current); + + await interaction.reply({ content: chunks[0], ephemeral: true }); + for (const chunk of chunks.slice(1)) { + await interaction.followUp({ content: chunk, ephemeral: true }); + } + return; + } +} + +export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("officerRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const target = interaction.options.getUser("user", true); + const targetMember = await interaction.guild!.members.fetch(target.id); + const entry = getUsermapEntryById(target.id, target.username); + + if (!entry) { + return void replyAndDelete(interaction, `❌ **${target.username}** is not registered in usermap.json. Use \`/tg-admin user map\` first.`, true); + } + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); + + const state = polls.get(slot)!; + + // Find voter entry by discord ID or by userKey + let foundId: string | null = null; + for (const [id, e] of [...state.yes.entries(), ...state.no.entries()]) { + if ((e as any).discordId === target.id || e.userKey === entry.file) { + foundId = id; + break; + } + } + + if (!foundId) return void replyAndDelete(interaction, `❌ **${target.username}** has not voted in the active poll.`, true); + + const { char, borrowedFrom } = getEffectiveCharacter(entry.file); + if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${entry.file}**.`, true); + + const nation = Nations.resolve(targetMember, entry.file); + + const updateEntry = (map: Map) => { + const e = map.get(foundId!); + if (e) { + e.userKey = entry.file; + e.characterName = char.name; + e.characterClass = char.class; + e.characterLevel = char.level; + e.characterNation = char.nation ?? nation; + e.borrowedFrom = borrowedFrom ?? undefined; + e.discordId = target.id; + } + }; + updateEntry(state.yes); + updateEntry(state.no); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as any; + await updatePollMessage(channel, slot); + + const { format } = require("@format"); + return void replyAndDelete(interaction, `✅ Fixed poll entry for **${target.username}** → ${format.char(char)}`, true); +} + +export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("officerRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const target = interaction.options.getUser("user", true); + const { polls } = require("@systems/poll"); + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); + + const state = polls.get(slot)!; + let found: any = null; + + for (const [id, e] of [...state.yes.entries(), ...state.no.entries()]) { + if ((e as any).discordId === target.id || e.userKey === target.id) { + found = { voteId: id, ...e }; + break; + } + } + + if (!found) return void replyAndDelete(interaction, `❌ No poll entry found for **${target.username}**.`, true); + + return void replyAndDelete(interaction, `\`\`\`json\n${JSON.stringify(found, null, 2)}\n\`\`\``, true); +} \ No newline at end of file diff --git a/src/subcommands/bringer/clear.ts b/src/subcommands/bringer/clear.ts index 59f2d7a..fed2153 100644 --- a/src/subcommands/bringer/clear.ts +++ b/src/subcommands/bringer/clear.ts @@ -2,9 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js"; import { clearBringerOverride } from "@systems/wrank"; import { replyAndDelete } from "@utils"; import { Nation } from "@types"; +import { Bringer } from "@systems/bringer"; +import { getCurrentWeek, saveWRank } from "@systems/wrank"; export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise { const nation = interaction.options.getString("nation", true) as Nation; - clearBringerOverride(nation); + Bringer.clearOverride({ nation }); + saveWRank(); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); } \ No newline at end of file diff --git a/src/subcommands/bringer/set.ts b/src/subcommands/bringer/set.ts index 389aecc..97b3c3a 100644 --- a/src/subcommands/bringer/set.ts +++ b/src/subcommands/bringer/set.ts @@ -1,12 +1,28 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { setBringerOverride } from "@systems/wrank"; import { replyAndDelete } from "@utils"; import { Nation } from "@types"; +import { Bringer } from "@systems/bringer"; +import { CharacterRegistry } from "@registry/character-registry"; export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise { - const nation = interaction.options.getString("nation", true) as Nation; - const charName = interaction.options.getString("name", true); + const nation = interaction.options.getString("nation", true) as Nation; + const charName = interaction.options.getString("char_name", true); - setBringerOverride(nation, charName); - return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`); + // Resolve character + const char = CharacterRegistry.find(charName); + if (!char) { + return void replyAndDelete(interaction, `❌ Character **${charName}** not found.`, true); + } + + // Validate nation matches + if (char.nation !== nation) { + return void replyAndDelete(interaction, + `❌ **${charName}** is a ${char.nation} character, not ${nation}.`, true + ); + } + + Bringer.override({ nation, character: char }); + return void replyAndDelete(interaction, + `✅ **${charName}** set as ${nation === Nation.Capella ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`, true + ); } \ No newline at end of file diff --git a/src/subcommands/char/accept.ts b/src/subcommands/char/accept.ts index 57c32f0..3c09dd5 100644 --- a/src/subcommands/char/accept.ts +++ b/src/subcommands/char/accept.ts @@ -1,13 +1,14 @@ import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; -import { getCharacterByName } from "../../systems/characters"; -import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "../../systems/borrow"; -import { polls, updatePollMessage } from "../../systems/poll"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { getCharacterByName } from "@systems/characters"; +import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow"; +import { polls, updatePollMessage } from "@systems/poll"; +import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages"; +import { replyAndDelete } from "@src/utils"; export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise { const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); - const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); + const ownerEntry = getUsermapEntryById(ownerMember.user.id, ownerMember.user.username); const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); @@ -50,7 +51,7 @@ async function acceptBorrow( for (const [, entry] of map) { if (entry.userKey === requesterKey) { entry.characterName = char.name; - entry.characterClass = char.class; + entry.characterClass = char.class.key; entry.characterLevel = char.level; entry.characterNation = char.nation; } diff --git a/src/subcommands/char/borrow.ts b/src/subcommands/char/borrow.ts index 2cf0f39..7edc8ab 100644 --- a/src/subcommands/char/borrow.ts +++ b/src/subcommands/char/borrow.ts @@ -5,6 +5,7 @@ import { getCharacterByName, getActiveCharacter } from "../../systems/characters import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; import { polls, updatePollMessage } from "../../systems/poll"; import { replyAndDelete } from "../../utils"; +import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages"; export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); @@ -44,7 +45,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { if (entry.userKey === requesterKey) { entry.characterName = char.name; - entry.characterClass = char.class; + entry.characterClass = char.class.key; entry.characterLevel = char.level; entry.characterNation = char.nation; entry.publicMessage = undefined; @@ -69,8 +70,8 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) const guild = interaction.guild!; await guild.members.fetch(); const ownerMember = guild.members.cache.find((m) => { - const entry = (require("../../systems/messages") as any).getUsermapEntry(m.user.username); - return entry?.file === ownerArg || entry === ownerArg; + const entry = getUsermapEntryById(m.user.id, m.user.username); + return entry?.file === ownerArg; }); if (!ownerMember) { @@ -85,7 +86,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) ownerArg, requesterKey, char.name, - char.class, + char.class.key, char.level, fallbackChannel ); diff --git a/src/subcommands/char/decline.ts b/src/subcommands/char/decline.ts index aff3c4b..5ba1b24 100644 --- a/src/subcommands/char/decline.ts +++ b/src/subcommands/char/decline.ts @@ -1,10 +1,11 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; -import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow"; -import { replyAndDelete } from "../../utils"; +import { getPendingRequest, removePendingRequest, updateBorrowDM } from "@systems/borrow"; +import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages"; +import { replyAndDelete } from "@src/utils"; export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise { const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); - const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); + const ownerEntry = getUsermapEntryById(ownerMember.user.id, ownerMember.user.username); const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); diff --git a/src/subcommands/char/setStats.ts b/src/subcommands/char/setStats.ts index 36f3c23..40074fb 100644 --- a/src/subcommands/char/setStats.ts +++ b/src/subcommands/char/setStats.ts @@ -5,30 +5,32 @@ import { setCharacterStats, getActiveCharacter } from "../../systems/characters" import { replyAndDelete } from "../../utils"; export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise { - const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); - const nameArg = interaction.options.getString("name"); - const charName = interaction.options.getString("char_name"); - const atk = interaction.options.getInteger("atk") ?? undefined; - const def = interaction.options.getInteger("def") ?? undefined; - const heal = interaction.options.getInteger("heal") ?? undefined; + return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true); - let userKey: string | null; - if (nameArg) { - if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - userKey = nameArg; - } else { - const user = await resolveUser(member); - userKey = user.userKey; - } + // const member = await interaction.guild!.members.fetch(interaction.user.id); + // const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + // const nameArg = interaction.options.getString("name"); + // const charName = interaction.options.getString("char_name"); + // const atk = interaction.options.getInteger("atk") ?? undefined; + // const def = interaction.options.getInteger("def") ?? undefined; + // const heal = interaction.options.getInteger("heal") ?? undefined; - if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + // let userKey: string | null; + // if (nameArg) { + // if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + // userKey = nameArg; + // } else { + // const user = await resolveUser(member); + // userKey = user.userKey; + // } - const targetName = charName ?? getActiveCharacter(userKey)?.name; - if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); + // if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const set = setCharacterStats(userKey, targetName, { atk, def, heal }); - if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); + // const targetName = charName ?? getActiveCharacter(userKey)?.name; + // if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); - return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`); + // const set = setCharacterStats(userKey, targetName, { atk, def, heal }); + // if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); + + // return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`); } diff --git a/src/subcommands/impersonate.ts b/src/subcommands/impersonate.ts index 21f11a2..2118834 100644 --- a/src/subcommands/impersonate.ts +++ b/src/subcommands/impersonate.ts @@ -61,7 +61,7 @@ function buildImpersonateButtons( const navBtns: ButtonBuilder[] = []; if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary)); if (hasNext) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page + 1}`).setLabel("Next →").setStyle(ButtonStyle.Secondary)); - navBtns.push(new ButtonBuilder().setCustomId("impersonate_release").setLabel("🚫 Release").setStyle(ButtonStyle.Danger)); + navBtns.push(new ButtonBuilder().setCustomId(`impersonate_release:${page}`).setLabel("🚫 Release").setStyle(ButtonStyle.Danger)); rows.push(new ActionRowBuilder().addComponents(...navBtns)); return rows; @@ -114,7 +114,7 @@ export async function handleImpersonateButton(interaction: ButtonInteraction): P return; } - if (customId === "impersonate_release") { + if (customId.startsWith("impersonate_release")) { clearImpersonation(realId); const users = getRegisteredUsers(); const embed = buildImpersonateEmbed(users, 0, null); diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts index b90f629..a131bf0 100644 --- a/src/subcommands/poll/reload.ts +++ b/src/subcommands/poll/reload.ts @@ -1,12 +1,15 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { loadMessages } from "@systems/messages"; -import { loadEmojis } from "@systems/emojis"; +import { Emoji } from "@systems/emojis"; import { loadCharacters } from "@systems/characters"; import { loadWRank } from "@systems/wrank"; import { loadConfig, cfg } from "@systems/config"; import { polls, updatePollMessage } from "@systems/poll"; import { persist } from "@systems/pollPersistence"; import { replyAndDelete } from "@utils"; +import { CharacterRegistry } from "@root/src/systems/registry/character-registry"; +import { invalidateUsermapCache } from "@root/src/handlers/autocomplete"; +import { UserRegistry } from "@root/src/systems/registry/user-registry"; const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const; type Reloadable = typeof RELOADABLE[number]; @@ -20,7 +23,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr if (should("config")) { loadConfig(); reloaded.push("config"); } if (should("messages")) { loadMessages(); reloaded.push("messages"); } - if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } + if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); } if (should("characters")) { loadCharacters(); reloaded.push("characters"); } if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } @@ -52,37 +55,8 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr } } + UserRegistry.invalidateCache(); + CharacterRegistry.invalidateCache(); + return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); -} - -// export async function handleReload(interaction: ChatInputCommandInteraction): Promise { -// const target = (interaction.options.getString("target") ?? "all") as Reloadable; - -// const reloaded: string[] = []; - -// const should = (k: Reloadable) => target === "all" || target === k; - -// if (should("config")) { loadConfig(); reloaded.push("config"); } -// if (should("messages")) { loadMessages(); reloaded.push("messages"); } -// if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } -// if (should("characters")) { loadCharacters(); reloaded.push("characters"); } -// if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } - -// // Re-render active poll message(s) so embed reflects reloaded data -// if (should("poll") || should("emojis") || should("all")) { -// const channelId = cfg("pollChannelId"); -// if (channelId && polls.size > 0) { -// try { -// const channel = await interaction.client.channels.fetch(channelId) as TextChannel; -// for (const slot of polls.keys()) { -// await updatePollMessage(channel, slot); -// } -// reloaded.push("poll message"); -// } catch (err) { -// console.error("Failed to re-render poll message on reload:", err); -// } -// } -// } - -// return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); -// } \ No newline at end of file +} \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts.bak b/src/subcommands/poll/reload.ts.bak deleted file mode 100644 index b332963..0000000 --- a/src/subcommands/poll/reload.ts.bak +++ /dev/null @@ -1,12 +0,0 @@ -import { ChatInputCommandInteraction } from "discord.js"; -import { loadMessages } from "../../systems/messages"; -import { loadEmojis } from "../../systems/emojis"; -import { loadCharacters } from "../../systems/characters"; -import { replyAndDelete } from "../../utils"; - -export async function handleReload(interaction: ChatInputCommandInteraction): Promise { - loadMessages(); // reloads global.json, usermap.json, users/*.json - loadEmojis(); // reloads emojis.json - loadCharacters(); // reloads characters.json and accounts.json - return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); -} \ No newline at end of file diff --git a/src/subcommands/poll/seed.ts b/src/subcommands/poll/seed.ts index 955bd4b..258f9cf 100644 --- a/src/subcommands/poll/seed.ts +++ b/src/subcommands/poll/seed.ts @@ -1,12 +1,11 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; -import { polls, updatePollMessage } from "../../systems/poll"; -import { nowFormatted, resolveMessage } from "../../systems/messages"; -import { getEffectiveCharacter } from "../../systems/borrow"; -import { replyAndDelete } from "../../utils"; -import { VoteEntry, UsermapEntry } from "../../types"; -import fs from "fs"; -import path from "path"; +import { cfg } from "@systems/config"; +import { polls, updatePollMessage } from "@systems/poll"; +import { nowFormatted, resolveMessage } from "@systems/messages"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { replyAndDelete } from "@utils"; +import { VoteEntry, UsermapEntry } from "@types"; +import { UserRegistry } from "@registry/user-registry"; export async function handleSeed(interaction: ChatInputCommandInteraction): Promise { const slot = [...polls.keys()][0]; @@ -17,18 +16,17 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom return void replyAndDelete(interaction, "❌ Poll is locked or confirmed."); } - let usermap: Record = {}; - try { - usermap = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8")); - } catch { - return void replyAndDelete(interaction, "❌ Could not load usermap.json."); + const usermapEntries = UserRegistry.all(); + + if (usermapEntries.length === 0) { + return void replyAndDelete(interaction, "❌ No registered users found."); } const now = nowFormatted(); let injected = 0; let skipped = 0; - for (const [discordUsername, entry] of Object.entries(usermap)) { + for (const { entry } of usermapEntries) { const userKey = typeof entry === "string" ? entry : entry.file; const { char, borrowedFrom } = getEffectiveCharacter(userKey); @@ -37,7 +35,7 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom const syntheticId = `injected:${userKey}`; if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; } - const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null); + const publicMsg = resolveMessage("public", "yes", 1, userKey, null, null); const voteEntry: VoteEntry = { userKey, diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts index b446497..4b11baa 100644 --- a/src/subcommands/rank/get.ts +++ b/src/subcommands/rank/get.ts @@ -1,8 +1,11 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; -import { resolveUser, hasOfficerRole } from "../../systems/users"; -import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { WRank } from "@systems/wrank"; +import { Bringer } from "@systems/bringer"; +import { replyAndDelete } from "@src/utils"; +import { Nation } from "@types"; +import { TG } from "@systems/tg"; export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); @@ -23,9 +26,9 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - const week = getCurrentWeek(); + const week = TG.currentWeek(); const goal = cfg("wRankGoal"); - const weekKey = getWeekKey(); + const weekKey = WRank.weekKey(); for (const nation of ["capella", "procyon"] as const) { const entry = week.entries[nation].find((e) => e.userKey === userKey); @@ -34,12 +37,12 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P const isDone = entry.tgCount >= goal; const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0; const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : ""; - const bringer = getBringer(entry.nation); + const bringer = Bringer.get({ nation: entry.nation }); const isBringer = bringer === userKey && isDone; const lines = [ `**${entry.characterName}** · ${entry.nation}`, - `**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""}`, + `**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === Nation.Capella ? "Luminous Bringer" : "Storm Bringer"}` : ""}`, `**Points:** ${entry.weeklyPoints}`, `**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`, `**Week:** ${weekKey}`, diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index c6ec08b..644b318 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -1,26 +1,34 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; import { cfg } from "@systems/config"; -import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank"; +import { WRank } from "@systems/wrank"; import { getEmoji } from "@systems/emojis"; import { replyAndDelete } from "@utils"; import { format } from "@src/systems/format"; +import { CharacterRegistry } from "@registry/character-registry"; +import { Nation } from "@types"; +import { Nations, NATION_FROM_KEY } from "@systems/nations"; +import { Bringer } from "@systems/bringer"; +import { TG } from "@systems/tg"; export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { - const week = getCurrentWeek(); + const week = TG.currentWeek(); const goal = cfg("wRankGoal"); - const weekKey = getWeekKey(); + const weekKey = WRank.weekKey(); - const formatNation = (nation: "capella" | "procyon"): string => { - const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); + const formatNation = (nation: Nation): string => { + const key = Nations.key(nation); + const entries = [...week.entries[key]].sort((a, b) => a.currentRank - b.currentRank); if (entries.length === 0) return "—"; - const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + const bringer = Bringer.get({ nation: NATION_FROM_KEY[key], week }); return entries.map((e) => { const isDone = e.tgCount >= goal; // ── Character indicator ─────────────────────────────────────────────────── - const charStr = format.char({ class: e.class, level: 79, name: e.characterName }); + const char = CharacterRegistry.find(e.characterName); + console.log(`[rank/post.ts:] char: ${char}`); + const charStr = char ? format.char(char) : `${e.class} ${e.characterName}`; // ── Rank indicator ─────────────────────────────────────────────────── const rankStr = format.wrank.rank(e, goal); @@ -28,7 +36,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): // ── Bringer label ──────────────────────────────────────────────────── const bringerStr = bringer === e.userKey && isDone - ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` + ? ` · ${key === "capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""; // ── Score indicator ─────────────────────────────────────────────────── @@ -44,8 +52,8 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): .setTitle(`⚔️ TG Leaderboard — ${weekKey}`) .setColor(0xe8a317) .addFields( - { name: format.nation("Capella"), value: formatNation("capella"), inline: true }, - { name: format.nation("Procyon"), value: formatNation("procyon"), inline: true }, + { name: format.nation(Nation.Capella), value: formatNation(Nation.Capella), inline: true }, + { name: format.nation(Nation.Procyon), value: formatNation(Nation.Procyon), inline: true }, ) .setTimestamp(); diff --git a/src/subcommands/result/post.ts b/src/subcommands/result/post.ts index 32425a0..9bb21a1 100644 --- a/src/subcommands/result/post.ts +++ b/src/subcommands/result/post.ts @@ -1,8 +1,9 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; -import { cfg } from "../../systems/config"; -import { loadResult, todayString } from "../../systems/history"; -import { normalizeSlot, detectSlot } from "../../systems/scores"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { loadResult, todayString } from "@systems/history"; +import { normalizeSlot, detectSlot } from "@systems/scores"; +import { replyAndDelete } from "@src/utils"; +import { Nation } from "@types"; export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise { const slotArg = interaction.options.getString("slot"); @@ -20,7 +21,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction) const kd = result.nationKD; - const formatScores = (nation: "Capella" | "Procyon"): string => { + const formatScores = (nation: Nation): string => { const scores = result.scores.filter((s) => s.nation === nation); if (scores.length === 0) return "—"; return scores @@ -33,8 +34,8 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction) .setTitle(`⚔️ TG Results — ${result.date} ${slot}:00`) .setColor(0xe8a317) .addFields( - { name: "🔵 Capella", value: `${kd.capella.k}K / ${kd.capella.d}D\n${formatScores("Capella")}`, inline: true }, - { name: "🔴 Procyon", value: `${kd.procyon.k}K / ${kd.procyon.d}D\n${formatScores("Procyon")}`, inline: true }, + { name: "🔵 Capella", value: `${kd.capella.k}K / ${kd.capella.d}D\n${formatScores(Nation.Capella)}`, inline: true }, + { name: "🔴 Procyon", value: `${kd.procyon.k}K / ${kd.procyon.d}D\n${formatScores(Nation.Procyon)}`, inline: true }, ) .setFooter({ text: `Source of truth: ${kd.source}` }) .setTimestamp(); diff --git a/src/subcommands/result/set.ts b/src/subcommands/result/set.ts index c37ed87..e334588 100644 --- a/src/subcommands/result/set.ts +++ b/src/subcommands/result/set.ts @@ -21,7 +21,7 @@ export async function handleResultSet(interaction: ChatInputCommandInteraction): } const result = setNationKD(todayString(), slot, nation, kills, deaths); - const other = nation === "Capella" ? "Procyon" : "Capella"; + const other = nation === Nation.Capella ? Nation.Procyon : Nation.Capella; const otherKD = result.nationKD[other.toLowerCase() as "capella" | "procyon"]; return void replyAndDelete(interaction, diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index 82fdb2b..9437f6a 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -1,19 +1,11 @@ -import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { ChatInputCommandInteraction } from "discord.js"; import { cfg } from "@systems/config"; import { resolveUser, hasOfficerRole } from "@systems/users"; -import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; -import { - getEffectiveCharacter, - setSessionBorrow, - setPersistentPreference, - clearPersistentPreference, - clearSessionBorrowForUser, -} from "@systems/borrow"; -import { polls, updatePollMessage } from "@systems/poll"; -import { getClassEmoji } from "@systems/emojis"; +import { getCharacterByName } from "@systems/characters"; +import { Character } from "@systems/character"; +import { Emoji } from "@systems/emojis"; import { replyAndDelete } from "@src/utils"; -import { format } from "@format"; -import { buildCharSelectButtons } from "@systems/charSelect"; +import { CharacterRegistry } from "@registry/character-registry"; import fs from "fs"; import path from "path"; @@ -33,13 +25,6 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string; return null; } -function findVoteIdInPoll(state: any, userKey: string): string | null { - for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.userKey === userKey) return id; - } - return null; -} - export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); const isOfficer = hasOfficerRole(member, cfg("officerRoles")); @@ -57,7 +42,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - // Resolve the target character without switching yet + // Resolve target character let resolvedChar: any = null; let borrowedFrom: string | null = null; @@ -65,7 +50,8 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr if (ownChar) { resolvedChar = ownChar; } else { - const shared = findSharedChar(userKey, charName); + const shared = CharacterRegistry.sharedWith(userKey) + .find(({ char }) => char.name.toLowerCase() === charName.toLowerCase()); if (shared) { resolvedChar = shared.char; borrowedFrom = shared.ownerKey; @@ -74,87 +60,16 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); - // If already active — just show current state without switching - const current = getEffectiveCharacter(userKey); - if (current.char?.name === resolvedChar.name) { - const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; - const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : ""; - return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); + // Delegate to shared switch logic + const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, interaction); + + if (result.replyData) { + await interaction.reply(result.replyData); + return; } - - // Check if target character is already in the active poll by another player - const slot = [...polls.keys()][0]; - if (slot !== undefined) { - const state = polls.get(slot)!; - for (const [id, entry] of state.yes.entries()) { - const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`; - if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) { - const slotHour = state.slot; - const charDisplay = format.char(resolvedChar); - const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name); - if (isOwner) { - await interaction.reply({ - content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`, - components: buildCharSelectButtons(userKey, { - customIdPrefix: `switch_after_reclaim:${userKey}`, - excludeCharName: resolvedChar.name, - appendToCustomId: ":yes", - }), - ephemeral: true, - }); - return; - } - const buttons = buildCharSelectButtons(userKey, { - customIdPrefix: `switch_after_reclaim:${userKey}`, - excludeCharName: resolvedChar.name, - appendToCustomId: `:${"yes"}`, - }); - await interaction.reply({ - content: `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - components: buttons, - ephemeral: true, - }); - return; - } - } + if (result.success && result.message) { + return void replyAndDelete(interaction, result.message, true); } - - // Now actually switch - if (borrowedFrom) { - setSessionBorrow(userKey, borrowedFrom, resolvedChar.name); - setPersistentPreference(userKey, borrowedFrom, resolvedChar.name); - } else { - setActiveCharacter(userKey, charName); - clearPersistentPreference(userKey); - clearSessionBorrowForUser(userKey); - resolvedChar = getActiveCharacter(userKey); - } - - // Update poll embed if user has already voted - if (slot !== undefined) { - const state = polls.get(slot)!; - const voteId = findVoteIdInPoll(state, userKey); - - if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) { - const updateEntry = (map: Map) => { - const entry = map.get(voteId); - if (entry) { - entry.characterName = resolvedChar.name; - entry.characterClass = resolvedChar.class; - entry.characterLevel = resolvedChar.level; - entry.characterNation = resolvedChar.nation; - entry.borrowedFrom = borrowedFrom ?? undefined; - } - }; - updateEntry(state.yes); - updateEntry(state.no); - - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; - await updatePollMessage(channel, slot); - } - } - - const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; - const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; - return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); + + // conflict handled inside performSwitch — already replied } \ No newline at end of file diff --git a/src/systems/attendance.ts b/src/systems/attendance.ts new file mode 100644 index 0000000..1d3ce80 --- /dev/null +++ b/src/systems/attendance.ts @@ -0,0 +1,80 @@ +/** + * Attendance — tracks who was in each TG. + * + * Usage: + * import { Attendance } from "@systems/attendance"; + * + * Attendance.snapshot(slot) + * Attendance.players(slot) + * Attendance.allSubmitted(slot, date) + */ + + import fs from "fs"; + import { Paths } from "@paths"; + import { UserKey, HistoryKey } from "@types"; + import { Store } from "@systems/store"; + + interface AttendanceData { + [historyKey: HistoryKey]: UserKey[]; + } + + let _data: AttendanceData = {}; + + function load(): void { + _data = Store.readOrDefault(Paths.data("attendance.json"), {}); +} +function save(): void { + Store.write(Paths.data("attendance.json"), _data); +} + + load(); + + export const Attendance = { + /** + * Snapshot attendance from poll state at lock time. + */ + snapshot(slot: number, lockedYesKeys: Set): void { + const date = new Date().toISOString().slice(0, 10); + const historyKey = `${date}-${slot}` as HistoryKey; + _data[historyKey] = [...lockedYesKeys]; + save(); + }, + + /** + * Get players who attended a specific TG. + */ + players(historyKey: HistoryKey): UserKey[] { + return _data[historyKey] ?? []; + }, + + /** + * Check if a specific player attended. + */ + includes(historyKey: HistoryKey, userKey: UserKey): boolean { + return (_data[historyKey] ?? []).includes(userKey); + }, + + /** + * Check if all attendees have submitted scores. + */ + allSubmitted(historyKey: HistoryKey): boolean { + const players = _data[historyKey] ?? []; + if (players.length === 0) return false; + try { + const result = JSON.parse( + fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8") + ); + const submitted = new Set(result.scores?.map((s: any) => s.userKey) ?? []); + return players.every((p) => submitted.has(p)); + } catch { + return false; + } + }, + + /** + * Get all history keys (for listing past TGs). + */ + all(): HistoryKey[] { + return Object.keys(_data) as HistoryKey[]; + }, + }; \ No newline at end of file diff --git a/src/systems/benchmark.ts b/src/systems/benchmark.ts new file mode 100644 index 0000000..dd61f2a --- /dev/null +++ b/src/systems/benchmark.ts @@ -0,0 +1,82 @@ +/** + * Benchmark — lightweight performance profiling. + * + * Usage: + * import { Benchmark } from "@systems/benchmark"; + * + * const bench = Benchmark.start("vote"); + * // ... do work ... + * bench.mark("resolveUser"); // logs: [vote] resolveUser: 12ms + * // ... more work ... + * bench.mark("updatePoll"); // logs: [vote] updatePoll: 145ms + * bench.end(); // logs: [vote] total: 157ms + * + * // One-liner: + * const result = await Benchmark.measure("vote", async () => { + * return await doSomething(); + * }); + */ + + import { Logger } from "@systems/logger"; + + const log = Logger.for("benchmark"); + + // ─── Active benchmark instance ──────────────────────────────────────────────── + + export interface BenchmarkInstance { + /** Log time since last mark (or start). */ + mark(label: string): void; + /** Log total time since start. */ + end(): number; + } + + function createBenchmark(name: string, enabled: boolean): BenchmarkInstance { + let last = Date.now(); + const t0 = last; + + return { + mark(label: string): void { + if (!enabled) return; + const now = Date.now(); + const delta = now - last; + last = now; + log.debug(`[${name}] ${label}: ${delta}ms`); + }, + + end(): number { + const total = Date.now() - t0; + if (enabled) log.debug(`[${name}] total: ${total}ms`); + return total; + }, + }; + } + + // ─── Namespace ──────────────────────────────────────────────────────────────── + + // Only profile when LOG_LEVEL=debug + const _enabled = () => process.env.LOG_LEVEL?.toUpperCase() === "DEBUG"; + + export const Benchmark = { + /** + * Start a named benchmark. + * Only active when LOG_LEVEL=debug — zero overhead in production. + */ + start(name: string): BenchmarkInstance { + return createBenchmark(name, _enabled()); + }, + + /** + * Measure and return the result of an async function. + */ + async measure(name: string, fn: () => Promise): Promise { + const bench = Benchmark.start(name); + try { + const result = await fn(); + bench.end(); + return result; + } catch (err) { + bench.end(); + throw err; + } + }, + }; \ No newline at end of file diff --git a/src/systems/borrow.ts b/src/systems/borrow.ts index 73206a6..70a26a7 100644 --- a/src/systems/borrow.ts +++ b/src/systems/borrow.ts @@ -1,23 +1,20 @@ import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; -import fs from "fs"; import path from "path"; -import { BorrowRequest } from "../types"; -import { cfg } from "./config"; -import { getCharacterByName } from "./characters"; +import { BorrowRequest } from "@src/types"; +import { cfg } from "@systems/config"; +import { Char } from "@systems/characters"; +import { Store } from "@systems/store"; +import { Paths } from "@helpers/paths"; -const PREFS_PATH = path.join(__dirname, "../../data/sessionPreferences.json"); // ─── Persistent preferences ─────────────────────────────────────────────────── let _prefs: Record = {}; function loadPrefs(): void { - try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); } - catch { _prefs = {}; } + _prefs = Store.readOrDefault(Paths.data("sessionPreferences.json"), {}); } - function savePrefs(): void { - try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); } - catch (err) { console.error("Failed to save sessionPreferences.json:", err); } + Store.write(Paths.data("sessionPreferences.json"), _prefs); } loadPrefs(); @@ -99,7 +96,7 @@ export function clearSessionBorrows(): void { export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean { if (requesterKey === ownerKey) return true; - const char = getCharacterByName(ownerKey, charName); + const char = Char.byName({ user: ownerKey, name: charName }); if (char?.sharedWith?.includes(requesterKey)) return true; const borrow = getSessionBorrow(requesterKey); if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true; @@ -122,7 +119,7 @@ export async function sendBorrowRequestDM( charLevel: number, fallbackChannel?: TextChannel ): Promise { - const content = `🔔 **Borrow Request**\n**${requesterDisplayName}** wants to borrow **«${charName}»** (${charClass} · Lv${charLevel}) for tonight's TG.`; + const content = `🔔 **Borrow Request**\n**${requesterDisplayName}** wants to borrow **${charName}** (${charClass} · Lv${charLevel}) for tonight's TG.`; const acceptBtn = new ButtonBuilder() .setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`) @@ -168,12 +165,10 @@ export async function updateBorrowDM( // ─── Effective character resolution ────────────────────────────────────────── export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } { - const { getActiveCharacter, getCharacterByName: getChar } = require("./characters"); - // 1. Session borrow (temporary, resets on poll start) const borrow = getSessionBorrow(userKey); if (borrow) { - const char = getChar(borrow.ownerKey, borrow.charName); + const char = Char.byName({ user: borrow.ownerKey, name: borrow.charName }); if (char) return { char, borrowedFrom: borrow.ownerKey }; } @@ -181,12 +176,12 @@ export function getEffectiveCharacter(userKey: string): { char: any; borrowedFro const pref = getPersistentPreference(userKey); console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`); if (pref) { - const char = getChar(pref.ownerKey, pref.charName); + const char = Char.byName({ user: pref.ownerKey, name: pref.charName }); if (char) return { char, borrowedFrom: pref.ownerKey }; clearPersistentPreference(userKey); } // 3. Own active character - const char = getActiveCharacter(userKey); + const char = Char.active({ user: userKey }); return { char: char ?? null, borrowedFrom: null }; } \ No newline at end of file diff --git a/src/systems/bringer.ts b/src/systems/bringer.ts new file mode 100644 index 0000000..b3222a2 --- /dev/null +++ b/src/systems/bringer.ts @@ -0,0 +1,79 @@ +/** + * Bringer namespace — manages Storm/Luminous Bringer state per nation. + * + * Usage: + * import { Bringer } from "@systems/bringer"; + * + * Bringer.get({ nation: Nation.Procyon }) // string | null + * Bringer.override({ nation: Nation.Capella, character: char }) + * Bringer.clearOverride({ nation: Nation.Capella }) + * Bringer.update({ week }) // recompute from scores + */ + + import { Nation, Character } from "@types"; + import { cfg } from "@systems/config"; + import { WRank, WRankWeek } from "@systems/wrank"; + + // ─── Helpers ────────────────────────────────────────────────────────────────── + + const NATION_BRINGER_KEY: Record = { + [Nation.Capella]: "capella", + [Nation.Procyon]: "procyon", + }; + + // ─── Namespace ──────────────────────────────────────────────────────────────── + + export const Bringer = { + /** + * Get the current Bringer character name for a nation. + * Returns the override if set, otherwise the earned Bringer. + */ + get({ nation, week }: { nation: Nation; week?: WRankWeek }): string | null { + const key = NATION_BRINGER_KEY[nation]; + const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const _week = week ?? WRank.currentWeek(); + return _week.bringer[overrideKey] ?? _week.bringer[key]; + }, + + /** + * Set a manual Bringer override for a nation. + * Stores the character name — will return Character once Character has ownerKey. + */ + override({ nation, character, week }: { nation: Nation; character: Character; week?: WRankWeek }): void { + const key = NATION_BRINGER_KEY[nation]; + const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const _week = week ?? WRank.currentWeek(); + _week.bringer[overrideKey] = character.name; + WRank.save(); + }, + + /** + * Clear the manual Bringer override for a nation. + */ + clearOverride({ nation, week }: { nation: Nation; week?: WRankWeek }): void { + const key = NATION_BRINGER_KEY[nation]; + const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const _week = week ?? WRank.currentWeek(); + delete _week.bringer[overrideKey]; + WRank.save(); + }, + + /** + * Recompute the earned Bringer from scores. + * Called after weekly reset — the top ranked player with >= goal TGs becomes Bringer. + */ + update({ week }: { week?: WRankWeek }): void { + const goal = cfg("wRankGoal"); + for (const nation of [Nation.Capella, Nation.Procyon]) { + const key = NATION_BRINGER_KEY[nation]; + const _week = week ?? WRank.currentWeek(); + const list = _week.entries[key]; + const qualified = list + .filter((e) => e.tgCount >= goal) + .sort((a, b) => a.currentRank - b.currentRank); + _week.bringer[key] = qualified[0]?.characterName ?? null; + } + + WRank.save(); + }, + }; \ No newline at end of file diff --git a/src/systems/charSelect.ts b/src/systems/charSelect.ts index 63ffe9a..71d311e 100644 --- a/src/systems/charSelect.ts +++ b/src/systems/charSelect.ts @@ -4,7 +4,7 @@ import { ActionRowBuilder, } from "discord.js"; import { getCharacters, getCharacterByName } from "@systems/characters"; -import { getClassEmoji } from "@systems/emojis"; +import { Emoji } from "@systems/emojis"; import { format } from "@format"; import { Character } from "@types"; import fs from "fs"; @@ -51,9 +51,11 @@ export function buildCharSelectButtons( } } catch {} + const allChars = [...ownChars, ...sharedChars] .filter((c) => c.name !== excludeCharName); + const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize); const hasNext = allChars.length > (page + 1) * pageSize; const hasPrev = page > 0; @@ -61,13 +63,15 @@ export function buildCharSelectButtons( // Char buttons if (pageChars.length > 0) { - const btns = pageChars.map((c) => { - const emojiStr = getClassEmoji(c.class); + const btns = pageChars.map((c: Character) => { + const emojiStr = Emoji.class(c.class); const emoji = format.emoji(emojiStr); + const isShared = sharedChars.some(sc => sc.name === c.name); + const btn = new ButtonBuilder() .setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`) .setStyle(ButtonStyle.Secondary) - .setLabel(`${c.level} ${c.name}`); + .setLabel(format.charButton(c, { shared: isShared })); if (emoji) btn.setEmoji(emoji as any); return btn; }); diff --git a/src/systems/character.ts b/src/systems/character.ts new file mode 100644 index 0000000..bb2c9a0 --- /dev/null +++ b/src/systems/character.ts @@ -0,0 +1,176 @@ +/** + * Character namespace — shared logic for character switching, resolution, and display. + * Use as: import { Character } from "@systems/Character"; + */ + + import { + ChatInputCommandInteraction, + ButtonInteraction, + TextChannel, + ButtonBuilder, + ActionRowBuilder +} from "discord.js"; + + import { cfg } from "@systems/config"; + import { setActiveCharacter, getCharacters } from "@systems/characters"; + import { + getEffectiveCharacter, + setSessionBorrow, + setPersistentPreference, + clearPersistentPreference, + clearSessionBorrowForUser, + } from "@systems/borrow"; + import { polls, updatePollMessage } from "@systems/poll"; + import { format } from "@format"; + import { buildCharSelectButtons } from "@systems/charSelect"; + import { Character as CharacterType, VoteType } from "@types"; + + export interface SwitchResult { + success: boolean; + conflicted?: boolean; // true if blocked by conflict + message?: string; // reply message + replyData?: { + content: string; + components: ActionRowBuilder[]; + flags?: number, + ephemeral?: boolean + // ephemeral: boolean + }; + } + + /** + * Core switch logic — resolves conflict, applies switch, updates poll. + * Used by both /tg switch and companion/reclaim button handlers. + */ + async function performSwitch( + userKey: string, + char: CharacterType, + borrowedFrom: string | null, + interaction: ChatInputCommandInteraction | ButtonInteraction, + voteType: VoteType = "yes" + ): Promise { + console.log(`[performSwitch] userKey=${userKey} char=${char.name} borrowedFrom=${borrowedFrom}`); + + // ── Already active check ─────────────────────────────────────────────────── + const current = getEffectiveCharacter(userKey); + if (current.char?.name === char.name) { + const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : ""; + return { + success: true, + message: `${format.char(char)}${borrowNote}`, + }; + } + + // ── Conflict check ───────────────────────────────────────────────────────── + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + // Find existing vote ID for this user + let existingVoteId: string | null = null; + if (state) { + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + if (entry.userKey === userKey) { existingVoteId = id; break; } + } + } + + if (state) { + for (const [id, entry] of state.yes.entries()) { + const isOwnEntry = id === existingVoteId || + id === (interaction as any).user?.id || + id === `impersonated:${userKey}`; + if (!isOwnEntry && entry.characterName === char.name && entry.userKey !== userKey) { + const isOwner = getCharacters(userKey).some((c) => c.name === char.name); + const slotHour = state.slot; + + if (isOwner) { + const buttons = buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: char.name, + appendToCustomId: `:${voteType}`, + }); + console.log(`[performSwitch] returning replyData, conflicted=${true}`); + return { + success: false, + conflicted: true, + replyData: { + content: `⚠️ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to another:`, + components: buttons, + ephemeral: true, + }, + }; + } + + console.log(`[performSwitch] applying switch`); + const buttons = buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: char.name, + appendToCustomId: `:${voteType}`, + }); + const reply = { + content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + components: buttons, + ephemeral: true, + }; + if (interaction.deferred || interaction.replied) await interaction.followUp(reply); + else await interaction.reply(reply); + return { success: false, conflicted: true }; + } + } + } + + console.log(`[performSwitch] applying switch`); + // ── Apply switch ─────────────────────────────────────────────────────────── + if (borrowedFrom) { + setSessionBorrow(userKey, borrowedFrom, char.name); + setPersistentPreference(userKey, borrowedFrom, char.name); + } else { + setActiveCharacter(userKey, char.name); + clearPersistentPreference(userKey); + clearSessionBorrowForUser(userKey); + } + + // ── Update poll ──────────────────────────────────────────────────────────── + if (state && !state.locked && state.confirmed === null) { + if (existingVoteId) { + state.yes.delete(existingVoteId); + state.no.delete(existingVoteId); + } + const voteId = existingVoteId ?? (interaction as any).user?.id; + + if (voteId) { + const { char: effectiveChar } = getEffectiveCharacter(userKey); + const voteEntry = { + userKey, + displayName: effectiveChar?.name ?? char.name, + characterName: effectiveChar?.name ?? char.name, + characterClass: effectiveChar?.class ?? char.class, + characterLevel: effectiveChar?.level ?? char.level, + characterNation: effectiveChar?.nation ?? char.nation, + borrowedFrom: borrowedFrom ?? undefined, + discordId: (interaction as any).user?.id, + votedAt: new Date().toLocaleTimeString("en-GB", { + timeZone: process.env.TZ ?? "Etc/GMT-2", + hour: "2-digit", minute: "2-digit", + }), + }; + + if (voteType === "yes") state.yes.set(voteId, voteEntry); + else state.no.set(voteId, voteEntry); + + try { + const channel = await (interaction as any).client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + } catch {} + } + } + + const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + return { + success: true, + message: `🔄 ${format.char(char)}${borrowNote}`, + }; + } + + export const Character = { + performSwitch, + }; \ No newline at end of file diff --git a/src/systems/characters.ts b/src/systems/characters.ts index 14a8645..3f7e89b 100644 --- a/src/systems/characters.ts +++ b/src/systems/characters.ts @@ -1,73 +1,112 @@ -import fs from "fs"; -import path from "path"; -import { CharacterMap, Character, ClassKey, Nation, AccountMap, AccountData } from "../types"; +import { Paths } from "@paths"; +import { + Character, CharacterStats, CharName, + CharacterClass, ClassKey, Nation, UserKey, AccountData, AccountMap, + CLASSES, +} from "@types"; +import { Store } from "@systems/store"; -const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); -const ACCOUNTS_PATH = path.join(__dirname, "../../data/accounts.json"); - -let _chars: CharacterMap = {}; +let _chars: CharacterMap = {}; let _accounts: AccountMap = {}; +/** Raw shape stored in characters.json */ +interface SerializableCharacter { + name: CharName; + class: ClassKey; // "FB" — serialized as string + level: number; + nation: Nation; + active?: boolean; + sharedWith?: UserKey[]; + stats?: CharacterStats; +} + +interface CharacterMap { + [userKey: string]: { + characters: SerializableCharacter[]; + }; +} + export function loadCharacters(): void { - try { _chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); } - catch { _chars = {}; } - try { _accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf8")); } - catch { _accounts = {}; } + _chars = Store.readOrDefault(Paths.data("characters.json"), {}); + _accounts = Store.readOrDefault(Paths.data("accounts.json"), {}); } function saveCharacters(): void { - fs.writeFileSync(CHARS_PATH, JSON.stringify(_chars, null, 2)); + // Dehydrate all characters before saving + const raw: CharacterMap = {}; + for (const [userKey, data] of Object.entries(_chars)) { + raw[userKey] = { + characters: data.characters.map((c) => + "ownerKey" in c ? Char.dehydrate(c as unknown as Character) : c + ), + }; + } + Store.write(Paths.data("characters.json"), raw); } function saveAccounts(): void { - fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2)); + Store.write(Paths.data("accounts.json"), _accounts); } -export function getCharacters(userKey: string): Character[] { - return _chars[userKey]?.characters ?? []; +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Get hydrated characters for a user */ +export function getCharacters(userKey: UserKey): Character[] { + return (_chars[userKey]?.characters ?? []).map((c) => + Char.hydrate(c as SerializableCharacter, userKey) + ); } -export function getActiveCharacter(userKey: string): Character | null { +export function getActiveCharacter(userKey: UserKey): Character | null { return getCharacters(userKey).find((c) => c.active) ?? null; } -export function getCharacterByName(userKey: string, name: string): Character | null { - return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; +export function getCharacterByName(userKey: UserKey, name: string): Character | null { + return getCharacters(userKey).find( + (c) => c.name.toLowerCase() === name.toLowerCase() + ) ?? null; } -export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null { - // Returns the active character of that class, or first found - const chars = getCharacters(userKey).filter((c) => c.class === cls); +export function getCharacterByClass(userKey: UserKey, cls: ClassKey): Character | null { + const chars = getCharacters(userKey).filter((c) => c.class.key === cls); return chars.find((c) => c.active) ?? chars[0] ?? null; } -export function addCharacter(userKey: string, char: Omit): boolean { +export function isCharacterOwner(userKey: UserKey | null, charName: string): boolean { + if (!userKey) return false; + return getCharacters(userKey).some((c) => c.name === charName); +} + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +export function addCharacter(userKey: UserKey, char: Omit): boolean { if (!_chars[userKey]) _chars[userKey] = { characters: [] }; - const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase()); + const exists = _chars[userKey].characters.some( + (c) => c.name.toLowerCase() === char.name.toLowerCase() + ); if (exists) return false; - // If no active character, set this one as active const hasActive = _chars[userKey].characters.some((c) => c.active); _chars[userKey].characters.push({ ...char, active: !hasActive }); saveCharacters(); return true; } -export function removeCharacter(userKey: string, name: string): boolean { +export function removeCharacter(userKey: UserKey, name: string): boolean { if (!_chars[userKey]) return false; const before = _chars[userKey].characters.length; _chars[userKey].characters = _chars[userKey].characters.filter( (c) => c.name.toLowerCase() !== name.toLowerCase() ); if (_chars[userKey].characters.length === before) return false; - // If we removed the active one, set the first remaining as active - if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) { + if (!_chars[userKey].characters.some((c) => c.active) && + _chars[userKey].characters.length > 0) { _chars[userKey].characters[0].active = true; } saveCharacters(); return true; } -export function setActiveCharacter(userKey: string, name: string): boolean { +export function setActiveCharacter(userKey: UserKey, name: string): boolean { const chars = _chars[userKey]?.characters; if (!chars) return false; const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); @@ -78,34 +117,125 @@ export function setActiveCharacter(userKey: string, name: string): boolean { return true; } -export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean { - const char = getCharacterByName(userKey, name); - if (!char) return false; - char.nation = nation; +export function setCharacterNation(userKey: UserKey, name: string, nation: Nation): boolean { + const raw = _chars[userKey]?.characters.find( + (c) => c.name.toLowerCase() === name.toLowerCase() + ); + if (!raw) return false; + raw.nation = nation; saveCharacters(); return true; } export function setCharacterStats( - userKey: string, + userKey: UserKey, name: string, stats: { atk?: number; def?: number; heal?: number } ): boolean { - const char = getCharacterByName(userKey, name); - if (!char) return false; - if (!char.stats) char.stats = {}; - Object.assign(char.stats, stats); + const raw = _chars[userKey]?.characters.find( + (c) => c.name.toLowerCase() === name.toLowerCase() + ); + if (!raw) return false; + if (!raw.stats) raw.stats = {}; + Object.assign(raw.stats, stats); + saveCharacters(); + return true; +} + +export function shareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean { + const raw = _chars[ownerKey]?.characters.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ); + if (!raw) return false; + if (!raw.sharedWith) raw.sharedWith = []; + if (raw.sharedWith.includes(targetKey)) return false; + raw.sharedWith.push(targetKey); + saveCharacters(); + return true; +} + +export function unshareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean { + const raw = _chars[ownerKey]?.characters.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ); + if (!raw || !raw.sharedWith) return false; + raw.sharedWith = raw.sharedWith.filter((k) => k !== targetKey); saveCharacters(); return true; } // ─── Account data ───────────────────────────────────────────────────────────── -export function getAccountData(userKey: string): AccountData { +export function getAccountData(userKey: UserKey): AccountData { return _accounts[userKey] ?? {}; } -export function setAccountData(userKey: string, data: Partial): void { +export function setAccountData(userKey: UserKey, data: Partial): void { if (!_accounts[userKey]) _accounts[userKey] = {}; Object.assign(_accounts[userKey], data); saveAccounts(); } + + +export const Char = { + load() { + _chars = Store.readOrDefault(Paths.data("characters.json"), {}); + _accounts = Store.readOrDefault(Paths.data("accounts.json"), {}); + }, + + byName({ user, name }: { user: UserKey, name: CharName }): Character|null { + return getCharacterByName(user, name); + }, + + byClass({ owner, charClass }: { owner: string, charClass: CharacterClass }): Character|null { + return getCharacterByClass(owner, charClass.key); + }, + + isOwner({ user, charName }: { user: UserKey, charName: string }): boolean { + return isCharacterOwner(user, charName); + }, + + add({ user, char }: { user: UserKey, char: Omit }): boolean { + return addCharacter(user, char); + }, + + remove({ user, name }: { user: UserKey, name: string }): boolean { + return removeCharacter(user, name); + }, + + active({ user }: { user: UserKey }): Character|null { + return getActiveCharacter(user); + }, + + setActive({ user, name }: { user: UserKey, name: string }): boolean { + return setActiveCharacter(user, name); + }, + + setNation({ user, name, nation }: { user: UserKey, name: string, nation: Nation }): boolean { + return setCharacterNation(user, name, nation); + }, + + // setStats({ user, name, stats }: { user: UserKey, name: string, stats: CharacterStats }): boolean { + // return setCharacterStats(user, name, stats); + // }, + + share({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean { + return shareCharacter(user, charName, targetUser); + }, + + unshare({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean { + return unshareCharacter(user, charName, targetUser); + }, + + hydrate(raw: SerializableCharacter, ownerKey: UserKey): Character { + return { + ...raw, + class: CLASSES[raw.class] ?? { key: raw.class as ClassKey, name: raw.class, shortName: raw.class }, + ownerKey, + }; + }, + + dehydrate(char: Character): SerializableCharacter { + const { ownerKey, ...rest } = char; + return { ...rest, class: char.class.key }; + }, +}; \ No newline at end of file diff --git a/src/systems/config.ts b/src/systems/config.ts index 39c1d05..46a9bfa 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -1,8 +1,8 @@ -import fs from "fs"; import path from "path"; import { BotConfig, Nation } from "../types"; +import { Store } from "@systems/store"; +import { Paths } from "@helpers/paths"; -const CONFIG_PATH = path.join(__dirname, "../../data/config.json"); // Function instead of const so env vars are read lazily at call time function getDefaults(): Required { @@ -21,7 +21,7 @@ function getDefaults(): Required { ], scoreWindowHours: 2, tgDurationMinutes: 35, - nationSource: "Procyon" as Nation, + nationSource: Nation.Procyon, wRankPostOnReset: false, wRankGoal: 7, wRankYellowColor: "#BA7517", @@ -43,13 +43,10 @@ function getDefaults(): Required { let _cfg: BotConfig = {}; export function loadConfig(): void { - try { _cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } - catch { _cfg = {}; } + _cfg = Store.readOrDefault(Paths.data("config.json"), {}); } - export function saveConfig(): void { - try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cfg, null, 2)); } - catch (err) { console.error("Failed to save config.json:", err); } + Store.write(Paths.data("config.json"), _cfg); } export function cfg(key: K): Required[K] { diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts index 608bee2..04bceae 100644 --- a/src/systems/conflict.ts +++ b/src/systems/conflict.ts @@ -12,7 +12,7 @@ import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveChara import { getImpersonation } from "@systems/impersonate"; import { polls, updatePollMessage } from "@systems/poll"; import { resolveMessage, nowFormatted } from "@systems/messages"; -import { getClassEmoji } from "@systems/emojis"; +import { Emoji } from "@systems/emojis"; import { format } from "@systems/format"; import { Character } from "@types"; import { buildCharSelectButtons } from "@systems/charSelect"; @@ -35,7 +35,7 @@ const pendingConflicts = new Map | null = null; + + function loadEmojiMap(): Record { + if (_map) return _map; + + _map = {}; + const dir = Paths.emojis(); + + if (!fs.existsSync(dir)) { + // Fallback to legacy messages/emojis.json + try { + _map = JSON.parse(fs.readFileSync(Paths.messages("emojis.json"), "utf8")); + console.warn("[emojis] data/emojis/ not found, using legacy messages/emojis.json"); + } catch {} + return _map!; + } + + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith(".json")) continue; + try { + const data = JSON.parse(fs.readFileSync(Paths.emojis(file), "utf8")); + Object.assign(_map!, data); + } catch (err) { + console.error(`[emojis] Failed to load ${file}:`, err); + } + } + + return _map!; + } + + export function invalidateEmojiCache(): void { + _map = null; + } + + // ─── Lookups ────────────────────────────────────────────────────────────────── + + export function getEmoji(name: string): string { + return loadEmojiMap()[name] ?? ""; + } -export function loadEmojis(): void { - try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); } - catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; } -} -export function getEmoji(key: string): string { - return _emojis[key] ?? ""; -} + export const Emoji = { + load(): Record { + return loadEmojiMap() + }, -export function getClassEmoji(cls: ClassKey): string { - return getEmoji(cls.toLowerCase()); -} + get(name: string): string { + return loadEmojiMap()[name] ?? ""; + }, -export function getNationEmoji(nation: string): string { - return getEmoji(nation.toLowerCase()); -} + class(cls: ClassKey | CharacterClass): string { + const key = typeof cls === "object" ? cls.key : cls; + return Emoji.get(key.toLowerCase()); + }, -export function resolveEmojiTokens(text: string): string { - return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key)); -} + nation(nation: Nation|string): string { + return getEmoji(nation.toLowerCase()); + }, + + bringer(nation: Nation): string { + const map: Record = { + [Nation.Procyon]: Emoji.get("storm_bringer"), + [Nation.Capella]: Emoji.get("luminous_bringer"), + }; + return map[nation] ?? ""; + }, + + invalidateCache(): void { + _map = null; + }, + + resolveTokens(text: string): string { + return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => Emoji.get(key) || `{emoji:${key}}`) + }, + } diff --git a/src/systems/format.ts b/src/systems/format.ts index 3af6630..33af684 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -1,5 +1,5 @@ -import { ClassKey, Nation, WRankEntry } from "@src/types"; -import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; +import { Character, ClassKey, CharacterClass, Nation, WRankEntry } from "@src/types"; +import { Emoji } from "@systems/emojis"; // ─── Individual formatters ──────────────────────────────────────────────────── @@ -8,17 +8,22 @@ export interface CharDisplayOptions { level?: boolean; // show level (default: true) } +function charButton(c: Character, options?: { shared?: boolean }): string { + const sharedMark = options?.shared ? " 🔗" : ""; + return `${c.level} ${c.name}${sharedMark}`; +} + /** * Format a character for display in embeds and messages. * Output: <:class:> 79 «Flash» */ function char( - c: { class: ClassKey; level: number; name: string }, + c: { class: ClassKey|CharacterClass; level: number; name: string }, options?: CharDisplayOptions ): string { const showEmoji = options?.emoji ?? true; const showLevel = options?.level ?? true; - const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class; + const classStr = showEmoji ? (Emoji.class(c.class) || c.class) : c.class; const levelStr = showLevel ? `${c.level} ` : ""; return `${classStr} ${levelStr}${c.name}`.trim(); } @@ -28,7 +33,7 @@ function char( * Output: <:capella:> Capella */ function nation(n: Nation): string { - const emoji = getNationEmoji(n); + const emoji = Emoji.nation(n); return emoji ? `${emoji} ${n}` : n; } @@ -37,8 +42,8 @@ function nation(n: Nation): string { * Output: <:score:> 3000 <:kd:> 32/18 */ function score(pts: number, k?: number, d?: number): string { - const scoreEmoji = getEmoji("score") || "📊"; - const kdEmoji = getEmoji("kd") || "⚔️"; + const scoreEmoji = Emoji.get("score") || "📊"; + const kdEmoji = Emoji.get("kd") || "⚔️"; const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : ""; return `${scoreEmoji} ${pts}${kdStr}`; } @@ -69,7 +74,7 @@ export interface WRankDisplayOptions { function wrankRank(entry: WRankEntry, goal: number): string { const isDone = entry.tgCount >= goal; const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`; - return getEmoji(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`); + return Emoji.get(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`); } /** @@ -86,13 +91,13 @@ function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string let inner: string; if (delta < 0) { const abs = Math.abs(delta); - const numEmoji = getEmoji(`wrank_up_${abs}`); - inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs); + const numEmoji = Emoji.get(`wrank_up_${abs}`); + inner = (Emoji.get("wrank_up") || "↑") + (numEmoji || abs); } else if (delta > 0) { - const numEmoji = getEmoji(`wrank_down_${delta}`); - inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta); + const numEmoji = Emoji.get(`wrank_down_${delta}`); + inner = (Emoji.get("wrank_down") || "↓") + (numEmoji || delta); } else { - inner = (getEmoji("wrank_no_dash") || "·") + (getEmoji("wrank_neutral_0") || "0"); + inner = (Emoji.get("wrank_no_dash") || "·") + (Emoji.get("wrank_neutral_0") || "0"); } return brackets ? ` (${inner})` : ` ${inner}`; @@ -108,20 +113,30 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string { /** * Placeholder for characters with no W.Rank when others in their nation have one. - * Output: — ( [] — ) + * Output: — ( — 0 ) */ function wrankNoRank(): string { - const norank = getEmoji("wrank_no_dash") || "—"; - const dash = getEmoji("wrank_no_rank_delta") || "—"; - const square = getEmoji("wrank_no_dash") || "■"; + const norank = Emoji.get("wrank_no_dash") || "—"; + const dash = Emoji.get("wrank_no_rank_delta") || "—"; + const square = Emoji.get("wrank_no_dash") || "■"; return `${norank} (${square}${dash})`; } +// ─── Bringer formatters ──────────────────────────────────────────────────────── + +function bringerDisplay(n: Nation): string { + const bringerMap: Record = { + Capella: Emoji.get("luminous_bringer") || "🔆 Luminous Bringer", + Procyon: Emoji.get("storm_bringer") || "⚡ Storm Bringer", + }; + return bringerMap[n]; +} // ─── Namespace export ───────────────────────────────────────────────────────── export const format = { char, + charButton, nation, score, emoji, @@ -131,4 +146,5 @@ export const format = { full: wrankFull, noRank: wrankNoRank, }, + bringer: bringerDisplay, }; \ No newline at end of file diff --git a/src/systems/history.ts b/src/systems/history.ts index 2a8423e..e752155 100644 --- a/src/systems/history.ts +++ b/src/systems/history.ts @@ -1,7 +1,9 @@ import fs from "fs"; import path from "path"; import { TGResult, TGScore, Nation } from "../types"; -import { oppositeNation } from "./nations"; +import { Nations } from "@systems/nations"; +import { Store } from "@systems/store"; +import { Paths } from "@helpers/paths"; const HISTORY_DIR = path.join(__dirname, "../../data/tg-history"); @@ -14,17 +16,11 @@ function historyPath(key: string): string { } export function loadResult(date: string, slot: number): TGResult | null { - try { - return JSON.parse(fs.readFileSync(historyPath(historyKey(date, slot)), "utf8")); - } catch { - return null; - } + return Store.read(historyPath(historyKey(date, slot))); } - export function saveResult(result: TGResult): void { if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); - const key = historyKey(result.date, result.slot); - fs.writeFileSync(historyPath(key), JSON.stringify(result, null, 2)); + Store.write(historyPath(historyKey(result.date, result.slot)), result); } export function upsertScore(score: TGScore): void { @@ -33,7 +29,7 @@ export function upsertScore(score: TGScore): void { date: score.date, confirmed: false, nationKD: { - source: "Procyon" as Nation, + source: Nation.Procyon, capella: { k: 0, d: 0 }, procyon: { k: 0, d: 0 }, }, @@ -64,7 +60,7 @@ export function setNationKD( }; result.nationKD.source = sourceNation; - const other = oppositeNation(sourceNation); + const other = Nations.opposite(sourceNation); result.nationKD[sourceNation.toLowerCase() as "capella" | "procyon"] = { k, d }; result.nationKD[other.toLowerCase() as "capella" | "procyon"] = { k: d, d: k }; diff --git a/src/systems/impersonate.ts b/src/systems/impersonate.ts index 71e4005..e3579a1 100644 --- a/src/systems/impersonate.ts +++ b/src/systems/impersonate.ts @@ -1,5 +1,5 @@ -import fs from "fs"; -import path from "path"; +import { UserRegistry } from "@registry/user-registry"; +import { UsermapEntry } from "@types"; const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false"; const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false"; @@ -28,16 +28,17 @@ export function shouldShowIndicator(): boolean { } // Returns all registered userKeys from usermap.json + export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] { try { - const usermap = JSON.parse( - fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") - ); - return Object.entries(usermap).map(([, entry]: [string, any]) => { - const fileKey = typeof entry === "string" ? entry : entry.file; - const aliases = typeof entry === "object" ? (entry.aliases ?? []) : []; - return { userKey: fileKey, aliases }; - }); + const entries = UserRegistry.all(); + const seen = new Set(); + return entries.map(({ entry }: { entry: UsermapEntry }) => ({ userKey: entry.file, aliases: entry.aliases ?? [] })) + .filter(({ userKey }) => { + if (seen.has(userKey)) return false; + seen.add(userKey); + return true; + }); } catch { return []; } diff --git a/src/systems/logger.ts b/src/systems/logger.ts new file mode 100644 index 0000000..e6d867b --- /dev/null +++ b/src/systems/logger.ts @@ -0,0 +1,140 @@ +/** + * Logger — structured logging with levels, contexts, and icons. + * Inspired by the shell logger pattern. + * + * Usage: + * import { Logger } from "@systems/logger"; + * + * const log = Logger.for("poll"); + * log.info("Poll started"); + * log.warn("No voters found"); + * log.error("Failed to update message", err); + * log.debug("State:", state); + */ + +// ─── Log levels ─────────────────────────────────────────────────────────────── + +export enum LogLevel { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, + Silent = 4, +} + +const LEVEL_LABELS: Record = { + [LogLevel.Debug]: "DEBUG", + [LogLevel.Info]: "INFO", + [LogLevel.Warn]: "WARN", + [LogLevel.Error]: "ERROR", + [LogLevel.Silent]: "SILENT", +}; + +const LEVEL_COLORS: Record = { + [LogLevel.Debug]: "\x1b[36m", // cyan + [LogLevel.Info]: "\x1b[34m", // blue + [LogLevel.Warn]: "\x1b[33m", // yellow + [LogLevel.Error]: "\x1b[31m", // red + [LogLevel.Silent]: "", +}; + +const RESET = "\x1b[0m"; + +// ─── Context icons ──────────────────────────────────────────────────────────── + +const CONTEXT_ICONS: Record = { + poll: "📊", + vote: "🗳️", + char: "⚔️", + wrank: "🏆", + borrow: "🔗", + conflict: "⚠️", + score: "📈", + attendance: "📋", + scheduler: "⏰", + store: "💾", + registry: "📦", + discord: "🤖", + bringer: "⚡", + impersonate:"🎭", + ephemeral: "💬", + default: "🔹", +}; + +function getIcon(context: string): string { + return CONTEXT_ICONS[context.toLowerCase()] ?? CONTEXT_ICONS.default; +} + +// ─── Global config ──────────────────────────────────────────────────────────── + +let _globalLevel: LogLevel = (() => { + const env = process.env.LOG_LEVEL?.toUpperCase(); + switch (env) { + case "DEBUG": return LogLevel.Debug; + case "WARN": return LogLevel.Warn; + case "ERROR": return LogLevel.Error; + case "SILENT": return LogLevel.Silent; + default: return LogLevel.Info; + } +})(); + +// ─── Logger instance ────────────────────────────────────────────────────────── + +export interface LoggerInstance { + debug(message: string, ...args: any[]): void; + info(message: string, ...args: any[]): void; + warn(message: string, ...args: any[]): void; + error(message: string, ...args: any[]): void; + success(message: string, ...args: any[]): void; +} + +function createLogger(context: string): LoggerInstance { + const icon = getIcon(context); + const prefix = `${icon} [${context}]`; + + function log(level: LogLevel, message: string, ...args: any[]): void { + if (level < _globalLevel) return; + const color = LEVEL_COLORS[level]; + const label = LEVEL_LABELS[level]; + const ts = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm + console.log(`${color}${ts} ${prefix} ${label}:${RESET} ${message}`, ...args); + } + + return { + debug: (msg, ...args) => log(LogLevel.Debug, msg, ...args), + info: (msg, ...args) => log(LogLevel.Info, msg, ...args), + warn: (msg, ...args) => log(LogLevel.Warn, msg, ...args), + error: (msg, ...args) => log(LogLevel.Error, msg, ...args), + success: (msg, ...args) => log(LogLevel.Info, `✅ ${msg}`, ...args), + }; +} + +// ─── Logger cache ────────────────────────────────────────────────────────────── +const _loggers = new Map(); + +export const Logger = { + /** + * Get a logger for a specific context. + * Cached — same context always returns the same instance. + */ + for(context: string): LoggerInstance { + if (!_loggers.has(context)) { + _loggers.set(context, createLogger(context)); + } + return _loggers.get(context)!; + }, + + /** + * Set the global log level. + */ + setLevel(level: LogLevel): void { + _globalLevel = level; + }, + + /** + * Get the current global log level. + */ + getLevel(): LogLevel { + return _globalLevel; + }, +}; \ No newline at end of file diff --git a/src/systems/messages.ts b/src/systems/messages.ts index e59c1a1..ff70577 100644 --- a/src/systems/messages.ts +++ b/src/systems/messages.ts @@ -1,7 +1,8 @@ import fs from "fs"; import path from "path"; -import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "../types"; -import { resolveEmojiTokens } from "./emojis"; +import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types"; +import { Emoji } from "@systems/emojis"; +import { UserRegistry } from "@registry/user-registry"; const MESSAGES_DIR = path.join(__dirname, "../../messages"); const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json"); @@ -104,7 +105,7 @@ export function interpolate( .replace(/\{date\}/g, dt.dateKey) .replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0")); - return resolveEmojiTokens(result); + return Emoji.resolveTokens(result); } // ─── Resolution ────────────────────────────────────────────────────────────── @@ -162,3 +163,18 @@ export function getUsermapEntry(discordUsername: string): UsermapEntry | null { if (typeof entry === "string") return { file: entry, aliases: [] }; return entry as UsermapEntry; } + +// Look up by Discord ID first, fall back to username for backwards compatibility +export function getUsermapEntryById(discordId: string, discordUsername: string): UsermapEntry | null { + return getUsermapEntry(discordId) ?? getUsermapEntry(discordUsername); +} + +export function setUsermapEntry(discordId: string, entry: UsermapEntry): void { + MESSAGES.userMap[discordId] = entry; + UserRegistry.set(discordId, entry); +} + +export function removeUsermapEntry(discordId: string): void { + delete MESSAGES.userMap[discordId]; + UserRegistry.remove(discordId); +} diff --git a/src/systems/nations.ts b/src/systems/nations.ts index 5278789..03d6a12 100644 --- a/src/systems/nations.ts +++ b/src/systems/nations.ts @@ -2,21 +2,49 @@ import { GuildMember } from "discord.js"; import { Nation } from "../types"; import { getActiveCharacter } from "./characters"; -// Resolve a user's nation — character nation takes priority over Discord role -export function resolveNation(member: GuildMember, userKey: string | null): Nation | null { - // 1. Active character's nation - if (userKey) { - const char = getActiveCharacter(userKey); - if (char) return char.nation; - } +export const NATION_UNICODE: Record = { + Capella: "🔴", + Procyon: "🔵", +}; - // 2. Discord role fallback - if (member.roles.cache.some((r) => r.name === "Capella")) return "Capella"; - if (member.roles.cache.some((r) => r.name === "Procyon")) return "Procyon"; +export const NATION_KEY: Record = { + [Nation.Capella]: "capella", + [Nation.Procyon]: "procyon", +}; - return null; -} +export const NATION_FROM_KEY: Record<"capella" | "procyon", Nation> = { + "capella": Nation.Capella, + "procyon": Nation.Procyon, +}; -export function oppositeNation(nation: Nation): Nation { - return nation === "Capella" ? "Procyon" : "Capella"; -} +export const Nations = { + key(nation: Nation): "capella" | "procyon" { + return NATION_KEY[nation]; + }, + + fromKey(key: "capella" | "procyon"): Nation { + return NATION_FROM_KEY[key]; + }, + + resolve(member: GuildMember, userKey: string|null): Nation | null { + // 1. Active character's nation + if (userKey) { + const char = getActiveCharacter(userKey); + if (char) return char.nation; + } + + // 2. Discord role fallback + if (member.roles.cache.some((r) => r.name === Nation.Capella)) return Nation.Capella; + if (member.roles.cache.some((r) => r.name === Nation.Procyon)) return Nation.Procyon; + + return null; + }, + + opposite(nation: Nation): Nation { + return nation === Nation.Capella ? Nation.Procyon : Nation.Capella; + }, + + unicode(nation: Nation): string { + return NATION_UNICODE[nation] ?? ""; + }, +} \ No newline at end of file diff --git a/src/systems/poll.ts b/src/systems/poll.ts index d6e1ca7..6d1251f 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -8,15 +8,16 @@ import { } from "discord.js"; import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; import { cfg } from "@systems/config"; -import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis"; -import { getActiveCharacter, getCharacterByName } from "@systems/characters"; -import { resolveNation } from "@systems/nations"; -import { getEntry, getBringer } from "@systems/wrank"; -import { nowFormatted } from "@systems/messages"; +import { Emoji } from "@systems/emojis"; +import { Nations } from "@systems/nations"; +import { WRank } from "@systems/wrank"; import { format } from "@format"; import { persist } from "@systems/pollPersistence" -import { clearSessionBorrows } from "@systems/borrow"; +import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow"; import { clearAllImpersonations } from "@systems/impersonate"; +import { Bringer } from "@systems/bringer"; +import { Attendance } from "@systems/attendance"; + // ─── Poll state ─────────────────────────────────────────────────────────────── export const polls: Map = new Map(); @@ -68,38 +69,17 @@ export function lockPoll(slot: number): void { ); persist.save(polls) + Attendance.snapshot(slot, state.lockedYesKeys); } // ─── Character display ──────────────────────────────────────────────────────── -function getNationBringerTitle(nation: Nation) { - const stormBringerIcon = getEmoji("storm_bringer") || "⚡"; - const stormBringer = `${stormBringerIcon}`; - - const luminousBringerIcon = getEmoji("luminous_bringer") || "🔆"; - const luminousBringer = `${luminousBringerIcon}` || `🔆 Luminous Bringer`; - - const nationMap = { - "Capella": luminousBringer, - "Procyon": stormBringer - }; - - return nationMap[nation]; -} - -function getBringerDisplay(nation: Nation): string { - const bringerMap: Record = { - Capella: getEmoji("luminous_bringer") || "🔆 Luminous Bringer", - Procyon: getEmoji("storm_bringer") || "⚡ Storm Bringer", - }; - return bringerMap[nation]; -} function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string { const cfgFormat = cfg("charDisplayFormat"); const nation = entry.characterNation; const wRankEntry = entry.characterName && entry.characterNation - ? getEntry(entry.characterName, entry.characterNation) + ? WRank.entry(entry.characterName, entry.characterNation) : null; let wrank = ""; @@ -111,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank } const classStr = entry.characterClass - ? (getClassEmoji(entry.characterClass) || entry.characterClass) + ? (Emoji.class(entry.characterClass) || entry.characterClass) : ""; const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) @@ -122,23 +102,24 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank .replace("{wrank}", wrank) .replace("{class}", classStr) .replace("{level}", levelStr) - .replace("{name}", entry.characterName ?? entry.displayName) + .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); + const bringer = Bringer.get({ nation }); + if (bringer && bringer === entry.characterName) { - row += ` · ${getBringerDisplay(nation)}`; + row += ` · ${format.bringer(nation)}`; } } if (entry.borrowedFrom) { - row += ` ${getEmoji("borrowed") || "🔗"}`; + row += ` ${Emoji.get("borrowed") || "🔗"}`; } - if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`; + if (showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; return row; } @@ -151,7 +132,7 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui const showNoInline = (cfg as any)("showNoInNationField") ?? false; for (const entry of state.yes.values()) { - const nation = entry.characterNation ?? "Capella"; + const nation = entry.characterNation ?? Nation.Capella; yesByNation[nation].push(entry); allMessages.push({ entry, voteType: "yes" }); } @@ -160,12 +141,12 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui allMessages.push({ entry, voteType: "no" }); } - const capellaEmoji = getEmoji("capella"); - const procyonEmoji = getEmoji("procyon"); + const capellaEmoji = Emoji.get("capella"); + const procyonEmoji = Emoji.get("procyon"); const formatNationField = (nation: Nation): string => { const yesEntries = yesByNation[nation]; - const hasRank = yesEntries.some((e) => e.characterName && getEntry(e.characterName, nation) !== null); + const hasRank = yesEntries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null); const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; @@ -212,9 +193,9 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui .setTitle(title) .setColor(color) .addFields( - { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false }, + { name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, value: formatNationField(Nation.Capella), inline: false }, { name: "\u200b", value: "\u200b", inline: false }, - { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false }, + { name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, value: formatNationField(Nation.Procyon), inline: false }, ) .setTimestamp(); @@ -238,7 +219,7 @@ export function buildButtons( showSubmit?: boolean ): ActionRowBuilder[] { if (showSubmit) { - const scoreEmoji = getEmoji("score"); + const scoreEmoji = Emoji.get("score"); const submitBtn = new ButtonBuilder() .setCustomId("tg_score_submit") .setLabel("Submit Score") @@ -303,8 +284,8 @@ export function createVoteEntry( const globalNickname = member.user.globalName ?? null; const displayName = serverNickname ?? globalNickname ?? discordUsername; - const { getEffectiveCharacter } = require("@systems/borrow"); const { char, borrowedFrom: bf } = userKey + ? getEffectiveCharacter(userKey) : { char: null, borrowedFrom: null }; console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); @@ -315,7 +296,7 @@ export function createVoteEntry( characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, - characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), + characterNation: char?.nation ?? (Nations.resolve(member, userKey) ?? undefined), borrowedFrom: bf ?? undefined, }; } \ No newline at end of file diff --git a/src/systems/pollPersistence.ts b/src/systems/pollPersistence.ts index 19dbe89..ebe9592 100644 --- a/src/systems/pollPersistence.ts +++ b/src/systems/pollPersistence.ts @@ -1,19 +1,21 @@ import fs from "fs"; import path from "path"; import { PollState, VoteEntry } from "@src/types"; +import { Store } from "@systems/store"; +import { Paths } from "@helpers/paths"; -const PERSIST_PATH = path.join(__dirname, "../../data/poll-state.json"); +const PERSIST_PATH = Paths.data("poll-state.json"); // ─── Serialized shape ───────────────────────────────────────────────────────── // Maps → arrays of [key, value] tuples, Sets → arrays of values interface SerializedPollState { - messageId: string | null; + messageId?: string | null; slot: number; yes: [string, VoteEntry][]; no: [string, VoteEntry][]; locked: boolean; - confirmed: "yes" | "no" | null; + confirmed?: "yes" | "no" | null; lockMessage?: string; confirmMessage?: string; lockedYesKeys?: string[]; @@ -58,7 +60,7 @@ function deserialize(data: SerializedPollState[]): Map { export namespace persist { export function save(polls: Map): void { try { - fs.writeFileSync(PERSIST_PATH, JSON.stringify(serialize(polls), null, 2), "utf8"); + Store.write(PERSIST_PATH, serialize(polls)); } catch (err) { console.error("[pollPersistence] Failed to save poll state:", err); } @@ -66,9 +68,9 @@ export namespace persist { export function load(): Map | null { try { - if (!fs.existsSync(PERSIST_PATH)) return null; - const raw = fs.readFileSync(PERSIST_PATH, "utf8"); - const data = JSON.parse(raw) as SerializedPollState[]; + const data = Store.read(PERSIST_PATH); + if (!data) return null; + const polls = deserialize(data); console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`); return polls; @@ -80,7 +82,7 @@ export namespace persist { export function clear(): void { try { - if (fs.existsSync(PERSIST_PATH)) fs.unlinkSync(PERSIST_PATH); + Store.delete(PERSIST_PATH); } catch (err) { console.error("[pollPersistence] Failed to clear poll state:", err); } diff --git a/src/systems/registry/character-registry.ts b/src/systems/registry/character-registry.ts new file mode 100644 index 0000000..9f3472c --- /dev/null +++ b/src/systems/registry/character-registry.ts @@ -0,0 +1,100 @@ +/** + * CharacterRegistry — central lookup for Character objects. + * + * Usage: + * import { CharacterRegistry } from "@registry/CharacterRegistry"; + * + * const char = CharacterRegistry.find("«Flash»"); + * const chars = CharacterRegistry.forUser("flash"); + * const active = CharacterRegistry.getActive("flash"); + */ + + import fs from "fs"; + import path from "path"; + import { Character, UserKey, CharName } from "@types"; + import { Paths } from "@helpers/paths"; + + const CHARS_PATH = Paths.data("characters.json"); + + let _cache: Record | null = null; + + function loadChars(): Record { + if (!_cache) { + try { + _cache = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); + console.log("[CharacterRegistry] cache miss — loading from disk"); + } + catch { _cache = {}; } + } + + return _cache!; + } + + export const CharacterRegistry = { + /** + * Find a character by name across all users. + * Character names are unique across users (enforced by conflict system). + */ + find(charName: CharName): Character | null { + const chars = loadChars(); + for (const data of Object.values(chars)) { + const found = data.characters?.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ); + if (found) return found; + } + return null; + }, + + all(): Character[] { + const chars = loadChars(); + return Object.values(chars).flatMap((data: any) => data.characters ?? []); + }, + + /** + * Find a character by name for a specific user. + */ + findForUser(userKey: UserKey, charName: CharName): Character | null { + const chars = loadChars(); + return chars[userKey]?.characters?.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ) ?? null; + }, + + /** + * Get all characters for a user. + */ + forUser(userKey: UserKey): Character[] { + const chars = loadChars(); + return chars[userKey]?.characters ?? []; + }, + + /** + * Get the active character for a user (from characters.json only, no borrow). + * For effective char (including borrows), use getEffectiveCharacter from borrow.ts. + */ + getActive(userKey: UserKey): Character | null { + return CharacterRegistry.forUser(userKey).find((c) => c.active) ?? null; + }, + + /** + * Get all characters shared with a given user across all owners. + */ + sharedWith(userKey: UserKey): { char: Character; ownerKey: UserKey }[] { + const chars = loadChars(); + const result: { char: Character; ownerKey: UserKey }[] = []; + for (const [ownerKey, data] of Object.entries(chars)) { + if (ownerKey === userKey) continue; + for (const char of data.characters ?? []) { + if (char.sharedWith?.includes(userKey)) { + result.push({ char, ownerKey }); + } + } + } + return result; + }, + + invalidateCache(): void { + _cache = null; + }, + }; \ No newline at end of file diff --git a/src/systems/registry/ephemeral-registry.ts b/src/systems/registry/ephemeral-registry.ts new file mode 100644 index 0000000..aeffc1b --- /dev/null +++ b/src/systems/registry/ephemeral-registry.ts @@ -0,0 +1,112 @@ +/** + * EphemeralRegistry — tracks ephemeral messages so they can be updated later + * using the original interaction token (works within Discord's 15 minute window). + * + * Usage: + * import { Ephemeral } from "@systems/ephemeral-registry"; + * + * // Store after sending followUp + * const msg = await interaction.followUp({ ..., fetchReply: true }); + * Ephemeral.store(voteId, "companion", interaction, msg.id); + * + * // Update later (within 15 minutes) + * await Ephemeral.update(voteId, "companion", "✅ Done", []); + * + * // Clear all for a user (e.g. on poll start) + * Ephemeral.clear(voteId); + */ + + import { ButtonInteraction, ChatInputCommandInteraction, Message } from "discord.js"; + + type AnyInteraction = ChatInputCommandInteraction | ButtonInteraction; + + interface EphemeralEntry { + interaction: AnyInteraction; + messageId: string; + storedAt: number; + } + + const DISCORD_TOKEN_TTL_MS = 14 * 60 * 1000; // 14 min (Discord allows 15) + const registry = new Map(); + + function makeKey(id: string, tag: string): string { + return `${id}:${tag}`; + } + + function isExpired(entry: EphemeralEntry): boolean { + return Date.now() - entry.storedAt > DISCORD_TOKEN_TTL_MS; + } + + export const Ephemeral = { + /** + * Store a reference to an ephemeral followUp message. + * @param id — voter ID or userKey + * @param tag — identifier (e.g. "companion", "conflict", "score_prompt") + * @param interaction — the original interaction that sent the followUp + * @param messageId — the message ID returned from fetchReply: true + */ + store(id: string, tag: string, interaction: AnyInteraction, messageId: string): void { + registry.set(makeKey(id, tag), { interaction, messageId, storedAt: Date.now() }); + }, + + /** + * Get a stored entry (returns null if not found or expired). + */ + get(id: string, tag: string): EphemeralEntry | null { + const entry = registry.get(makeKey(id, tag)); + if (!entry) return null; + if (isExpired(entry)) { + registry.delete(makeKey(id, tag)); + return null; + } + return entry; + }, + + /** + * Update a stored ephemeral message via the original interaction token. + * Silently fails if not found, expired, or Discord rejects the edit. + */ + async update( + id: string, + tag: string, + content: string, + components: any[] = [], + options: { final?: boolean } = { final: true } + ): Promise { + const entry = Ephemeral.get(id, tag); + console.log(`[Ephemeral.update] id=${id} tag=${tag} found=${!!entry}`); + if (!entry) return; + try { + console.log(`[Ephemeral.update] editing messageId=${entry.messageId}`); + await entry.interaction.webhook.editMessage(entry.messageId, { content, components }); + if (options.final !== false) Ephemeral.delete(id, tag); + console.log(`[Ephemeral.update] success`); + } catch (err: any) { + console.error(`[Ephemeral.update] failed:`, err.message); + Ephemeral.delete(id, tag); + } + }, + + /** + * Delete a stored entry. + */ + delete(id: string, tag: string): void { + registry.delete(makeKey(id, tag)); + }, + + /** + * Clear all ephemerals for a given ID. + */ + clear(id: string): void { + for (const key of registry.keys()) { + if (key.startsWith(`${id}:`)) registry.delete(key); + } + }, + + /** + * Clear all entries (e.g. on full poll reset). + */ + clearAll(): void { + registry.clear(); + }, + }; \ No newline at end of file diff --git a/src/systems/registry/user-registry.ts b/src/systems/registry/user-registry.ts new file mode 100644 index 0000000..eebd766 --- /dev/null +++ b/src/systems/registry/user-registry.ts @@ -0,0 +1,108 @@ +/** + * UserRegistry — central lookup for user identity. + * + * Usage: + * import { UserRegistry } from "@registry/user-registry"; + * + * UserRegistry.find({ id: "1234567890" }) + * UserRegistry.find({ userKey: "flash" }) + * UserRegistry.all() + * UserRegistry.invalidateCache() + */ + + import fs from "fs"; + import { Paths } from "@helpers/paths"; + import { UserKey } from "@types"; + import { Store } from "@systems/store"; + + export interface UsermapEntry { + file: UserKey; + aliases: string[]; + } + + type RawUsermapValue = string | UsermapEntry; + + // ─── Cache ──────────────────────────────────────────────────────────────────── + let _cache: Record | null = null; + + function loadUsermap(): Record { + if (!_cache) { + _cache = Store.readOrDefault(Paths.data("usermap.json"), {}); + } + return _cache!; +} + + function normalize(value: RawUsermapValue): UsermapEntry { + if (typeof value === "string") return { file: value, aliases: [] }; + return value as UsermapEntry; + } + + // ─── Registry ───────────────────────────────────────────────────────────────── + export const UserRegistry = { + /** + * Find a user by Discord ID (new format) or Discord username (legacy). + * ID-first lookup with username fallback for backwards compatibility. + */ + find({ id, username }: { id?: string; username?: string }): UsermapEntry | null { + const usermap = loadUsermap(); + + if (id) { + const byId = usermap[id]; + if (byId) return normalize(byId); + } + + if (username) { + const byUsername = usermap[username]; + if (byUsername) return normalize(byUsername); + } + + return null; + }, + + /** + * Find a user by userKey (the file/system key). + */ + findByKey(userKey: UserKey): UsermapEntry | null { + const usermap = loadUsermap(); + const entry = Object.values(usermap).find((v) => normalize(v).file === userKey); + return entry ? normalize(entry) : null; + }, + + /** + * Get all registered users. + */ + all(): { key: string; entry: UsermapEntry }[] { + const usermap = loadUsermap(); + return Object.entries(usermap).map(([key, value]) => ({ + key, + entry: normalize(value), + })); + }, + + /** + * Register a new mapping (Discord ID → userKey). + */ + set(discordId: string, entry: UsermapEntry): void { + const usermap = loadUsermap(); + usermap[discordId] = entry; + _cache = usermap; + Store.write(Paths.data("usermap.json"), usermap); + }, + + /** + * Remove a mapping. + */ + remove(key: string): void { + const usermap = loadUsermap(); + delete usermap[key]; + _cache = usermap; + Store.write(Paths.data("usermap.json"), usermap); + }, + + /** + * Invalidate the cache (call after reload). + */ + invalidateCache(): void { + _cache = null; + }, + }; \ No newline at end of file diff --git a/src/systems/scheduler.ts b/src/systems/scheduler.ts new file mode 100644 index 0000000..1077703 --- /dev/null +++ b/src/systems/scheduler.ts @@ -0,0 +1,115 @@ +/** + * Scheduler — manages all cron jobs for the bot. + * + * Usage: + * import { Scheduler } from "@systems/scheduler"; + * Scheduler.schedule(client); + * Scheduler.reschedule(client); // after slot config changes + */ + + import cron from "node-cron"; + import { Client, TextChannel } from "discord.js"; + import { cfg } from "@systems/config"; + import { TGSlot } from "@types"; + import { polls, updatePollMessage } from "@systems/poll"; + import { WRank } from "@systems/wrank"; + import { Nation } from "@types"; + + type PollCallback = (slot: TGSlot) => Promise; + type LockCallback = (slot: TGSlot) => Promise; + type CloseCallback = (slot: TGSlot) => Promise; + + let _tasks: cron.ScheduledTask[] = []; + + function stopAll(): void { + _tasks.forEach((t) => t.stop()); + _tasks = []; + } + + export const Scheduler = { + /** + * Schedule all cron jobs — slot polls, weekly reset. + * Call once on bot startup, and again after slot config changes. + */ + schedule( + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, + ): void { + stopAll(); + + const tz = process.env.TZ ?? "Etc/GMT-2"; + const slots = cfg("slots").filter((s) => s.active); + + for (const slot of slots) { + // Poll open + const [openHour, openMin] = slot.pollOpens.split(":").map(Number); + _tasks.push(cron.schedule( + `${openMin} ${openHour} * * *`, + () => onPollOpen(slot), + { timezone: tz } + )); + + // Poll lock — at tgHour (TG start, voting closes, attendance snapshotted) + _tasks.push(cron.schedule( + `0 ${slot.tgHour} * * *`, + () => onPollLock(slot), + { timezone: tz } + )); + + // Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears) + const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; + const closeHour = Math.floor(closeMinTotal / 60) % 24; + const closeMin = closeMinTotal % 60; + _tasks.push(cron.schedule( + `${closeMin} ${closeHour} * * *`, + () => onPollClose(slot), + { timezone: tz } + )); + + // Midnight cleanup — remove Submit Score button if poll is still showing it + _tasks.push(cron.schedule("0 0 * * *", async () => { + const state = polls.get(slot.tgHour); + if (!state?.locked) return; + try { + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot.tgHour, undefined, false); + console.log(`[Scheduler] Submit Score button removed for ${slot.tgHour}:00`); + } catch (err) { + console.error(`[Scheduler] Failed to remove Submit Score button:`, err); + } + }, { timezone: tz })); + } + + // Weekly reset — Monday 00:00 + _tasks.push(cron.schedule("0 0 * * 1", () => { + console.log(`[Scheduler] Weekly W.Rank reset starting...`); + WRank.resetWeek(); + console.log(`[Scheduler] Weekly W.Rank reset complete.`); + }, { timezone: tz })); + + console.log(`[Scheduler] Scheduled ${slots.length} slot(s) + weekly reset.`); + }, + + /** + * Reschedule all jobs — call after slot config changes. + */ + reschedule( + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, + ): void { + console.log(`[Scheduler] Rescheduling...`); + Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); + }, + + /** + * Stop all scheduled jobs. + */ + stop(): void { + stopAll(); + console.log(`[Scheduler] All jobs stopped.`); + }, + }; \ No newline at end of file diff --git a/src/systems/scheduler/index.ts b/src/systems/scheduler/index.ts new file mode 100644 index 0000000..bcd2a50 --- /dev/null +++ b/src/systems/scheduler/index.ts @@ -0,0 +1,106 @@ +/** + * Scheduler — plugin-based cron job manager. + * + * Drop a file exporting `job: ScheduledJob` in this directory + * and it will be automatically scheduled. + * + * Slot-specific jobs (poll open/lock/close) are registered separately + * via Scheduler.scheduleSlots() since they depend on runtime config. + */ + + import cron from "node-cron"; + import { Client, TextChannel } from "discord.js"; + import { ScheduledJob } from "./types"; + import { cfg } from "@systems/config"; + import { TGSlot } from "@types"; + + // Import all jobs + import { job as weeklyReset } from "@scheduler/weekly-reset"; + import { job as midnightCleanup } from "@scheduler/midnight-cleanup"; + + const STATIC_JOBS: ScheduledJob[] = [ + weeklyReset, + midnightCleanup, + ]; + + type PollCallback = (slot: TGSlot) => Promise; + type LockCallback = (slot: TGSlot) => Promise; + type CloseCallback = (slot: TGSlot) => Promise; + + let _tasks: cron.ScheduledTask[] = []; + + function stopAll(): void { + _tasks.forEach((t) => t.stop()); + _tasks = []; + } + + export const Scheduler = { + /** + * Schedule all jobs — static crons + slot-based polls. + */ + schedule( + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, + ): void { + stopAll(); + + const tz = process.env.TZ ?? "Etc/GMT-2"; + const slots = cfg("slots").filter((s) => s.active); + + console.log(`[Scheduler] Weekly reset scheduled: "0 0 * * 1" in ${tz}`); + + // Static jobs + for (const job of STATIC_JOBS) { + _tasks.push(cron.schedule( + job.cron, + () => job.run(client), + { timezone: job.timezone ?? tz } + )); + console.log(`[Scheduler] Registered: ${job.name} (${job.cron})`); + } + + // Slot-based jobs + for (const slot of slots) { + const [openHour, openMin] = slot.pollOpens.split(":").map(Number); + + _tasks.push(cron.schedule( + `${openMin} ${openHour} * * *`, + () => onPollOpen(slot), + { timezone: tz } + )); + + _tasks.push(cron.schedule( + `0 ${slot.tgHour} * * *`, + () => onPollLock(slot), + { timezone: tz } + )); + + const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; + const closeHour = Math.floor(closeMinTotal / 60) % 24; + const closeMin = closeMinTotal % 60; + _tasks.push(cron.schedule( + `${closeMin} ${closeHour} * * *`, + () => onPollClose(slot), + { timezone: tz } + )); + } + + console.log(`[Scheduler] ${STATIC_JOBS.length} static jobs + ${slots.length} slot(s) scheduled.`); + }, + + reschedule( + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, + ): void { + Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); + }, + + stop(): void { + stopAll(); + console.log(`[Scheduler] All jobs stopped.`); + }, + }; \ No newline at end of file diff --git a/src/systems/scheduler/midnight-cleanup.ts b/src/systems/scheduler/midnight-cleanup.ts new file mode 100644 index 0000000..0f63b61 --- /dev/null +++ b/src/systems/scheduler/midnight-cleanup.ts @@ -0,0 +1,19 @@ +import { Client, TextChannel } from "discord.js"; +import { ScheduledJob } from "@scheduler/types"; +import { polls, updatePollMessage } from "@systems/poll"; +import { cfg } from "@systems/config"; + +export const job: ScheduledJob = { + name: "midnight-cleanup", + cron: "0 0 * * *", + async run(client: Client) { + for (const [slot, state] of polls.entries()) { + if (!state?.locked) continue; + try { + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, undefined, false); + console.log(`[Scheduler] Submit Score button removed for ${slot}:00`); + } catch {} + } + }, +}; \ No newline at end of file diff --git a/src/systems/scheduler/types.ts b/src/systems/scheduler/types.ts new file mode 100644 index 0000000..3717e27 --- /dev/null +++ b/src/systems/scheduler/types.ts @@ -0,0 +1,8 @@ +import { Client } from "discord.js"; + +export interface ScheduledJob { + name: string; + cron: string; + timezone?: string; + run: (client: Client) => Promise | void; +} \ No newline at end of file diff --git a/src/systems/scheduler/weekly-reset.ts b/src/systems/scheduler/weekly-reset.ts new file mode 100644 index 0000000..2a1f927 --- /dev/null +++ b/src/systems/scheduler/weekly-reset.ts @@ -0,0 +1,12 @@ +import { Client } from "discord.js"; +import { ScheduledJob } from "@scheduler/types"; +import { TG } from "@systems/tg"; + +export const job: ScheduledJob = { + name: "weekly-reset", + cron: "0 0 * * 1", + async run(_client: Client) { + TG.resetWeek(); + console.log(`[Scheduler] Weekly reset complete.`); + }, +}; \ No newline at end of file diff --git a/src/systems/score.ts b/src/systems/score.ts new file mode 100644 index 0000000..a05ad2a --- /dev/null +++ b/src/systems/score.ts @@ -0,0 +1,179 @@ +/** + * Score — manages TG score submission and retrieval. + * + * Usage: + * import { Score } from "@systems/score"; + * + * Score.get({ character, slot, date }) + * Score.getWeeklySummary({ userKey }) + * Score.submit({ character, borrowedFrom, pts, k, d, slot }) + */ + + import { Character, Nation, UserKey, HistoryKey, SlotHour } from "@types"; + import { WRank } from "@systems/wrank"; + import { Store } from "@systems/store"; + import { Paths } from "@helpers/paths"; + + export interface TGScore { + userKey: UserKey; + playedBy?: UserKey; // if borrowed + characterName: string; + class: string; + nation: Nation; + pts: number; + k?: number; + d?: number; + atk?: number; + def?: number; + heal?: number; + submittedAt: string; + slot: SlotHour; + date: string; + submittedByOfficer: boolean; + wRankAtSubmission?: { + rank: number; + delta: number; + }; + } + + export interface WeeklySummary { + userKey: UserKey; + character: Character; + scores: TGScore[]; + totalPts: number; + totalK: number; + totalD: number; + tgCount: number; + currentRank?: number; + previousRank?: number; + } + + function getHistoryPath(historyKey: HistoryKey): string { + return Paths.data("tg-history", `${historyKey}.json`); + } + + function loadHistory(historyKey: HistoryKey): { scores: TGScore[] } { + return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] }); +} +function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void { + Store.write(getHistoryPath(historyKey), data); +} + + export const Score = { + /** + * Get a score for a character in a specific TG. + */ + get({ character, slot, date }: { + character: Character; + slot: SlotHour; + date?: string; + }): TGScore | null { + const d = date ?? new Date().toISOString().slice(0, 10); + const historyKey = `${d}-${slot}` as HistoryKey; + const history = loadHistory(historyKey); + return history.scores.find( + (s) => s.userKey === character.ownerKey && s.characterName === character.name + ) ?? null; + }, + + /** + * Get weekly summary for a character. + */ + 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 score = history.scores.find( + (s) => s.userKey === character.ownerKey && s.characterName === character.name + ); + if (score) scores.push(score); + } + + const totalPts = scores.reduce((sum, s) => sum + s.pts, 0); + const totalK = scores.reduce((sum, s) => sum + (s.k ?? 0), 0); + const totalD = scores.reduce((sum, s) => sum + (s.d ?? 0), 0); + + return { + userKey: character.ownerKey, + character, + scores, + totalPts, + totalK, + totalD, + tgCount: scores.length, + currentRank: entry?.currentRank, + previousRank: entry?.previousRank, + }; + }, + + /** + * Submit a score for a character. + * Handles W.Rank snapshot at submission time. + */ + submit({ character, borrowedFrom, pts, k, d, atk, def, heal, slot, submittedByOfficer }: { + character: Character; + borrowedFrom?: UserKey; + pts: number; + k?: number; + d?: number; + atk?: number; + def?: number; + heal?: number; + slot: SlotHour; + submittedByOfficer?: boolean; + }): void { + const date = new Date().toISOString().slice(0, 10); + const historyKey = `${date}-${slot}` as HistoryKey; + const history = loadHistory(historyKey); + + // Snapshot W.Rank before recording score + const existingEntry = WRank.entry(character.name, character.nation); + const wRankAtSubmission = existingEntry ? { + rank: existingEntry.currentRank, + delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank), + } : undefined; + + const score: TGScore = { + userKey: character.ownerKey, + playedBy: borrowedFrom, + characterName: character.name, + class: character.class.key, + nation: character.nation, + pts, + k, + d, + atk, + def, + heal, + submittedAt: new Date().toISOString(), + slot, + date, + submittedByOfficer: submittedByOfficer ?? false, + wRankAtSubmission, + }; + + // Upsert — replace existing score for same character/slot + history.scores = history.scores.filter( + (s) => !(s.userKey === character.ownerKey && + s.characterName === character.name && + s.slot === slot && + s.date === date) + ); + history.scores.push(score); + saveHistory(historyKey, history); + + // Record in W.Rank + WRank.recordScore( + character.ownerKey, + character.name, + character.class.key, + character.nation, + pts, + historyKey + ); + }, + }; \ No newline at end of file diff --git a/src/systems/slots.ts b/src/systems/slots.ts index 8eeb685..0d4389c 100644 --- a/src/systems/slots.ts +++ b/src/systems/slots.ts @@ -10,59 +10,3 @@ type LockCallback = (slot: TGSlot) => Promise; let _scheduledTasks: cron.ScheduledTask[] = []; -export function scheduleSlots( - client: Client, - onPollOpen: PollCallback, - onPollLock: LockCallback, - onPollClose: CloseCallback, -): void { - _scheduledTasks.forEach((t) => t.stop()); - _scheduledTasks = []; - - const tz = process.env.TZ ?? "Etc/GMT-2"; - const slots = cfg("slots").filter((s) => s.active); - - for (const slot of slots) { - // Poll open - const [openHour, openMin] = slot.pollOpens.split(":").map(Number); - _scheduledTasks.push(cron.schedule( - `${openMin} ${openHour} * * *`, - () => onPollOpen(slot), - { timezone: tz } - )); - - // Poll lock — exactly at tgHour (TG start, voting closes, lockedYesKeys snapshotted) - _scheduledTasks.push(cron.schedule( - `0 ${slot.tgHour} * * *`, - () => onPollLock(slot), - { timezone: tz } - )); - - // Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears) - const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; - const closeHour = Math.floor(closeMinTotal / 60) % 24; - const closeMin = closeMinTotal % 60; - _scheduledTasks.push(cron.schedule( - `${closeMin} ${closeHour} * * *`, - () => onPollClose(slot), - { timezone: tz } - )); - - _scheduledTasks.push(cron.schedule("0 0 * * *", async () => { - const state = polls.get(slot.tgHour); - if (!state?.locked) return; // only if poll has been locked (TG happened) - const channel = await (client as any).channels.fetch(cfg("pollChannelId")) as TextChannel; - await updatePollMessage(channel, slot.tgHour, undefined, false); - console.log(`[${new Date().toISOString()}] Submit Score button removed.`); - }, { timezone: tz })); - } - - // Weekly reset — Monday 00:00 - _scheduledTasks.push(cron.schedule("0 0 * * 1", () => { - const { resetWeek } = require("./wrank"); - resetWeek(); - console.log("W.Rank weekly reset complete."); - }, { timezone: tz })); - - console.log(`Scheduled ${slots.length} slot(s).`); -} \ No newline at end of file diff --git a/src/systems/store.ts b/src/systems/store.ts new file mode 100644 index 0000000..bd4989e --- /dev/null +++ b/src/systems/store.ts @@ -0,0 +1,55 @@ +/** + * Store — centralized file I/O for JSON data. + * Replaces scattered fs.readFileSync/writeFileSync calls. + * + * Usage: + * import { Store } from "@systems/store"; + * + * const data = Store.read("path/to/file.json"); + * Store.write("path/to/file.json", data); + * const data = Store.readOrDefault("path/to/file.json", {}); + */ + + import fs from "fs"; + + export const Store = { + /** + * Read and parse a JSON file. + * Returns null if file doesn't exist or can't be parsed. + */ + read(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return null; + } + }, + + /** + * Read and parse a JSON file, returning a default value on failure. + */ + readOrDefault(filePath: string, defaultValue: T): T { + return Store.read(filePath) ?? defaultValue; + }, + + /** + * Serialize and write data to a JSON file. + */ + write(filePath: string, data: T): void { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + }, + + /** + * Check if a file exists. + */ + exists(filePath: string): boolean { + return fs.existsSync(filePath); + }, + + /** + * Delete a file if it exists. + */ + delete(filePath: string): void { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }, + }; \ No newline at end of file diff --git a/src/systems/tg.ts b/src/systems/tg.ts new file mode 100644 index 0000000..fbc34fb --- /dev/null +++ b/src/systems/tg.ts @@ -0,0 +1,114 @@ +/** + * TG — thin dispatcher for TG-related operations. + * Orchestrates Score, Attendance, WRank, and Bringer. + * + * Usage: + * import { TG } from "@systems/tg"; + * + * TG.currentWeek() + * TG.resetWeek() + * TG.getAttendance({ historyKey }) + * TG.getScore({ character, slot }) + * TG.getWeeklySummary({ character }) + */ + + import { Nation, Character, UserKey, HistoryKey, 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"; + + export const TG = { + // ── Week ────────────────────────────────────────────────────────────────── + + currentWeek() { + return WRank.currentWeek(); + }, + + /** + * Reset to a new week — carry Bringer forward, clear ranks. + * Called every Monday 00:00 by the Scheduler. + */ + resetWeek(): void { + const now = new Date(); + const newWeekKey = WRank.weekKey(now); + const prevWeekKey = WRank.weekKey(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)); + + // Access internal data via WRank + const prevWeek = WRank.weekFromKey(prevWeekKey); + const newWeek = WRank.currentWeek(); // ensures new week exists + + if (prevWeek) { + const goal = (require("@systems/config") as any).cfg("wRankGoal"); + for (const nation of [Nation.Capella, Nation.Procyon]) { + const key = Nations.key(nation); + const entries = prevWeek.entries[key]; + const rank1 = entries.find((e) => e.currentRank === 1); + + // Rank 1 with >= goal TGs becomes Bringer — no exceptions + // Officers use Bringer.override() for manual adjustments + if (rank1 && rank1.tgCount >= goal) { + newWeek.bringer[key] = rank1.characterName; + } else { + newWeek.bringer[key] = null; + } + + // Overrides do NOT carry forward — each week starts clean + delete (newWeek.bringer as any)[`${key}Override`]; + } + } + + WRank.save(); + console.log(`[TG] Week reset to ${newWeekKey}. Bringer:`, newWeek.bringer); + }, + + // ── Attendance ──────────────────────────────────────────────────────────── + + getAttendance({ historyKey, nation }: { + historyKey: HistoryKey; + nation?: Nation; + }): UserKey[] { + const players = Attendance.players(historyKey); + if (!nation) return players; + // Filter by nation requires character lookup — caller handles if needed + return players; + }, + + allSubmitted(historyKey: HistoryKey): boolean { + return Attendance.allSubmitted(historyKey); + }, + + // ── Score ───────────────────────────────────────────────────────────────── + + getScore({ character, slot, date }: { + character: Character; + slot: SlotHour; + date?: string; + }): TGScore | null { + return Score.get({ character, slot, date }); + }, + + getWeeklySummary({ character }: { character: Character }): WeeklySummary { + return Score.getWeeklySummary({ character }); + }, + + submitScore(params: Parameters[0]): void { + Score.submit(params); + }, + + // ── Bringer ─────────────────────────────────────────────────────────────── + + getBringer({ nation }: { nation: Nation }): string | null { + return Bringer.get({ nation }); + }, + getBringer1(nation: Nation): string | null { + return Bringer.get({ nation }); + }, + + // ── W.Rank ──────────────────────────────────────────────────────────────── + + getEntry(character: Character) { + return WRank.entry(character.name, character.nation); + }, + }; \ No newline at end of file diff --git a/src/systems/users.ts b/src/systems/users.ts index fb0b2c5..2102b0a 100644 --- a/src/systems/users.ts +++ b/src/systems/users.ts @@ -1,8 +1,8 @@ import { GuildMember } from "discord.js"; -import { getImpersonation } from "./impersonate"; -import { ResolvedUser } from "../types"; -import { getUsermapEntry } from "./messages"; -import { getActiveCharacter } from "./characters"; +import { getImpersonation } from "@systems/impersonate"; +import { ResolvedUser } from "@src/types"; +import { getUsermapEntry, getUsermapEntryById } from "@systems/messages"; +import { getActiveCharacter } from "@systems/characters"; // Resolves a full user context from a GuildMember + discord username export async function resolveUser(member: GuildMember): Promise { @@ -14,16 +14,18 @@ export async function resolveUser(member: GuildMember): Promise { // Check for active impersonation const impersonatedKey = getImpersonation(member.user.id); - const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntry(discordUsername); + const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntryById(member.user.id, member.user.username); const userKey = entry?.file ?? null; const aliases = entry?.aliases ?? []; const activeChar = userKey ? getActiveCharacter(userKey) : null; + // lookupUsername is used for message system lookups — use impersonated key if impersonating - const lookupUsername = impersonatedKey ?? discordUsername; + const idEntry = getUsermapEntry(member.user.id); + const lookupUsername = impersonatedKey ?? (idEntry ? member.user.id : discordUsername); return { userId: member.user.id, - discordUsername, + discordUsername: member.user.username, lookupUsername, userKey, displayName, diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 3c7e919..6d1ade5 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -1,18 +1,49 @@ import fs from "fs"; import path from "path"; -import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types"; -import { cfg } from "./config"; +import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types"; +import { cfg } from "@systems/config"; +import { Bringer } from "@systems/bringer"; +import { Nations } from "@systems/nations"; +import { Store } from "@systems/store"; +import { Paths } from "@paths"; const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); let _data: WRankData = {}; -export function loadWRank(): void { - try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); } - catch { _data = {}; } +/** Raw shape stored in wrank.json */ +interface SerializableWRankEntry { + userKey: UserKey; + characterName: CharName; + class: ClassKey; + nation: Nation; + weeklyPoints: number; + tgCount: number; + currentRank: number; + previousRank?: number; } -function saveWRank(): void { - fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2)); +export interface WRankWeek { + weekKey: string; // "2026-W22" + entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now + scoreIndex: Record; + bringer: { + capella: string | null; // userKey of bringer, null if none qualified + procyon: string | null; + capellaOverride?: string; // manually set by officer + procyonOverride?: string; + }; +} + +export interface WRankData { + [weekKey: string]: WRankWeek; +} + +export function loadWRank(): void { + _data = Store.readOrDefault(Paths.data("wrank.json"), {}); +} + +export function saveWRank(): void { + Store.write(Paths.data("wrank.json"), _data); } export function getWeekKey(date: Date = new Date()): string { @@ -36,7 +67,7 @@ function ensureWeek(weekKey: string): WRankWeek { } export function getCurrentWeek(): WRankWeek { - return ensureWeek(getWeekKey()); + return ensureWeek(WRank.weekKey()); } export function getWeek(weekKey: string): WRankWeek | null { @@ -52,9 +83,9 @@ export function recordScore( pts: number, historyKey: string // e.g. "2026-05-31-20" ): void { - const weekKey = getWeekKey(); + const weekKey = WRank.weekKey(); const week = ensureWeek(weekKey); - const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; + const list = week.entries[Nations.key(nation)]; const existing = list.find((e) => e.characterName === characterName); @@ -94,7 +125,6 @@ export function recordScore( } recomputeRanks(week, nation); - updateBringer(week); saveWRank(); } @@ -127,15 +157,15 @@ function updateBringer(week: WRankWeek): void { } export function setBringerOverride(nation: Nation, charName: string): void { - const week = ensureWeek(getWeekKey()); - if (nation === "Capella") week.bringer.capellaOverride = charName; + const week = ensureWeek(WRank.weekKey()); + if (nation === Nation.Capella) week.bringer.capellaOverride = charName; else week.bringer.procyonOverride = charName; saveWRank(); } export function clearBringerOverride(nation: Nation): void { - const week = ensureWeek(getWeekKey()); - if (nation === "Capella") delete week.bringer.capellaOverride; + const week = ensureWeek(WRank.weekKey()); + if (nation === Nation.Capella) delete week.bringer.capellaOverride; else delete week.bringer.procyonOverride; updateBringer(week); saveWRank(); @@ -143,11 +173,11 @@ export function clearBringerOverride(nation: Nation): void { export function getBringer(nation: Nation): string | null { const week = getCurrentWeek(); - if (nation === "Capella") return week.bringer.capellaOverride ?? week.bringer.capella; + if (nation === Nation.Capella) return week.bringer.capellaOverride ?? week.bringer.capella; return week.bringer.procyonOverride ?? week.bringer.procyon; } -export function getEntry(characterName: string, nation: Nation): WRankEntry | null { +export function getEntry(characterName: string, nation: Nation): SerializableWRankEntry | null { const week = getCurrentWeek(); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`); @@ -158,6 +188,33 @@ export function getEntry(characterName: string, nation: Nation): WRankEntry | nu // Called every Monday 00:00 by cron export function resetWeek(): void { // Week is already archived in _data by weekKey — just ensure next week exists - ensureWeek(getWeekKey(new Date())); - saveWRank(); -} \ No newline at end of file + const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)); + const prevWeek = _data[prevWeekKey]; + const newWeek = ensureWeek(WRank.weekKey(new Date())); + + if (prevWeek) { + // Carry Bringer forward — W.Rank 1 with goal achieved becomes new Bringer + Bringer.update({ week: prevWeek }); + for (const nation of [Nation.Capella, Nation.Procyon]) { + const key = nation === Nation.Capella ? "capella" : "procyon"; + const bringer = prevWeek.bringer[key]; + if (bringer) { + // Set as override in new week so it carries forward + newWeek.bringer[key] = bringer; + } + } + } + WRank.save(); +} + + +export const WRank = { + save: saveWRank, + load: loadWRank, + currentWeek: getCurrentWeek, + weekFromKey: getWeek, + weekKey: getWeekKey, + recordScore, + entry: getEntry, + resetWeek, +}; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b33e6ba..d3edd90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,21 @@ -// ─── Enums ─────────────────────────────────────────────────────────────────── -export type Nation = "Capella" | "Procyon"; +// ─── Branded primitive types ───────────────────────────────────────────────── +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"; + +// ─── Nation ─────────────────────────────────────────────────────────────────── +export enum Nation { + Capella = "Capella", + Procyon = "Procyon", +} + +// ─── Class ──────────────────────────────────────────────────────────────────── export type ClassKey = | "BL" // Blader | "FB" // Force Blader @@ -13,6 +27,24 @@ export type ClassKey = | "WI" // Wizard | "WA"; // Warrior +export interface CharacterClass { + key: ClassKey; + name: string; // "Force Blader" + shortName: string; // "FB" +} + +export const CLASSES: Record = { + FB: { key: "FB", name: "Force Blader", shortName: "FB" }, + WI: { key: "WI", name: "Wizard", shortName: "WI" }, + BL: { key: "BL", name: "Blader", shortName: "BL" }, + FS: { key: "FS", name: "Force Shielder", shortName: "FS" }, + FA: { key: "FA", name: "Force Archer", shortName: "FA" }, + FG: { key: "FG", name: "Force Gunner", shortName: "FG" }, + GL: { key: "GL", name: "Gladiator", shortName: "GL" }, + DM: { key: "DM", name: "Dark Mage", shortName: "DM" }, + WA: { key: "WA", name: "Warrior", shortName: "WA" }, +}; + export const CLASS_NAMES: Record = { BL: "Blader", FB: "Force Blader", @@ -38,20 +70,15 @@ export interface CharacterStats { export interface Character { name: string; - class: ClassKey; + class: CharacterClass; level: number; nation: Nation; - active: boolean; + active?: boolean; + ownerKey: UserKey; stats?: CharacterStats; sharedWith?: string[]; // usermap keys with permanent access } -export interface CharacterMap { - [userKey: string]: { - characters: Character[]; - }; -} - export interface BorrowRequest { requesterKey: string; // who wants to borrow ownerKey: string; // who owns the character @@ -71,6 +98,18 @@ export interface AccountMap { [userKey: string]: AccountData; } +// interface UserIdentity { +// discordId: DiscordId; // never changes — primary key +// discordUsername: string; // current username — may change +// userKey: UserKey | null; // system key +// lookupId: string; // discordId if ID-mapped, discordUsername if legacy +// displayName: string; +// serverNickname: string | null; +// globalNickname: string | null; +// aliases: string[]; +// activeCharacter: Character | null; +// } + // ─── Usermap ───────────────────────────────────────────────────────────────── export interface UsermapEntry { @@ -93,35 +132,63 @@ export interface TGSlot { // ─── Poll ──────────────────────────────────────────────────────────────────── +// export interface VoteEntry { +// userKey: string; +// displayName: string; // server nickname → global nickname → username +// characterName?: string; // active character name at time of vote +// characterClass?: ClassKey; // snapshotted +// characterLevel?: number; // snapshotted +// characterNation?: Nation; // snapshotted at vote time +// votedAt: string; // HH:MM formatted +// previousYesAt?: string; +// previousNoAt?: string; +// publicMessage?: string; // resolved from message system or officer override +// publicMessageOverride?: string;// set by officer via /tg poll set-message +// ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral +// borrowedFrom?: string // Borrowed character from who +// discordId?: string; // real Discord ID of the voter (for notifications) +// } + export interface VoteEntry { - userKey: string; - displayName: string; // server nickname → global nickname → username - characterName?: string; // active character name at time of vote - characterClass?: ClassKey; // snapshotted - characterLevel?: number; // snapshotted - characterNation?: Nation; // snapshotted at vote time - votedAt: string; // HH:MM formatted - previousYesAt?: string; - previousNoAt?: string; - publicMessage?: string; // resolved from message system or officer override - publicMessageOverride?: string;// set by officer via /tg poll set-message - ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral - borrowedFrom?: string // Borrowed character from who - discordId?: string; // real Discord ID of the voter (for notifications) + userKey?: UserKey; + displayName?: string; + characterName?: CharName; + characterClass?: ClassKey; + characterLevel?: number; + characterNation?: Nation; + borrowedFrom?: UserKey; + discordId?: DiscordId; + votedAt?: string; + publicMessage?: string; + previousYesAt?: string; + previousNoAt?: string; } export interface PollState { - messageId: string | null; - slot: number; - yes: Map; // userId → VoteEntry - no: Map; - locked: boolean; - confirmed: "yes" | "no" | null; + slot: SlotHour; + messageId?: string | null; + locked: boolean; + confirmed?: "yes" | "no" | null; + yes: Map; + no: Map; + lockedYesKeys?: Set; lockMessage?: string; confirmMessage?: string; - lockedYesKeys?: Set; // snapshot of userKeys in yes at lock time } + +// export interface PollState { +// messageId: string | null; +// slot: number; +// yes: Map; // userId → VoteEntry +// no: Map; +// locked: boolean; +// confirmed: "yes" | "no" | null; +// lockMessage?: string; +// confirmMessage?: string; +// lockedYesKeys?: Set; // snapshot of userKeys in yes at lock time +// } + // ─── Scores ────────────────────────────────────────────────────────────────── export interface TGScore { @@ -164,37 +231,71 @@ export interface TGResult { // ─── W.Rank ────────────────────────────────────────────────────────────────── +// temporary until WRank refactor export interface WRankEntry { - userKey: string; - characterName: string; // snapshotted - class: ClassKey; // snapshotted - nation: Nation; // snapshotted - weeklyPoints: number; // cumulative pts this week - tgCount: number; // number of slots with submission this week - currentRank: number; // computed after each submission - previousRank?: number; // before latest recomputation + userKey: UserKey; + characterName: CharName; + class: ClassKey; + nation: Nation; + weeklyPoints: number; + tgCount: number; + currentRank: number; + previousRank?: number; } -export interface WRankWeek { - weekKey: string; // "2026-W22" - entries: { - capella: WRankEntry[]; - procyon: WRankEntry[]; - }; - scoreIndex: { - [userKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] - }; - bringer: { - capella: string | null; // userKey of bringer, null if none qualified - procyon: string | null; - capellaOverride?: string; // manually set by officer - procyonOverride?: string; - }; -} +// export interface WRankEntry { +// character: Character; +// weeklyPoints: number; +// tgCount: number; +// currentRank: number; +// previousRank?: number; +// } + -export interface WRankData { - [weekKey: string]: WRankWeek; -} +// interface UserIdentity { +// discordId: DiscordId; // never changes — primary key +// discordUsername: string; // current username — may change +// userKey: UserKey | null; // system key +// lookupId: string; // discordId if ID-mapped, discordUsername if legacy +// displayName: string; +// serverNickname: string | null; +// globalNickname: string | null; +// aliases: string[]; +// activeCharacter: Character | null; +// } + +// export interface WRankEntry { +// identity: UserIdentity, +// character: Character, +// weeklyPoints: number; // cumulative pts this week +// tgCount: number; // number of slots with submission this week +// currentRank: number; // computed after each submission +// previousRank?: number; // before latest recomputation +// } + +// function serialize(entry: WRankEntry): SerializableWRankEntry { +// return { +// userKey: entry.identity.userKey, +// characterName: entry.character.name, +// class: entry.character.class, +// nation: entry.character.nation, +// weeklyPoints: entry.weeklyPoints, +// tgCount: entry.tgCount, +// currentRank: entry.currentRank, +// previousRank: entry.previousRank +// }; +// } + +// interface SerializableWRankEntry { +// userKey: string | null; +// characterName: string; // snapshotted +// class: ClassKey; // snapshotted +// nation: Nation; // snapshotted +// weeklyPoints: number; // cumulative pts this week +// tgCount: number; // number of slots with submission this week +// currentRank: number; // computed after each submission +// previousRank?: number; // before latest recomputation +// } // ─── Bringer ───────────────────────────────────────────────────────────────── diff --git a/tsconfig.json b/tsconfig.json index d5f7ea7..0aecf5c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,13 +12,16 @@ "rootDir": "./src", "baseUrl": ".", "paths": { + "@root/*": ["./*"], "@src/*": ["src/*"], "@data/*": ["data/*"], "@tests/*": ["tests/*"], "@messages/*": ["messages/*"], "@tgHistory/*": ["data/tg-history/*"], "@scripts/*": ["scripts/*"], + "@helpers/*": ["src/helpers/*"], "@systems/*": ["src/systems/*"], + "@registry/*": ["src/systems/registry/*"], "@commands/*": ["src/commands/*"], "@subcommands/*": ["src/subcommands/*"], "@handlers/*": ["src/handlers/*"], @@ -26,7 +29,10 @@ "@types": ["src/types"], "@format": ["src/systems/format"], "@emojis": ["src/systems/emojis"], + "@scheduler/*": ["src/systems/scheduler/*"], + "@paths": ["src/helpers/paths"], "@characters": ["src/systems/characters"], + "@systems/scheduler": ["src/systems/scheduler/index"], } }, "include": ["src/**/*"],