big architectural changes, add Attendance/Score/TG/Registry/Scheduler systems, logger & benchmarker, tg-admin command
This commit is contained in:
parent
61bb590c87
commit
3c4aed93df
68 changed files with 3431 additions and 883 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
11
data/emojis/classes.json
Normal 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
14
data/emojis/misc.json
Normal 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
102
data/emojis/wrank-down.json
Normal 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>"
|
||||
}
|
||||
22
data/emojis/wrank-gold.json
Normal file
22
data/emojis/wrank-gold.json
Normal 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>"
|
||||
}
|
||||
5
data/emojis/wrank-neutral.json
Normal file
5
data/emojis/wrank-neutral.json
Normal 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
102
data/emojis/wrank-up.json
Normal 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
22
data/emojis/wrank.json
Normal 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>"
|
||||
}
|
||||
|
|
@ -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>"
|
||||
}
|
||||
|
|
@ -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
88
src/commands/tgAdmin.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
// 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();
|
||||
|
||||
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;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -183,3 +117,45 @@ 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);
|
||||
}
|
||||
29
src/helpers/interaction-lock.ts
Normal file
29
src/helpers/interaction-lock.ts
Normal 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
13
src/helpers/paths.ts
Normal 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),
|
||||
};
|
||||
23
src/index.ts
23
src/index.ts
|
|
@ -1,16 +1,17 @@
|
|||
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
|
||||
import { loadConfig, cfg } from "@systems/config";
|
||||
import { loadMessages } from "@systems/messages";
|
||||
import { loadEmojis } from "@systems/emojis";
|
||||
import { loadCharacters } from "@systems/characters";
|
||||
import { loadWRank } from "@systems/wrank";
|
||||
import { scheduleSlots } from "@systems/slots";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { Char } from "@systems/characters";
|
||||
import { WRank } from "@systems/wrank";
|
||||
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
|
||||
import { handleInteraction } from "@handlers/interactions";
|
||||
import { buildTgCommand } from "@commands/tg";
|
||||
import { buildTgConfigCommand } from "@commands/tgConfig";
|
||||
import { TGSlot } from "@src/types";
|
||||
import { persist } from "@systems/pollPersistence"
|
||||
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
||||
import { Scheduler } from "@systems/scheduler";
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
|
|
@ -23,7 +24,11 @@ const client = new Client({
|
|||
async function registerCommands(): Promise<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.");
|
||||
});
|
||||
|
|
|
|||
173
src/subcommands/admin/userMap.ts
Normal file
173
src/subcommands/admin/userMap.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}**.`);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
||||
|
|
|
|||
|
|
@ -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}»**.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(", ")}.`);
|
||||
// }
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
if (result.success && result.message) {
|
||||
return void replyAndDelete(interaction, result.message, true);
|
||||
}
|
||||
|
||||
// Check if target character is already in the active poll by another player
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
for (const [id, entry] of state.yes.entries()) {
|
||||
const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`;
|
||||
if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) {
|
||||
const slotHour = state.slot;
|
||||
const charDisplay = format.char(resolvedChar);
|
||||
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
|
||||
if (isOwner) {
|
||||
await interaction.reply({
|
||||
content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`,
|
||||
components: buildCharSelectButtons(userKey, {
|
||||
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||
excludeCharName: resolvedChar.name,
|
||||
appendToCustomId: ":yes",
|
||||
}),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const buttons = buildCharSelectButtons(userKey, {
|
||||
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||
excludeCharName: resolvedChar.name,
|
||||
appendToCustomId: `:${"yes"}`,
|
||||
});
|
||||
await interaction.reply({
|
||||
content: `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||
components: buttons,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now actually switch
|
||||
if (borrowedFrom) {
|
||||
setSessionBorrow(userKey, borrowedFrom, resolvedChar.name);
|
||||
setPersistentPreference(userKey, borrowedFrom, resolvedChar.name);
|
||||
} else {
|
||||
setActiveCharacter(userKey, charName);
|
||||
clearPersistentPreference(userKey);
|
||||
clearSessionBorrowForUser(userKey);
|
||||
resolvedChar = getActiveCharacter(userKey);
|
||||
}
|
||||
|
||||
// Update poll embed if user has already voted
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
const voteId = findVoteIdInPoll(state, userKey);
|
||||
|
||||
if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) {
|
||||
const updateEntry = (map: Map<string, any>) => {
|
||||
const entry = map.get(voteId);
|
||||
if (entry) {
|
||||
entry.characterName = resolvedChar.name;
|
||||
entry.characterClass = resolvedChar.class;
|
||||
entry.characterLevel = resolvedChar.level;
|
||||
entry.characterNation = resolvedChar.nation;
|
||||
entry.borrowedFrom = borrowedFrom ?? undefined;
|
||||
}
|
||||
};
|
||||
updateEntry(state.yes);
|
||||
updateEntry(state.no);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
}
|
||||
}
|
||||
|
||||
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
|
||||
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||
return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
|
||||
// conflict handled inside performSwitch — already replied
|
||||
}
|
||||
80
src/systems/attendance.ts
Normal file
80
src/systems/attendance.ts
Normal 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
82
src/systems/benchmark.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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
79
src/systems/bringer.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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
176
src/systems/character.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
export function loadEmojis(): void {
|
||||
try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); }
|
||||
catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; }
|
||||
}
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
let _map: Record<string, string> | null = null;
|
||||
|
||||
export function getEmoji(key: string): string {
|
||||
return _emojis[key] ?? "";
|
||||
}
|
||||
function loadEmojiMap(): Record<string, string> {
|
||||
if (_map) return _map;
|
||||
|
||||
export function getClassEmoji(cls: ClassKey): string {
|
||||
return getEmoji(cls.toLowerCase());
|
||||
}
|
||||
_map = {};
|
||||
const dir = Paths.emojis();
|
||||
|
||||
export function getNationEmoji(nation: string): string {
|
||||
return getEmoji(nation.toLowerCase());
|
||||
}
|
||||
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!;
|
||||
}
|
||||
|
||||
export function resolveEmojiTokens(text: string): string {
|
||||
return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key));
|
||||
}
|
||||
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 const Emoji = {
|
||||
load(): Record<string, string> {
|
||||
return loadEmojiMap()
|
||||
},
|
||||
|
||||
get(name: string): string {
|
||||
return loadEmojiMap()[name] ?? "";
|
||||
},
|
||||
|
||||
class(cls: ClassKey | CharacterClass): string {
|
||||
const key = typeof cls === "object" ? cls.key : cls;
|
||||
return Emoji.get(key.toLowerCase());
|
||||
},
|
||||
|
||||
nation(nation: Nation|string): string {
|
||||
return getEmoji(nation.toLowerCase());
|
||||
},
|
||||
|
||||
bringer(nation: Nation): string {
|
||||
const map: Record<Nation, string> = {
|
||||
[Nation.Procyon]: Emoji.get("storm_bringer"),
|
||||
[Nation.Capella]: Emoji.get("luminous_bringer"),
|
||||
};
|
||||
return map[nation] ?? "";
|
||||
},
|
||||
|
||||
invalidateCache(): void {
|
||||
_map = null;
|
||||
},
|
||||
|
||||
resolveTokens(text: string): string {
|
||||
return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => Emoji.get(key) || `{emoji:${key}}`)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
140
src/systems/logger.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 function oppositeNation(nation: Nation): Nation {
|
||||
return nation === "Capella" ? "Procyon" : "Capella";
|
||||
export const NATION_FROM_KEY: Record<"capella" | "procyon", Nation> = {
|
||||
"capella": Nation.Capella,
|
||||
"procyon": Nation.Procyon,
|
||||
};
|
||||
|
||||
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] ?? "";
|
||||
},
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
100
src/systems/registry/character-registry.ts
Normal file
100
src/systems/registry/character-registry.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
112
src/systems/registry/ephemeral-registry.ts
Normal file
112
src/systems/registry/ephemeral-registry.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
108
src/systems/registry/user-registry.ts
Normal file
108
src/systems/registry/user-registry.ts
Normal 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
115
src/systems/scheduler.ts
Normal 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.`);
|
||||
},
|
||||
};
|
||||
106
src/systems/scheduler/index.ts
Normal file
106
src/systems/scheduler/index.ts
Normal 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.`);
|
||||
},
|
||||
};
|
||||
19
src/systems/scheduler/midnight-cleanup.ts
Normal file
19
src/systems/scheduler/midnight-cleanup.ts
Normal 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 {}
|
||||
}
|
||||
},
|
||||
};
|
||||
8
src/systems/scheduler/types.ts
Normal file
8
src/systems/scheduler/types.ts
Normal 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;
|
||||
}
|
||||
12
src/systems/scheduler/weekly-reset.ts
Normal file
12
src/systems/scheduler/weekly-reset.ts
Normal 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
179
src/systems/score.ts
Normal 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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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
55
src/systems/store.ts
Normal 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
114
src/systems/tg.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
217
src/types.ts
217
src/types.ts
|
|
@ -1,7 +1,21 @@
|
|||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Nation = "Capella" | "Procyon";
|
||||
// ─── Branded primitive types ─────────────────────────────────────────────────
|
||||
export type UserKey = string;
|
||||
export type DiscordId = string;
|
||||
export type CharName = string;
|
||||
export type SlotHour = number;
|
||||
export type HistoryKey = string;
|
||||
export type VoteType = "yes" | "no";
|
||||
export type ConfirmType = "yes" | "no";
|
||||
|
||||
|
||||
// ─── Nation ───────────────────────────────────────────────────────────────────
|
||||
export enum Nation {
|
||||
Capella = "Capella",
|
||||
Procyon = "Procyon",
|
||||
}
|
||||
|
||||
// ─── Class ────────────────────────────────────────────────────────────────────
|
||||
export type ClassKey =
|
||||
| "BL" // Blader
|
||||
| "FB" // Force Blader
|
||||
|
|
@ -13,6 +27,24 @@ export type ClassKey =
|
|||
| "WI" // Wizard
|
||||
| "WA"; // Warrior
|
||||
|
||||
export interface CharacterClass {
|
||||
key: ClassKey;
|
||||
name: string; // "Force Blader"
|
||||
shortName: string; // "FB"
|
||||
}
|
||||
|
||||
export const CLASSES: Record<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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**/*"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue