big architectural changes, add Attendance/Score/TG/Registry/Scheduler systems, logger & benchmarker, tg-admin command

This commit is contained in:
Nuno Duque Nunes 2026-06-09 23:13:21 +01:00
parent 61bb590c87
commit 3c4aed93df
68 changed files with 3431 additions and 883 deletions

4
.gitignore vendored
View file

@ -15,12 +15,16 @@ data/poll-state.json
data/usermap.json data/usermap.json
data/wrank.json data/wrank.json
data/bringer.json data/bringer.json
data/attendance.json
data/sessionPreferences.json data/sessionPreferences.json
data/tg-history/ data/tg-history/
# Emoji data # Emoji data
emoji-uploads/ emoji-uploads/
# Scripts
scripts/
# Tests # Tests
tests/ tests/

11
data/emojis/classes.json Normal file
View file

@ -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>"
}

14
data/emojis/misc.json Normal file
View file

@ -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>"
}

102
data/emojis/wrank-down.json Normal file
View file

@ -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>"
}

View file

@ -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>"
}

View file

@ -0,0 +1,5 @@
{
"wrank_neutral": "<:wrank_neutral:1511950713713070160>",
"wrank_no_dash": "<:wrank_no_dash:1511956379403943979>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>"
}

102
data/emojis/wrank-up.json Normal file
View file

@ -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>"
}

22
data/emojis/wrank.json Normal file
View file

@ -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>"
}

View file

@ -102,5 +102,165 @@
"wrank_8_gold": "<:wrank_8_gold:1512125123874918661>", "wrank_8_gold": "<:wrank_8_gold:1512125123874918661>",
"wrank_9_gold": "<:wrank_9_gold:1512125128299905104>", "wrank_9_gold": "<:wrank_9_gold:1512125128299905104>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>", "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>"
} }

View file

@ -8,52 +8,53 @@ import { cfg } from "../systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasOfficerRole } from "../systems/users";
// Poll subcommands // Poll subcommands
import { handleStart } from "../subcommands/poll/start"; import { handleStart } from "@subcommands/poll/start";
import { handleLock } from "../subcommands/poll/lock"; import { handleLock } from "@subcommands/poll/lock";
import { handleUnlock } from "../subcommands/poll/unlock"; import { handleUnlock } from "@subcommands/poll/unlock";
import { handleConfirm } from "../subcommands/poll/confirm"; import { handleConfirm } from "@subcommands/poll/confirm";
import { handleStatus } from "../subcommands/poll/status"; import { handleStatus } from "@subcommands/poll/status";
import { handleReload } from "../subcommands/poll/reload"; import { handleReload } from "@subcommands/poll/reload";
import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "../subcommands/poll/setMessage"; import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "@subcommands/poll/setMessage";
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; import { handleInject, handleRemoveVote } from "@subcommands/poll/inject";
import { handleSeed } from "../subcommands/poll/seed"; import { handleSeed } from "@subcommands/poll/seed";
import { handlePurge } from "../subcommands/poll/purge"; import { handlePurge } from "@subcommands/poll/purge";
import { handleImpersonate } from "../subcommands/impersonate"; import { handleImpersonate } from "@subcommands/impersonate";
// Char subcommands (borrow / sharing system) // Char subcommands (borrow / sharing system)
import { handleCharBorrow } from "../subcommands/char/borrow"; import { handleCharBorrow } from "@subcommands/char/borrow";
import { handleCharAccept } from "../subcommands/char/accept"; import { handleCharAccept } from "@subcommands/char/accept";
import { handleCharDecline } from "../subcommands/char/decline"; import { handleCharDecline } from "@subcommands/char/decline";
import { handleCharShare, handleCharUnshare } from "../subcommands/char/share"; import { handleCharShare, handleCharUnshare } from "@subcommands/char/share";
// Score subcommands // Score subcommands
import { handleScoreSet } from "../subcommands/score/set"; import { handleScoreSet } from "@subcommands/score/set";
import { handleScoreGet } from "../subcommands/score/get"; import { handleScoreGet } from "@subcommands/score/get";
// Rank subcommands // Rank subcommands
import { handleRankGet } from "../subcommands/rank/get"; import { handleRankGet } from "@subcommands/rank/get";
import { handleRankPost } from "../subcommands/rank/post"; import { handleRankPost } from "@subcommands/rank/post";
// Result subcommands // Result subcommands
import { handleResultSet } from "../subcommands/result/set"; import { handleResultSet } from "@subcommands/result/set";
import { handleResultView } from "../subcommands/result/view"; import { handleResultView } from "@subcommands/result/view";
import { handleResultPost } from "../subcommands/result/post"; import { handleResultPost } from "@subcommands/result/post";
// Bringer subcommands // Bringer subcommands
import { handleBringerSet } from "../subcommands/bringer/set"; import { handleBringerSet } from "@subcommands/bringer/set";
import { handleBringerClear } from "../subcommands/bringer/clear"; import { handleBringerClear } from "@subcommands/bringer/clear";
// Other // Other
import { handleSwitch } from "../subcommands/switch"; import { handleSwitch } from "@subcommands/switch";
import { handleHistory } from "../subcommands/history"; import { handleHistory } from "@subcommands/history";
// Import char handlers here to keep tg.ts clean // Import char handlers here to keep tg.ts clean
import { handleCharAdd } from "../subcommands/char/add"; import { handleCharAdd } from "@subcommands/char/add";
import { handleCharRemove } from "../subcommands/char/remove"; import { handleCharRemove } from "@subcommands/char/remove";
import { handleCharSetActive } from "../subcommands/char/setActive"; import { handleCharSetActive } from "@subcommands/char/setActive";
import { handleCharSetNation } from "../subcommands/char/setNation"; import { handleCharSetNation } from "@subcommands/char/setNation";
import { handleCharSetStats } from "../subcommands/char/setStats"; import { handleCharSetStats } from "@subcommands/char/setStats";
import { handleCharActive } from "../subcommands/char/active"; import { handleCharActive } from "@subcommands/char/active";
import { Nation } from "@types";
export function buildTgCommand(): SlashCommandBuilder { export function buildTgCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
@ -164,7 +165,7 @@ export function buildTgCommand(): SlashCommandBuilder {
.setDescription("TG result management") .setDescription("TG result management")
.addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)") .addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)")
.addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true) .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("kills").setDescription("Kills").setRequired(true))
.addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true)) .addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true))
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
@ -180,12 +181,12 @@ export function buildTgCommand(): SlashCommandBuilder {
.setDescription("Bringer management (officer only)") .setDescription("Bringer management (officer only)")
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
.addStringOption((o) => o.setName("nation").setDescription("Nation").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").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character ").setRequired(true).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override") .addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
.addStringOption((o) => o.setName("nation").setDescription("Nation").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 })))
); );
// ── switch ───────────────────────────────────────────────────────────────── // ── switch ─────────────────────────────────────────────────────────────────
@ -214,7 +215,7 @@ export function buildTgCommand(): SlashCommandBuilder {
)) ))
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
.addStringOption((o) => o.setName("nation").setDescription("Nation").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)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character") .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") .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
.addStringOption((o) => o.setName("nation").setDescription("Nation").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("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) .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)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
) )

88
src/commands/tgAdmin.ts Normal file
View file

@ -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<void> {
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);
}
}

View file

@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { cfg, setCfg, resetCfg } from "../systems/config"; import { cfg, setCfg, resetCfg } from "../systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasOfficerRole } from "../systems/users";
import { replyAndDelete } from "../utils"; import { replyAndDelete } from "../utils";
import { Nation } from "../types"; import { Nation } from "@types";
export function buildTgConfigCommand(): SlashCommandBuilder { export function buildTgConfigCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
@ -16,7 +16,7 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }); .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" });
const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true) 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 roleOpt = strOpt("role", "Role name");
const rolesOpt = strOpt("roles", "Comma-separated role names"); const rolesOpt = strOpt("roles", "Comma-separated role names");

View file

