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/wrank.json
data/bringer.json
data/attendance.json
data/sessionPreferences.json
data/tg-history/
# Emoji data
emoji-uploads/
# Scripts
scripts/
# 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_9_gold": "<:wrank_9_gold:1512125128299905104>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>",
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>"
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>",
"wrank_down_100": "<:wrank_down_100:1513360157923475496>",
"wrank_down_21": "<:wrank_down_21:1513360161446559745>",
"wrank_down_22": "<:wrank_down_22:1513360165443993720>",
"wrank_down_23": "<:wrank_down_23:1513360169453752380>",
"wrank_down_24": "<:wrank_down_24:1513360173241077762>",
"wrank_down_25": "<:wrank_down_25:1513360177087254620>",
"wrank_down_26": "<:wrank_down_26:1513360181054935134>",
"wrank_down_27": "<:wrank_down_27:1513360184758505673>",
"wrank_down_28": "<:wrank_down_28:1513360188353282100>",
"wrank_down_29": "<:wrank_down_29:1513360191826038966>",
"wrank_down_30": "<:wrank_down_30:1513360195387129896>",
"wrank_down_31": "<:wrank_down_31:1513360198981648434>",
"wrank_down_32": "<:wrank_down_32:1513360202492149861>",
"wrank_down_33": "<:wrank_down_33:1513360206166360146>",
"wrank_down_34": "<:wrank_down_34:1513360209790242887>",
"wrank_down_35": "<:wrank_down_35:1513360214429007954>",
"wrank_down_36": "<:wrank_down_36:1513360218250022984>",
"wrank_down_37": "<:wrank_down_37:1513360223484510218>",
"wrank_down_38": "<:wrank_down_38:1513360226873512078>",
"wrank_down_39": "<:wrank_down_39:1513360230606442557>",
"wrank_down_40": "<:wrank_down_40:1513360234469523547>",
"wrank_down_41": "<:wrank_down_41:1513360238399586314>",
"wrank_down_42": "<:wrank_down_42:1513360241834594356>",
"wrank_down_43": "<:wrank_down_43:1513360245609730109>",
"wrank_down_44": "<:wrank_down_44:1513360248667246805>",
"wrank_down_45": "<:wrank_down_45:1513360253025124453>",
"wrank_down_46": "<:wrank_down_46:1513360256946929715>",
"wrank_down_47": "<:wrank_down_47:1513360261128654971>",
"wrank_down_48": "<:wrank_down_48:1513360265935192104>",
"wrank_down_49": "<:wrank_down_49:1513360269768654888>",
"wrank_down_50": "<:wrank_down_50:1513360273468297438>",
"wrank_down_51": "<:wrank_down_51:1513360277117337770>",
"wrank_down_52": "<:wrank_down_52:1513360280808067172>",
"wrank_down_53": "<:wrank_down_53:1513360284033618092>",
"wrank_down_54": "<:wrank_down_54:1513360288081117284>",
"wrank_down_55": "<:wrank_down_55:1513360291398946868>",
"wrank_down_56": "<:wrank_down_56:1513360295630868550>",
"wrank_down_57": "<:wrank_down_57:1513360299078713447>",
"wrank_down_58": "<:wrank_down_58:1513360303360970896>",
"wrank_down_59": "<:wrank_down_59:1513360306993369229>",
"wrank_down_60": "<:wrank_down_60:1513360310583427092>",
"wrank_down_61": "<:wrank_down_61:1513360314203246773>",
"wrank_down_62": "<:wrank_down_62:1513360318410002522>",
"wrank_down_63": "<:wrank_down_63:1513360322268893296>",
"wrank_down_64": "<:wrank_down_64:1513360325771137095>",
"wrank_down_65": "<:wrank_down_65:1513360329676034068>",
"wrank_down_66": "<:wrank_down_66:1513360333518143668>",
"wrank_down_67": "<:wrank_down_67:1513360337112662026>",
"wrank_down_68": "<:wrank_down_68:1513360340635877467>",
"wrank_down_69": "<:wrank_down_69:1513360344318349352>",
"wrank_down_70": "<:wrank_down_70:1513360347925319862>",
"wrank_down_71": "<:wrank_down_71:1513360352023285770>",
"wrank_down_72": "<:wrank_down_72:1513360356070785066>",
"wrank_down_73": "<:wrank_down_73:1513360359803846716>",
"wrank_down_74": "<:wrank_down_74:1513360363595239596>",
"wrank_down_75": "<:wrank_down_75:1513360367538016410>",
"wrank_down_76": "<:wrank_down_76:1513360372302745721>",
"wrank_down_77": "<:wrank_down_77:1513360376035545328>",
"wrank_down_78": "<:wrank_down_78:1513360379810545755>",
"wrank_down_79": "<:wrank_down_79:1513360383451201707>",
"wrank_down_80": "<:wrank_down_80:1513360387012296895>",
"wrank_down_81": "<:wrank_down_81:1513360390573002872>",
"wrank_down_82": "<:wrank_down_82:1513360393983234242>",
"wrank_down_83": "<:wrank_down_83:1513360398076743792>",
"wrank_down_84": "<:wrank_down_84:1513360401763405947>",
"wrank_down_85": "<:wrank_down_85:1513360405681016843>",
"wrank_down_86": "<:wrank_down_86:1513360409929973812>",
"wrank_down_87": "<:wrank_down_87:1513360413985865828>",
"wrank_down_88": "<:wrank_down_88:1513360417802420395>",
"wrank_down_89": "<:wrank_down_89:1513360421430497441>",
"wrank_down_90": "<:wrank_down_90:1513360424882667554>",
"wrank_down_91": "<:wrank_down_91:1513360428900548628>",
"wrank_down_92": "<:wrank_down_92:1513360432856043550>",
"wrank_down_93": "<:wrank_down_93:1513360436706283530>",
"wrank_down_94": "<:wrank_down_94:1513360440879747112>",
"wrank_down_95": "<:wrank_down_95:1513360445728096456>",
"wrank_down_96": "<:wrank_down_96:1513360449276477509>",
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
"wrank_down_99": "<:wrank_down_99:1513360463558082590>",
"wrank_up_100": "<:wrank_up_100:1513360467228364852>",
"wrank_up_21": "<:wrank_up_21:1513360471611408494>",
"wrank_up_22": "<:wrank_up_22:1513360476015296603>",
"wrank_up_23": "<:wrank_up_23:1513360479433785436>",
"wrank_up_24": "<:wrank_up_24:1513360483351007355>",
"wrank_up_25": "<:wrank_up_25:1513360487096651876>",
"wrank_up_26": "<:wrank_up_26:1513360490334654555>",
"wrank_up_27": "<:wrank_up_27:1513360494264713409>",
"wrank_up_28": "<:wrank_up_28:1513360497972609175>",
"wrank_up_29": "<:wrank_up_29:1513360501730709746>",
"wrank_up_30": "<:wrank_up_30:1513360505530745004>",
"wrank_up_31": "<:wrank_up_31:1513360508819079350>",
"wrank_up_32": "<:wrank_up_32:1513360512749142086>",
"wrank_up_33": "<:wrank_up_33:1513360516695986206>",
"wrank_up_34": "<:wrank_up_34:1513360521850785993>",
"wrank_up_35": "<:wrank_up_35:1513360525508087900>",
"wrank_up_36": "<:wrank_up_36:1513360529278632009>",
"wrank_up_37": "<:wrank_up_37:1513360533745569882>",
"wrank_up_38": "<:wrank_up_38:1513360537235226804>",
"wrank_up_39": "<:wrank_up_39:1513360541228470474>",
"wrank_up_40": "<:wrank_up_40:1513360545078579290>",
"wrank_up_41": "<:wrank_up_41:1513360549411422288>",
"wrank_up_42": "<:wrank_up_42:1513360552896888904>",
"wrank_up_43": "<:wrank_up_43:1513360557485326397>",
"wrank_up_44": "<:wrank_up_44:1513360561348280450>",
"wrank_up_45": "<:wrank_up_45:1513360564905312286>",
"wrank_up_46": "<:wrank_up_46:1513360568445046914>",
"wrank_up_47": "<:wrank_up_47:1513360571964067940>",
"wrank_up_48": "<:wrank_up_48:1513360575776817353>",
"wrank_up_49": "<:wrank_up_49:1513360579501232189>",
"wrank_up_50": "<:wrank_up_50:1513360583519371364>",
"wrank_up_51": "<:wrank_up_51:1513360586732343471>",
"wrank_up_52": "<:wrank_up_52:1513360590641299568>",
"wrank_up_53": "<:wrank_up_53:1513360594051399731>",
"wrank_up_54": "<:wrank_up_54:1513360597981335572>",
"wrank_up_55": "<:wrank_up_55:1513360601567723641>",
"wrank_up_56": "<:wrank_up_56:1513360605560438825>",
"wrank_up_57": "<:wrank_up_57:1513360609725382798>",
"wrank_up_58": "<:wrank_up_58:1513360613517037669>",
"wrank_up_59": "<:wrank_up_59:1513360617397031084>",
"wrank_up_60": "<:wrank_up_60:1513360620865454122>",
"wrank_up_61": "<:wrank_up_61:1513360624720281630>",
"wrank_up_62": "<:wrank_up_62:1513360628528447592>",
"wrank_up_63": "<:wrank_up_63:1513360632517365870>",
"wrank_up_64": "<:wrank_up_64:1513360635713552466>",
"wrank_up_65": "<:wrank_up_65:1513360639404544094>",
"wrank_up_66": "<:wrank_up_66:1513360643867021464>",
"wrank_up_67": "<:wrank_up_67:1513360647809667092>",
"wrank_up_68": "<:wrank_up_68:1513360652750684200>",
"wrank_up_69": "<:wrank_up_69:1513360656626094140>",
"wrank_up_70": "<:wrank_up_70:1513360660531118330>",
"wrank_up_71": "<:wrank_up_71:1513360663987355710>",
"wrank_up_72": "<:wrank_up_72:1513360668462551100>",
"wrank_up_73": "<:wrank_up_73:1513360672740737104>",
"wrank_up_74": "<:wrank_up_74:1513360676666478723>",
"wrank_up_75": "<:wrank_up_75:1513360680399409293>",
"wrank_up_76": "<:wrank_up_76:1513360683914367116>",
"wrank_up_77": "<:wrank_up_77:1513360688045887672>",
"wrank_up_78": "<:wrank_up_78:1513360691443138684>",
"wrank_up_79": "<:wrank_up_79:1513360695972987061>",
"wrank_up_80": "<:wrank_up_80:1513360699793997875>",
"wrank_up_81": "<:wrank_up_81:1513360703560618136>",
"wrank_up_82": "<:wrank_up_82:1513360708899967116>",
"wrank_up_83": "<:wrank_up_83:1513360712393560214>",
"wrank_up_84": "<:wrank_up_84:1513360716101582910>",
"wrank_up_85": "<:wrank_up_85:1513360720094302329>",
"wrank_up_86": "<:wrank_up_86:1513360724091473950>",
"wrank_up_87": "<:wrank_up_87:1513360728390897896>",
"wrank_up_88": "<:wrank_up_88:1513360732249391104>",
"wrank_up_89": "<:wrank_up_89:1513360735923736606>",
"wrank_up_90": "<:wrank_up_90:1513360739530833950>",
"wrank_up_91": "<:wrank_up_91:1513360743402176604>",
"wrank_up_92": "<:wrank_up_92:1513360747600805918>",
"wrank_up_93": "<:wrank_up_93:1513360752197767329>",
"wrank_up_94": "<:wrank_up_94:1513360755494359161>",
"wrank_up_95": "<:wrank_up_95:1513360759197794385>",
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>"
}

