A Discord bot for TFT Ultimate Bravery rolls rollbot.bpev.me
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: move from gh workflow to deno kv+cron

Ben Pevsner (Apr 5, 2026, 11:34 AM +0200) 9ce5b414 306145ec

+1401 -833
+7 -2
.example.env
··· 1 - # Copy file, rename to `.env`, and fill in ids 1 + # /oauth2/general 2 + CLIENT_ID=$ID 3 + 4 + # /bot 5 + BOT_TOKEN=$BOT_TOKEN 2 6 3 - DISCORD_TOKEN=$TOKEN 7 + # /information 8 + PUBLIC_KEY=$PUBLIC_KEY
-28
.github/workflows/list_sets.yml
··· 1 - # This workflow uses actions that are not certified by GitHub. 2 - # They are provided by a third-party and are governed by 3 - # separate terms of service, privacy policy, and support 4 - # documentation. 5 - 6 - # This workflow will run the update script to build the emoji db 7 - 8 - name: check sets 9 - 10 - on: 11 - workflow_dispatch: 12 - 13 - jobs: 14 - publish: 15 - runs-on: ubuntu-latest 16 - 17 - steps: 18 - - name: Setup repo 19 - uses: actions/checkout@v3 20 - 21 - - name: Setup Deno 22 - # uses: denoland/setup-deno@v1 23 - uses: denoland/setup-deno@004814556e37c54a2f6e31384c9e18e983317366 24 - with: 25 - deno-version: v1.x 26 - 27 - - name: Run Check Script 28 - run: deno run --allow-net ./db/list_sets.ts
-44
.github/workflows/update.yml
··· 1 - # This workflow uses actions that are not certified by GitHub. 2 - # They are provided by a third-party and are governed by 3 - # separate terms of service, privacy policy, and support 4 - # documentation. 5 - 6 - # This workflow will run the update script to build the emoji db 7 - 8 - name: update set 9 - 10 - on: 11 - workflow_dispatch: 12 - inputs: 13 - version: 14 - description: 'Set Name' 15 - required: true 16 - default: 7 17 - 18 - jobs: 19 - publish: 20 - runs-on: ubuntu-latest 21 - 22 - steps: 23 - - name: Setup repo 24 - uses: actions/checkout@v3 25 - 26 - - name: Setup Deno 27 - # uses: denoland/setup-deno@v1 28 - uses: denoland/setup-deno@004814556e37c54a2f6e31384c9e18e983317366 29 - with: 30 - deno-version: v1.x 31 - 32 - - name: Run Update Script 33 - run: deno run --allow-net --allow-write ./db/update.ts ${{ github.event.inputs.version }} 34 - 35 - - name: Run deno fmt 36 - run: deno fmt 37 - 38 - - name: Create Pull Request 39 - uses: peter-evans/create-pull-request@v4 40 - with: 41 - commit-message: "chore: updates to set ${{ github.event.inputs.version }}" 42 - branch: update-set 43 - title: "Updates champions to set ${{ github.event.inputs.version }}" 44 - body: "# Updates champions to set ${{ github.event.inputs.version }}"
-3
.vscode/settings.json
··· 1 - { 2 - "deno.enable": true 3 - }
-336
db/data.json
··· 1 - { 2 - "name": "TFTSet15", 3 - "traits": { 4 - "Executioner": [ 5 - "Akali", 6 - "Kalista", 7 - "Katarina", 8 - "Ryze", 9 - "Senna" 10 - ], 11 - "Star Student": [], 12 - "Infernal Speed": [], 13 - "Unstoppable": [], 14 - "Denting Blows": [], 15 - "Stance Master": [ 16 - "Lee Sin" 17 - ], 18 - "Magic Expert": [], 19 - "Mastermind": [], 20 - "Edgelord": [ 21 - "Samira", 22 - "Xayah", 23 - "Yasuo", 24 - "Yone", 25 - "Volibear", 26 - "Shen" 27 - ], 28 - "Crystal Gambit": [ 29 - "Ashe", 30 - "Janna", 31 - "Swain", 32 - "Syndra", 33 - "Vi", 34 - "Zyra" 35 - ], 36 - "Best Defense": [], 37 - "Max Vitality": [], 38 - "Wind Wall": [], 39 - "Ordinary": [], 40 - "Colossal": [], 41 - "Robo Ranger": [], 42 - "Max Attack": [], 43 - "All Out": [], 44 - "Golden Edge": [], 45 - "Regenerative": [], 46 - "Unflinching": [], 47 - "Rocket Grab": [], 48 - "Stretchy Arms": [], 49 - "Blood Brothers": [], 50 - "Hero's Arc": [], 51 - "Pack Tactics": [], 52 - "Rising Chaos": [], 53 - "Midas Touch": [], 54 - "Sniper": [ 55 - "Caitlyn", 56 - "Jhin", 57 - "Jinx", 58 - "Varus", 59 - "Gnar", 60 - "Sivir" 61 - ], 62 - "Star Guardian": [ 63 - "Ahri", 64 - "Jinx", 65 - "Poppy", 66 - "Rell", 67 - "Seraphine", 68 - "Syndra", 69 - "Xayah", 70 - "Neeko" 71 - ], 72 - "Mage": [], 73 - "Shine Brighter": [], 74 - "Solar Breath": [], 75 - "Fury Break": [], 76 - "Precision": [], 77 - "Ice Bender": [], 78 - "100 Push Ups": [], 79 - "Heart of Gold": [], 80 - "Veteran": [], 81 - "Tank-zilla": [], 82 - "Fighter Spirit": [], 83 - "Space Ace": [], 84 - "Weights": [], 85 - "Heavyweight": [ 86 - "Aatrox", 87 - "Darius", 88 - "Jayce", 89 - "Kobuko", 90 - "Poppy", 91 - "Zac" 92 - ], 93 - "Spiky Shell": [], 94 - "Hyperactive": [], 95 - "The Crew": [ 96 - "Malphite", 97 - "Ziggs", 98 - "Sivir", 99 - "Twisted Fate", 100 - "Shen" 101 - ], 102 - "Tentacle Smash": [], 103 - "Mech Pilot": [], 104 - "Cyclone Rush": [], 105 - "Sky Piercer": [], 106 - "Gather Force": [], 107 - "Corner Carry": [], 108 - "Killer Instinct": [], 109 - "Atomic": [], 110 - "Quick Tag": [], 111 - "Selfish": [], 112 - "S.O.L.E. Fighter": [], 113 - "Stand Alone": [], 114 - "Supreme Cells": [ 115 - "Akali", 116 - "Darius", 117 - "Kai'Sa", 118 - "Kennen" 119 - ], 120 - "Round Two": [], 121 - "Over 9000": [], 122 - "Max Speed": [], 123 - "Keen Eye": [], 124 - "Drift Duo": [], 125 - "Soul Fighter": [ 126 - "Gwen", 127 - "Kalista", 128 - "Lux", 129 - "Naafiri", 130 - "Samira", 131 - "Sett", 132 - "Viego", 133 - "Xin Zhao" 134 - ], 135 - "Bladenado": [], 136 - "Wonder Twins": [], 137 - "Super Mega": [], 138 - "Hat Trick": [], 139 - "Superstar": [], 140 - "Max Arcana": [], 141 - "Final Ascent": [], 142 - "Living Wall": [], 143 - "Blink Attack": [], 144 - "Mana Rush": [], 145 - "The Champ": [ 146 - "Braum" 147 - ], 148 - "Rare Treat": [], 149 - "Surge 66": [], 150 - "Snax Time": [], 151 - "Final Form": [], 152 - "Classy": [], 153 - "Bonus Bloom": [], 154 - "Serious Slam": [], 155 - "Sorcerer": [ 156 - "Ahri", 157 - "Gwen", 158 - "Kennen", 159 - "Lux", 160 - "Swain", 161 - "Lucian", 162 - "Karma" 163 - ], 164 - "Needlework": [], 165 - "Hungry Hero": [], 166 - "Bullet Hell": [], 167 - "Noxious Trap": [], 168 - "Finalist": [], 169 - "Adaptive Skin": [], 170 - "Hemorrhage": [], 171 - "Fairy Tail": [], 172 - "Monster Trainer": [ 173 - "Lulu", 174 - "Kog'Maw", 175 - "Smolder", 176 - "Rammus" 177 - ], 178 - "Juggernaut": [ 179 - "Aatrox", 180 - "Naafiri", 181 - "Sett", 182 - "Udyr", 183 - "Dr. Mundo", 184 - "Vi" 185 - ], 186 - "Rosemother": [ 187 - "Zyra" 188 - ], 189 - "Critical Threat": [], 190 - "Demolitionist": [], 191 - "Storm Bender": [], 192 - "Mechablade": [], 193 - "Doomsayer": [], 194 - "Shadow Clone": [], 195 - "Dread Note": [], 196 - "Pursuit": [], 197 - "Corrupted": [], 198 - "Warming Up": [], 199 - "Corrosive": [], 200 - "Soul Chipper": [], 201 - "Double Down": [], 202 - "Thrillseeker": [], 203 - "Untouchable": [], 204 - "Efficient": [], 205 - "Rogue Captain": [ 206 - "Twisted Fate" 207 - ], 208 - "Annihilation": [], 209 - "Resistant": [], 210 - "Mentor": [ 211 - "Kobuko", 212 - "Ryze", 213 - "Udyr", 214 - "Yasuo" 215 - ], 216 - "Tiny Terror": [], 217 - "Trickster": [], 218 - "Caretaker": [], 219 - "Mind Battery": [], 220 - "Attempt #37": [], 221 - "Kahunahuna": [], 222 - "Duelist": [ 223 - "Ashe", 224 - "Kai'Sa", 225 - "Kayle", 226 - "Udyr", 227 - "Viego", 228 - "Gangplank" 229 - ], 230 - "Attack Expert": [], 231 - "Strategist": [ 232 - "Janna", 233 - "Jarvan IV", 234 - "Ryze", 235 - "Ziggs" 236 - ], 237 - "Bastion": [ 238 - "Braum", 239 - "Garen", 240 - "Leona", 241 - "Rell", 242 - "Swain", 243 - "Xin Zhao", 244 - "Shen" 245 - ], 246 - "Star Sailor": [], 247 - "Not Done Yet": [], 248 - "Ramping Rage": [], 249 - "Power Font": [], 250 - "Final Boss": [], 251 - "Dark Amulet": [], 252 - "Wraith": [ 253 - "Jhin", 254 - "K'Sante", 255 - "Kayle", 256 - "Malzahar", 257 - "Varus", 258 - "Zac" 259 - ], 260 - "Supremacy": [], 261 - "Prodigy": [ 262 - "Ezreal", 263 - "Malzahar", 264 - "Seraphine", 265 - "Syndra", 266 - "Yuumi" 267 - ], 268 - "Singularity": [], 269 - "Team Captain": [], 270 - "Bestest Boy": [], 271 - "Super Genius": [], 272 - "2XKO": [], 273 - "Doom Barrage": [], 274 - "Protector": [ 275 - "Janna", 276 - "K'Sante", 277 - "Kennen", 278 - "Malphite", 279 - "Rakan", 280 - "Neeko" 281 - ], 282 - "Frost Touch": [], 283 - "Golden Gloves": [], 284 - "Spirit Sword": [], 285 - "Inner Fire": [], 286 - "Stalking Dart": [], 287 - "Body Change": [], 288 - "Ultra Stance": [], 289 - "Pure Heart": [], 290 - "Battle Academia": [ 291 - "Caitlyn", 292 - "Ezreal", 293 - "Garen", 294 - "Jayce", 295 - "Katarina", 296 - "Leona", 297 - "Rakan", 298 - "Yuumi" 299 - ], 300 - "404 Power Up": [], 301 - "Mechador": [], 302 - "Crimson Veil": [], 303 - "Supreme Surge": [], 304 - "Fusion Dance": [], 305 - "Assassinate": [], 306 - "Fan Service": [], 307 - "Gem Core": [], 308 - "Socialite": [], 309 - "Momentum": [], 310 - "2X Contender": [], 311 - "Strong Spark": [], 312 - "Essence Share": [], 313 - "Bludgeoner": [], 314 - "Artistic KO": [], 315 - "Doublestrike": [], 316 - "Luchador": [ 317 - "Braum", 318 - "Volibear", 319 - "Dr. Mundo", 320 - "Gnar" 321 - ], 322 - "Starry Knight": [], 323 - "On The Edge": [], 324 - "Stand United": [], 325 - "Desperado": [], 326 - "Mighty Mech": [ 327 - "Aatrox", 328 - "Jarvan IV", 329 - "Yone", 330 - "Senna", 331 - "Lucian", 332 - "Gangplank", 333 - "Karma" 334 - ] 335 - } 336 - }
-4
db/list_sets.ts
··· 1 - const resp = await fetch('https://raw.communitydragon.org/latest/cdragon/tft/en_us.json'); 2 - const data = await resp.json(); 3 - const setNames = data.setData.map(setData => setData.mutator); 4 - console.log(setNames);
-74
db/update.ts
··· 1 - // deno-lint-ignore-file no-explicit-any 2 - 3 - /** 4 - * There is no auto-generated static content for TFT currently... 5 - * Latest update is from set 5.... 6 - * https://developer.riotgames.com/docs/tft#static-data 7 - * 8 - * Therefore, use community-driven site cdragon: 9 - * - https://cdn.communitydragon.org 10 - * - https://raw.communitydragon.org/latest/cdragon/tft/en_us.json 11 - * 12 - * We may need to add some set-specific filters if the set has 13 - * unobtainable champions 14 - */ 15 - interface Data { 16 - items: any[]; 17 - setData: SetData[]; 18 - sets: any; 19 - } 20 - 21 - interface SetData { 22 - champions: Champion[]; 23 - mutator: string; 24 - name: string; 25 - number: number; 26 - traits: Trait[]; 27 - } 28 - 29 - interface Champion { 30 - apiName: string; 31 - cost: number; 32 - icon: string; 33 - name: string; 34 - traits: string[]; 35 - ability: any; 36 - stats: any; 37 - } 38 - 39 - interface Trait { 40 - apiName: string; 41 - desc: string; 42 - effects: any[]; 43 - icon: string; 44 - name: string; 45 - } 46 - 47 - const resp = await fetch('https://raw.communitydragon.org/latest/cdragon/tft/en_us.json'); 48 - const data = await resp.json(); 49 - 50 - const setName = Deno.args[0]; 51 - const setData = data.setData.find(setData => setData.mutator == setName); 52 - 53 - if (!setData) throw new Error('Invalid Set Name'); 54 - 55 - const { champions, traits } = setData; 56 - 57 - const traitsSet: Set<string> = new Set(); 58 - traits.forEach(({ name }) => traitsSet.add(name)); 59 - 60 - const results: { [name: string]: string[] } = {}; 61 - traitsSet.forEach((trait: string) => { 62 - results[trait] = []; 63 - }); 64 - 65 - champions.forEach((champion: Champion) => { 66 - champion.traits.forEach((trait: string) => { 67 - results[trait].push(champion.name); 68 - }); 69 - }); 70 - 71 - Deno.writeTextFileSync(`./db/data.json`, JSON.stringify({ 72 - name: setName, 73 - traits: results 74 - }, null, 2));
+37 -8
deno.json
··· 1 1 { 2 + "deploy": { 3 + "org": "bpev", 4 + "app": "rollbot" 5 + }, 6 + "compilerOptions": { 7 + "lib": [ 8 + "deno.ns", 9 + "deno.unstable", 10 + "esnext", 11 + "webworker" 12 + ], 13 + "strict": true, 14 + "noImplicitReturns": true, 15 + "noImplicitAny": true, 16 + "noImplicitThis": true, 17 + "noImplicitOverride": true 18 + }, 2 19 "fmt": { 3 - "files": { 4 - "include": ["source/"] 5 - } 20 + "singleQuote": true, 21 + "proseWrap": "preserve", 22 + "semiColons": false 6 23 }, 7 24 "tasks": { 8 - "roll": "deno run source/cli.ts", 9 - "db:update": "deno run --allow-net --allow-write db/update.ts", 10 - "db:list": "deno run --allow-net --allow-write db/list_sets.ts", 11 - "discord:setup": "deno run --allow-env --allow-net --allow-read source/discord_setup.ts", 12 - "discord:serve": "deno run --allow-env --allow-net --allow-read source/discord_serve.ts" 25 + "roll": "deno run --unstable-kv src/cli.ts", 26 + "db:update": "deno run --unstable-kv --allow-net --allow-write src/db/update.ts", 27 + "discord:serve": "deno run --unstable-kv --allow-env --allow-net --allow-read --allow-import src/discord_serve.ts", 28 + "discord:setup": "deno run --allow-env --allow-net --allow-read src/discord_setup.ts", 29 + "test": "deno fmt && deno lint && deno check ./src && deno test --unstable-kv" 30 + }, 31 + "imports": { 32 + "@std/assert": "jsr:@std/assert@^1.0.19", 33 + "@std/cache": "jsr:@std/cache@^0.2.2", 34 + "@std/dotenv": "jsr:@std/dotenv@0.225.6", 35 + "@std/http": "jsr:@std/http@1.0.25", 36 + "@std/http/status": "jsr:@std/http@1.0.25/status", 37 + "@std/media-types": "jsr:@std/media-types@0.224.0", 38 + "tweetnacl": "npm:tweetnacl@1.0.3", 39 + "preact": "npm:preact@10.22.0", 40 + "preact-render-to-string": "npm:preact-render-to-string@6.5.0", 41 + "sift": "./src/sift/mod.ts" 13 42 } 14 43 }
+139 -43
deno.lock
··· 1 1 { 2 - "version": "2", 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@std/assert@^1.0.19": "1.0.19", 5 + "jsr:@std/cache@~0.2.2": "0.2.2", 6 + "jsr:@std/cli@^1.0.28": "1.0.28", 7 + "jsr:@std/dotenv@0.225.6": "0.225.6", 8 + "jsr:@std/encoding@^1.0.10": "1.0.10", 9 + "jsr:@std/fmt@^1.0.9": "1.0.9", 10 + "jsr:@std/fs@^1.0.23": "1.0.23", 11 + "jsr:@std/html@^1.0.5": "1.0.5", 12 + "jsr:@std/http@1.0.25": "1.0.25", 13 + "jsr:@std/internal@^1.0.12": "1.0.12", 14 + "jsr:@std/media-types@0.224.0": "0.224.0", 15 + "jsr:@std/media-types@^1.1.0": "1.1.0", 16 + "jsr:@std/net@^1.0.6": "1.0.6", 17 + "jsr:@std/path@^1.1.4": "1.1.4", 18 + "jsr:@std/streams@^1.0.17": "1.0.17", 19 + "npm:preact-render-to-string@6.5.0": "6.5.0_preact@10.22.0", 20 + "npm:preact@10.22.0": "10.22.0", 21 + "npm:tweetnacl@1.0.3": "1.0.3" 22 + }, 23 + "jsr": { 24 + "@std/assert@1.0.19": { 25 + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 26 + "dependencies": [ 27 + "jsr:@std/internal" 28 + ] 29 + }, 30 + "@std/cache@0.2.2": { 31 + "integrity": "4c8fb31c7553837b537f55e26c9c91b2784f9a8c64f43772a7884a059b04d46e" 32 + }, 33 + "@std/cli@1.0.28": { 34 + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" 35 + }, 36 + "@std/dotenv@0.225.6": { 37 + "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 38 + }, 39 + "@std/encoding@1.0.10": { 40 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 41 + }, 42 + "@std/fmt@1.0.9": { 43 + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 44 + }, 45 + "@std/fs@1.0.23": { 46 + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37" 47 + }, 48 + "@std/html@1.0.5": { 49 + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 50 + }, 51 + "@std/http@1.0.25": { 52 + "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", 53 + "dependencies": [ 54 + "jsr:@std/cli", 55 + "jsr:@std/encoding", 56 + "jsr:@std/fmt", 57 + "jsr:@std/fs", 58 + "jsr:@std/html", 59 + "jsr:@std/media-types@^1.1.0", 60 + "jsr:@std/net", 61 + "jsr:@std/path", 62 + "jsr:@std/streams" 63 + ] 64 + }, 65 + "@std/internal@1.0.12": { 66 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 67 + }, 68 + "@std/media-types@0.224.0": { 69 + "integrity": "5ac87989393f8cb1c81bee02aef6f5d4c8289b416deabc04f9ad25dff292d0b0" 70 + }, 71 + "@std/media-types@1.1.0": { 72 + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 73 + }, 74 + "@std/net@1.0.6": { 75 + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" 76 + }, 77 + "@std/path@1.1.4": { 78 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 79 + "dependencies": [ 80 + "jsr:@std/internal" 81 + ] 82 + }, 83 + "@std/streams@1.0.17": { 84 + "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" 85 + } 86 + }, 87 + "npm": { 88 + "preact-render-to-string@6.5.0_preact@10.22.0": { 89 + "integrity": "sha512-4vTsKlL51D6zAJObcyjDEnFCY5OBH/oaW+97X8z4IM6DK/BYD6exzaRZblTZhCeqzdBpQejPsfoS20xFc05SPA==", 90 + "dependencies": [ 91 + "preact", 92 + "pretty-format" 93 + ] 94 + }, 95 + "preact@10.22.0": { 96 + "integrity": "sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==" 97 + }, 98 + "pretty-format@3.8.0": { 99 + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" 100 + }, 101 + "tweetnacl@1.0.3": { 102 + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" 103 + } 104 + }, 3 105 "remote": { 4 - "https://cdn.skypack.dev/-/http-cache-semantics@v4.1.0-5IkAyzxageW9utYlM9Kq/dist=es2019,mode=imports/optimized/http-cache-semantics.js": "abb90d8731ed016549017a010e198e5075e5ca5308bfe429b7acaa07c3fe7200", 5 - "https://cdn.skypack.dev/-/http-cache-semantics@v4.1.0-5IkAyzxageW9utYlM9Kq/dist=es2019,mode=types/index.d.ts": "0bf440ab914e79e890cb15e5ffacb5e18bec678338b3eab069a5829d3d5c96e3", 6 - "https://cdn.skypack.dev/-/tweetnacl@v1.0.3-G4yM3nQ8lnXXlGGQADqJ/dist=es2019,mode=imports/optimized/tweetnacl.js": "d26554516df57e5cb58954e90c633c8871b4e66016b9fe4e07a36db5430bc8c7", 7 - "https://cdn.skypack.dev/-/tweetnacl@v1.0.3-G4yM3nQ8lnXXlGGQADqJ/dist=es2019,mode=types/nacl.d.ts": "60592f5ae1b739c9607a99895d4a3ad5c865b16903e4180e50b256e360a4a104", 8 - "https://cdn.skypack.dev/http-cache-semantics?dts": "dc31b1d6078daceb7667ec025de2206541c4f1c8fe480836bbaf9f8f666f9880", 9 - "https://cdn.skypack.dev/tweetnacl@v1.0.3?dts": "6610aad2ac175c2d575995fc7de8ed552c2e5e05aef80ed8588cf3c6e2db61d7", 10 - "https://deno.land/std@0.118.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58", 11 - "https://deno.land/std@0.118.0/_util/os.ts": "dfb186cc4e968c770ab6cc3288bd65f4871be03b93beecae57d657232ecffcac", 12 - "https://deno.land/std@0.118.0/path/_constants.ts": "1247fee4a79b70c89f23499691ef169b41b6ccf01887a0abd131009c5581b853", 13 - "https://deno.land/std@0.118.0/path/_interface.ts": "1fa73b02aaa24867e481a48492b44f2598cd9dfa513c7b34001437007d3642e4", 14 - "https://deno.land/std@0.118.0/path/_util.ts": "2e06a3b9e79beaf62687196bd4b60a4c391d862cfa007a20fc3a39f778ba073b", 15 - "https://deno.land/std@0.118.0/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", 16 - "https://deno.land/std@0.118.0/path/glob.ts": "ea87985765b977cc284b92771003b2070c440e0807c90e1eb0ff3e095911a820", 17 - "https://deno.land/std@0.118.0/path/mod.ts": "4465dc494f271b02569edbb4a18d727063b5dbd6ed84283ff906260970a15d12", 18 - "https://deno.land/std@0.118.0/path/posix.ts": "34349174b9cd121625a2810837a82dd8b986bbaaad5ade690d1de75bbb4555b2", 19 - "https://deno.land/std@0.118.0/path/separator.ts": "8fdcf289b1b76fd726a508f57d3370ca029ae6976fcde5044007f062e643ff1c", 20 - "https://deno.land/std@0.118.0/path/win32.ts": "11549e8c6df8307a8efcfa47ad7b2a75da743eac7d4c89c9723a944661c8bd2e", 21 - "https://deno.land/std@0.130.0/async/abortable.ts": "a896ac6b0d4237bd2d2d248217cfa1f0d85ccda93cb25ebda55e33850e526be6", 22 - "https://deno.land/std@0.130.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", 23 - "https://deno.land/std@0.130.0/async/debounce.ts": "564273ef242bcfcda19a439132f940db8694173abffc159ea34f07d18fc42620", 24 - "https://deno.land/std@0.130.0/async/deferred.ts": "bc18e28108252c9f67dfca2bbc4587c3cbf3aeb6e155f8c864ca8ecff992b98a", 25 - "https://deno.land/std@0.130.0/async/delay.ts": "cbbdf1c87d1aed8edc7bae13592fb3e27e3106e0748f089c263390d4f49e5f6c", 26 - "https://deno.land/std@0.130.0/async/mod.ts": "2240c6841157738414331f47dee09bb8c0482c5b1980b6e3234dd03515c8132f", 27 - "https://deno.land/std@0.130.0/async/mux_async_iterator.ts": "f4d1d259b0c694d381770ddaaa4b799a94843eba80c17f4a2ec2949168e52d1e", 28 - "https://deno.land/std@0.130.0/async/pool.ts": "97b0dd27c69544e374df857a40902e74e39532f226005543eabacb551e277082", 29 - "https://deno.land/std@0.130.0/async/tee.ts": "1341feb1f5b1a96f8628d0f8fc07d8c43d3813423f18a63bf1b4785568d21b1f", 30 - "https://deno.land/std@0.130.0/http/http_status.ts": "71838ad33cb03ff2575f5e8fdea5f930979cdff8a81c11ade497f11d4c026019", 31 - "https://deno.land/std@0.130.0/http/server.ts": "10c3a7814666cdbabd6f9c18acdcc4593e222275a628280a4868881445fbb5bc", 32 - "https://deno.land/x/dotenv@v3.2.0/load.ts": "cbd76a0aee01aad8d09222afaa1dd04b84d9d3e44637503b01bf77a91df9e041", 33 - "https://deno.land/x/dotenv@v3.2.0/mod.ts": "077b48773de9205266a0b44c3c3a3c3083449ed64bb0b6cc461b95720678d38e", 34 - "https://deno.land/x/dotenv@v3.2.0/util.ts": "693730877b13f8ead2b79b2aa31e2a0652862f7dc0c5f6d2f313f4d39c7b7670", 35 - "https://deno.land/x/httpcache@0.1.2/in_memory.ts": "9f7d2d1600f64934c45493bf300475f3edef8ca540da60f308bda32d30de849e", 36 - "https://deno.land/x/httpcache@0.1.2/mod.ts": "1247683a7e4f573759afb47709b2b5cd954986cfc4882ed7cc3a21b0e959be58", 37 - "https://deno.land/x/lru@1.0.2/mod.ts": "1d44b87c4d40ff33749ae5fd85fe234344e0dace835fdfeb48413edea9461159", 38 - "https://deno.land/x/media_types@v2.11.1/db.ts": "1eac2b1b57195e41d7e40d7b236a3adeb93b4fa18084b4c9db49144fe685c78d", 39 - "https://deno.land/x/media_types@v2.11.1/mod.ts": "1be09ec3626332f9fe52a8a9ede852bb76d2207f84bec1f1b16c10555478008f", 40 - "https://deno.land/x/sift@0.5.0/mod.ts": "900cc5dc7be287a48b1f4f036010334a92d1783fb8427c69ad40070dc6b918b4", 41 - "https://x.lcas.dev/preact@10.5.12/jsx.d.ts": "55e7b86443e17edf4252c12fc8114678c733f8831a81b622d49097f28b0be98b", 42 - "https://x.lcas.dev/preact@10.5.12/mod.d.ts": "1b7da14b252cc408c27429d0b8dfad08eac125daa054507b58435c4390dc7c99", 43 - "https://x.lcas.dev/preact@10.5.12/mod.js": "62eb264b333369c5c3f5186fc97964c1787f9352d52decfde9c50084add3a86f", 44 - "https://x.lcas.dev/preact@10.5.12/ssr.d.ts": "969fee1e06346d80d97414ffa0a49a13c0672462324a7f5e2e335662097ed595", 45 - "https://x.lcas.dev/preact@10.5.12/ssr.js": "17b5eebfac9321092cee4a6fcbf9a88885936824eadf2c1c16b297cb829721e1" 106 + "https://deno.land/std@0.107.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58", 107 + "https://deno.land/std@0.107.0/_util/os.ts": "dfb186cc4e968c770ab6cc3288bd65f4871be03b93beecae57d657232ecffcac", 108 + "https://deno.land/std@0.107.0/fmt/colors.ts": "8368ddf2d48dfe413ffd04cdbb7ae6a1009cf0dccc9c7ff1d76259d9c61a0621", 109 + "https://deno.land/std@0.107.0/path/_constants.ts": "1247fee4a79b70c89f23499691ef169b41b6ccf01887a0abd131009c5581b853", 110 + "https://deno.land/std@0.107.0/path/_interface.ts": "1fa73b02aaa24867e481a48492b44f2598cd9dfa513c7b34001437007d3642e4", 111 + "https://deno.land/std@0.107.0/path/_util.ts": "2e06a3b9e79beaf62687196bd4b60a4c391d862cfa007a20fc3a39f778ba073b", 112 + "https://deno.land/std@0.107.0/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", 113 + "https://deno.land/std@0.107.0/path/glob.ts": "46708a3249cb5dc4a116cae3055114d6339bd5f0c1f412db6a4e0cb44c828a7d", 114 + "https://deno.land/std@0.107.0/path/mod.ts": "4465dc494f271b02569edbb4a18d727063b5dbd6ed84283ff906260970a15d12", 115 + "https://deno.land/std@0.107.0/path/posix.ts": "34349174b9cd121625a2810837a82dd8b986bbaaad5ade690d1de75bbb4555b2", 116 + "https://deno.land/std@0.107.0/path/separator.ts": "8fdcf289b1b76fd726a508f57d3370ca029ae6976fcde5044007f062e643ff1c", 117 + "https://deno.land/std@0.107.0/path/win32.ts": "2edb2f71f10578ee1168de01a8cbd3c65483e45a46bc2fa3156a0c6bfbd2720d", 118 + "https://deno.land/std@0.107.0/testing/_diff.ts": "ccd6c3af6e44c74bf1591acb1361995f5f50df64323a6e7fb3f16c8ea792c940", 119 + "https://deno.land/std@0.107.0/testing/asserts.ts": "05ab5d53d1e2b8402987c50c622d6d8e6f89d63c0798ab26923ea5fcbe10f9ed", 120 + "https://deno.land/x/dectyl@0.10.7/deps.ts": "f0fff6303a345d7654ee021946dc07a27cd011eb357a9a511307f52019a0346e", 121 + "https://deno.land/x/dectyl@0.10.7/lib/deploy_worker.ts": "43952c5c34851b9115857d1e95d4ae454880d6d0c5adb5e9fe5470f845d67b41", 122 + "https://deno.land/x/dectyl@0.10.7/lib/handlers.ts": "d3d79349c7df5d9b4a313681a17d0553f391899511c3dd0614fc62fb90dba956", 123 + "https://deno.land/x/dectyl@0.10.7/lib/logger.ts": "1220e970b54db7f1eed03e60572588240cae2e5022e62f2802fa2910bea1657f", 124 + "https://deno.land/x/dectyl@0.10.7/lib/testing.ts": "747d89f3fe4935a1b3f3396ab7bee280c1929edc48ed5feafdf7d26a5a418895", 125 + "https://deno.land/x/dectyl@0.10.7/lib/util.ts": "1b9f0ee38ab05e44c002f8b996e47ade9927fb6b0afc82958b9d48036cc086d2", 126 + "https://deno.land/x/dectyl@0.10.7/mod.ts": "4268a1bc583e41b07536393e78ccd0339912565b755c006fce710e5bce998146", 127 + "https://deno.land/x/media_types@v2.10.2/db.ts": "d5ecfa410b570874a648aa12bac90d9c6704dc75f1e8c03b8cac97dd02b003ce", 128 + "https://deno.land/x/media_types@v2.10.2/deps.ts": "adc4e59af148fe5a49087932536cdf24a7027daa16498c2983124e3ec4d90c20", 129 + "https://deno.land/x/media_types@v2.10.2/mod.ts": "d63583b978d32eff8b76e1ae5d83cba2fb27baa90cc1bcb0ad15a06122ea8c19" 130 + }, 131 + "workspace": { 132 + "dependencies": [ 133 + "jsr:@std/assert@^1.0.19", 134 + "jsr:@std/cache@~0.2.2", 135 + "jsr:@std/dotenv@0.225.6", 136 + "jsr:@std/http@1.0.25", 137 + "jsr:@std/media-types@0.224.0", 138 + "npm:preact-render-to-string@6.5.0", 139 + "npm:preact@10.22.0", 140 + "npm:tweetnacl@1.0.3" 141 + ] 46 142 } 47 143 }
-21
source/cli.ts
··· 1 - /** 2 - * Entrypoint for using Rollbot as a command line utility. 3 - */ 4 - 5 - import { ENVIRONMENT } from "./constants.ts"; 6 - import { roll } from "./utilities/roll.ts"; 7 - import { formatOutputString } from "./utilities/formatOutputString.ts"; 8 - 9 - const playerNames = new Set<string>(Deno.args); 10 - 11 - if (playerNames.size === 0) { 12 - playerNames.add("default"); 13 - } 14 - 15 - if (playerNames.size > 8) { 16 - throw new Error("Too many players! TFT has a maximum of 8 players."); 17 - } 18 - 19 - const { POWER_SHELL, TERMINAL } = ENVIRONMENT; 20 - const platform = Deno.build.os === "windows" ? POWER_SHELL : TERMINAL; 21 - console.log(formatOutputString(roll(playerNames), platform));
-7
source/constants.ts
··· 1 - export const ENVIRONMENT = { 2 - DISCORD: "DISCORD", 3 - TERMINAL: "TERMINAL", 4 - POWER_SHELL: "POWER_SHELL", 5 - } as const; 6 - 7 - export type Environment = typeof ENVIRONMENT[keyof typeof ENVIRONMENT];
-70
source/discord_serve.ts
··· 1 - import "https://deno.land/x/dotenv@v3.2.0/load.ts"; 2 - import { json, serve, validateRequest } from "https://deno.land/x/sift/mod.ts"; 3 - import nacl from "https://cdn.skypack.dev/tweetnacl@v1.0.3?dts"; 4 - 5 - import { ENVIRONMENT } from "./constants.ts"; 6 - import { formatOutputString } from "./utilities/formatOutputString.ts"; 7 - import { roll } from "./utilities/roll.ts"; 8 - 9 - const headers = ["X-Signature-Ed25519", "X-Signature-Timestamp"]; 10 - 11 - enum RequestType { 12 - PING = 1, 13 - COMMAND = 2, 14 - } 15 - 16 - serve({ "/": handle }); 17 - 18 - async function handle(request: Request) { 19 - const { error } = await validateRequest(request, { POST: { headers } }); 20 - if (error) return json({ error: error.message }, { status: error.status }); 21 - 22 - const { valid, body } = await verifySignature(request); 23 - if (!valid) return json({ error: "Invalid request" }, { status: 401 }); 24 - 25 - const { type = 0, data } = JSON.parse(body); 26 - const options = data?.options || []; 27 - 28 - if (type === RequestType.PING) return json({ type: 1 }); 29 - 30 - if (type === RequestType.COMMAND) { 31 - console.log("Attempting to Roll..."); 32 - console.log("Players:", options); 33 - const players = (options[0]?.value || "").split(" ").filter(Boolean) || []; 34 - 35 - if (players.length > 8) { 36 - return json({ type: 4, data: { content: "Too many players!" } }); 37 - } 38 - 39 - // Default to rolling for 1 player 40 - const rolls = roll(new Set(players.length ? players : ["default"])); 41 - const content = formatOutputString(rolls, ENVIRONMENT.DISCORD); 42 - // Type 4 responds with the below message retaining the user's input at top 43 - return json({ type: 4, data: { content } }); 44 - } 45 - 46 - return json({ error: "bad request" }, { status: 400 }); 47 - } 48 - 49 - // Verify whether the request is coming from Discord. 50 - async function verifySignature( 51 - request: Request, 52 - ): Promise<{ valid: boolean; body: string }> { 53 - const PUBLIC_KEY = Deno.env.get("PUBLIC_KEY")!; 54 - // Discord sends these headers with every request. 55 - const signature = request.headers.get("X-Signature-Ed25519")!; 56 - const timestamp = request.headers.get("X-Signature-Timestamp")!; 57 - const body = await request.text(); 58 - const valid = nacl.sign.detached.verify( 59 - new TextEncoder().encode(timestamp + body), 60 - hexToUint8Array(signature), 61 - hexToUint8Array(PUBLIC_KEY), 62 - ); 63 - 64 - return { valid, body }; 65 - } 66 - 67 - // Convert hexadecimal string to Uint8Array 68 - function hexToUint8Array(hex: string) { 69 - return new Uint8Array(hex.match(/.{1,2}/g)!.map((val) => parseInt(val, 16))); 70 - }
-30
source/discord_setup.ts
··· 1 - import "https://deno.land/x/dotenv@v3.2.0/load.ts"; 2 - 3 - const rollCommand = { 4 - name: "roll", 5 - description: "Roll a TFT Comp", 6 - options: [{ 7 - name: "names", 8 - description: "Adding player names avoids overlapping comps", 9 - required: false, 10 - // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type 11 - type: 3, 12 - }], 13 - }; 14 - 15 - const BOT_TOKEN = Deno.env.get("BOT_TOKEN")!; 16 - const CLIENT_ID = Deno.env.get("CLIENT_ID")!; 17 - 18 - const resp = await fetch( 19 - `https://discord.com/api/v8/applications/${CLIENT_ID}/commands`, 20 - { 21 - body: JSON.stringify(rollCommand), 22 - headers: { 23 - Authorization: `Bot ${BOT_TOKEN}`, 24 - "Content-Type": "application/json", 25 - }, 26 - method: "POST", 27 - }, 28 - ); 29 - 30 - console.log(resp);
-66
source/utilities/formatOutputString.ts
··· 1 - import data from "../../db/data.json" assert { type: "json" }; 2 - import { ENVIRONMENT, Environment } from "../constants.ts"; 3 - import type { RollResult, RollResultCollection } from "./roll.ts"; 4 - 5 - const { name: setName } = data; 6 - const { DISCORD, POWER_SHELL, TERMINAL } = ENVIRONMENT; 7 - 8 - interface RollData extends RollResult { 9 - playerName: string; 10 - showPlayerName: boolean; 11 - } 12 - 13 - type Formatter = (roll: RollData) => string; 14 - 15 - const formatters: { [environment: string]: Formatter } = { 16 - [DISCORD]: formatPlayerTextForDiscord, 17 - [POWER_SHELL]: formatPlayerTextForPowerShell, 18 - [TERMINAL]: formatPlayerTextForTerminal, 19 - }; 20 - 21 - const titleText = `TFT RollBot (${setName}):\n\n`; 22 - 23 - export function formatOutputString( 24 - rolls: RollResultCollection, 25 - environment: Environment = POWER_SHELL, 26 - ): string { 27 - // Default to POWER_SHELL, because it's the most basic formatter 28 - const formatter = formatters[environment] || formatters[POWER_SHELL]; 29 - const playerText = Object.keys(rolls) 30 - .map((playerName: string) => { 31 - const roll = rolls[playerName]; 32 - const showPlayerName = Object.keys(rolls).length > 1; 33 - return formatter({ ...roll, playerName, showPlayerName }); 34 - }) 35 - .join("\n"); 36 - 37 - return titleText + playerText; 38 - } 39 - 40 - function formatPlayerTextForDiscord(roll: RollData) { 41 - const { showPlayerName, playerName, king, traitOne, traitTwo } = roll; 42 - const playerNameText = showPlayerName ? `${playerName}\n` : ""; 43 - const kingText = ` 👑 ${king}\n`; 44 - const teamText = ` ⚔ ${traitOne} + ${traitTwo}\n`; 45 - 46 - return playerNameText + kingText + teamText; 47 - } 48 - 49 - // PowerShell does not support emoji 50 - function formatPlayerTextForPowerShell(roll: RollData) { 51 - const { showPlayerName, playerName, king, traitOne, traitTwo } = roll; 52 - const playerNameText = showPlayerName ? `${playerName}\n` : ""; 53 - const kingText = ` King: ${king}\n`; 54 - const teamText = ` Team: ${traitOne} + ${traitTwo}\n`; 55 - 56 - return playerNameText + kingText + teamText; 57 - } 58 - 59 - function formatPlayerTextForTerminal(roll: RollData) { 60 - const { showPlayerName, playerName, king, traitOne, traitTwo } = roll; 61 - const playerNameText = showPlayerName ? `${playerName}\n` : ""; 62 - const kingText = ` 👑: ${king}\n`; 63 - const teamText = ` 👨‍👩‍👦: ${traitOne} + ${traitTwo}\n`; 64 - 65 - return playerNameText + kingText + teamText; 66 - }
-97
source/utilities/roll.ts
··· 1 - import data from "../../db/data.json" assert { type: "json" }; 2 - 3 - const { traits } = data; 4 - 5 - export interface RollResult { 6 - king: string; 7 - traitOne: string; 8 - traitTwo: string; 9 - } 10 - 11 - export type RollResultCollection = { 12 - [playerName: string]: RollResult; 13 - }; 14 - 15 - export type RollResults = { [playerName: string]: RollResult }; 16 - type ChampionSet = { [name: string]: string[] }; 17 - 18 - /** 19 - * Creates a set of rolls based on the names given 20 - */ 21 - export function roll(playerNames: Set<string> = new Set()): RollResults { 22 - const results: RollResults = {}; 23 - 24 - const championTraits: ChampionSet = filter<string[]>( 25 - traits, 26 - hasMoreThan2Champions, 27 - ); 28 - 29 - const takenKings: string[] = []; 30 - let availableTraits = Object.keys(championTraits); 31 - 32 - playerNames.forEach((name) => { 33 - const indexOne: number = getRandomIndex(availableTraits); 34 - const traitOne: string = availableTraits[indexOne]; 35 - 36 - const indexTwo: number = getRandomIndex(availableTraits, indexOne); 37 - const traitTwo: string = availableTraits[indexTwo]; 38 - 39 - const availableKings: Set<string> = new Set(); 40 - 41 - championTraits[traitOne] 42 - .concat(championTraits[traitTwo]) 43 - .forEach((champion: string) => { 44 - availableKings.add(champion); 45 - }); 46 - 47 - const kingIndex: number = getRandomIndex( 48 - Array.from(availableKings).filter( 49 - (king: string) => !takenKings.includes(king), 50 - ), 51 - ); 52 - const king: string = Array.from(availableKings)[kingIndex]; 53 - 54 - results[name] = { king, traitOne, traitTwo }; 55 - 56 - // Update type, origin, and king, so they aren't used by subsequent players 57 - availableTraits = availableTraits.filter( 58 - (traitName: string) => traitName !== traitOne && traitName !== traitTwo, 59 - ); 60 - 61 - takenKings.push(king); 62 - }); 63 - 64 - return results; 65 - } 66 - 67 - // Get a randon string from an array 68 - function getRandomIndex(arr: string[], illegalIndex?: number): number { 69 - let index = (arr.length * Math.random()) << 0; 70 - while (illegalIndex != null && illegalIndex === index) { 71 - index = (arr.length * Math.random()) << 0; 72 - } 73 - return index; 74 - } 75 - 76 - // It sucks to get a set with only 2 or 1 champions, 77 - // so let's ignore these cases 78 - function hasMoreThan2Champions(champions: string[]) { 79 - return champions.length > 2; 80 - } 81 - 82 - // Iterate and filter over objects 83 - function filter<Item>( 84 - collection: { [name: string]: Item }, 85 - func: (item: Item, index: string) => boolean, 86 - ): { [name: string]: Item } { 87 - const result: { [name: string]: Item } = {}; 88 - 89 - for (const index in collection) { 90 - if (index in collection) { 91 - const item = collection[index]; 92 - if (func(item, index)) result[index] = item; 93 - } 94 - } 95 - 96 - return result; 97 - }
+70
src/__tests__/format.test.ts
··· 1 + import { assertEquals, assertStringIncludes } from '@std/assert' 2 + import { formatOutputString } from '../utilities/format.ts' 3 + import { setSwitchOverride } from '../utilities/roll.ts' 4 + import { setSet } from '../utilities/storage.ts' 5 + import { ENVIRONMENT } from '../utilities/constants.ts' 6 + import type { StoredSet } from '../utilities/storage.ts' 7 + 8 + const { DISCORD, TERMINAL } = ENVIRONMENT 9 + 10 + const testSet: StoredSet = { 11 + name: 'TestSet', 12 + mutator: 'test_format_set', 13 + number: 99, 14 + index: 0, 15 + traits: { Warrior: ['A', 'B'], Mage: ['C'] }, 16 + } 17 + 18 + Deno.test('format: formats output for Discord', async () => { 19 + await setSet(testSet) 20 + setSwitchOverride('test_format_set') 21 + 22 + const result = formatOutputString({ 23 + Player1: { king: 'Arthur', traitOne: 'Warrior', traitTwo: 'Mage' }, 24 + }, DISCORD) 25 + 26 + assertStringIncludes(result, 'TFT RollBot') 27 + assertStringIncludes(result, '👑') 28 + assertStringIncludes(result, 'Arthur') 29 + }) 30 + 31 + Deno.test('format: formats output for Terminal', async () => { 32 + await setSet(testSet) 33 + setSwitchOverride('test_format_set') 34 + 35 + const result = formatOutputString({ 36 + Player1: { king: 'Arthur', traitOne: 'Warrior', traitTwo: 'Mage' }, 37 + }, TERMINAL) 38 + 39 + assertStringIncludes(result, 'TFT RollBot') 40 + assertStringIncludes(result, '👑:') 41 + assertStringIncludes(result, 'Arthur') 42 + }) 43 + 44 + Deno.test('format: shows player name when multiple players', async () => { 45 + await setSet(testSet) 46 + setSwitchOverride('test_format_set') 47 + 48 + const result = formatOutputString({ 49 + Player1: { king: 'Arthur', traitOne: 'Warrior', traitTwo: 'Mage' }, 50 + Player2: { king: 'Morgana', traitOne: 'Mage', traitTwo: 'Warrior' }, 51 + }, TERMINAL) 52 + 53 + assertStringIncludes(result, 'Player1') 54 + assertStringIncludes(result, 'Player2') 55 + }) 56 + 57 + Deno.test('format: does not show player name for single player', async () => { 58 + await setSet(testSet) 59 + setSwitchOverride('test_format_set') 60 + 61 + const result = formatOutputString({ 62 + Player1: { king: 'Arthur', traitOne: 'Warrior', traitTwo: 'Mage' }, 63 + }, TERMINAL) 64 + 65 + assertEquals(result.includes('Player1'), false) 66 + }) 67 + 68 + Deno.test('format: clears switch override', () => { 69 + setSwitchOverride(null) 70 + })
+63
src/__tests__/roll.test.ts
··· 1 + import { assertEquals, assertExists } from '@std/assert' 2 + import { loadSetData, roll, setSwitchOverride } from '../utilities/roll.ts' 3 + import { setSet } from '../utilities/storage.ts' 4 + import type { StoredSet } from '../utilities/storage.ts' 5 + 6 + const testSet: StoredSet = { 7 + name: 'TestSet', 8 + mutator: 'test_roll_set', 9 + number: 99, 10 + index: 0, 11 + traits: { 12 + Warrior: ['Arthur', 'Lancelot', 'Galahad'], 13 + Mage: ['Merlin', 'Morgana', 'Vivian'], 14 + Rogue: ['Ezio', 'Connor'], 15 + Noble: ['King', 'Queen'], 16 + }, 17 + } 18 + 19 + Deno.test('roll: returns correct structure for single player', async () => { 20 + await setSet(testSet) 21 + setSwitchOverride('test_roll_set') 22 + await loadSetData() 23 + 24 + const result = roll(new Set(['Player1'])) 25 + 26 + assertExists(result.Player1) 27 + assertExists(result.Player1.king) 28 + assertExists(result.Player1.traitOne) 29 + assertExists(result.Player1.traitTwo) 30 + }) 31 + 32 + Deno.test({ 33 + name: 'roll: returns correct structure for multiple players', 34 + fn: async () => { 35 + await setSet(testSet) 36 + setSwitchOverride('test_roll_set') 37 + await loadSetData() 38 + 39 + const result = roll(new Set(['Player1', 'Player2'])) 40 + 41 + assertExists(result.Player1) 42 + assertExists(result.Player2) 43 + assertEquals(Object.keys(result).length, 2) 44 + }, 45 + sanitizeOps: false, 46 + sanitizeResources: false, 47 + }) 48 + 49 + Deno.test('roll: king is from trait teams', async () => { 50 + await setSet(testSet) 51 + setSwitchOverride('test_roll_set') 52 + await loadSetData() 53 + 54 + const result = roll(new Set(['Player1'])) 55 + const { king, traitOne, traitTwo } = result.Player1 56 + 57 + const validKings = [...testSet.traits[traitOne], ...testSet.traits[traitTwo]] 58 + assertEquals(validKings.includes(king), true) 59 + }) 60 + 61 + Deno.test('roll: clears switch override after test', () => { 62 + setSwitchOverride(null) 63 + })
+160
src/__tests__/sift.test.ts
··· 1 + import { assertEquals } from '@std/assert' 2 + import { STATUS_CODE } from '@std/http/status' 3 + import { json, jsx, validateRequest, VNode } from '../utilities/sift.ts' 4 + 5 + Deno.test('json() response has correct content-type', () => { 6 + const response = json({}) 7 + assertEquals( 8 + response.headers.get('content-type'), 9 + 'application/json; charset=utf-8', 10 + ) 11 + }) 12 + 13 + Deno.test('validateRequest() validates methods', async () => { 14 + const request = new Request('https://example.com', { 15 + method: 'POST', 16 + }) 17 + 18 + const { error } = await validateRequest(request, { 19 + GET: {}, 20 + }) 21 + 22 + assertEquals(error!.message, 'method POST is not allowed for the URL') 23 + assertEquals(error!.status, STATUS_CODE.MethodNotAllowed) 24 + }) 25 + 26 + Deno.test('validateRequest() validates headers', async () => { 27 + const request = new Request('https://example.com', { 28 + method: 'POST', 29 + headers: { 30 + Authorization: 'Bearer token', 31 + }, 32 + }) 33 + 34 + const { error } = await validateRequest(request, { 35 + POST: { 36 + headers: ['Authorization', 'Content-Type'], 37 + }, 38 + }) 39 + 40 + assertEquals(error!.message, "header 'Content-Type' not available") 41 + assertEquals(error!.status, STATUS_CODE.BadRequest) 42 + }) 43 + 44 + Deno.test('validateRequest() validates query strings', async () => { 45 + const request = new Request('https://example.com?name=Satya', { 46 + method: 'GET', 47 + }) 48 + 49 + const { error } = await validateRequest(request, { 50 + GET: { 51 + params: ['name', 'age'], 52 + }, 53 + }) 54 + 55 + assertEquals( 56 + error!.message, 57 + "param 'age' is required to process the request", 58 + ) 59 + assertEquals(error!.status, STATUS_CODE.BadRequest) 60 + }) 61 + 62 + Deno.test('validateRequest() validates body of POST request', async () => { 63 + const request = new Request('https://example.com', { 64 + method: 'POST', 65 + body: JSON.stringify({ 66 + name: 'Satya', 67 + }), 68 + }) 69 + 70 + const { error, body } = await validateRequest(request, { 71 + POST: { 72 + body: ['name', 'age'], 73 + }, 74 + }) 75 + 76 + assertEquals(body, undefined) 77 + assertEquals(error!.message, "field 'age' is not available in the body") 78 + }) 79 + 80 + Deno.test('validateRequest() populates body as per schema', async () => { 81 + const request = new Request('https://example.com', { 82 + method: 'POST', 83 + body: JSON.stringify({ 84 + name: 'Satya', 85 + age: 98, 86 + }), 87 + }) 88 + 89 + const { error, body } = await validateRequest(request, { 90 + POST: { 91 + body: ['name', 'age'], 92 + }, 93 + }) 94 + 95 + assertEquals(error, undefined) 96 + assertEquals(body, { name: 'Satya', age: 98 }) 97 + }) 98 + 99 + const headersInitCases: { 100 + description: string 101 + headers: HeadersInit 102 + entries: [string, string][] 103 + }[] = [ 104 + { 105 + description: 'merges Headers', 106 + headers: new Headers({ 107 + 'content-type': 'type/subtype', 108 + 'custom-header-1': '1', 109 + 'custom-header-2': '2', 110 + }), 111 + entries: [ 112 + ['content-type', 'type/subtype'], 113 + ['custom-header-1', '1'], 114 + ['custom-header-2', '2'], 115 + ], 116 + }, 117 + { 118 + description: 'merges [string, string][]', 119 + headers: [ 120 + ['content-type', 'type/subtype'], 121 + ['custom-header-1', '1'], 122 + ['custom-header-2', '2'], 123 + ], 124 + entries: [ 125 + ['content-type', 'type/subtype'], 126 + ['custom-header-1', '1'], 127 + ['custom-header-2', '2'], 128 + ], 129 + }, 130 + { 131 + description: 'merges Record<string, string>', 132 + headers: { 133 + 'content-type': 'type/subtype', 134 + 'custom-header-1': '1', 135 + 'custom-header-2': '2', 136 + }, 137 + entries: [ 138 + ['content-type', 'type/subtype'], 139 + ['custom-header-1', '1'], 140 + ['custom-header-2', '2'], 141 + ], 142 + }, 143 + ] 144 + 145 + for (const { entries, headers, description } of headersInitCases) { 146 + Deno.test(`HeadersInit: json() ${description}`, () => { 147 + const response = json(null, { headers }) 148 + for (const [key, value] of entries) { 149 + assertEquals(response.headers.get(key), value) 150 + } 151 + }) 152 + 153 + Deno.test(`HeadersInit: jsx() ${description}`, () => { 154 + const vnode: VNode = { type: 'div', props: { children: null }, key: 'div' } 155 + const response = jsx(vnode, { headers }) 156 + for (const [key, value] of entries) { 157 + assertEquals(response.headers.get(key), value) 158 + } 159 + }) 160 + }
+56
src/__tests__/storage.test.ts
··· 1 + import { assertEquals, assertExists } from '@std/assert' 2 + import { 3 + getAllSets, 4 + getCurrentSet, 5 + getSet, 6 + getSetNames, 7 + listSets, 8 + setSet, 9 + } from '../utilities/storage.ts' 10 + import type { StoredSet } from '../utilities/storage.ts' 11 + 12 + Deno.test('storage: setSet and getSet', async () => { 13 + const testSet: StoredSet = { 14 + name: 'TestSet', 15 + mutator: 'test_storage_set', 16 + number: 99, 17 + index: 0, 18 + traits: { Warrior: ['A', 'B'], Mage: ['C'] }, 19 + } 20 + 21 + await setSet(testSet) 22 + const retrieved = await getSet('test_storage_set') 23 + 24 + assertExists(retrieved) 25 + assertEquals(retrieved.name, 'TestSet') 26 + assertEquals(retrieved.mutator, 'test_storage_set') 27 + assertEquals(retrieved.number, 99) 28 + }) 29 + 30 + Deno.test('storage: getAllSets returns all sets', async () => { 31 + const sets = await getAllSets() 32 + assertExists(sets) 33 + assertEquals(sets.length > 0, true) 34 + }) 35 + 36 + Deno.test('storage: getSetNames returns set mutators', async () => { 37 + const names = await getSetNames() 38 + assertExists(names) 39 + assertEquals(names.includes('test_storage_set'), true) 40 + }) 41 + 42 + Deno.test('storage: getCurrentSet returns highest set by number then index', async () => { 43 + const current = await getCurrentSet() 44 + assertExists(current) 45 + assertEquals(typeof current.number, 'number') 46 + assertEquals(typeof current.index, 'number') 47 + }) 48 + 49 + Deno.test('storage: listSets returns formatted list sorted by number then index', async () => { 50 + const listed = await listSets() 51 + assertExists(listed) 52 + assertEquals(listed.length > 0, true) 53 + 54 + const first = listed[0] 55 + assertEquals(first.includes('Set'), true) 56 + })
+19
src/cli.ts
··· 1 + import { ENVIRONMENT } from './utilities/constants.ts' 2 + import { loadSetData, roll } from './utilities/roll.ts' 3 + import { formatOutputString } from './utilities/format.ts' 4 + 5 + const playerNames = new Set<string>(Deno.args) 6 + 7 + if (playerNames.size === 0) { 8 + playerNames.add('default') 9 + } 10 + 11 + if (playerNames.size > 8) { 12 + throw new Error('Too many players! TFT has a maximum of 8 players.') 13 + } 14 + 15 + await loadSetData() 16 + 17 + const { POWER_SHELL, TERMINAL } = ENVIRONMENT 18 + const platform = Deno.build.os === 'windows' ? POWER_SHELL : TERMINAL 19 + console.log(formatOutputString(roll(playerNames), platform))
+225
src/main.ts
··· 1 + import nacl from 'tweetnacl' 2 + import { ENVIRONMENT } from './utilities/constants.ts' 3 + import { formatOutputString } from './utilities/format.ts' 4 + import { loadSetData, roll, setSwitchOverride } from './utilities/roll.ts' 5 + import { json, serve, validateRequest } from './utilities/sift.ts' 6 + import { getCurrentSet, getSet, getSetNames } from './utilities/storage.ts' 7 + 8 + const headers = ['X-Signature-Ed25519', 'X-Signature-Timestamp'] 9 + 10 + enum RequestType { 11 + PING = 1, 12 + COMMAND = 2, 13 + } 14 + 15 + interface Champion { 16 + apiName: string 17 + cost: number 18 + icon: string 19 + name: string 20 + traits: string[] 21 + ability: unknown 22 + stats: unknown 23 + } 24 + 25 + interface Trait { 26 + apiName: string 27 + desc: string 28 + effects: unknown[] 29 + icon: string 30 + name: string 31 + } 32 + 33 + interface SetData { 34 + champions: Champion[] 35 + mutator: string 36 + name: string 37 + number: number 38 + traits: Trait[] 39 + } 40 + 41 + interface Data { 42 + items: unknown[] 43 + setData: SetData[] 44 + sets: unknown 45 + } 46 + 47 + let _switchOverride: string | null = null 48 + 49 + Deno.cron('update-sets', '0 0 * * *', async () => { 50 + console.log('Running scheduled set update...') 51 + await updateSets() 52 + }) 53 + 54 + serve({ '/': handle, '/cron': handleCron }) 55 + 56 + async function updateSets(): Promise< 57 + { success: boolean; message?: string; error?: string } 58 + > { 59 + try { 60 + const resp = await fetch( 61 + 'https://raw.communitydragon.org/latest/cdragon/tft/en_us.json', 62 + ) 63 + const data = await resp.json() 64 + 65 + const kv = await Deno.openKv() 66 + let index = 0 67 + 68 + for (const setData of data.setData) { 69 + const { champions, traits, mutator, name: setName, number } = setData 70 + 71 + const traitsSet: Set<string> = new Set() 72 + traits.forEach((trait: Trait) => traitsSet.add(trait.name)) 73 + 74 + const results: { [name: string]: string[] } = {} 75 + traitsSet.forEach((trait: string) => { 76 + results[trait] = [] 77 + }) 78 + 79 + champions.forEach((champion: Champion) => { 80 + champion.traits.forEach((trait: string) => { 81 + results[trait].push(champion.name) 82 + }) 83 + }) 84 + 85 + await kv.set(['sets', mutator], { 86 + name: setName, 87 + mutator, 88 + number, 89 + index: index++, 90 + traits: results, 91 + }) 92 + } 93 + 94 + return { success: true, message: `Updated ${data.setData.length} sets` } 95 + } catch (error) { 96 + return { 97 + success: false, 98 + error: error instanceof Error ? error.message : 'Unknown error', 99 + } 100 + } 101 + } 102 + 103 + async function handleCron(request: Request) { 104 + const cronSecret = Deno.env.get('CRON_SECRET') 105 + const authHeader = request.headers.get('Authorization') 106 + 107 + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { 108 + return json({ error: 'Unauthorized' }, { status: 401 }) 109 + } 110 + 111 + const result = await updateSets() 112 + 113 + if (result.success) { 114 + return json(result) 115 + } else { 116 + return json(result, { status: 500 }) 117 + } 118 + } 119 + 120 + async function handle(request: Request) { 121 + const { error } = await validateRequest(request, { POST: { headers } }) 122 + if (error) return json({ error: error.message }, { status: error.status }) 123 + 124 + const { valid, body } = await verifySignature(request) 125 + if (!valid) return json({ error: 'Invalid request' }, { status: 401 }) 126 + 127 + const { type = 0, data } = JSON.parse(body) 128 + const options = data?.options || [] 129 + 130 + if (type === RequestType.PING) return json({ type: 1 }) 131 + 132 + if (type === RequestType.COMMAND) { 133 + const commandName = data?.name 134 + 135 + if (commandName === 'switch') { 136 + const setOption = options.find((o: { name: string }) => o.name === 'set') 137 + const setValue = setOption?.value 138 + 139 + if (setValue) { 140 + const set = await getSet(setValue) 141 + if (set) { 142 + _switchOverride = setValue 143 + return json({ 144 + type: 4, 145 + data: { content: `Switched to set: ${set.name}` }, 146 + }) 147 + } 148 + const availableSets = await getSetNames() 149 + return json({ 150 + type: 4, 151 + data: { 152 + content: `Unknown set: ${setValue}. Available sets: ${ 153 + availableSets.join(', ') 154 + }`, 155 + }, 156 + }) 157 + } else { 158 + const currentSet = await getCurrentSet() 159 + const allSets = await getSetNames() 160 + return json({ 161 + type: 4, 162 + data: { 163 + content: `Current set: ${currentSet?.name ?? 'None'}. Available: ${ 164 + allSets.join(', ') 165 + }`, 166 + }, 167 + }) 168 + } 169 + } 170 + 171 + if (commandName === 'reset') { 172 + _switchOverride = null 173 + setSwitchOverride(null) 174 + const currentSet = await getCurrentSet() 175 + return json({ 176 + type: 4, 177 + data: { 178 + content: `Reset to current set: ${currentSet?.name ?? 'None'}`, 179 + }, 180 + }) 181 + } 182 + 183 + console.log('Attempting to Roll...') 184 + console.log('Players:', options) 185 + const players = (options[0]?.value || '').split(' ').filter(Boolean) || [] 186 + 187 + if (players.length > 8) { 188 + return json({ type: 4, data: { content: 'Too many players!' } }) 189 + } 190 + 191 + const setData = await loadSetData() 192 + if (!setData) { 193 + return json({ 194 + type: 4, 195 + data: { content: 'No set data available. Run db:update first.' }, 196 + }) 197 + } 198 + 199 + const rolls = roll(new Set(players.length ? players : ['default'])) 200 + const content = formatOutputString(rolls, ENVIRONMENT.DISCORD) 201 + return json({ type: 4, data: { content } }) 202 + } 203 + 204 + return json({ error: 'bad request' }, { status: 400 }) 205 + } 206 + 207 + async function verifySignature( 208 + request: Request, 209 + ): Promise<{ valid: boolean; body: string }> { 210 + const PUBLIC_KEY = Deno.env.get('PUBLIC_KEY')! 211 + const signature = request.headers.get('X-Signature-Ed25519')! 212 + const timestamp = request.headers.get('X-Signature-Timestamp')! 213 + const body = await request.text() 214 + const valid = nacl.sign.detached.verify( 215 + new TextEncoder().encode(timestamp + body), 216 + hexToUint8Array(signature), 217 + hexToUint8Array(PUBLIC_KEY), 218 + ) 219 + 220 + return { valid, body } 221 + } 222 + 223 + function hexToUint8Array(hex: string) { 224 + return new Uint8Array(hex.match(/.{1,2}/g)!.map((val) => parseInt(val, 16))) 225 + }
+30
src/setup.ts
··· 1 + import '@std/dotenv' 2 + 3 + const rollCommand = { 4 + name: 'roll', 5 + description: 'Roll a TFT Comp', 6 + options: [{ 7 + name: 'names', 8 + description: 'Adding player names avoids overlapping comps', 9 + required: false, 10 + // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type 11 + type: 3, 12 + }], 13 + } 14 + 15 + const BOT_TOKEN = Deno.env.get('BOT_TOKEN')! 16 + const CLIENT_ID = Deno.env.get('CLIENT_ID')! 17 + 18 + const resp = await fetch( 19 + `https://discord.com/api/v8/applications/${CLIENT_ID}/commands`, 20 + { 21 + body: JSON.stringify(rollCommand), 22 + headers: { 23 + Authorization: `Bot ${BOT_TOKEN}`, 24 + 'Content-Type': 'application/json', 25 + }, 26 + method: 'POST', 27 + }, 28 + ) 29 + 30 + console.log(resp)
+7
src/utilities/constants.ts
··· 1 + export const ENVIRONMENT = { 2 + DISCORD: 'DISCORD', 3 + TERMINAL: 'TERMINAL', 4 + POWER_SHELL: 'POWER_SHELL', 5 + } as const 6 + 7 + export type Environment = typeof ENVIRONMENT[keyof typeof ENVIRONMENT]
+64
src/utilities/format.ts
··· 1 + import { getSetName } from './roll.ts' 2 + import { ENVIRONMENT, type Environment } from './constants.ts' 3 + import type { RollResult, RollResultCollection } from './roll.ts' 4 + 5 + const { DISCORD, POWER_SHELL, TERMINAL } = ENVIRONMENT 6 + 7 + interface RollData extends RollResult { 8 + playerName: string 9 + showPlayerName: boolean 10 + } 11 + 12 + type Formatter = (roll: RollData) => string 13 + 14 + const formatters: { [environment: string]: Formatter } = { 15 + [DISCORD]: formatPlayerTextForDiscord, 16 + [POWER_SHELL]: formatPlayerTextForPowerShell, 17 + [TERMINAL]: formatPlayerTextForTerminal, 18 + } 19 + 20 + const titleText = (setName: string) => `TFT RollBot (${setName}):\n\n` 21 + 22 + export function formatOutputString( 23 + rolls: RollResultCollection, 24 + environment: Environment = POWER_SHELL, 25 + ): string { 26 + const formatter = formatters[environment] || formatters[POWER_SHELL] 27 + const setName = getSetName() 28 + const playerText = Object.keys(rolls) 29 + .map((playerName: string) => { 30 + const roll = rolls[playerName] 31 + const showPlayerName = Object.keys(rolls).length > 1 32 + return formatter({ ...roll, playerName, showPlayerName }) 33 + }) 34 + .join('\n') 35 + 36 + return titleText(setName) + playerText 37 + } 38 + 39 + function formatPlayerTextForDiscord(roll: RollData) { 40 + const { showPlayerName, playerName, king, traitOne, traitTwo } = roll 41 + const playerNameText = showPlayerName ? `${playerName}\n` : '' 42 + const kingText = ` 👑 ${king}\n` 43 + const teamText = ` ⚔ ${traitOne} + ${traitTwo}\n` 44 + 45 + return playerNameText + kingText + teamText 46 + } 47 + 48 + function formatPlayerTextForPowerShell(roll: RollData) { 49 + const { showPlayerName, playerName, king, traitOne, traitTwo } = roll 50 + const playerNameText = showPlayerName ? `${playerName}\n` : '' 51 + const kingText = ` King: ${king}\n` 52 + const teamText = ` Team: ${traitOne} + ${traitTwo}\n` 53 + 54 + return playerNameText + kingText + teamText 55 + } 56 + 57 + function formatPlayerTextForTerminal(roll: RollData) { 58 + const { showPlayerName, playerName, king, traitOne, traitTwo } = roll 59 + const playerNameText = showPlayerName ? `${playerName}\n` : '' 60 + const kingText = ` 👑: ${king}\n` 61 + const teamText = ` 👨‍👩‍👦: ${traitOne} + ${traitTwo}\n` 62 + 63 + return playerNameText + kingText + teamText 64 + }
+119
src/utilities/roll.ts
··· 1 + import { getCurrentSet, getSet, type StoredSet } from './storage.ts' 2 + 3 + let data: StoredSet | null = null 4 + let switchOverride: string | null = null 5 + 6 + export function setSwitchOverride(mutator: string | null) { 7 + switchOverride = mutator 8 + } 9 + 10 + export async function loadSetData(): Promise<StoredSet | null> { 11 + if (switchOverride) { 12 + data = await getSet(switchOverride) 13 + } else { 14 + data = await getCurrentSet() 15 + } 16 + return data 17 + } 18 + 19 + export function getSetName(): string { 20 + return data?.name ?? 'Unknown' 21 + } 22 + 23 + export interface RollResult { 24 + king: string 25 + traitOne: string 26 + traitTwo: string 27 + } 28 + 29 + export type RollResultCollection = { 30 + [playerName: string]: RollResult 31 + } 32 + 33 + export type RollResults = { [playerName: string]: RollResult } 34 + type ChampionSet = { [name: string]: string[] } 35 + 36 + export function roll(playerNames: Set<string> = new Set()): RollResults { 37 + if (!data) { 38 + throw new Error('No set data loaded. Call loadSetData() first.') 39 + } 40 + 41 + const results: RollResults = {} 42 + const { traits } = data 43 + 44 + const championTraits: ChampionSet = filter<string[]>( 45 + traits, 46 + hasMoreThan2Champions, 47 + ) 48 + 49 + const takenKings: string[] = [] 50 + let availableTraits = Object.keys(championTraits) 51 + 52 + playerNames.forEach((name) => { 53 + if (availableTraits.length < 2) { 54 + availableTraits = Object.keys(championTraits) 55 + } 56 + 57 + const indexOne: number = getRandomIndex(availableTraits) 58 + const traitOne: string = availableTraits[indexOne] 59 + 60 + const indexTwo: number = getRandomIndex(availableTraits, indexOne) 61 + const traitTwo: string = availableTraits[indexTwo] 62 + 63 + const championsOne = championTraits[traitOne] ?? [] 64 + const championsTwo = championTraits[traitTwo] ?? [] 65 + const availableKings: Set<string> = new Set() 66 + 67 + championsOne.concat(championsTwo).forEach((champion: string) => { 68 + availableKings.add(champion) 69 + }) 70 + 71 + const kingIndex: number = getRandomIndex( 72 + Array.from(availableKings).filter( 73 + (king: string) => !takenKings.includes(king), 74 + ), 75 + ) 76 + const king: string = Array.from(availableKings)[kingIndex] 77 + 78 + results[name] = { king, traitOne, traitTwo } 79 + 80 + availableTraits = availableTraits.filter( 81 + (traitName: string) => traitName !== traitOne && traitName !== traitTwo, 82 + ) 83 + 84 + takenKings.push(king) 85 + }) 86 + 87 + return results 88 + } 89 + 90 + function getRandomIndex(arr: string[], illegalIndex?: number): number { 91 + if (arr.length === 0) { 92 + return 0 93 + } 94 + let index = (arr.length * Math.random()) << 0 95 + while (illegalIndex != null && illegalIndex === index && arr.length > 1) { 96 + index = (arr.length * Math.random()) << 0 97 + } 98 + return index 99 + } 100 + 101 + function hasMoreThan2Champions(champions: string[]) { 102 + return champions.length > 2 103 + } 104 + 105 + function filter<Item>( 106 + collection: { [name: string]: Item }, 107 + func: (item: Item, index: string) => boolean, 108 + ): { [name: string]: Item } { 109 + const result: { [name: string]: Item } = {} 110 + 111 + for (const index in collection) { 112 + if (index in collection) { 113 + const item = collection[index] 114 + if (func(item, index)) result[index] = item 115 + } 116 + } 117 + 118 + return result 119 + }
+320
src/utilities/sift.ts
··· 1 + /** 2 + * Modified version of sift for use with jsr 3 + * @link https://github.com/satyarohith/sift 4 + */ 5 + import { STATUS_CODE, STATUS_TEXT } from '@std/http/status' 6 + import { LruCache } from '@std/cache' 7 + 8 + import { contentType as getContentType } from '@std/media-types' 9 + import { renderToString } from 'preact-render-to-string' 10 + import { type VNode } from 'preact' 11 + export * from 'preact' 12 + 13 + export { STATUS_CODE, STATUS_TEXT } from '@std/http/status' 14 + 15 + export interface ConnInfo { 16 + localAddr: Deno.NetAddr 17 + remoteAddr: Deno.NetAddr 18 + } 19 + 20 + export type PathParams = Record<string, string | undefined> | undefined 21 + 22 + export type Handler = ( 23 + request: Request, 24 + connInfo: ConnInfo, 25 + params: PathParams, 26 + ) => Promise<Response> | Response 27 + 28 + export interface Routes { 29 + [path: string]: Handler 30 + } 31 + 32 + class ResponseCache { 33 + #cache: LruCache<string, Response> 34 + 35 + constructor(maxSize: number) { 36 + this.#cache = new LruCache(maxSize) 37 + } 38 + 39 + match(request: Request): Response | undefined { 40 + return this.#cache.get(request.url) 41 + } 42 + 43 + put(request: Request, response: Response): void { 44 + this.#cache.set(request.url, response.clone()) 45 + } 46 + 47 + delete(request: Request): boolean { 48 + return this.#cache.delete(request.url) 49 + } 50 + } 51 + 52 + const globalCache = new ResponseCache(20) 53 + 54 + let routes: Routes = { 404: defaultNotFoundPage } 55 + 56 + export function serve( 57 + userRoutes: Routes, 58 + options: { port: number } = { port: 8000 }, 59 + ): void { 60 + routes = { ...routes, ...userRoutes } 61 + 62 + const handler = (req: Request): Promise<Response> => { 63 + const connInfo: ConnInfo = { 64 + localAddr: { 65 + transport: 'tcp', 66 + port: options.port, 67 + hostname: 'localhost', 68 + }, 69 + remoteAddr: { transport: 'tcp', port: 0, hostname: 'unknown' }, 70 + } 71 + return handleRequest(req, connInfo, routes) 72 + } 73 + 74 + Deno.serve({ port: options.port }, handler) 75 + } 76 + 77 + async function handleRequest( 78 + request: Request, 79 + connInfo: ConnInfo, 80 + routes: Routes, 81 + ): Promise<Response> { 82 + const { search, pathname } = new URL(request.url) 83 + 84 + try { 85 + const startTime = Date.now() 86 + let response = globalCache.match(request) 87 + if (response === undefined) { 88 + for (const route of Object.keys(routes)) { 89 + const pattern = new URLPattern({ pathname: route }) 90 + if (pattern.test({ pathname })) { 91 + const params = pattern.exec({ pathname })?.pathname.groups 92 + try { 93 + response = await routes[route](request, connInfo, params) 94 + } catch (error) { 95 + if (error instanceof Error && error.message.includes('NotFound')) { 96 + break 97 + } 98 + 99 + console.error('Error serving request:', error) 100 + response = json({ 101 + error: error instanceof Error ? error.message : 'Unknown error', 102 + }, { status: 500 }) 103 + } 104 + if (!(response instanceof Response)) { 105 + response = jsx(response) 106 + } 107 + break 108 + } 109 + } 110 + } else { 111 + response.headers.set('x-function-cache-hit', 'true') 112 + } 113 + 114 + if (response === undefined) { 115 + response = await routes['404'](request, connInfo, {}) 116 + } 117 + 118 + console.log( 119 + `${request.method} ${pathname + search}${ 120 + response.headers.has('x-function-cache-hit') 121 + ? String.fromCodePoint(0x26a1) 122 + : '' 123 + }${Date.now() - startTime}ms ${response.status}`, 124 + ) 125 + 126 + return response 127 + } catch (error) { 128 + console.error('Error serving request:', error) 129 + return json({ 130 + error: error instanceof Error ? error.message : 'Unknown error', 131 + }, { status: 500 }) 132 + } 133 + } 134 + 135 + function defaultNotFoundPage() { 136 + return new Response('<h1 align=center>page not found</h1>', { 137 + status: 404, 138 + headers: { 'Content-Type': 'text/html; charset=utf-8' }, 139 + }) 140 + } 141 + 142 + export interface ServeStaticOptions { 143 + baseUrl: string 144 + intervene?: ( 145 + request: Request, 146 + response: Response, 147 + ) => Promise<Response> | Response 148 + cache?: boolean 149 + } 150 + 151 + export function serveStatic( 152 + relativePath: string, 153 + { baseUrl, intervene, cache = true }: ServeStaticOptions, 154 + ): Handler { 155 + return async ( 156 + request: Request, 157 + connInfo: ConnInfo, 158 + params: PathParams, 159 + ): Promise<Response> => { 160 + const filename = params?.filename 161 + let filePath = relativePath 162 + if (filename) { 163 + filePath = relativePath.endsWith('/') 164 + ? relativePath + filename 165 + : relativePath + '/' + filename 166 + } 167 + const fileUrl = new URL(filePath, baseUrl) 168 + 169 + let response: Response | undefined 170 + if (cache) { 171 + response = globalCache.match(request) 172 + } 173 + 174 + if (response === undefined) { 175 + const body = await Deno.readFile(fileUrl) 176 + response = new Response(body) 177 + const contentType = getContentType(filePath) 178 + if (contentType) { 179 + response.headers.set('content-type', contentType) 180 + } 181 + if (typeof intervene === 'function') { 182 + response = await intervene(request, response) 183 + } 184 + 185 + if (cache) { 186 + const TEN_MB = 1024 * 1024 * 10 187 + if (Number(response.headers.get('content-length')) < TEN_MB) { 188 + globalCache.put(request, response) 189 + } 190 + } 191 + } 192 + 193 + if (response.status == 404) { 194 + return routes[404](request, connInfo, {}) 195 + } 196 + return response 197 + } 198 + } 199 + 200 + export function json( 201 + jsobj: Parameters<typeof JSON.stringify>[0], 202 + init?: ResponseInit, 203 + ): Response { 204 + const headers = init?.headers instanceof Headers 205 + ? init.headers 206 + : new Headers(init?.headers) 207 + 208 + if (!headers.has('Content-Type')) { 209 + headers.set('Content-Type', 'application/json; charset=utf-8') 210 + } 211 + const statusText = init?.statusText ?? 212 + STATUS_TEXT[(init?.status ?? STATUS_CODE.OK) as keyof typeof STATUS_TEXT] 213 + return new Response(JSON.stringify(jsobj) + '\n', { 214 + statusText, 215 + status: init?.status ?? STATUS_CODE.OK, 216 + headers, 217 + }) 218 + } 219 + 220 + export function jsx(jsx: VNode, init?: ResponseInit): Response { 221 + const headers = init?.headers instanceof Headers 222 + ? init.headers 223 + : new Headers(init?.headers) 224 + 225 + if (!headers.has('Content-Type')) { 226 + headers.set('Content-Type', 'text/html; charset=utf-8') 227 + } 228 + const statusText = init?.statusText ?? 229 + STATUS_TEXT[(init?.status ?? STATUS_CODE.OK) as keyof typeof STATUS_TEXT] 230 + return new Response(renderToString(jsx), { 231 + statusText, 232 + status: init?.status ?? STATUS_CODE.OK, 233 + headers, 234 + }) 235 + } 236 + 237 + export interface RequestTerms { 238 + [key: string]: { 239 + headers?: string[] 240 + body?: string[] 241 + params?: string[] 242 + } 243 + } 244 + 245 + export async function validateRequest( 246 + request: Request, 247 + terms: RequestTerms, 248 + ): Promise<{ 249 + error?: { message: string; status: number } 250 + body?: { [key: string]: unknown } 251 + }> { 252 + let body = {} 253 + 254 + if (!terms[request.method]) { 255 + return { 256 + error: { 257 + message: `method ${request.method} is not allowed for the URL`, 258 + status: STATUS_CODE.MethodNotAllowed, 259 + }, 260 + } 261 + } 262 + 263 + if ( 264 + terms[request.method]?.params && 265 + terms[request.method].params!.length > 0 266 + ) { 267 + const url = new URL(request.url) 268 + const requestParams: string[] = [] 269 + url.searchParams.forEach((_, key) => requestParams.push(key)) 270 + 271 + for (const param of terms[request.method].params!) { 272 + if (!requestParams.includes(param)) { 273 + return { 274 + error: { 275 + message: `param '${param}' is required to process the request`, 276 + status: STATUS_CODE.BadRequest, 277 + }, 278 + } 279 + } 280 + } 281 + } 282 + 283 + if ( 284 + terms[request.method].headers && 285 + terms[request.method].headers!.length > 0 286 + ) { 287 + const requestHeaderKeys: string[] = [] 288 + request.headers.forEach((_, key) => requestHeaderKeys.push(key)) 289 + 290 + for (const header of terms[request.method].headers!) { 291 + if (!requestHeaderKeys.includes(header.toLowerCase())) { 292 + return { 293 + error: { 294 + message: `header '${header}' not available`, 295 + status: STATUS_CODE.BadRequest, 296 + }, 297 + } 298 + } 299 + } 300 + } 301 + 302 + if (terms[request.method].body && terms[request.method].body!.length > 0) { 303 + const requestBody = await request.json() 304 + const bodyKeys = Object.keys(requestBody) 305 + for (const key of terms[request.method].body!) { 306 + if (!bodyKeys.includes(key)) { 307 + return { 308 + error: { 309 + message: `field '${key}' is not available in the body`, 310 + status: STATUS_CODE.BadRequest, 311 + }, 312 + } 313 + } 314 + } 315 + 316 + body = requestBody 317 + } 318 + 319 + return { body } 320 + }
+85
src/utilities/storage.ts
··· 1 + export interface ChampionData { 2 + apiName: string 3 + cost: number 4 + icon: string 5 + name: string 6 + traits: string[] 7 + ability: unknown 8 + stats: unknown 9 + } 10 + 11 + export interface TraitData { 12 + apiName: string 13 + desc: string 14 + effects: unknown[] 15 + icon: string 16 + name: string 17 + } 18 + 19 + export interface SetData { 20 + mutator: string 21 + name: string 22 + number: number 23 + champions: ChampionData[] 24 + traits: TraitData[] 25 + } 26 + 27 + export interface TFTData { 28 + items: unknown[] 29 + setData: SetData[] 30 + sets: unknown 31 + } 32 + 33 + export interface StoredSet { 34 + name: string 35 + mutator: string 36 + number: number 37 + index: number 38 + traits: { [traitName: string]: string[] } 39 + } 40 + 41 + const kv = await Deno.openKv() 42 + 43 + export async function getAllSets(): Promise<StoredSet[]> { 44 + const iter = kv.list<StoredSet>({ prefix: ['sets'] }) 45 + const sets: StoredSet[] = [] 46 + for await (const res of iter) { 47 + sets.push(res.value) 48 + } 49 + return sets 50 + } 51 + 52 + export async function getSet(mutator: string): Promise<StoredSet | null> { 53 + const res = await kv.get<StoredSet>(['sets', mutator]) 54 + return res.value 55 + } 56 + 57 + export async function setSet(set: StoredSet): Promise<void> { 58 + await kv.set(['sets', set.mutator], set) 59 + } 60 + 61 + export async function getCurrentSet(): Promise<StoredSet | null> { 62 + const sets = await getAllSets() 63 + if (sets.length === 0) return null 64 + 65 + sets.sort((a, b) => { 66 + if (a.number !== b.number) return b.number - a.number 67 + return b.index - a.index 68 + }) 69 + 70 + return sets[0] 71 + } 72 + 73 + export async function getSetNames(): Promise<string[]> { 74 + const sets = await getAllSets() 75 + return sets.map((s) => s.mutator) 76 + } 77 + 78 + export async function listSets(): Promise<string[]> { 79 + const sets = await getAllSets() 80 + sets.sort((a, b) => { 81 + if (a.number !== b.number) return b.number - a.number 82 + return b.index - a.index 83 + }) 84 + return sets.map((s) => `${s.mutator} (Set ${s.number})`) 85 + }