@ -2,46 +2,70 @@ import { AutocompleteInteraction } from "discord.js";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { getCharacters } from "@systems/characters"; import { getCharacters } from "@systems/characters";
import { cfg } from "@systems/config"; 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 fs from "fs";
import path from "path";
// ─── Usermap cache ────────────────────────────────────────────────────────────
let _usermapCache: Record<string, any> | null = null;
function getUsermapCache(): Record<string, any> {
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 ───────────────────────────────────────────────────── // ─── Autocomplete subsets ─────────────────────────────────────────────────────
async function autocompleteCharNames( async function autocompleteCharNames(
interaction: AutocompleteInteraction, interaction: AutocompleteInteraction,
focused: string focused: string,
nation?: Nation | null // optional — if provided, filter by nation
): Promise<void> { ): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const user = await resolveUser(member); 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([]); if (!user.userKey) return interaction.respond([]);
// Own chars
const ownChars = getCharacters(user.userKey).map((c) => { 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 { return {
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
value: c.name, value: c.name,
}; };
}); });
const sharedChars: { name: string; value: string }[] = []; // Shared chars
try { const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => {
const chars = JSON.parse( const nationEmoji = char.nation ? (Emoji.nation(char.nation) || char.nation) : "";
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") return {
); name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { value: char.name,
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 {}
const all = [...ownChars, ...sharedChars] const all = [...ownChars, ...sharedChars]
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
@ -55,9 +79,7 @@ async function autocompleteUserKeys(
focused: string focused: string
): Promise<void> { ): Promise<void> {
try { try {
const usermap = JSON.parse( const usermap = getUsermapCache();
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
);
const choices = Object.entries(usermap) const choices = Object.entries(usermap)
.map(([, entry]: [string, any]) => { .map(([, entry]: [string, any]) => {
const fileKey = typeof entry === "string" ? entry : entry.file; 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 focused = interaction.options.getFocused(true);
const optionName = focused.name; const optionName = focused.name;
const focusedValue = focused.value as string; 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 === "char_name") {
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); // Bringer set — filter by selected nation
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (sub === "set" && subGroup === "bringer") {
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); 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([]); await interaction.respond([]);
} catch (err) { } catch (err) {

View file

@ -9,19 +9,27 @@ import { cfg } from "@systems/config";
import { pollReplyAndDelete } from "../utils"; import { pollReplyAndDelete } from "../utils";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { resolveMessage, nowFormatted } from "@systems/messages"; 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 { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { showConflictEmbed } from "@systems/conflict"; import { showConflictEmbed } from "@systems/conflict";
import { getCharacters } from "@systems/characters"; import { getCharacters } from "@systems/characters";
import { getImpersonation } from "@systems/impersonate"; import { getImpersonation } from "@systems/impersonate";
import { format } from "@format"; import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect";
import { getEffectiveCharacter } from "@systems/borrow";
import { Character } from "@src/types"; import { Character } from "@src/types";
import { modals } from "@handlers/modals"; 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 LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
const clickCounts = new Map<string, { yes: number; no: number }>(); const clickCounts = new Map<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>();
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -71,11 +79,6 @@ async function handleCharacterConflict(
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; 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 { buildCharSelectButtons } = require("@systems/charSelect");
const buttons = buildCharSelectButtons(userKey ?? "", { const buttons = buildCharSelectButtons(userKey ?? "", {
customIdPrefix: `switch_after_reclaim:${userKey}`, customIdPrefix: `switch_after_reclaim:${userKey}`,
@ -93,123 +96,175 @@ async function handleCharacterConflict(
// ─── Main button handler ────────────────────────────────────────────────────── // ─── Main button handler ──────────────────────────────────────────────────────
export async function handleButton(interaction: ButtonInteraction): Promise<void> { export async function handleButton(interaction: ButtonInteraction): Promise<void> {
console.log(`[handleButton] ${interaction.customId} - start`);
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
try { await InteractionLock.with(interaction, async () => {
await interaction.deferUpdate(); const bench = Benchmark.start("handleButton");
} catch {
return;
}
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return; if (slot === undefined) return;
const state = polls.get(slot)!; const state = polls.get(slot)!;
if (state.locked || state.confirmed !== null) return; 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<void> {
const bench = Benchmark.start("showActiveCharSwitching");
const userId = interaction.user.id; const userId = interaction.user.id;
const member = interaction.guild!.members.cache.get(userId) const member = interaction.guild!.members.cache.get(userId)
?? await interaction.guild!.members.fetch(userId); ?? await interaction.guild!.members.fetch(userId);
const user = await resolveUser(member); const user = await resolveUser(member);
const votedYes = interaction.customId === "tg_yes";
const now = nowFormatted();
const impersonating = getImpersonation(userId); const impersonating = getImpersonation(userId);
const voteId = impersonating ? `impersonated:${impersonating}` : 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)!; const clicks = clickCounts.get(voteId)!;
if (votedYes && clicks.yes >= LOCK_AT) return; const votedYes = interaction.customId === "tg_yes";
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; const clickCount = votedYes ? clicks.yes : clicks.no;
// Resolve messages const locked = clickCount >= LOCK_AT;
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") // Yes vote companion — show active char + switch buttons
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); 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();
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); Ephemeral.store(voteId, "companion", interaction, companionMsg.id);
}
// 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;
} }
} }
// 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 { export function resetClickCounts(): void {

View file

@ -7,107 +7,73 @@ import { handleBorrowDeclineButton } from "@subcommands/char/decline";
import { handleConflictButton } from "@systems/conflict"; import { handleConflictButton } from "@systems/conflict";
import { handleImpersonateButton } from "@subcommands/impersonate"; import { handleImpersonateButton } from "@subcommands/impersonate";
import { handleAutocomplete } from "@handlers/autocomplete"; import { handleAutocomplete } from "@handlers/autocomplete";
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; import { Ephemeral } from "@registry/ephemeral-registry";
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow"; import { getImpersonation } from "@systems/impersonate";
import { polls, updatePollMessage } from "@systems/poll";
import { cfg } from "@systems/config";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { format } from "@format";
import { modals } from "@handlers/modals"; import { modals } from "@handlers/modals";
import fs from "fs"; import { Character } from "@systems/character";
import path from "path"; import { handleTgAdminCommand } from "@commands/tgAdmin";
import { InteractionLock } from "@helpers/interaction-lock";
import { CharacterRegistry } from "@registry/character-registry";
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> { async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
const parts = btn.customId.split(":"); const prefix = btn.customId.startsWith("companion_switch:") ? "companion_switch:" : "switch_after_reclaim:";
const userKey = parts[1]; const withoutPrefix = btn.customId.slice(prefix.length);
const charName = parts[2]; const firstColon = withoutPrefix.indexOf(":");
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no"; 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( const impersonating = getImpersonation(btn.user.id);
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") 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; let borrowedFrom: string | null = null;
// Try own char first await InteractionLock.with(btn, async () => {
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName); const ownEntry = CharacterRegistry.findForUser(userKey, 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;
}
}
}
if (!resolvedChar) { if (ownEntry) {
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); resolvedChar = ownEntry;
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);
} else { } 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}`); if (!resolvedChar) {
console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`)); await btn.followUp({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`)); return;
}
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; // Delegate to shared switch logic
await updatePollMessage(channel, slot!); const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, btn, prevVoteType);
}
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName; if (result.replyData) {
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; const { content, components } = result.replyData;
await btn.reply({ console.log(`[switchAfterReclaim] replyData, isCompanion=${btn.customId.startsWith("companion_switch:")}`);
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`, if (btn.customId.startsWith("companion_switch:")) {
ephemeral: true, 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<void>
} }
if (interaction.isButton()) { if (interaction.isButton()) {
const btn = interaction as ButtonInteraction; return await handleButtonInteraction(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);
} }
if (interaction.isModalSubmit()) { if (interaction.isModalSubmit()) {
@ -167,9 +103,7 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} }
if (interaction.isChatInputCommand()) { if (interaction.isChatInputCommand()) {
const cmd = interaction as ChatInputCommandInteraction; await handleChatInputCommandInteraction(interaction as ChatInputCommandInteraction);
if (cmd.commandName === "tg") await handleTgCommand(cmd);
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
} }
} catch (err) { } catch (err) {
console.error("Interaction error:", err); console.error("Interaction error:", err);
@ -183,3 +117,45 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} catch {} } catch {}
} }
} }
async function handleChatInputCommandInteraction(cmd: ChatInputCommandInteraction): Promise<void> {
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<void> {
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);
}

View file

@ -0,0 +1,29 @@
type LockableInteraction = {
user: { id: string };
customId: string;
deferUpdate(): Promise<any>;
};
const _processing = new Set<string>();
export async function withInteractionLock<T>(
interaction: LockableInteraction,
fn: () => Promise<T>
): Promise<T | null> {
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
};

13
src/helpers/paths.ts Normal file
View file

@ -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),
};

View file