View file

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

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

View file

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

View file

@ -9,19 +9,27 @@ import { cfg } from "@systems/config";
import { pollReplyAndDelete } from "../utils";
import { resolveUser } from "@systems/users";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { resolveNation } from "@systems/nations";
import { Nations } from "@systems/nations";
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
import { persist } from "@systems/pollPersistence"
import { showConflictEmbed } from "@systems/conflict";
import { getCharacters } from "@systems/characters";
import { getImpersonation } from "@systems/impersonate";
import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect";
import { getEffectiveCharacter } from "@systems/borrow";
import { Character } from "@src/types";
import { modals } from "@handlers/modals";
import { Ephemeral } from "@registry/ephemeral-registry";
import { Nation, CLASSES } from "@types";
import { InteractionLock } from "@helpers/interaction-lock";
import { Benchmark } from "@systems/benchmark";
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
const clickCounts = new Map<string, { yes: number; no: number }>();
const clickCounts = new Map<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>();
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -71,11 +79,6 @@ async function handleCharacterConflict(
const slot = [...polls.keys()][0];
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
// await interaction.followUp({
// content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
// // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
// ephemeral: true
// });
const { buildCharSelectButtons } = require("@systems/charSelect");
const buttons = buildCharSelectButtons(userKey ?? "", {
customIdPrefix: `switch_after_reclaim:${userKey}`,
@ -93,123 +96,175 @@ async function handleCharacterConflict(
// ─── Main button handler ──────────────────────────────────────────────────────
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
console.log(`[handleButton] ${interaction.customId} - start`);
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
try {
await interaction.deferUpdate();
} catch {
return;
}
await InteractionLock.with(interaction, async () => {
const bench = Benchmark.start("handleButton");
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return;
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return;
const state = polls.get(slot)!;
if (state.locked || state.confirmed !== null) return;
const state = polls.get(slot)!;
if (state.locked || state.confirmed !== null) return;
const userId = interaction.user.id;
const member = interaction.guild!.members.cache.get(userId)
?? await interaction.guild!.members.fetch(userId);
bench.mark("fetchMember");
const user = await resolveUser(member);
bench.mark("resolveUser");
const votedYes = interaction.customId === "tg_yes";
const now = nowFormatted();
const impersonating = getImpersonation(userId);
const voteId = impersonating ? `impersonated:${impersonating}` : userId;
const lookupUsername = user.lookupUsername ?? user.discordUsername;
// Nation check
const nation = Nations.resolve(member, user.userKey);
if (!nation) {
const capella = format.nation(Nation.Capella);
const procyon = format.nation(Nation.Procyon);
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
return;
}
// Click tracking
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
const clicks = clickCounts.get(voteId)!;
if (votedYes && clicks.yes >= LOCK_AT) return;
if (!votedYes && clicks.no >= LOCK_AT) return;
// Ignore same vote
if (votedYes && state.yes.has(voteId)) return;
if (!votedYes && state.no.has(voteId)) return;
// Increment click (may be decremented in conflict handler)
if (votedYes) clicks.yes += 1;
else clicks.no += 1;
const clickCount = votedYes ? clicks.yes : clicks.no;
// Resolve messages
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
// Character conflict check — applies to both Yes and No
if (baseEntry.characterName) {
const conflictChar = {
name: baseEntry.characterName!,
class: CLASSES[baseEntry.characterClass!] ?? { key: baseEntry.characterClass!, name: baseEntry.characterClass!, shortName: baseEntry.characterClass! },
level: baseEntry.characterLevel!,
nation: baseEntry.characterNation!,
ownerKey: user.userKey ?? "",
active: false, // not needed for display
};
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
state, baseEntry.characterName, voteId, user.userKey ?? ""
);
if (found) {
await handleCharacterConflict(
interaction, user.userKey, conflictChar,
entryUserKey, clicks, votedYes
);
return;
}
}
// Register vote
if (votedYes) {
const previousNo = state.no.get(voteId);
state.no.delete(voteId);
state.yes.set(voteId, {
...baseEntry,
discordId: userId,
votedAt: now,
previousNoAt: previousNo?.votedAt,
publicMessage: publicMsg ?? undefined,
});
} else {
const previousYes = state.yes.get(voteId);
state.yes.delete(voteId);
state.no.set(voteId, {
...baseEntry,
votedAt: now,
discordId: userId,
previousYesAt: previousYes?.votedAt,
publicMessage: publicMsg ?? undefined,
});
}
const locked = clickCount >= LOCK_AT;
if (locked) state.locked = true;
persist.save(polls);
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
const msgContent = ephemeralMsg
? `${ephemeralMsg}${lockedSuffix}`
: locked ? "🔒 You've been locked in." : null;
await pollReplyAndDelete(interaction, msgContent);
const channel = interaction.channel as TextChannel;
await Promise.all([
showActiveCharSwitching(interaction),
updatePollMessage(channel, slot),
]);
bench.mark("updatePollMessage+companion");
});
}
export async function showActiveCharSwitching(interaction: ButtonInteraction): Promise<void> {
const bench = Benchmark.start("showActiveCharSwitching");
const userId = interaction.user.id;
const member = interaction.guild!.members.cache.get(userId)
?? await interaction.guild!.members.fetch(userId);
const user = await resolveUser(member);
const votedYes = interaction.customId === "tg_yes";
const now = nowFormatted();
const user = await resolveUser(member);
const impersonating = getImpersonation(userId);
const voteId = impersonating ? `impersonated:${impersonating}` : userId;
const lookupUsername = user.lookupUsername ?? user.discordUsername;
// Nation check
const nation = resolveNation(member, user.userKey);
if (!nation) {
const capella = format.nation("Capella");
const procyon = format.nation("Procyon");
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
return;
}
// Click tracking
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
const clicks = clickCounts.get(voteId)!;
if (votedYes && clicks.yes >= LOCK_AT) return;
if (!votedYes && clicks.no >= LOCK_AT) return;
// Ignore same vote
if (votedYes && state.yes.has(voteId)) return;
if (!votedYes && state.no.has(voteId)) return;
// Increment click (may be decremented in conflict handler)
if (votedYes) clicks.yes += 1;
else clicks.no += 1;
const votedYes = interaction.customId === "tg_yes";
const clickCount = votedYes ? clicks.yes : clicks.no;
// Resolve messages
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const locked = clickCount >= LOCK_AT;
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
// Character conflict check — applies to both Yes and No
if (baseEntry.characterName) {
const conflictChar = {
name: baseEntry.characterName!,
class: baseEntry.characterClass!,
level: baseEntry.characterLevel!,
nation: baseEntry.characterNation!,
active: false, // not needed for display
};
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
state, baseEntry.characterName, voteId, user.userKey ?? ""
);
if (found) {
await handleCharacterConflict(
interaction, user.userKey, conflictChar,
entryUserKey, clicks, votedYes
);
return;
// Yes vote companion — show active char + switch buttons
if (votedYes && user.userKey && !locked) {
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
bench.mark("getEffectiveCharacter");
if (char) {
const starEmoji = process.env.ACTIVE_CHAR_EMOJI || "⭐";
const borrowNote = borrowedFrom ? ` 🔗` : "";
const buttons = buildCharSelectButtons(user.userKey, {
customIdPrefix: `companion_switch:${user.userKey}`,
excludeCharName: char.name,
appendToCustomId: ":yes",
});
if (buttons.length > 0) {
const companionMsg = await interaction.followUp({
content: `${starEmoji} ${format.char(char)}${borrowNote}`,
components: buttons,
ephemeral: true,
fetchReply: true,
});
bench.mark("companion");
bench.end();
Ephemeral.store(voteId, "companion", interaction, companionMsg.id);
}
}
}
// Register vote
if (votedYes) {
const previousNo = state.no.get(voteId);
state.no.delete(voteId);
state.yes.set(voteId, {
...baseEntry,
discordId: userId,
votedAt: now,
previousNoAt: previousNo?.votedAt,
publicMessage: publicMsg ?? undefined,
});
} else {
const previousYes = state.yes.get(voteId);
state.yes.delete(voteId);
state.no.set(voteId, {
...baseEntry,
votedAt: now,
discordId: userId,
previousYesAt: previousYes?.votedAt,
publicMessage: publicMsg ?? undefined,
});
}
const locked = clickCount >= LOCK_AT;
if (locked) state.locked = true;
persist.save(polls);
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
const msgContent = ephemeralMsg
? `${ephemeralMsg}${lockedSuffix}`
: locked ? "🔒 You've been locked in." : null;
await pollReplyAndDelete(interaction, msgContent);
const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot);
}
export function resetClickCounts(): void {

View file

@ -7,107 +7,73 @@ import { handleBorrowDeclineButton } from "@subcommands/char/decline";
import { handleConflictButton } from "@systems/conflict";
import { handleImpersonateButton } from "@subcommands/impersonate";
import { handleAutocomplete } from "@handlers/autocomplete";
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow";
import { polls, updatePollMessage } from "@systems/poll";
import { cfg } from "@systems/config";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { format } from "@format";
import { Ephemeral } from "@registry/ephemeral-registry";
import { getImpersonation } from "@systems/impersonate";
import { modals } from "@handlers/modals";
import fs from "fs";
import path from "path";
import { Character } from "@systems/character";
import { handleTgAdminCommand } from "@commands/tgAdmin";
import { InteractionLock } from "@helpers/interaction-lock";
import { CharacterRegistry } from "@registry/character-registry";
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
const parts = btn.customId.split(":");
const userKey = parts[1];
const charName = parts[2];
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no";
const prefix = btn.customId.startsWith("companion_switch:") ? "companion_switch:" : "switch_after_reclaim:";
const withoutPrefix = btn.customId.slice(prefix.length);
const firstColon = withoutPrefix.indexOf(":");
const userKey = withoutPrefix.slice(0, firstColon);
const rest = withoutPrefix.slice(firstColon + 1);
const lastColon = rest.lastIndexOf(":");
const charName = rest.slice(0, lastColon);
const prevVoteType = (rest.slice(lastColon + 1) || "yes") as "yes" | "no";
const chars = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
);
const impersonating = getImpersonation(btn.user.id);
const voteId = impersonating ? `impersonated:${impersonating}` : btn.user.id;
let resolvedChar: any = null;
// Resolve char without switching
let resolvedChar: any = null;
let borrowedFrom: string | null = null;
// Try own char first
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName);
if (ownEntry) {
setActiveCharacter(userKey, charName);
clearSessionBorrowForUser(userKey);
resolvedChar = ownEntry;
} else {
// Try shared char
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
const char = data.characters?.find(
(c: any) => c.name === charName && c.sharedWith?.includes(userKey)
);
if (char) {
setPersistentPreference(userKey, ownerKey, charName);
clearSessionBorrowForUser(userKey);
resolvedChar = char;
borrowedFrom = ownerKey;
break;
}
}
}
await InteractionLock.with(btn, async () => {
const ownEntry = CharacterRegistry.findForUser(userKey, charName);
if (!resolvedChar) {
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
return;
}
// Re-add to poll with previous vote type
const slot = [...polls.keys()][0];
const state = slot !== undefined ? polls.get(slot) : null;
if (state && !state.locked && state.confirmed === null) {
const { char } = getEffectiveCharacter(userKey);
const now = nowFormatted();
const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null);
const voteEntry = {
userKey,
displayName: charName,
characterName: char?.name ?? charName,
characterClass: char?.class ?? resolvedChar.class,
characterLevel: char?.level ?? resolvedChar.level,
characterNation: char?.nation ?? resolvedChar.nation,
borrowedFrom: borrowedFrom ?? undefined,
discordId: btn.user.id,
votedAt: now,
publicMessage: publicMsg ?? undefined,
};
// Find and reuse existing vote ID — avoids duplicate entries
let existingVoteId: string | null = null;
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === userKey) {
if (!existingVoteId) existingVoteId = id;
state.yes.delete(id);
state.no.delete(id);
}
}
const voteId = existingVoteId ?? btn.user.id;
if (prevVoteType === "yes") {
state.yes.set(voteId, voteEntry);
if (ownEntry) {
resolvedChar = ownEntry;
} else {
state.no.set(voteId, voteEntry);
const shared = CharacterRegistry.sharedWith(userKey).find(({ char }) => char.name === charName);
if (shared) {
resolvedChar = shared.char;
borrowedFrom = shared.ownerKey;
}
}
console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`);
console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`));
console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`));
if (!resolvedChar) {
await btn.followUp({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
return;
}
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
await updatePollMessage(channel, slot!);
}
// Delegate to shared switch logic
const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, btn, prevVoteType);
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName;
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
await btn.reply({
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`,
ephemeral: true,
if (result.replyData) {
const { content, components } = result.replyData;
console.log(`[switchAfterReclaim] replyData, isCompanion=${btn.customId.startsWith("companion_switch:")}`);
if (btn.customId.startsWith("companion_switch:")) {
await Ephemeral.update(voteId, "companion", content, components, { final: false });
} else {
await btn.followUp(result.replyData);
}
return;
}
if (result.success && result.message) {
const companionExists = !!Ephemeral.get(voteId, "companion");
console.log(`[switchAfterReclaim] success, companionExists=${companionExists} voteId=${voteId}`);
await Ephemeral.update(voteId, "companion", result.message, []);
// Ephemeral.delete(voteId, "companion"); // clean up after final switch
if (!companionExists) {
await btn.followUp({ content: result.message, ephemeral: true });
}
}
});
}
@ -119,37 +85,7 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
}
if (interaction.isButton()) {
const btn = interaction as ButtonInteraction;
console.log("[interactions] interaction btnId:", btn.customId);
if (btn.customId.startsWith("conflict_")) {
console.log("[interactions] routing to conflict handler:", btn.customId);
return await handleConflictButton(btn);
}
if (btn.customId.startsWith("impersonate_")) {
return await handleImpersonateButton(btn);
}
if (btn.customId.startsWith("switch_after_reclaim:")) {
return await handleSwitchAfterReclaim(btn);
}
if (btn.customId.startsWith("borrow_accept:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
}
if (btn.customId.startsWith("borrow_decline:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
}
if (btn.customId === "tg_score_submit") {
return await handleScoreSubmitButton(btn);
}
return await handleButton(btn);
return await handleButtonInteraction(interaction as ButtonInteraction);
}
if (interaction.isModalSubmit()) {
@ -167,9 +103,7 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
}
if (interaction.isChatInputCommand()) {
const cmd = interaction as ChatInputCommandInteraction;
if (cmd.commandName === "tg") await handleTgCommand(cmd);
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
await handleChatInputCommandInteraction(interaction as ChatInputCommandInteraction);
}
} catch (err) {
console.error("Interaction error:", err);
@ -182,4 +116,46 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
}
} 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 { loadConfig, cfg } from "@systems/config";
import { loadMessages } from "@systems/messages";
import { loadEmojis } from "@systems/emojis";
import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank";
import { scheduleSlots } from "@systems/slots";
import { Emoji } from "@systems/emojis";
import { Char } from "@systems/characters";
import { WRank } from "@systems/wrank";
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
import { handleInteraction } from "@handlers/interactions";
import { buildTgCommand } from "@commands/tg";
import { buildTgConfigCommand } from "@commands/tgConfig";
import { TGSlot } from "@src/types";
import { persist } from "@systems/pollPersistence"
import { buildTgAdminCommand } from "@commands/tgAdmin";
import { Scheduler } from "@systems/scheduler";
const TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.CLIENT_ID!;
@ -23,7 +24,11 @@ const client = new Client({
async function registerCommands(): Promise<void> {
const rest = new REST({ version: "10" }).setToken(TOKEN);
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
body: [buildTgCommand().toJSON(), buildTgConfigCommand().toJSON()],
body: [
buildTgCommand().toJSON(),
buildTgConfigCommand().toJSON(),
buildTgAdminCommand().toJSON(),
],
});
console.log("Slash commands registered.");
}
@ -68,9 +73,9 @@ client.once("clientReady", async () => {
loadConfig();
loadMessages();
loadEmojis();
loadCharacters();
loadWRank();
Emoji.load();
Char.load();
WRank.load();
const restored = persist.load();
if (restored) {
@ -93,7 +98,7 @@ client.once("clientReady", async () => {
await registerCommands();
}
scheduleSlots(client, onPollOpen, onPollLock, onPollClose);
Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose);
console.log("Bot ready.");
});

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

View file

@ -1,12 +1,28 @@
import { ChatInputCommandInteraction } from "discord.js";
import { setBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils";
import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { CharacterRegistry } from "@registry/character-registry";
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("name", true);
const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("char_name", true);
setBringerOverride(nation, charName);
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`);
// Resolve character
const char = CharacterRegistry.find(charName);
if (!char) {
return void replyAndDelete(interaction, `❌ Character **${charName}** not found.`, true);
}
// Validate nation matches
if (char.nation !== nation) {
return void replyAndDelete(interaction,
`❌ **${charName}** is a ${char.nation} character, not ${nation}.`, true
);
}
Bringer.override({ nation, character: char });
return void replyAndDelete(interaction,
`✅ **${charName}** set as ${nation === Nation.Capella ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`, true
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,15 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { loadMessages } from "@systems/messages";
import { loadEmojis } from "@systems/emojis";
import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank";
import { loadConfig, cfg } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { replyAndDelete } from "@utils";
import { CharacterRegistry } from "@root/src/systems/registry/character-registry";
import { invalidateUsermapCache } from "@root/src/handlers/autocomplete";
import { UserRegistry } from "@root/src/systems/registry/user-registry";
const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const;
type Reloadable = typeof RELOADABLE[number];
@ -20,7 +23,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr
if (should("config")) { loadConfig(); reloaded.push("config"); }
if (should("messages")) { loadMessages(); reloaded.push("messages"); }
if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); }
if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); }
if (should("characters")) { loadCharacters(); reloaded.push("characters"); }
if (should("wrank")) { loadWRank(); reloaded.push("wrank"); }
@ -52,37 +55,8 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr
}
}
UserRegistry.invalidateCache();
CharacterRegistry.invalidateCache();
return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`);
}
// export async function handleReload(interaction: ChatInputCommandInteraction): Promise<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 { cfg } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll";
import { nowFormatted, resolveMessage } from "../../systems/messages";
import { getEffectiveCharacter } from "../../systems/borrow";
import { replyAndDelete } from "../../utils";
import { VoteEntry, UsermapEntry } from "../../types";
import fs from "fs";
import path from "path";
import { cfg } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { nowFormatted, resolveMessage } from "@systems/messages";
import { getEffectiveCharacter } from "@systems/borrow";
import { replyAndDelete } from "@utils";
import { VoteEntry, UsermapEntry } from "@types";
import { UserRegistry } from "@registry/user-registry";
export async function handleSeed(interaction: ChatInputCommandInteraction): Promise<void> {
const slot = [...polls.keys()][0];
@ -17,18 +16,17 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
}
let usermap: Record<string, UsermapEntry | string> = {};
try {
usermap = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8"));
} catch {
return void replyAndDelete(interaction, "❌ Could not load usermap.json.");
const usermapEntries = UserRegistry.all();
if (usermapEntries.length === 0) {
return void replyAndDelete(interaction, "❌ No registered users found.");
}
const now = nowFormatted();
let injected = 0;
let skipped = 0;
for (const [discordUsername, entry] of Object.entries(usermap)) {
for (const { entry } of usermapEntries) {
const userKey = typeof entry === "string" ? entry : entry.file;
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
@ -37,7 +35,7 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
const syntheticId = `injected:${userKey}`;
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; }
const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null);
const publicMsg = resolveMessage("public", "yes", 1, userKey, null, null);
const voteEntry: VoteEntry = {
userKey,

View file

@ -1,8 +1,11 @@
import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank";
import { replyAndDelete } from "../../utils";
import { cfg } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users";
import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer";
import { replyAndDelete } from "@src/utils";
import { Nation } from "@types";
import { TG } from "@systems/tg";
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
@ -23,9 +26,9 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
const week = getCurrentWeek();
const week = TG.currentWeek();
const goal = cfg("wRankGoal");
const weekKey = getWeekKey();
const weekKey = WRank.weekKey();
for (const nation of ["capella", "procyon"] as const) {
const entry = week.entries[nation].find((e) => e.userKey === userKey);
@ -34,12 +37,12 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
const isDone = entry.tgCount >= goal;
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : "";
const bringer = getBringer(entry.nation);
const bringer = Bringer.get({ nation: entry.nation });
const isBringer = bringer === userKey && isDone;
const lines = [
`**${entry.characterName}** · ${entry.nation}`,
`**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""}`,
`**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === Nation.Capella ? "Luminous Bringer" : "Storm Bringer"}` : ""}`,
`**Points:** ${entry.weeklyPoints}`,
`**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`,
`**Week:** ${weekKey}`,

View file

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

View file

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

View file

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

View file

@ -1,19 +1,11 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users";
import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
import {
getEffectiveCharacter,
setSessionBorrow,
setPersistentPreference,
clearPersistentPreference,
clearSessionBorrowForUser,
} from "@systems/borrow";
import { polls, updatePollMessage } from "@systems/poll";
import { getClassEmoji } from "@systems/emojis";
import { getCharacterByName } from "@systems/characters";
import { Character } from "@systems/character";
import { Emoji } from "@systems/emojis";
import { replyAndDelete } from "@src/utils";
import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect";
import { CharacterRegistry } from "@registry/character-registry";
import fs from "fs";
import path from "path";
@ -33,13 +25,6 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
return null;
}
function findVoteIdInPoll(state: any, userKey: string): string | null {
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === userKey) return id;
}
return null;
}
export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
@ -57,7 +42,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
// Resolve the target character without switching yet
// Resolve target character
let resolvedChar: any = null;
let borrowedFrom: string | null = null;
@ -65,7 +50,8 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
if (ownChar) {
resolvedChar = ownChar;
} else {
const shared = findSharedChar(userKey, charName);
const shared = CharacterRegistry.sharedWith(userKey)
.find(({ char }) => char.name.toLowerCase() === charName.toLowerCase());
if (shared) {
resolvedChar = shared.char;
borrowedFrom = shared.ownerKey;
@ -74,87 +60,16 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
// If already active — just show current state without switching
const current = getEffectiveCharacter(userKey);
if (current.char?.name === resolvedChar.name) {
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : "";
return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
// Delegate to shared switch logic
const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, interaction);
if (result.replyData) {
await interaction.reply(result.replyData);
return;
}
// Check if target character is already in the active poll by another player
const slot = [...polls.keys()][0];
if (slot !== undefined) {
const state = polls.get(slot)!;
for (const [id, entry] of state.yes.entries()) {
const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`;
if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) {
const slotHour = state.slot;
const charDisplay = format.char(resolvedChar);
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
if (isOwner) {
await interaction.reply({
content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`,
components: buildCharSelectButtons(userKey, {
customIdPrefix: `switch_after_reclaim:${userKey}`,
excludeCharName: resolvedChar.name,
appendToCustomId: ":yes",
}),
ephemeral: true,
});
return;
}
const buttons = buildCharSelectButtons(userKey, {
customIdPrefix: `switch_after_reclaim:${userKey}`,
excludeCharName: resolvedChar.name,
appendToCustomId: `:${"yes"}`,
});
await interaction.reply({
content: `${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
components: buttons,
ephemeral: true,
});
return;
}
}
if (result.success && result.message) {
return void replyAndDelete(interaction, result.message, true);
}
// Now actually switch
if (borrowedFrom) {
setSessionBorrow(userKey, borrowedFrom, resolvedChar.name);
setPersistentPreference(userKey, borrowedFrom, resolvedChar.name);
} else {
setActiveCharacter(userKey, charName);
clearPersistentPreference(userKey);
clearSessionBorrowForUser(userKey);
resolvedChar = getActiveCharacter(userKey);
}
// Update poll embed if user has already voted
if (slot !== undefined) {
const state = polls.get(slot)!;
const voteId = findVoteIdInPoll(state, userKey);
if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) {
const updateEntry = (map: Map<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);
// conflict handled inside performSwitch — already replied
}

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

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

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 path from "path";
import { CharacterMap, Character, ClassKey, Nation, AccountMap, AccountData } from "../types";
import { Paths } from "@paths";
import {
Character, CharacterStats, CharName,
CharacterClass, ClassKey, Nation, UserKey, AccountData, AccountMap,
CLASSES,
} from "@types";
import { Store } from "@systems/store";
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
const ACCOUNTS_PATH = path.join(__dirname, "../../data/accounts.json");
let _chars: CharacterMap = {};
let _chars: CharacterMap = {};
let _accounts: AccountMap = {};
/** Raw shape stored in characters.json */
interface SerializableCharacter {
name: CharName;
class: ClassKey; // "FB" — serialized as string
level: number;
nation: Nation;
active?: boolean;
sharedWith?: UserKey[];
stats?: CharacterStats;
}
interface CharacterMap {
[userKey: string]: {
characters: SerializableCharacter[];
};
}
export function loadCharacters(): void {
try { _chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); }
catch { _chars = {}; }
try { _accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf8")); }
catch { _accounts = {}; }
_chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {});
_accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {});
}
function saveCharacters(): void {
fs.writeFileSync(CHARS_PATH, JSON.stringify(_chars, null, 2));
// Dehydrate all characters before saving
const raw: CharacterMap = {};
for (const [userKey, data] of Object.entries(_chars)) {
raw[userKey] = {
characters: data.characters.map((c) =>
"ownerKey" in c ? Char.dehydrate(c as unknown as Character) : c
),
};
}
Store.write(Paths.data("characters.json"), raw);
}
function saveAccounts(): void {
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2));
Store.write(Paths.data("accounts.json"), _accounts);
}
export function getCharacters(userKey: string): Character[] {
return _chars[userKey]?.characters ?? [];
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Get hydrated characters for a user */
export function getCharacters(userKey: UserKey): Character[] {
return (_chars[userKey]?.characters ?? []).map((c) =>
Char.hydrate(c as SerializableCharacter, userKey)
);
}
export function getActiveCharacter(userKey: string): Character | null {
export function getActiveCharacter(userKey: UserKey): Character | null {
return getCharacters(userKey).find((c) => c.active) ?? null;
}
export function getCharacterByName(userKey: string, name: string): Character | null {
return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
export function getCharacterByName(userKey: UserKey, name: string): Character | null {
return getCharacters(userKey).find(
(c) => c.name.toLowerCase() === name.toLowerCase()
) ?? null;
}
export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null {
// Returns the active character of that class, or first found
const chars = getCharacters(userKey).filter((c) => c.class === cls);
export function getCharacterByClass(userKey: UserKey, cls: ClassKey): Character | null {
const chars = getCharacters(userKey).filter((c) => c.class.key === cls);
return chars.find((c) => c.active) ?? chars[0] ?? null;
}
export function addCharacter(userKey: string, char: Omit<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: [] };
const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
const exists = _chars[userKey].characters.some(
(c) => c.name.toLowerCase() === char.name.toLowerCase()
);
if (exists) return false;
// If no active character, set this one as active
const hasActive = _chars[userKey].characters.some((c) => c.active);
_chars[userKey].characters.push({ ...char, active: !hasActive });
saveCharacters();
return true;
}
export function removeCharacter(userKey: string, name: string): boolean {
export function removeCharacter(userKey: UserKey, name: string): boolean {
if (!_chars[userKey]) return false;
const before = _chars[userKey].characters.length;
_chars[userKey].characters = _chars[userKey].characters.filter(
(c) => c.name.toLowerCase() !== name.toLowerCase()
);
if (_chars[userKey].characters.length === before) return false;
// If we removed the active one, set the first remaining as active
if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) {
if (!_chars[userKey].characters.some((c) => c.active) &&
_chars[userKey].characters.length > 0) {
_chars[userKey].characters[0].active = true;
}
saveCharacters();
return true;
}
export function setActiveCharacter(userKey: string, name: string): boolean {
export function setActiveCharacter(userKey: UserKey, name: string): boolean {
const chars = _chars[userKey]?.characters;
if (!chars) return false;
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
@ -78,34 +117,125 @@ export function setActiveCharacter(userKey: string, name: string): boolean {
return true;
}
export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean {
const char = getCharacterByName(userKey, name);
if (!char) return false;
char.nation = nation;
export function setCharacterNation(userKey: UserKey, name: string, nation: Nation): boolean {
const raw = _chars[userKey]?.characters.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
if (!raw) return false;
raw.nation = nation;
saveCharacters();
return true;
}
export function setCharacterStats(
userKey: string,
userKey: UserKey,
name: string,
stats: { atk?: number; def?: number; heal?: number }
): boolean {
const char = getCharacterByName(userKey, name);
if (!char) return false;
if (!char.stats) char.stats = {};
Object.assign(char.stats, stats);
const raw = _chars[userKey]?.characters.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
if (!raw) return false;
if (!raw.stats) raw.stats = {};
Object.assign(raw.stats, stats);
saveCharacters();
return true;
}
export function shareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean {
const raw = _chars[ownerKey]?.characters.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
);
if (!raw) return false;
if (!raw.sharedWith) raw.sharedWith = [];
if (raw.sharedWith.includes(targetKey)) return false;
raw.sharedWith.push(targetKey);
saveCharacters();
return true;
}
export function unshareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean {
const raw = _chars[ownerKey]?.characters.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
);
if (!raw || !raw.sharedWith) return false;
raw.sharedWith = raw.sharedWith.filter((k) => k !== targetKey);
saveCharacters();
return true;
}
// ─── Account data ─────────────────────────────────────────────────────────────
export function getAccountData(userKey: string): AccountData {
export function getAccountData(userKey: UserKey): AccountData {
return _accounts[userKey] ?? {};
}
export function setAccountData(userKey: string, data: Partial<AccountData>): void {
export function setAccountData(userKey: UserKey, data: Partial<AccountData>): void {
if (!_accounts[userKey]) _accounts[userKey] = {};
Object.assign(_accounts[userKey], data);
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 { BotConfig, Nation } from "../types";
import { Store } from "@systems/store";
import { Paths } from "@helpers/paths";
const CONFIG_PATH = path.join(__dirname, "../../data/config.json");
// Function instead of const so env vars are read lazily at call time
function getDefaults(): Required<BotConfig> {
@ -21,7 +21,7 @@ function getDefaults(): Required<BotConfig> {
],
scoreWindowHours: 2,
tgDurationMinutes: 35,
nationSource: "Procyon" as Nation,
nationSource: Nation.Procyon,
wRankPostOnReset: false,
wRankGoal: 7,
wRankYellowColor: "#BA7517",
@ -43,13 +43,10 @@ function getDefaults(): Required<BotConfig> {
let _cfg: BotConfig = {};
export function loadConfig(): void {
try { _cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
catch { _cfg = {}; }
_cfg = Store.readOrDefault(Paths.data("config.json"), {});
}
export function saveConfig(): void {
try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cfg, null, 2)); }
catch (err) { console.error("Failed to save config.json:", err); }
Store.write(Paths.data("config.json"), _cfg);
}
export function cfg<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 { polls, updatePollMessage } from "@systems/poll";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { getClassEmoji } from "@systems/emojis";
import { Emoji } from "@systems/emojis";
import { format } from "@systems/format";
import { Character } from "@types";
import { buildCharSelectButtons } from "@systems/charSelect";
@ -35,7 +35,7 @@ const pendingConflicts = new Map<string, {
// ─── Helpers ──────────────────────────────────────────────────────────────────
function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
const emojiStr = getClassEmoji(char.class);
const emojiStr = Emoji.class(char.class);
const emoji = format.emoji(emojiStr);
btn.setLabel(`${char.level} ${char.name}`);
if (emoji) btn.setEmoji(emoji as any);
@ -73,7 +73,7 @@ function buildConflictButtons(
const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE);
if (borrowed) {
reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`);
const emojiStr = getClassEmoji(borrowed.class);
const emojiStr = Emoji.class(borrowed.class);
const emoji = format.emoji(emojiStr);
if (emoji) reclaimBtn.setEmoji(emoji as any);
} else {

View file

@ -1,27 +1,89 @@
import fs from "fs";
import path from "path";
import { EmojiMap, ClassKey } from "../types";
/**
* Emoji system loads from categorized JSON files under data/emojis/
* 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");
let _emojis: EmojiMap = {};
import fs from "fs";
import { Paths } from "@paths";
import { Nation, ClassKey, CharacterClass } from "@types";
// ─── Cache ────────────────────────────────────────────────────────────────────
let _map: Record<string, string> | null = null;
function loadEmojiMap(): Record<string, string> {
if (_map) return _map;
_map = {};
const dir = Paths.emojis();
if (!fs.existsSync(dir)) {
// Fallback to legacy messages/emojis.json
try {
_map = JSON.parse(fs.readFileSync(Paths.messages("emojis.json"), "utf8"));
console.warn("[emojis] data/emojis/ not found, using legacy messages/emojis.json");
} catch {}
return _map!;
}
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith(".json")) continue;
try {
const data = JSON.parse(fs.readFileSync(Paths.emojis(file), "utf8"));
Object.assign(_map!, data);
} catch (err) {
console.error(`[emojis] Failed to load ${file}:`, err);
}
}
return _map!;
}
export function invalidateEmojiCache(): void {
_map = null;
}
// ─── Lookups ──────────────────────────────────────────────────────────────────
export function getEmoji(name: string): string {
return loadEmojiMap()[name] ?? "";
}
export function loadEmojis(): void {
try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); }
catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; }
}
export function getEmoji(key: string): string {
return _emojis[key] ?? "";
}
export const Emoji = {
load(): Record<string, string> {
return loadEmojiMap()
},
export function getClassEmoji(cls: ClassKey): string {
return getEmoji(cls.toLowerCase());
}
get(name: string): string {
return loadEmojiMap()[name] ?? "";
},
export function getNationEmoji(nation: string): string {
return getEmoji(nation.toLowerCase());
}
class(cls: ClassKey | CharacterClass): string {
const key = typeof cls === "object" ? cls.key : cls;
return Emoji.get(key.toLowerCase());
},
export function resolveEmojiTokens(text: string): string {
return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key));
}
nation(nation: Nation|string): string {
return getEmoji(nation.toLowerCase());
},
bringer(nation: Nation): string {
const map: Record<Nation, 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 { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis";
import { Character, ClassKey, CharacterClass, Nation, WRankEntry } from "@src/types";
import { Emoji } from "@systems/emojis";
// ─── Individual formatters ────────────────────────────────────────────────────
@ -8,17 +8,22 @@ export interface CharDisplayOptions {
level?: boolean; // show level (default: true)
}
function charButton(c: Character, options?: { shared?: boolean }): string {
const sharedMark = options?.shared ? " 🔗" : "";
return `${c.level} ${c.name}${sharedMark}`;
}
/**
* Format a character for display in embeds and messages.
* Output: <:class:> 79 «Flash»
*/
function char(
c: { class: ClassKey; level: number; name: string },
c: { class: ClassKey|CharacterClass; level: number; name: string },
options?: CharDisplayOptions
): string {
const showEmoji = options?.emoji ?? true;
const showLevel = options?.level ?? true;
const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class;
const classStr = showEmoji ? (Emoji.class(c.class) || c.class) : c.class;
const levelStr = showLevel ? `${c.level} ` : "";
return `${classStr} ${levelStr}${c.name}`.trim();
}
@ -28,7 +33,7 @@ function char(
* Output: <:capella:> Capella
*/
function nation(n: Nation): string {
const emoji = getNationEmoji(n);
const emoji = Emoji.nation(n);
return emoji ? `${emoji} ${n}` : n;
}
@ -37,8 +42,8 @@ function nation(n: Nation): string {
* Output: <:score:> 3000 <:kd:> 32/18
*/
function score(pts: number, k?: number, d?: number): string {
const scoreEmoji = getEmoji("score") || "📊";
const kdEmoji = getEmoji("kd") || "⚔️";
const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = Emoji.get("kd") || "⚔️";
const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : "";
return `${scoreEmoji} ${pts}${kdStr}`;
}
@ -69,7 +74,7 @@ export interface WRankDisplayOptions {
function wrankRank(entry: WRankEntry, goal: number): string {
const isDone = entry.tgCount >= goal;
const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`;
return getEmoji(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`);
return Emoji.get(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`);
}
/**
@ -86,13 +91,13 @@ function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string
let inner: string;
if (delta < 0) {
const abs = Math.abs(delta);
const numEmoji = getEmoji(`wrank_up_${abs}`);
inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs);
const numEmoji = Emoji.get(`wrank_up_${abs}`);
inner = (Emoji.get("wrank_up") || "↑") + (numEmoji || abs);
} else if (delta > 0) {
const numEmoji = getEmoji(`wrank_down_${delta}`);
inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta);
const numEmoji = Emoji.get(`wrank_down_${delta}`);
inner = (Emoji.get("wrank_down") || "↓") + (numEmoji || delta);
} else {
inner = (getEmoji("wrank_no_dash") || "·") + (getEmoji("wrank_neutral_0") || "0");
inner = (Emoji.get("wrank_no_dash") || "·") + (Emoji.get("wrank_neutral_0") || "0");
}
return brackets ? ` (${inner})` : ` ${inner}`;
@ -108,20 +113,30 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string {
/**
* Placeholder for characters with no W.Rank when others in their nation have one.
* Output: ( [] )
* Output: ( 0 )
*/
function wrankNoRank(): string {
const norank = getEmoji("wrank_no_dash") || "—";
const dash = getEmoji("wrank_no_rank_delta") || "—";
const square = getEmoji("wrank_no_dash") || "■";
const norank = Emoji.get("wrank_no_dash") || "—";
const dash = Emoji.get("wrank_no_rank_delta") || "—";
const square = Emoji.get("wrank_no_dash") || "■";
return `${norank} (${square}${dash})`;
}
// ─── Bringer formatters ────────────────────────────────────────────────────────
function bringerDisplay(n: Nation): string {
const bringerMap: Record<Nation, string> = {
Capella: Emoji.get("luminous_bringer") || "🔆 Luminous Bringer",
Procyon: Emoji.get("storm_bringer") || "⚡ Storm Bringer",
};
return bringerMap[n];
}
// ─── Namespace export ─────────────────────────────────────────────────────────
export const format = {
char,
charButton,
nation,
score,
emoji,
@ -131,4 +146,5 @@ export const format = {
full: wrankFull,
noRank: wrankNoRank,
},
bringer: bringerDisplay,
};

View file

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

View file

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

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 path from "path";
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "../types";
import { resolveEmojiTokens } from "./emojis";
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "@src/types";
import { Emoji } from "@systems/emojis";
import { UserRegistry } from "@registry/user-registry";
const MESSAGES_DIR = path.join(__dirname, "../../messages");
const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json");
@ -104,7 +105,7 @@ export function interpolate(
.replace(/\{date\}/g, dt.dateKey)
.replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0"));
return resolveEmojiTokens(result);
return Emoji.resolveTokens(result);
}
// ─── Resolution ──────────────────────────────────────────────────────────────
@ -162,3 +163,18 @@ export function getUsermapEntry(discordUsername: string): UsermapEntry | null {
if (typeof entry === "string") return { file: entry, aliases: [] };
return entry as UsermapEntry;
}
// Look up by Discord ID first, fall back to username for backwards compatibility
export function getUsermapEntryById(discordId: string, discordUsername: string): UsermapEntry | null {
return getUsermapEntry(discordId) ?? getUsermapEntry(discordUsername);
}
export function setUsermapEntry(discordId: string, entry: UsermapEntry): void {
MESSAGES.userMap[discordId] = entry;
UserRegistry.set(discordId, entry);
}
export function removeUsermapEntry(discordId: string): void {
delete MESSAGES.userMap[discordId];
UserRegistry.remove(discordId);
}

View file

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

View file

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

View file

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

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

View file

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

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

View file

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