@ -1,16 +1,17 @@
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
import { loadConfig, cfg } from "@systems/config"; import { loadConfig, cfg } from "@systems/config";
import { loadMessages } from "@systems/messages"; import { loadMessages } from "@systems/messages";
import { loadEmojis } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters"; import { Char } from "@systems/characters";
import { loadWRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { scheduleSlots } from "@systems/slots";
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
import { handleInteraction } from "@handlers/interactions"; import { handleInteraction } from "@handlers/interactions";
import { buildTgCommand } from "@commands/tg"; import { buildTgCommand } from "@commands/tg";
import { buildTgConfigCommand } from "@commands/tgConfig"; import { buildTgConfigCommand } from "@commands/tgConfig";
import { TGSlot } from "@src/types"; import { TGSlot } from "@src/types";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { buildTgAdminCommand } from "@commands/tgAdmin";
import { Scheduler } from "@systems/scheduler";
const TOKEN = process.env.DISCORD_TOKEN!; const TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.CLIENT_ID!; const CLIENT_ID = process.env.CLIENT_ID!;
@ -23,7 +24,11 @@ const client = new Client({
async function registerCommands(): Promise<void> { async function registerCommands(): Promise<void> {
const rest = new REST({ version: "10" }).setToken(TOKEN); const rest = new REST({ version: "10" }).setToken(TOKEN);
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { 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."); console.log("Slash commands registered.");
} }
@ -68,9 +73,9 @@ client.once("clientReady", async () => {
loadConfig(); loadConfig();
loadMessages(); loadMessages();
loadEmojis(); Emoji.load();
loadCharacters(); Char.load();
loadWRank(); WRank.load();
const restored = persist.load(); const restored = persist.load();
if (restored) { if (restored) {
@ -93,7 +98,7 @@ client.once("clientReady", async () => {
await registerCommands(); await registerCommands();
} }
scheduleSlots(client, onPollOpen, onPollLock, onPollClose); Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose);
console.log("Bot ready."); console.log("Bot ready.");
}); });

View file

@ -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<void> {
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<void> {
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<string, any>) => {
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<void> {
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);
}

View file

@ -2,9 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
import { clearBringerOverride } from "@systems/wrank"; import { clearBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { getCurrentWeek, saveWRank } from "@systems/wrank";
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
clearBringerOverride(nation); Bringer.clearOverride({ nation });
saveWRank();
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
} }

View file

@ -1,12 +1,28 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { setBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { CharacterRegistry } from "@registry/character-registry";
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("name", true); const charName = interaction.options.getString("char_name", true);
setBringerOverride(nation, charName); // Resolve character
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`); 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
);
} }

View file

@ -1,13 +1,14 @@
import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { cfg } from "@systems/config";
import { getCharacterByName } from "../../systems/characters"; import { getCharacterByName } from "@systems/characters";
import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "../../systems/borrow"; import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { replyAndDelete } from "../../utils"; import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
import { replyAndDelete } from "@src/utils";
export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise<void> {
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); 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; const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); 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) { for (const [, entry] of map) {
if (entry.userKey === requesterKey) { if (entry.userKey === requesterKey) {
entry.characterName = char.name; entry.characterName = char.name;
entry.characterClass = char.class; entry.characterClass = char.class.key;
entry.characterLevel = char.level; entry.characterLevel = char.level;
entry.characterNation = char.nation; entry.characterNation = char.nation;
} }

View file

@ -5,6 +5,7 @@ import { getCharacterByName, getActiveCharacter } from "../../systems/characters
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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()]) { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === requesterKey) { if (entry.userKey === requesterKey) {
entry.characterName = char.name; entry.characterName = char.name;
entry.characterClass = char.class; entry.characterClass = char.class.key;
entry.characterLevel = char.level; entry.characterLevel = char.level;
entry.characterNation = char.nation; entry.characterNation = char.nation;
entry.publicMessage = undefined; entry.publicMessage = undefined;
@ -69,8 +70,8 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
const guild = interaction.guild!; const guild = interaction.guild!;
await guild.members.fetch(); await guild.members.fetch();
const ownerMember = guild.members.cache.find((m) => { const ownerMember = guild.members.cache.find((m) => {
const entry = (require("../../systems/messages") as any).getUsermapEntry(m.user.username); const entry = getUsermapEntryById(m.user.id, m.user.username);
return entry?.file === ownerArg || entry === ownerArg; return entry?.file === ownerArg;
}); });
if (!ownerMember) { if (!ownerMember) {
@ -85,7 +86,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
ownerArg, ownerArg,
requesterKey, requesterKey,
char.name, char.name,
char.class, char.class.key,
char.level, char.level,
fallbackChannel fallbackChannel
); );

View file

@ -1,10 +1,11 @@
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow"; import { getPendingRequest, removePendingRequest, updateBorrowDM } from "@systems/borrow";
import { replyAndDelete } from "../../utils"; import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
import { replyAndDelete } from "@src/utils";
export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> {
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); 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; const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");

View file

@ -5,30 +5,32 @@ import { setCharacterStats, getActiveCharacter } from "../../systems/characters"
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true);
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;
let userKey: string | null; // const member = await interaction.guild!.members.fetch(interaction.user.id);
if (nameArg) { // const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); // const nameArg = interaction.options.getString("name");
userKey = nameArg; // const charName = interaction.options.getString("char_name");
} else { // const atk = interaction.options.getInteger("atk") ?? undefined;
const user = await resolveUser(member); // const def = interaction.options.getInteger("def") ?? undefined;
userKey = user.userKey; // 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 (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
const set = setCharacterStats(userKey, targetName, { atk, def, heal }); // const targetName = charName ?? getActiveCharacter(userKey)?.name;
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); // 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}»**.`);
} }

View file

@ -61,7 +61,7 @@ function buildImpersonateButtons(
const navBtns: ButtonBuilder[] = []; const navBtns: ButtonBuilder[] = [];
if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary)); 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)); 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<ButtonBuilder>().addComponents(...navBtns)); rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navBtns));
return rows; return rows;
@ -114,7 +114,7 @@ export async function handleImpersonateButton(interaction: ButtonInteraction): P
return; return;
} }
if (customId === "impersonate_release") { if (customId.startsWith("impersonate_release")) {
clearImpersonation(realId); clearImpersonation(realId);
const users = getRegisteredUsers(); const users = getRegisteredUsers();
const embed = buildImpersonateEmbed(users, 0, null); const embed = buildImpersonateEmbed(users, 0, null);

View file

@ -1,12 +1,15 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { loadMessages } from "@systems/messages"; import { loadMessages } from "@systems/messages";
import { loadEmojis } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters"; import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank"; import { loadWRank } from "@systems/wrank";
import { loadConfig, cfg } from "@systems/config"; import { loadConfig, cfg } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence"; import { persist } from "@systems/pollPersistence";
import { replyAndDelete } from "@utils"; 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; const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const;
type Reloadable = typeof RELOADABLE[number]; 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("config")) { loadConfig(); reloaded.push("config"); }
if (should("messages")) { loadMessages(); reloaded.push("messages"); } 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("characters")) { loadCharacters(); reloaded.push("characters"); }
if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } 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(", ")}.`); return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`);
} }
// export async function handleReload(interaction: ChatInputCommandInteraction): Promise<void> {
// 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(", ")}.`);
// }

View file

@ -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<void> {
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.");
}

View file

@ -1,12 +1,11 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { cfg } from "@systems/config";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { nowFormatted, resolveMessage } from "../../systems/messages"; import { nowFormatted, resolveMessage } from "@systems/messages";
import { getEffectiveCharacter } from "../../systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "@utils";
import { VoteEntry, UsermapEntry } from "../../types"; import { VoteEntry, UsermapEntry } from "@types";
import fs from "fs"; import { UserRegistry } from "@registry/user-registry";
import path from "path";
export async function handleSeed(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSeed(interaction: ChatInputCommandInteraction): Promise<void> {
const slot = [...polls.keys()][0]; 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."); return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
} }
let usermap: Record<string, UsermapEntry | string> = {}; const usermapEntries = UserRegistry.all();
try {
usermap = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8")); if (usermapEntries.length === 0) {
} catch { return void replyAndDelete(interaction, "❌ No registered users found.");
return void replyAndDelete(interaction, "❌ Could not load usermap.json.");
} }
const now = nowFormatted(); const now = nowFormatted();
let injected = 0; let injected = 0;
let skipped = 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 userKey = typeof entry === "string" ? entry : entry.file;
const { char, borrowedFrom } = getEffectiveCharacter(userKey); const { char, borrowedFrom } = getEffectiveCharacter(userKey);
@ -37,7 +35,7 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
const syntheticId = `injected:${userKey}`; const syntheticId = `injected:${userKey}`;
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; } 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 = { const voteEntry: VoteEntry = {
userKey, userKey,

View file

@ -1,8 +1,11 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { cfg } from "@systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; import { WRank } from "@systems/wrank";
import { replyAndDelete } from "../../utils"; 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<void> { export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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."); 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 goal = cfg("wRankGoal");
const weekKey = getWeekKey(); const weekKey = WRank.weekKey();
for (const nation of ["capella", "procyon"] as const) { for (const nation of ["capella", "procyon"] as const) {
const entry = week.entries[nation].find((e) => e.userKey === userKey); 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 isDone = entry.tgCount >= goal;
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0; const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : ""; 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 isBringer = bringer === userKey && isDone;
const lines = [ const lines = [
`**${entry.characterName}** · ${entry.nation}`, `**${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}`, `**Points:** ${entry.weeklyPoints}`,
`**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`, `**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`,
`**Week:** ${weekKey}`, `**Week:** ${weekKey}`,

View file

@ -1,26 +1,34 @@
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
import { cfg } from "@systems/config"; import { cfg } from "@systems/config";
import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { getEmoji } from "@systems/emojis"; import { getEmoji } from "@systems/emojis";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { format } from "@src/systems/format"; 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<void> { export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
const week = getCurrentWeek(); const week = TG.currentWeek();
const goal = cfg("wRankGoal"); const goal = cfg("wRankGoal");
const weekKey = getWeekKey(); const weekKey = WRank.weekKey();
const formatNation = (nation: "capella" | "procyon"): string => { const formatNation = (nation: Nation): string => {
const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); const key = Nations.key(nation);
const entries = [...week.entries[key]].sort((a, b) => a.currentRank - b.currentRank);
if (entries.length === 0) return "—"; 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) => { return entries.map((e) => {
const isDone = e.tgCount >= goal; const isDone = e.tgCount >= goal;
// ── Character indicator ─────────────────────────────────────────────────── // ── 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 ─────────────────────────────────────────────────── // ── Rank indicator ───────────────────────────────────────────────────
const rankStr = format.wrank.rank(e, goal); const rankStr = format.wrank.rank(e, goal);
@ -28,7 +36,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
// ── Bringer label ──────────────────────────────────────────────────── // ── Bringer label ────────────────────────────────────────────────────
const bringerStr = bringer === e.userKey && isDone const bringerStr = bringer === e.userKey && isDone
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` ? ` · ${key === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
: ""; : "";
// ── Score indicator ─────────────────────────────────────────────────── // ── Score indicator ───────────────────────────────────────────────────
@ -44,8 +52,8 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
.setTitle(`⚔️ TG Leaderboard — ${weekKey}`) .setTitle(`⚔️ TG Leaderboard — ${weekKey}`)
.setColor(0xe8a317) .setColor(0xe8a317)
.addFields( .addFields(
{ name: format.nation("Capella"), value: formatNation("capella"), inline: true }, { name: format.nation(Nation.Capella), value: formatNation(Nation.Capella), inline: true },
{ name: format.nation("Procyon"), value: formatNation("procyon"), inline: true }, { name: format.nation(Nation.Procyon), value: formatNation(Nation.Procyon), inline: true },
) )
.setTimestamp(); .setTimestamp();

View file

@ -1,8 +1,9 @@
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
import { cfg } from "../../systems/config"; import { cfg } from "@systems/config";
import { loadResult, todayString } from "../../systems/history"; import { loadResult, todayString } from "@systems/history";
import { normalizeSlot, detectSlot } from "../../systems/scores"; import { normalizeSlot, detectSlot } from "@systems/scores";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "@src/utils";
import { Nation } from "@types";
export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> {
const slotArg = interaction.options.getString("slot"); const slotArg = interaction.options.getString("slot");
@ -20,7 +21,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
const kd = result.nationKD; const kd = result.nationKD;
const formatScores = (nation: "Capella" | "Procyon"): string => { const formatScores = (nation: Nation): string => {
const scores = result.scores.filter((s) => s.nation === nation); const scores = result.scores.filter((s) => s.nation === nation);
if (scores.length === 0) return "—"; if (scores.length === 0) return "—";
return scores return scores
@ -33,8 +34,8 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
.setTitle(`⚔️ TG Results — ${result.date} ${slot}:00`) .setTitle(`⚔️ TG Results — ${result.date} ${slot}:00`)
.setColor(0xe8a317) .setColor(0xe8a317)
.addFields( .addFields(
{ name: "🔵 Capella", value: `${kd.capella.k}K / ${kd.capella.d}D\n${formatScores("Capella")}`, 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("Procyon")}`, 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}` }) .setFooter({ text: `Source of truth: ${kd.source}` })
.setTimestamp(); .setTimestamp();

View file

@ -21,7 +21,7 @@ export async function handleResultSet(interaction: ChatInputCommandInteraction):
} }
const result = setNationKD(todayString(), slot, nation, kills, deaths); 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"]; const otherKD = result.nationKD[other.toLowerCase() as "capella" | "procyon"];
return void replyAndDelete(interaction, return void replyAndDelete(interaction,

View file

@ -1,19 +1,11 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config"; import { cfg } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; import { getCharacterByName } from "@systems/characters";
import { import { Character } from "@systems/character";
getEffectiveCharacter, import { Emoji } from "@systems/emojis";
setSessionBorrow,
setPersistentPreference,
clearPersistentPreference,
clearSessionBorrowForUser,
} from "@systems/borrow";
import { polls, updatePollMessage } from "@systems/poll";
import { getClassEmoji } from "@systems/emojis";
import { replyAndDelete } from "@src/utils"; import { replyAndDelete } from "@src/utils";
import { format } from "@format"; import { CharacterRegistry } from "@registry/character-registry";
import { buildCharSelectButtons } from "@systems/charSelect";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -33,13 +25,6 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
return null; 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<void> { export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); 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."); 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 resolvedChar: any = null;
let borrowedFrom: string | null = null; let borrowedFrom: string | null = null;
@ -65,7 +50,8 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
if (ownChar) { if (ownChar) {
resolvedChar = ownChar; resolvedChar = ownChar;
} else { } else {
const shared = findSharedChar(userKey, charName); const shared = CharacterRegistry.sharedWith(userKey)
.find(({ char }) => char.name.toLowerCase() === charName.toLowerCase());
if (shared) { if (shared) {
resolvedChar = shared.char; resolvedChar = shared.char;
borrowedFrom = shared.ownerKey; 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 (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
// If already active — just show current state without switching // Delegate to shared switch logic
const current = getEffectiveCharacter(userKey); const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, interaction);
if (current.char?.name === resolvedChar.name) {
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; if (result.replyData) {
const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : ""; await interaction.reply(result.replyData);
return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); return;
}
if (result.success && result.message) {
return void replyAndDelete(interaction, result.message, true);
} }
// Check if target character is already in the active poll by another player // conflict handled inside performSwitch — already replied
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;
}
}
}
// 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<string, any>) => {
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);
} }

80
src/systems/attendance.ts Normal file
View file

@ -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<AttendanceData>(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<UserKey>): 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[];
},
};

82
src/systems/benchmark.ts Normal file
View file

@ -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<T>(name: string, fn: () => Promise<T>): Promise<T> {
const bench = Benchmark.start(name);
try {
const result = await fn();
bench.end();
return result;
} catch (err) {
bench.end();
throw err;
}
},
};

View file

@ -1,23 +1,20 @@
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import fs from "fs";
import path from "path"; import path from "path";
import { BorrowRequest } from "../types"; import { BorrowRequest } from "@src/types";
import { cfg } from "./config"; import { cfg } from "@systems/config";
import { getCharacterByName } from "./characters"; 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 ─────────────────────────────────────────────────── // ─── Persistent preferences ───────────────────────────────────────────────────
let _prefs: Record<string, { ownerKey: string; charName: string }> = {}; let _prefs: Record<string, { ownerKey: string; charName: string }> = {};
function loadPrefs(): void { function loadPrefs(): void {
try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); } _prefs = Store.readOrDefault(Paths.data("sessionPreferences.json"), {});
catch { _prefs = {}; }
} }
function savePrefs(): void { function savePrefs(): void {
try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); } Store.write(Paths.data("sessionPreferences.json"), _prefs);
catch (err) { console.error("Failed to save sessionPreferences.json:", err); }
} }
loadPrefs(); loadPrefs();
@ -99,7 +96,7 @@ export function clearSessionBorrows(): void {
export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean { export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean {
if (requesterKey === ownerKey) return true; 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; if (char?.sharedWith?.includes(requesterKey)) return true;
const borrow = getSessionBorrow(requesterKey); const borrow = getSessionBorrow(requesterKey);
if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true; if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
@ -122,7 +119,7 @@ export async function sendBorrowRequestDM(
charLevel: number, charLevel: number,
fallbackChannel?: TextChannel fallbackChannel?: TextChannel
): Promise<void> { ): Promise<void> {
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() const acceptBtn = new ButtonBuilder()
.setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`) .setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`)
@ -168,12 +165,10 @@ export async function updateBorrowDM(
// ─── Effective character resolution ────────────────────────────────────────── // ─── Effective character resolution ──────────────────────────────────────────
export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } { export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } {
const { getActiveCharacter, getCharacterByName: getChar } = require("./characters");
// 1. Session borrow (temporary, resets on poll start) // 1. Session borrow (temporary, resets on poll start)
const borrow = getSessionBorrow(userKey); const borrow = getSessionBorrow(userKey);
if (borrow) { 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 }; if (char) return { char, borrowedFrom: borrow.ownerKey };
} }
@ -181,12 +176,12 @@ export function getEffectiveCharacter(userKey: string): { char: any; borrowedFro
const pref = getPersistentPreference(userKey); const pref = getPersistentPreference(userKey);
console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`); console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`);
if (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 }; if (char) return { char, borrowedFrom: pref.ownerKey };
clearPersistentPreference(userKey); clearPersistentPreference(userKey);
} }
// 3. Own active character // 3. Own active character
const char = getActiveCharacter(userKey); const char = Char.active({ user: userKey });
return { char: char ?? null, borrowedFrom: null }; return { char: char ?? null, borrowedFrom: null };
} }

79
src/systems/bringer.ts Normal file
View file

@ -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" | "procyon"> = {
[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();
},
};

View file

@ -4,7 +4,7 @@ import {
ActionRowBuilder, ActionRowBuilder,
} from "discord.js"; } from "discord.js";
import { getCharacters, getCharacterByName } from "@systems/characters"; import { getCharacters, getCharacterByName } from "@systems/characters";
import { getClassEmoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { format } from "@format"; import { format } from "@format";
import { Character } from "@types"; import { Character } from "@types";
import fs from "fs"; import fs from "fs";
@ -51,9 +51,11 @@ export function buildCharSelectButtons(
} }
} catch {} } catch {}
const allChars = [...ownChars, ...sharedChars] const allChars = [...ownChars, ...sharedChars]
.filter((c) => c.name !== excludeCharName); .filter((c) => c.name !== excludeCharName);
const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize); const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize);
const hasNext = allChars.length > (page + 1) * pageSize; const hasNext = allChars.length > (page + 1) * pageSize;
const hasPrev = page > 0; const hasPrev = page > 0;
@ -61,13 +63,15 @@ export function buildCharSelectButtons(
// Char buttons // Char buttons
if (pageChars.length > 0) { if (pageChars.length > 0) {
const btns = pageChars.map((c) => { const btns = pageChars.map((c: Character) => {
const emojiStr = getClassEmoji(c.class); const emojiStr = Emoji.class(c.class);
const emoji = format.emoji(emojiStr); const emoji = format.emoji(emojiStr);
const isShared = sharedChars.some(sc => sc.name === c.name);
const btn = new ButtonBuilder() const btn = new ButtonBuilder()
.setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`) .setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`)
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
.setLabel(`${c.level} ${c.name}`); .setLabel(format.charButton(c, { shared: isShared }));
if (emoji) btn.setEmoji(emoji as any); if (emoji) btn.setEmoji(emoji as any);
return btn; return btn;
}); });

176
src/systems/character.ts Normal file
View file

@ -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<ButtonBuilder>[];
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<SwitchResult> {
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,
};

View file

@ -1,73 +1,112 @@
import fs from "fs"; import { Paths } from "@paths";
import path from "path"; import {
import { CharacterMap, Character, ClassKey, Nation, AccountMap, AccountData } from "../types"; 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"); let _chars: CharacterMap = {};
const ACCOUNTS_PATH = path.join(__dirname, "../../data/accounts.json");
let _chars: CharacterMap = {};
let _accounts: AccountMap = {}; 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 { export function loadCharacters(): void {
try { _chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); } _chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {});
catch { _chars = {}; } _accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {});
try { _accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf8")); }
catch { _accounts = {}; }
} }
function saveCharacters(): void { 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 { 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[] { // ─── Helpers ──────────────────────────────────────────────────────────────────
return _chars[userKey]?.characters ?? [];
/** 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; return getCharacters(userKey).find((c) => c.active) ?? null;
} }
export function getCharacterByName(userKey: string, name: string): Character | null { export function getCharacterByName(userKey: UserKey, name: string): Character | null {
return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; return getCharacters(userKey).find(
(c) => c.name.toLowerCase() === name.toLowerCase()
) ?? null;
} }
export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null { export function getCharacterByClass(userKey: UserKey, cls: ClassKey): Character | null {
// Returns the active character of that class, or first found const chars = getCharacters(userKey).filter((c) => c.class.key === cls);
const chars = getCharacters(userKey).filter((c) => c.class === cls);
return chars.find((c) => c.active) ?? chars[0] ?? null; return chars.find((c) => c.active) ?? chars[0] ?? null;
} }
export function addCharacter(userKey: string, char: Omit<Character, "active">): 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<SerializableCharacter, "active">): boolean {
if (!_chars[userKey]) _chars[userKey] = { characters: [] }; 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 (exists) return false;
// If no active character, set this one as active
const hasActive = _chars[userKey].characters.some((c) => c.active); const hasActive = _chars[userKey].characters.some((c) => c.active);
_chars[userKey].characters.push({ ...char, active: !hasActive }); _chars[userKey].characters.push({ ...char, active: !hasActive });
saveCharacters(); saveCharacters();
return true; return true;
} }
export function removeCharacter(userKey: string, name: string): boolean { export function removeCharacter(userKey: UserKey, name: string): boolean {
if (!_chars[userKey]) return false; if (!_chars[userKey]) return false;
const before = _chars[userKey].characters.length; const before = _chars[userKey].characters.length;
_chars[userKey].characters = _chars[userKey].characters.filter( _chars[userKey].characters = _chars[userKey].characters.filter(
(c) => c.name.toLowerCase() !== name.toLowerCase() (c) => c.name.toLowerCase() !== name.toLowerCase()
); );
if (_chars[userKey].characters.length === before) return false; 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) &&
if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) { _chars[userKey].characters.length > 0) {
_chars[userKey].characters[0].active = true; _chars[userKey].characters[0].active = true;
} }
saveCharacters(); saveCharacters();
return true; return true;
} }
export function setActiveCharacter(userKey: string, name: string): boolean { export function setActiveCharacter(userKey: UserKey, name: string): boolean {
const chars = _chars[userKey]?.characters; const chars = _chars[userKey]?.characters;
if (!chars) return false; if (!chars) return false;
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
@ -78,34 +117,125 @@ export function setActiveCharacter(userKey: string, name: string): boolean {
return true; return true;
} }
export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean { export function setCharacterNation(userKey: UserKey, name: string, nation: Nation): boolean {
const char = getCharacterByName(userKey, name); const raw = _chars[userKey]?.characters.find(
if (!char) return false; (c) => c.name.toLowerCase() === name.toLowerCase()
char.nation = nation; );
if (!raw) return false;
raw.nation = nation;
saveCharacters(); saveCharacters();
return true; return true;
} }
export function setCharacterStats( export function setCharacterStats(
userKey: string, userKey: UserKey,
name: string, name: string,
stats: { atk?: number; def?: number; heal?: number } stats: { atk?: number; def?: number; heal?: number }
): boolean { ): boolean {
const char = getCharacterByName(userKey, name); const raw = _chars[userKey]?.characters.find(
if (!char) return false; (c) => c.name.toLowerCase() === name.toLowerCase()
if (!char.stats) char.stats = {}; );
Object.assign(char.stats, stats); 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(); saveCharacters();
return true; return true;
} }
// ─── Account data ───────────────────────────────────────────────────────────── // ─── Account data ─────────────────────────────────────────────────────────────
export function getAccountData(userKey: string): AccountData { export function getAccountData(userKey: UserKey): AccountData {
return _accounts[userKey] ?? {}; return _accounts[userKey] ?? {};
} }
export function setAccountData(userKey: string, data: Partial<AccountData>): void { export function setAccountData(userKey: UserKey, data: Partial<AccountData>): void {
if (!_accounts[userKey]) _accounts[userKey] = {}; if (!_accounts[userKey]) _accounts[userKey] = {};
Object.assign(_accounts[userKey], data); Object.assign(_accounts[userKey], data);
saveAccounts(); saveAccounts();
} }
export const Char = {
load() {
_chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {});
_accounts = Store.readOrDefault<AccountMap>(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<SerializableCharacter, "active"> }): 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 };
},
};

View file

@ -1,8 +1,8 @@
import fs from "fs";
import path from "path"; import path from "path";
import { BotConfig, Nation } from "../types"; 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 instead of const so env vars are read lazily at call time
function getDefaults(): Required<BotConfig> { function getDefaults(): Required<BotConfig> {
@ -21,7 +21,7 @@ function getDefaults(): Required<BotConfig> {
], ],
scoreWindowHours: 2, scoreWindowHours: 2,
tgDurationMinutes: 35, tgDurationMinutes: 35,
nationSource: "Procyon" as Nation, nationSource: Nation.Procyon,
wRankPostOnReset: false, wRankPostOnReset: false,
wRankGoal: 7, wRankGoal: 7,
wRankYellowColor: "#BA7517", wRankYellowColor: "#BA7517",
@ -43,13 +43,10 @@ function getDefaults(): Required<BotConfig> {
let _cfg: BotConfig = {}; let _cfg: BotConfig = {};
export function loadConfig(): void { export function loadConfig(): void {
try { _cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } _cfg = Store.readOrDefault(Paths.data("config.json"), {});
catch { _cfg = {}; }
} }
export function saveConfig(): void { export function saveConfig(): void {
try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cfg, null, 2)); } Store.write(Paths.data("config.json"), _cfg);
catch (err) { console.error("Failed to save config.json:", err); }
} }
export function cfg<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] { export function cfg<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {

View file

@ -12,7 +12,7 @@ import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveChara
import { getImpersonation } from "@systems/impersonate"; import { getImpersonation } from "@systems/impersonate";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveMessage, nowFormatted } from "@systems/messages";
import { getClassEmoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { format } from "@systems/format"; import { format } from "@systems/format";
import { Character } from "@types"; import { Character } from "@types";
import { buildCharSelectButtons } from "@systems/charSelect"; import { buildCharSelectButtons } from "@systems/charSelect";
@ -35,7 +35,7 @@ const pendingConflicts = new Map<string, {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder { function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
const emojiStr = getClassEmoji(char.class); const emojiStr = Emoji.class(char.class);
const emoji = format.emoji(emojiStr); const emoji = format.emoji(emojiStr);
btn.setLabel(`${char.level} ${char.name}`); btn.setLabel(`${char.level} ${char.name}`);
if (emoji) btn.setEmoji(emoji as any); if (emoji) btn.setEmoji(emoji as any);
@ -73,7 +73,7 @@ function buildConflictButtons(
const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE); const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE);
if (borrowed) { if (borrowed) {
reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`); reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`);
const emojiStr = getClassEmoji(borrowed.class); const emojiStr = Emoji.class(borrowed.class);
const emoji = format.emoji(emojiStr); const emoji = format.emoji(emojiStr);
if (emoji) reclaimBtn.setEmoji(emoji as any); if (emoji) reclaimBtn.setEmoji(emoji as any);
} else { } else {

View file

@ -1,27 +1,89 @@
import fs from "fs"; /**
import path from "path"; * Emoji system loads from categorized JSON files under data/emojis/
import { EmojiMap, ClassKey } from "../types"; * and merges into a single lookup map.
*
* Files in data/emojis/:
* misc.json, classes.json, wrank.json, wrank-up.json,
* wrank-down.json, wrank-x.json, wrank-x-gold.json, wrank-neutral.json
*/
const EMOJI_PATH = path.join(__dirname, "../../messages/emojis.json"); import fs from "fs";
let _emojis: EmojiMap = {}; import { Paths } from "@paths";
import { Nation, ClassKey, CharacterClass } from "@types";
export function loadEmojis(): void { // ─── Cache ────────────────────────────────────────────────────────────────────
try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); } let _map: Record<string, string> | null = null;
catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; }
}
export function getEmoji(key: string): string { function loadEmojiMap(): Record<string, string> {
return _emojis[key] ?? ""; if (_map) return _map;
}
export function getClassEmoji(cls: ClassKey): string { _map = {};
return getEmoji(cls.toLowerCase()); const dir = Paths.emojis();
}
export function getNationEmoji(nation: string): string { if (!fs.existsSync(dir)) {
return getEmoji(nation.toLowerCase()); // 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!;
}
export function resolveEmojiTokens(text: string): string { for (const file of fs.readdirSync(dir)) {
return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key)); 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 const Emoji = {
load(): Record<string, string> {
return loadEmojiMap()
},
get(name: string): string {
return loadEmojiMap()[name] ?? "";
},
class(cls: ClassKey | CharacterClass): string {
const key = typeof cls === "object" ? cls.key : cls;
return Emoji.get(key.toLowerCase());
},
nation(nation: Nation|string): string {
return getEmoji(nation.toLowerCase());
},
bringer(nation: Nation): string {
const map: Record<Nation, string> = {
[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}}`)
},
}

View file

@ -1,5 +1,5 @@
import { ClassKey, Nation, WRankEntry } from "@src/types"; import { Character, ClassKey, CharacterClass, Nation, WRankEntry } from "@src/types";
import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
// ─── Individual formatters ──────────────────────────────────────────────────── // ─── Individual formatters ────────────────────────────────────────────────────
@ -8,17 +8,22 @@ export interface CharDisplayOptions {
level?: boolean; // show level (default: true) 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. * Format a character for display in embeds and messages.
* Output: <:class:> 79 «Flash» * Output: <:class:> 79 «Flash»
*/ */
function char( function char(
c: { class: ClassKey; level: number; name: string }, c: { class: ClassKey|CharacterClass; level: number; name: string },
options?: CharDisplayOptions options?: CharDisplayOptions
): string { ): string {
const showEmoji = options?.emoji ?? true; const showEmoji = options?.emoji ?? true;
const showLevel = options?.level ?? 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} ` : ""; const levelStr = showLevel ? `${c.level} ` : "";
return `${classStr} ${levelStr}${c.name}`.trim(); return `${classStr} ${levelStr}${c.name}`.trim();
} }
@ -28,7 +33,7 @@ function char(
* Output: <:capella:> Capella * Output: <:capella:> Capella
*/ */
function nation(n: Nation): string { function nation(n: Nation): string {
const emoji = getNationEmoji(n); const emoji = Emoji.nation(n);
return emoji ? `${emoji} ${n}` : n; return emoji ? `${emoji} ${n}` : n;
} }
@ -37,8 +42,8 @@ function nation(n: Nation): string {
* Output: <:score:> 3000 <:kd:> 32/18 * Output: <:score:> 3000 <:kd:> 32/18
*/ */
function score(pts: number, k?: number, d?: number): string { function score(pts: number, k?: number, d?: number): string {
const scoreEmoji = getEmoji("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = getEmoji("kd") || "⚔️"; const kdEmoji = Emoji.get("kd") || "⚔️";
const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : ""; const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : "";
return `${scoreEmoji} ${pts}${kdStr}`; return `${scoreEmoji} ${pts}${kdStr}`;
} }
@ -69,7 +74,7 @@ export interface WRankDisplayOptions {
function wrankRank(entry: WRankEntry, goal: number): string { function wrankRank(entry: WRankEntry, goal: number): string {
const isDone = entry.tgCount >= goal; const isDone = entry.tgCount >= goal;
const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`; 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; let inner: string;
if (delta < 0) { if (delta < 0) {
const abs = Math.abs(delta); const abs = Math.abs(delta);
const numEmoji = getEmoji(`wrank_up_${abs}`); const numEmoji = Emoji.get(`wrank_up_${abs}`);
inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs); inner = (Emoji.get("wrank_up") || "↑") + (numEmoji || abs);
} else if (delta > 0) { } else if (delta > 0) {
const numEmoji = getEmoji(`wrank_down_${delta}`); const numEmoji = Emoji.get(`wrank_down_${delta}`);
inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta); inner = (Emoji.get("wrank_down") || "↓") + (numEmoji || delta);
} else { } 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}`; 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. * Placeholder for characters with no W.Rank when others in their nation have one.
* Output: ( [] ) * Output: ( 0 )
*/ */
function wrankNoRank(): string { function wrankNoRank(): string {
const norank = getEmoji("wrank_no_dash") || "—"; const norank = Emoji.get("wrank_no_dash") || "—";
const dash = getEmoji("wrank_no_rank_delta") || "—"; const dash = Emoji.get("wrank_no_rank_delta") || "—";
const square = getEmoji("wrank_no_dash") || "■"; const square = Emoji.get("wrank_no_dash") || "■";
return `${norank} (${square}${dash})`; return `${norank} (${square}${dash})`;
} }
// ─── Bringer formatters ────────────────────────────────────────────────────────
function bringerDisplay(n: Nation): string {
const bringerMap: Record<Nation, string> = {
Capella: Emoji.get("luminous_bringer") || "🔆 Luminous Bringer",
Procyon: Emoji.get("storm_bringer") || "⚡ Storm Bringer",
};
return bringerMap[n];
}
// ─── Namespace export ───────────────────────────────────────────────────────── // ─── Namespace export ─────────────────────────────────────────────────────────
export const format = { export const format = {
char, char,
charButton,
nation, nation,
score, score,
emoji, emoji,
@ -131,4 +146,5 @@ export const format = {
full: wrankFull, full: wrankFull,
noRank: wrankNoRank, noRank: wrankNoRank,
}, },
bringer: bringerDisplay,
}; };

View file

@ -1,7 +1,9 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { TGResult, TGScore, Nation } from "../types"; 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"); 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 { export function loadResult(date: string, slot: number): TGResult | null {
try { return Store.read(historyPath(historyKey(date, slot)));
return JSON.parse(fs.readFileSync(historyPath(historyKey(date, slot)), "utf8"));
} catch {
return null;
}
} }
export function saveResult(result: TGResult): void { export function saveResult(result: TGResult): void {
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
const key = historyKey(result.date, result.slot); Store.write(historyPath(historyKey(result.date, result.slot)), result);
fs.writeFileSync(historyPath(key), JSON.stringify(result, null, 2));
} }
export function upsertScore(score: TGScore): void { export function upsertScore(score: TGScore): void {
@ -33,7 +29,7 @@ export function upsertScore(score: TGScore): void {
date: score.date, date: score.date,
confirmed: false, confirmed: false,
nationKD: { nationKD: {
source: "Procyon" as Nation, source: Nation.Procyon,
capella: { k: 0, d: 0 }, capella: { k: 0, d: 0 },
procyon: { k: 0, d: 0 }, procyon: { k: 0, d: 0 },
}, },
@ -64,7 +60,7 @@ export function setNationKD(
}; };
result.nationKD.source = sourceNation; result.nationKD.source = sourceNation;
const other = oppositeNation(sourceNation); const other = Nations.opposite(sourceNation);
result.nationKD[sourceNation.toLowerCase() as "capella" | "procyon"] = { k, d }; result.nationKD[sourceNation.toLowerCase() as "capella" | "procyon"] = { k, d };
result.nationKD[other.toLowerCase() as "capella" | "procyon"] = { k: d, d: k }; result.nationKD[other.toLowerCase() as "capella" | "procyon"] = { k: d, d: k };

View file

@ -1,5 +1,5 @@
import fs from "fs"; import { UserRegistry } from "@registry/user-registry";
import path from "path"; import { UsermapEntry } from "@types";
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false"; const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false"; const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
@ -28,16 +28,17 @@ export function shouldShowIndicator(): boolean {
} }
// Returns all registered userKeys from usermap.json // Returns all registered userKeys from usermap.json
export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] { export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] {
try { try {
const usermap = JSON.parse( const entries = UserRegistry.all();
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") const seen = new Set<string>();
); return entries.map(({ entry }: { entry: UsermapEntry }) => ({ userKey: entry.file, aliases: entry.aliases ?? [] }))
return Object.entries(usermap).map(([, entry]: [string, any]) => { .filter(({ userKey }) => {
const fileKey = typeof entry === "string" ? entry : entry.file; if (seen.has(userKey)) return false;
const aliases = typeof entry === "object" ? (entry.aliases ?? []) : []; seen.add(userKey);
return { userKey: fileKey, aliases }; return true;
}); });
} catch { } catch {
return []; return [];
} }

140
src/systems/logger.ts Normal file
View file

@ -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, string> = {
[LogLevel.Debug]: "DEBUG",
[LogLevel.Info]: "INFO",
[LogLevel.Warn]: "WARN",
[LogLevel.Error]: "ERROR",
[LogLevel.Silent]: "SILENT",
};
const LEVEL_COLORS: Record<LogLevel, string> = {
[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<string, string> = {
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<string, LoggerInstance>();
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;
},
};

View file

@ -1,7 +1,8 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "../types"; import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types";
import { resolveEmojiTokens } from "./emojis"; import { Emoji } from "@systems/emojis";
import { UserRegistry } from "@registry/user-registry";
const MESSAGES_DIR = path.join(__dirname, "../../messages"); const MESSAGES_DIR = path.join(__dirname, "../../messages");
const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json"); const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json");
@ -104,7 +105,7 @@ export function interpolate(
.replace(/\{date\}/g, dt.dateKey) .replace(/\{date\}/g, dt.dateKey)
.replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0")); .replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0"));
return resolveEmojiTokens(result); return Emoji.resolveTokens(result);
} }
// ─── Resolution ────────────────────────────────────────────────────────────── // ─── Resolution ──────────────────────────────────────────────────────────────
@ -162,3 +163,18 @@ export function getUsermapEntry(discordUsername: string): UsermapEntry | null {
if (typeof entry === "string") return { file: entry, aliases: [] }; if (typeof entry === "string") return { file: entry, aliases: [] };
return entry as UsermapEntry; 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);
}

View file

@ -2,21 +2,49 @@ import { GuildMember } from "discord.js";
import { Nation } from "../types"; import { Nation } from "../types";
import { getActiveCharacter } from "./characters"; import { getActiveCharacter } from "./characters";
// Resolve a user's nation — character nation takes priority over Discord role export const NATION_UNICODE: Record<string, string> = {
export function resolveNation(member: GuildMember, userKey: string | null): Nation | null { Capella: "🔴",
// 1. Active character's nation Procyon: "🔵",
if (userKey) { };
const char = getActiveCharacter(userKey);
if (char) return char.nation;
}
// 2. Discord role fallback export const NATION_KEY: Record<Nation, "capella" | "procyon"> = {
if (member.roles.cache.some((r) => r.name === "Capella")) return "Capella"; [Nation.Capella]: "capella",
if (member.roles.cache.some((r) => r.name === "Procyon")) return "Procyon"; [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] ?? "";
},
} }

View file

@ -8,15 +8,16 @@ import {
} from "discord.js"; } from "discord.js";
import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; import { PollState, VoteEntry, Nation, TGSlot } from "@src/types";
import { cfg } from "@systems/config"; import { cfg } from "@systems/config";
import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { getActiveCharacter, getCharacterByName } from "@systems/characters"; import { Nations } from "@systems/nations";
import { resolveNation } from "@systems/nations"; import { WRank } from "@systems/wrank";
import { getEntry, getBringer } from "@systems/wrank";
import { nowFormatted } from "@systems/messages";
import { format } from "@format"; import { format } from "@format";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { clearSessionBorrows } from "@systems/borrow"; import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow";
import { clearAllImpersonations } from "@systems/impersonate"; import { clearAllImpersonations } from "@systems/impersonate";
import { Bringer } from "@systems/bringer";
import { Attendance } from "@systems/attendance";
// ─── Poll state ─────────────────────────────────────────────────────────────── // ─── Poll state ───────────────────────────────────────────────────────────────
export const polls: Map<number, PollState> = new Map(); export const polls: Map<number, PollState> = new Map();
@ -68,38 +69,17 @@ export function lockPoll(slot: number): void {
); );
persist.save(polls) persist.save(polls)
Attendance.snapshot(slot, state.lockedYesKeys);
} }
// ─── Character display ──────────────────────────────────────────────────────── // ─── 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<Nation, string> = {
Capella: getEmoji("luminous_bringer") || "🔆 Luminous Bringer",
Procyon: getEmoji("storm_bringer") || "⚡ Storm Bringer",
};
return bringerMap[nation];
}
function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string { function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string {
const cfgFormat = cfg("charDisplayFormat"); const cfgFormat = cfg("charDisplayFormat");
const nation = entry.characterNation; const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation const wRankEntry = entry.characterName && entry.characterNation
? getEntry(entry.characterName, entry.characterNation) ? WRank.entry(entry.characterName, entry.characterNation)
: null; : null;
let wrank = ""; let wrank = "";
@ -111,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank
} }
const classStr = entry.characterClass const classStr = entry.characterClass
? (getClassEmoji(entry.characterClass) || entry.characterClass) ? (Emoji.class(entry.characterClass) || entry.characterClass)
: ""; : "";
const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
@ -122,23 +102,24 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank
.replace("{wrank}", wrank) .replace("{wrank}", wrank)
.replace("{class}", classStr) .replace("{class}", classStr)
.replace("{level}", levelStr) .replace("{level}", levelStr)
.replace("{name}", entry.characterName ?? entry.displayName) .replace("{name}", entry.characterName ?? entry.displayName ?? "")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
// Bringer title — independent of W.Rank so override always shows // Bringer title — independent of W.Rank so override always shows
if (nation && entry.userKey) { if (nation && entry.userKey) {
const bringer = getBringer(nation); const bringer = Bringer.get({ nation });
if (bringer && bringer === entry.characterName) { if (bringer && bringer === entry.characterName) {
row += ` · ${getBringerDisplay(nation)}`; row += ` · ${format.bringer(nation)}`;
} }
} }
if (entry.borrowedFrom) { 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; return row;
} }
@ -151,7 +132,7 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui
const showNoInline = (cfg as any)("showNoInNationField") ?? false; const showNoInline = (cfg as any)("showNoInNationField") ?? false;
for (const entry of state.yes.values()) { for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? "Capella"; const nation = entry.characterNation ?? Nation.Capella;
yesByNation[nation].push(entry); yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" }); allMessages.push({ entry, voteType: "yes" });
} }
@ -160,12 +141,12 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui
allMessages.push({ entry, voteType: "no" }); allMessages.push({ entry, voteType: "no" });
} }
const capellaEmoji = getEmoji("capella"); const capellaEmoji = Emoji.get("capella");
const procyonEmoji = getEmoji("procyon"); const procyonEmoji = Emoji.get("procyon");
const formatNationField = (nation: Nation): string => { const formatNationField = (nation: Nation): string => {
const yesEntries = yesByNation[nation]; 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 const noEntries = showNoInline
? noVoters.filter((e) => e.characterNation === nation) ? noVoters.filter((e) => e.characterNation === nation)
: []; : [];
@ -212,9 +193,9 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui
.setTitle(title) .setTitle(title)
.setColor(color) .setColor(color)
.addFields( .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: "\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(); .setTimestamp();
@ -238,7 +219,7 @@ export function buildButtons(
showSubmit?: boolean showSubmit?: boolean
): ActionRowBuilder<ButtonBuilder>[] { ): ActionRowBuilder<ButtonBuilder>[] {
if (showSubmit) { if (showSubmit) {
const scoreEmoji = getEmoji("score"); const scoreEmoji = Emoji.get("score");
const submitBtn = new ButtonBuilder() const submitBtn = new ButtonBuilder()
.setCustomId("tg_score_submit") .setCustomId("tg_score_submit")
.setLabel("Submit Score") .setLabel("Submit Score")
@ -303,8 +284,8 @@ export function createVoteEntry(
const globalNickname = member.user.globalName ?? null; const globalNickname = member.user.globalName ?? null;
const displayName = serverNickname ?? globalNickname ?? discordUsername; const displayName = serverNickname ?? globalNickname ?? discordUsername;
const { getEffectiveCharacter } = require("@systems/borrow");
const { char, borrowedFrom: bf } = userKey const { char, borrowedFrom: bf } = userKey
? getEffectiveCharacter(userKey) ? getEffectiveCharacter(userKey)
: { char: null, borrowedFrom: null }; : { char: null, borrowedFrom: null };
console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
@ -315,7 +296,7 @@ export function createVoteEntry(
characterName: char?.name, characterName: char?.name,
characterClass: char?.class, characterClass: char?.class,
characterLevel: char?.level, characterLevel: char?.level,
characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), characterNation: char?.nation ?? (Nations.resolve(member, userKey) ?? undefined),
borrowedFrom: bf ?? undefined, borrowedFrom: bf ?? undefined,
}; };
} }

View file

@ -1,19 +1,21 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { PollState, VoteEntry } from "@src/types"; 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 ───────────────────────────────────────────────────────── // ─── Serialized shape ─────────────────────────────────────────────────────────
// Maps → arrays of [key, value] tuples, Sets → arrays of values // Maps → arrays of [key, value] tuples, Sets → arrays of values
interface SerializedPollState { interface SerializedPollState {
messageId: string | null; messageId?: string | null;
slot: number; slot: number;
yes: [string, VoteEntry][]; yes: [string, VoteEntry][];
no: [string, VoteEntry][]; no: [string, VoteEntry][];
locked: boolean; locked: boolean;
confirmed: "yes" | "no" | null; confirmed?: "yes" | "no" | null;
lockMessage?: string; lockMessage?: string;
confirmMessage?: string; confirmMessage?: string;
lockedYesKeys?: string[]; lockedYesKeys?: string[];
@ -58,7 +60,7 @@ function deserialize(data: SerializedPollState[]): Map<number, PollState> {
export namespace persist { export namespace persist {
export function save(polls: Map<number, PollState>): void { export function save(polls: Map<number, PollState>): void {
try { try {
fs.writeFileSync(PERSIST_PATH, JSON.stringify(serialize(polls), null, 2), "utf8"); Store.write(PERSIST_PATH, serialize(polls));
} catch (err) { } catch (err) {
console.error("[pollPersistence] Failed to save poll state:", err); console.error("[pollPersistence] Failed to save poll state:", err);
} }
@ -66,9 +68,9 @@ export namespace persist {
export function load(): Map<number, PollState> | null { export function load(): Map<number, PollState> | null {
try { try {
if (!fs.existsSync(PERSIST_PATH)) return null; const data = Store.read<SerializedPollState[]>(PERSIST_PATH);
const raw = fs.readFileSync(PERSIST_PATH, "utf8"); if (!data) return null;
const data = JSON.parse(raw) as SerializedPollState[];
const polls = deserialize(data); const polls = deserialize(data);
console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`); console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`);
return polls; return polls;
@ -80,7 +82,7 @@ export namespace persist {
export function clear(): void { export function clear(): void {
try { try {
if (fs.existsSync(PERSIST_PATH)) fs.unlinkSync(PERSIST_PATH); Store.delete(PERSIST_PATH);
} catch (err) { } catch (err) {
console.error("[pollPersistence] Failed to clear poll state:", err); console.error("[pollPersistence] Failed to clear poll state:", err);
} }

View file

@ -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<string, { characters: Character[] }> | null = null;
function loadChars(): Record<string, { characters: Character[] }> {
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;
},
};

View file

@ -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<string, EphemeralEntry>();
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<void> {
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();
},
};

View file

@ -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<string, RawUsermapValue> | null = null;
function loadUsermap(): Record<string, RawUsermapValue> {
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;
},
};

115
src/systems/scheduler.ts Normal file
View file

@ -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<void>;
type LockCallback = (slot: TGSlot) => Promise<void>;
type CloseCallback = (slot: TGSlot) => Promise<void>;
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.`);
},
};

View file

@ -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<void>;
type LockCallback = (slot: TGSlot) => Promise<void>;
type CloseCallback = (slot: TGSlot) => Promise<void>;
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.`);
},
};

View file

@ -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 {}
}
},
};

View file

@ -0,0 +1,8 @@
import { Client } from "discord.js";
export interface ScheduledJob {
name: string;
cron: string;
timezone?: string;
run: (client: Client) => Promise<void> | void;
}

View file

@ -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.`);
},
};

179
src/systems/score.ts Normal file
View file

@ -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
);
},
};

View file

@ -10,59 +10,3 @@ type LockCallback = (slot: TGSlot) => Promise<void>;
let _scheduledTasks: cron.ScheduledTask[] = []; 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).`);
}

55
src/systems/store.ts Normal file
View file

@ -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<MyType>("path/to/file.json");
* Store.write("path/to/file.json", data);
* const data = Store.readOrDefault<MyType>("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<T>(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<T>(filePath: string, defaultValue: T): T {
return Store.read<T>(filePath) ?? defaultValue;
},
/**
* Serialize and write data to a JSON file.
*/
write<T>(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);
},
};

114
src/systems/tg.ts Normal file
View file

@ -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<typeof Score.submit>[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);
},
};

View file

@ -1,8 +1,8 @@
import { GuildMember } from "discord.js"; import { GuildMember } from "discord.js";
import { getImpersonation } from "./impersonate"; import { getImpersonation } from "@systems/impersonate";
import { ResolvedUser } from "../types"; import { ResolvedUser } from "@src/types";
import { getUsermapEntry } from "./messages"; import { getUsermapEntry, getUsermapEntryById } from "@systems/messages";
import { getActiveCharacter } from "./characters"; import { getActiveCharacter } from "@systems/characters";
// Resolves a full user context from a GuildMember + discord username // Resolves a full user context from a GuildMember + discord username
export async function resolveUser(member: GuildMember): Promise<ResolvedUser> { export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
@ -14,16 +14,18 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
// Check for active impersonation // Check for active impersonation
const impersonatedKey = getImpersonation(member.user.id); 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 userKey = entry?.file ?? null;
const aliases = entry?.aliases ?? []; const aliases = entry?.aliases ?? [];
const activeChar = userKey ? getActiveCharacter(userKey) : null; const activeChar = userKey ? getActiveCharacter(userKey) : null;
// lookupUsername is used for message system lookups — use impersonated key if impersonating // 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 { return {
userId: member.user.id, userId: member.user.id,
discordUsername, discordUsername: member.user.username,
lookupUsername, lookupUsername,
userKey, userKey,
displayName, displayName,

View file

@ -1,18 +1,49 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types"; import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types";
import { cfg } from "./config"; 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"); const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
let _data: WRankData = {}; let _data: WRankData = {};
export function loadWRank(): void { /** Raw shape stored in wrank.json */
try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); } interface SerializableWRankEntry {
catch { _data = {}; } userKey: UserKey;
characterName: CharName;
class: ClassKey;
nation: Nation;
weeklyPoints: number;
tgCount: number;
currentRank: number;
previousRank?: number;
} }
function saveWRank(): void { export interface WRankWeek {
fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2)); weekKey: string; // "2026-W22"
entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now
scoreIndex: Record<CharName, HistoryKey[]>;
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<WRankData>(Paths.data("wrank.json"), {});
}
export function saveWRank(): void {
Store.write(Paths.data("wrank.json"), _data);
} }
export function getWeekKey(date: Date = new Date()): string { export function getWeekKey(date: Date = new Date()): string {
@ -36,7 +67,7 @@ function ensureWeek(weekKey: string): WRankWeek {
} }
export function getCurrentWeek(): WRankWeek { export function getCurrentWeek(): WRankWeek {
return ensureWeek(getWeekKey()); return ensureWeek(WRank.weekKey());
} }
export function getWeek(weekKey: string): WRankWeek | null { export function getWeek(weekKey: string): WRankWeek | null {
@ -52,9 +83,9 @@ export function recordScore(
pts: number, pts: number,
historyKey: string // e.g. "2026-05-31-20" historyKey: string // e.g. "2026-05-31-20"
): void { ): void {
const weekKey = getWeekKey(); const weekKey = WRank.weekKey();
const week = ensureWeek(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); const existing = list.find((e) => e.characterName === characterName);
@ -94,7 +125,6 @@ export function recordScore(
} }
recomputeRanks(week, nation); recomputeRanks(week, nation);
updateBringer(week);
saveWRank(); saveWRank();
} }
@ -127,15 +157,15 @@ function updateBringer(week: WRankWeek): void {
} }
export function setBringerOverride(nation: Nation, charName: string): void { export function setBringerOverride(nation: Nation, charName: string): void {
const week = ensureWeek(getWeekKey()); const week = ensureWeek(WRank.weekKey());
if (nation === "Capella") week.bringer.capellaOverride = charName; if (nation === Nation.Capella) week.bringer.capellaOverride = charName;
else week.bringer.procyonOverride = charName; else week.bringer.procyonOverride = charName;
saveWRank(); saveWRank();
} }
export function clearBringerOverride(nation: Nation): void { export function clearBringerOverride(nation: Nation): void {
const week = ensureWeek(getWeekKey()); const week = ensureWeek(WRank.weekKey());
if (nation === "Capella") delete week.bringer.capellaOverride; if (nation === Nation.Capella) delete week.bringer.capellaOverride;
else delete week.bringer.procyonOverride; else delete week.bringer.procyonOverride;
updateBringer(week); updateBringer(week);
saveWRank(); saveWRank();
@ -143,11 +173,11 @@ export function clearBringerOverride(nation: Nation): void {
export function getBringer(nation: Nation): string | null { export function getBringer(nation: Nation): string | null {
const week = getCurrentWeek(); 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; 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 week = getCurrentWeek();
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`); 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 // Called every Monday 00:00 by cron
export function resetWeek(): void { export function resetWeek(): void {
// Week is already archived in _data by weekKey — just ensure next week exists // Week is already archived in _data by weekKey — just ensure next week exists
ensureWeek(getWeekKey(new Date())); const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
saveWRank(); 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,
};

View file

@ -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 = export type ClassKey =
| "BL" // Blader | "BL" // Blader
| "FB" // Force Blader | "FB" // Force Blader
@ -13,6 +27,24 @@ export type ClassKey =
| "WI" // Wizard | "WI" // Wizard
| "WA"; // Warrior | "WA"; // Warrior
export interface CharacterClass {
key: ClassKey;
name: string; // "Force Blader"
shortName: string; // "FB"
}
export const CLASSES: Record<ClassKey, CharacterClass> = {
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<ClassKey, string> = { export const CLASS_NAMES: Record<ClassKey, string> = {
BL: "Blader", BL: "Blader",
FB: "Force Blader", FB: "Force Blader",
@ -38,20 +70,15 @@ export interface CharacterStats {
export interface Character { export interface Character {
name: string; name: string;
class: ClassKey; class: CharacterClass;
level: number; level: number;
nation: Nation; nation: Nation;
active: boolean; active?: boolean;
ownerKey: UserKey;
stats?: CharacterStats; stats?: CharacterStats;
sharedWith?: string[]; // usermap keys with permanent access sharedWith?: string[]; // usermap keys with permanent access
} }
export interface CharacterMap {
[userKey: string]: {
characters: Character[];
};
}
export interface BorrowRequest { export interface BorrowRequest {
requesterKey: string; // who wants to borrow requesterKey: string; // who wants to borrow
ownerKey: string; // who owns the character ownerKey: string; // who owns the character
@ -71,6 +98,18 @@ export interface AccountMap {
[userKey: string]: AccountData; [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 ───────────────────────────────────────────────────────────────── // ─── Usermap ─────────────────────────────────────────────────────────────────
export interface UsermapEntry { export interface UsermapEntry {
@ -93,35 +132,63 @@ export interface TGSlot {
// ─── Poll ──────────────────────────────────────────────────────────────────── // ─── 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 { export interface VoteEntry {
userKey: string; userKey?: UserKey;
displayName: string; // server nickname → global nickname → username displayName?: string;
characterName?: string; // active character name at time of vote characterName?: CharName;
characterClass?: ClassKey; // snapshotted characterClass?: ClassKey;
characterLevel?: number; // snapshotted characterLevel?: number;
characterNation?: Nation; // snapshotted at vote time characterNation?: Nation;
votedAt: string; // HH:MM formatted borrowedFrom?: UserKey;
previousYesAt?: string; discordId?: DiscordId;
previousNoAt?: string; votedAt?: string;
publicMessage?: string; // resolved from message system or officer override publicMessage?: string;
publicMessageOverride?: string;// set by officer via /tg poll set-message previousYesAt?: string;
ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral previousNoAt?: string;
borrowedFrom?: string // Borrowed character from who
discordId?: string; // real Discord ID of the voter (for notifications)
} }
export interface PollState { export interface PollState {
messageId: string | null; slot: SlotHour;
slot: number; messageId?: string | null;
yes: Map<string, VoteEntry>; // userId → VoteEntry locked: boolean;
no: Map<string, VoteEntry>; confirmed?: "yes" | "no" | null;
locked: boolean; yes: Map<string, VoteEntry>;
confirmed: "yes" | "no" | null; no: Map<string, VoteEntry>;
lockedYesKeys?: Set<UserKey>;
lockMessage?: string; lockMessage?: string;
confirmMessage?: string; confirmMessage?: string;
lockedYesKeys?: Set<string>; // snapshot of userKeys in yes at lock time
} }
// export interface PollState {
// messageId: string | null;
// slot: number;
// yes: Map<string, VoteEntry>; // userId → VoteEntry
// no: Map<string, VoteEntry>;
// locked: boolean;
// confirmed: "yes" | "no" | null;
// lockMessage?: string;
// confirmMessage?: string;
// lockedYesKeys?: Set<string>; // snapshot of userKeys in yes at lock time
// }
// ─── Scores ────────────────────────────────────────────────────────────────── // ─── Scores ──────────────────────────────────────────────────────────────────
export interface TGScore { export interface TGScore {
@ -164,37 +231,71 @@ export interface TGResult {
// ─── W.Rank ────────────────────────────────────────────────────────────────── // ─── W.Rank ──────────────────────────────────────────────────────────────────
// temporary until WRank refactor
export interface WRankEntry { export interface WRankEntry {
userKey: string; userKey: UserKey;
characterName: string; // snapshotted characterName: CharName;
class: ClassKey; // snapshotted class: ClassKey;
nation: Nation; // snapshotted nation: Nation;
weeklyPoints: number; // cumulative pts this week weeklyPoints: number;
tgCount: number; // number of slots with submission this week tgCount: number;
currentRank: number; // computed after each submission currentRank: number;
previousRank?: number; // before latest recomputation previousRank?: number;
} }
export interface WRankWeek { // export interface WRankEntry {
weekKey: string; // "2026-W22" // character: Character;
entries: { // weeklyPoints: number;
capella: WRankEntry[]; // tgCount: number;
procyon: WRankEntry[]; // currentRank: number;
}; // previousRank?: number;
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 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 ───────────────────────────────────────────────────────────────── // ─── Bringer ─────────────────────────────────────────────────────────────────

View file

@ -12,13 +12,16 @@
"rootDir": "./src", "rootDir": "./src",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@root/*": ["./*"],
"@src/*": ["src/*"], "@src/*": ["src/*"],
"@data/*": ["data/*"], "@data/*": ["data/*"],
"@tests/*": ["tests/*"], "@tests/*": ["tests/*"],
"@messages/*": ["messages/*"], "@messages/*": ["messages/*"],
"@tgHistory/*": ["data/tg-history/*"], "@tgHistory/*": ["data/tg-history/*"],
"@scripts/*": ["scripts/*"], "@scripts/*": ["scripts/*"],
"@helpers/*": ["src/helpers/*"],
"@systems/*": ["src/systems/*"], "@systems/*": ["src/systems/*"],
"@registry/*": ["src/systems/registry/*"],
"@commands/*": ["src/commands/*"], "@commands/*": ["src/commands/*"],
"@subcommands/*": ["src/subcommands/*"], "@subcommands/*": ["src/subcommands/*"],
"@handlers/*": ["src/handlers/*"], "@handlers/*": ["src/handlers/*"],
@ -26,7 +29,10 @@
"@types": ["src/types"], "@types": ["src/types"],
"@format": ["src/systems/format"], "@format": ["src/systems/format"],
"@emojis": ["src/systems/emojis"], "@emojis": ["src/systems/emojis"],
"@scheduler/*": ["src/systems/scheduler/*"],
"@paths": ["src/helpers/paths"],
"@characters": ["src/systems/characters"], "@characters": ["src/systems/characters"],
"@systems/scheduler": ["src/systems/scheduler/index"],
} }
}, },
"include": ["src/**/*"], "include": ["src/**/*"],