├── .gitignore ├── logo.png ├── favicon.png ├── components ├── QuestType.js ├── SubQuest.js ├── Card.js └── Quest.js ├── helpers ├── CommandLine.js ├── Translation.js └── Cache.js ├── package.json ├── .github └── workflows │ └── build.yml ├── .eslintrc ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | cfg 4 | local 5 | table.* 6 | out 7 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepIsla/operation-missions/HEAD/logo.png -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepIsla/operation-missions/HEAD/favicon.png -------------------------------------------------------------------------------- /components/QuestType.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SingleNormalMission: 0, 3 | AnyOrderMission: 1, 4 | SpecificOrder: 2, 5 | EitherOrMission: 3, 6 | SubMission: 4, 7 | 8 | 0: "SingleNormalMission", 9 | 1: "AnyOrderMission", 10 | 2: "SpecificOrder", 11 | 3: "EitherOrMission", 12 | 4: "SubMission" 13 | }; 14 | -------------------------------------------------------------------------------- /helpers/CommandLine.js: -------------------------------------------------------------------------------- 1 | export default class CommandLine { 2 | static Includes(arg) { 3 | return process.argv.join(" ").toLowerCase().includes(arg.toLowerCase()); 4 | } 5 | 6 | static Get(arg) { 7 | let index = process.argv.map(a => a.toLowerCase()).indexOf(arg.toLowerCase()); 8 | if (index <= -1) { 9 | return undefined; 10 | } 11 | 12 | return process.argv[index + 1]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "operation-missions", 3 | "version": "2.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "dependencies": { 8 | "vdf-parser": "^1.1.0" 9 | }, 10 | "scripts": { 11 | "build": "node index.js" 12 | }, 13 | "keywords": [], 14 | "author": "BeepIsla", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/eslint-parser": "^7.18.9", 18 | "eslint": "^8.23.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /components/SubQuest.js: -------------------------------------------------------------------------------- 1 | import Cache from "../helpers/Cache.js"; 2 | import QuestType from "./QuestType.js"; 3 | 4 | export default class SubQuest { 5 | constructor(loc, questID) { 6 | this.loc = loc; 7 | 8 | let itemsGame = Cache.GetFileNoFetch("itemsGame"); 9 | this.quest = itemsGame.items_game.quest_definitions.find(q => q[questID])[questID]; 10 | this.subQuests = []; 11 | this.type = QuestType.SubMission; 12 | } 13 | 14 | GetGoal() { 15 | return this.loc.Get(this.quest.loc_description, { 16 | points: this.quest.points 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2.3.1 13 | 14 | - name: Setup NodeJS 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | 19 | - name: Build 20 | run: | 21 | npm ci 22 | npm run build 23 | 24 | - name: Deploy 25 | uses: JamesIves/github-pages-deploy-action@4.1.0 26 | with: 27 | branch: gh-pages 28 | folder: out 29 | -------------------------------------------------------------------------------- /helpers/Translation.js: -------------------------------------------------------------------------------- 1 | export default class Translation { 2 | constructor(lang, eng) { 3 | this.language = this.NormalizeLanguage(lang.lang.Tokens); 4 | this.english = this.NormalizeLanguage(eng.lang.Tokens); 5 | } 6 | 7 | NormalizeLanguage(tokens) { 8 | let obj = {}; 9 | for (let key in tokens) { 10 | obj[key.toLowerCase()] = Array.isArray(tokens[key]) ? tokens[key][0] : tokens[key]; 11 | } 12 | return obj; 13 | } 14 | 15 | Get(token, attribs = {}) { 16 | if (!token) { 17 | return ""; 18 | } 19 | token = token.toString().toLowerCase(); 20 | 21 | if (token.startsWith("#")) { 22 | token = token.slice(1); 23 | } 24 | 25 | let translation = this.language[token]; 26 | if (!translation) { 27 | translation = this.english[token]; 28 | } 29 | 30 | if (!attribs || Object.keys(attribs).length <= 0) { 31 | return translation; 32 | } 33 | 34 | for (let key in attribs) { 35 | let regex = new RegExp("{[a-z]:" + key + "}", "i"); 36 | translation = translation.replace(regex, attribs[key]); 37 | } 38 | 39 | return translation; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "env": { 4 | "commonjs": false, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly", 12 | "BigInt": true, 13 | "fetch": true 14 | }, 15 | "parserOptions": { 16 | "ecmaVersion": 2022, 17 | "sourceType": "module", 18 | "requireConfigFile": false 19 | }, 20 | "rules": { 21 | "indent": [ 22 | "error", 23 | "tab", 24 | { 25 | "SwitchCase": 1 26 | } 27 | ], 28 | "linebreak-style": [ 29 | "error", 30 | "unix" 31 | ], 32 | "quotes": [ 33 | "error", 34 | "double" 35 | ], 36 | "semi": [ 37 | "error", 38 | "always" 39 | ], 40 | "no-unused-vars": "off", 41 | "prefer-arrow-callback": "error", 42 | "no-var": "error", 43 | "eol-last": "error", 44 | "object-curly-newline": "error", 45 | "object-curly-spacing": "off", 46 | "object-property-newline": "error", 47 | "no-constant-condition": [ 48 | "error", 49 | { 50 | "checkLoops": false 51 | } 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Operation Missions 2 | 3 | This is a parser for the upcoming operation missions which are yet to be revealed but are available in the game files for Operation Riptide. Every time a new CSGO update is released [a workflow](https://github.com/BeepIsla/operation-missions/actions) will run automatically and the website linked below will update with the latest data. 4 | 5 | **Available at [beepisla.github.io/operation-missions](https://beepisla.github.io/operation-missions)** 6 | 7 | # Build 8 | 9 | To build the `index.html` yourself simply open a command prompt and run `npm ci`, once finished run `node index.js`. 10 | Make sure you don't run an ancient [NodeJS](https://nodejs.org/) version and upgrade regularly. Last tested using v18.7.0. 11 | 12 | ## Optional command line arguments 13 | 14 | - `--language ` Force parser to translate using this language file 15 | - Example: `--language csgo_danish.txt` 16 | - Defaults to `csgo_english.txt` 17 | - Note: It is not guaranteed that your translation file will include all strings, you may have to wait for [Steam Translators](https://translation.steampowered.com/) to catch up. 18 | - Note: Non-English languages won't properly work because some text in this program is hardcoded for english. You would have to go through the code and modify it. 19 | - `--local` Skip downloading of `items_game.txt`, `gamemodes.txt` and translation files and use local files instead 20 | - Parser will look for the listed files above in a folder called `local` 21 | - Note: Regardless of language defined there should always be a fallback `csgo_english.txt` available 22 | -------------------------------------------------------------------------------- /helpers/Cache.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import * as path from "path"; 3 | 4 | export default class Cache { 5 | static files = {}; 6 | static urls = {}; 7 | static fetchInProgress = []; 8 | 9 | static GetFileNoFetch(name) { 10 | let file = Cache.files[name]; 11 | if (file) { 12 | return file; 13 | } 14 | 15 | throw new Error(`No file found named "${name}"`); 16 | } 17 | 18 | static async GetFileLocal(name, fileName, parser = null, extraArg = {}) { 19 | let file = Cache.files[name]; 20 | if (file) { 21 | return content; 22 | } 23 | 24 | let filePath = path.join(process.cwd(), "local", fileName); 25 | let buffer = await fs.readFile(filePath); 26 | let content = buffer.toString(buffer.readUInt16LE(0) === 65279 ? "ucs2" : "utf8"); 27 | 28 | Cache.files[name] = typeof parser === "function" ? await parser(content, extraArg) : content; 29 | Cache.urls[name] = undefined; 30 | return Cache.files[name]; 31 | } 32 | 33 | static async GetFile(url, name, parser = null, extraArg = {}) { 34 | let file = Cache.files[name]; 35 | if (file) { 36 | return file; 37 | } 38 | 39 | if (!url) { 40 | throw new Error("No URL available to fetch from"); 41 | } 42 | 43 | if (Cache.fetchInProgress.includes(name)) { 44 | await new Promise(p => setTimeout(p, 5000)); 45 | return Cache.GetFile(url, name, parser, extraArg); 46 | } 47 | Cache.fetchInProgress.push(name); 48 | 49 | try { 50 | let content = await fetch(url).then(r => r.text()); 51 | Cache.files[name] = typeof parser === "function" ? await parser(content, extraArg) : content; 52 | Cache.urls[name] = url; 53 | return Cache.files[name]; 54 | } finally { 55 | let index = Cache.fetchInProgress.indexOf(name); 56 | if (index >= 0) { 57 | Cache.fetchInProgress.splice(index, 1); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/Card.js: -------------------------------------------------------------------------------- 1 | import Quest from "./Quest.js"; 2 | 3 | export default class Card { 4 | constructor(loc, card, dateStartTimestamp, weekIndex) { 5 | this.loc = loc; 6 | this.weekIndex = weekIndex; 7 | 8 | this.id = parseInt(card.id); 9 | this.name = card.name; 10 | this.start = new Date(dateStartTimestamp); 11 | this.quests = []; 12 | this.locked = card.quests === "locked"; 13 | if (!this.locked) { 14 | let parts = card.quests.split(","); 15 | for (let part of parts) { 16 | if (part.includes("-")) { 17 | let range = part.split("-"); 18 | let min = Number(range[0]); 19 | let max = Number(range[1]); 20 | for (let i = min; i <= max; i++) { 21 | this.quests.push(new Quest(loc, i.toString())); 22 | } 23 | } else { 24 | this.quests.push(new Quest(loc, part.toString())); 25 | } 26 | } 27 | } 28 | } 29 | 30 | GetName() { 31 | return `Week ${this.weekIndex}: ${this.loc.Get(this.name)} `; 32 | } 33 | 34 | ToTable() { 35 | if (this.locked) { 36 | return [ 37 | "", 38 | " ", 39 | " ", 40 | " ", 41 | " ", 42 | " ", 43 | " ", 44 | " ", 45 | "", 46 | " ", 47 | " ", 48 | " ", 49 | " ", 50 | " ", 51 | " ", 52 | " ", 53 | " ", 54 | " ", 55 | "", 56 | " ", 57 | " ", 58 | " ", 59 | "
NameModeDescriptionRewardDetails
Quests Unavailable
" 60 | ].join("\n"); 61 | } else { 62 | return [ 63 | "", 64 | " ", 65 | " ", 66 | " ", 67 | " ", 68 | " ", 69 | " ", 70 | " ", 71 | "", 72 | " ", 73 | " ", 74 | " ", 75 | " ", 76 | " ", 77 | " ", 78 | " ", 79 | " ", 80 | " ", 81 | "", 82 | " ", 83 | this.quests.map(q => q.ToColumn()).join("\n"), 84 | " ", 85 | "
NameModeDescriptionRewardDetails
" 86 | ].join("\n"); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /components/Quest.js: -------------------------------------------------------------------------------- 1 | import Cache from "../helpers/Cache.js"; 2 | import QuestType from "./QuestType.js"; 3 | import SubQuest from "./SubQuest.js"; 4 | 5 | export default class Quest { 6 | constructor(loc, questID) { 7 | this.loc = loc; 8 | 9 | let itemsGame = Cache.GetFileNoFetch("itemsGame"); 10 | this.quest = itemsGame.items_game.quest_definitions.find(q => q[questID])[questID]; 11 | this.subQuests = []; 12 | 13 | if (this.quest.expression.startsWith("QQ:>")) { 14 | this.type = QuestType.SpecificOrder; 15 | 16 | let parts = this.quest.expression.split(">").slice(1); 17 | for (let part of parts) { 18 | this.subQuests.push(new SubQuest(loc, part)); 19 | } 20 | } else if (this.quest.expression.startsWith("QQ:|")) { 21 | let parts = this.quest.expression.split("|").slice(1); 22 | for (let part of parts) { 23 | this.subQuests.push(new SubQuest(loc, part)); 24 | } 25 | 26 | if (parts.length === 2) { 27 | this.type = QuestType.EitherOrMission; 28 | } else { 29 | this.type = QuestType.AnyOrderMission; 30 | } 31 | } else { 32 | this.type = QuestType.SingleNormalMission; 33 | } 34 | } 35 | 36 | GetName() { 37 | let name = this.loc.Get(this.quest.loc_name); 38 | let match = name.match(/(?.*)<\/i>/i); 39 | if (match) { 40 | return match.groups.name.trim(); 41 | } 42 | 43 | let parts = name.split("-"); 44 | if (parts.length === 3) { 45 | return parts[1].trim(); 46 | } 47 | return parts.pop().trim(); 48 | } 49 | 50 | IsSkirmish() { 51 | let gameModes = Cache.GetFileNoFetch("gamemodes")["GameModes.txt"]; 52 | if (this.quest.mapgroup) { 53 | let mapgroup = gameModes.mapgroups[this.quest.mapgroup]; 54 | if (!mapgroup) { 55 | throw new Error("Failed to get mapgroup"); 56 | } 57 | 58 | return mapgroup.icon_image_path === "map_icons/mapgroup_icon_skirmish"; 59 | } 60 | 61 | // Singular maps are never Skirmish 62 | return false; 63 | } 64 | 65 | GetMode() { 66 | if (this.IsSkirmish()) { 67 | return this.loc.Get("SFUI_GameModeSkirmish"); 68 | } 69 | 70 | let gameModes = Cache.GetFileNoFetch("gamemodes")["GameModes.txt"]; 71 | for (let type in gameModes.gameTypes) { 72 | if (gameModes.gameTypes[type].gameModes[this.quest.gamemode]) { 73 | return this.loc.Get(gameModes.gameTypes[type].gameModes[this.quest.gamemode].nameID); 74 | } 75 | } 76 | 77 | throw new Error("Failed to get mode"); 78 | } 79 | 80 | GetMap() { 81 | let gameModes = Cache.GetFileNoFetch("gamemodes")["GameModes.txt"]; 82 | if (this.quest.mapgroup) { 83 | let mapgroup = gameModes.mapgroups[this.quest.mapgroup]; 84 | if (!mapgroup) { 85 | throw new Error("Failed to get mapgroup"); 86 | } 87 | 88 | return this.loc.Get(mapgroup.nameID); 89 | } 90 | 91 | if (this.quest.map) { 92 | let map = gameModes.maps[this.quest.map]; 93 | if (!map) { 94 | throw new Error("Failed to get map"); 95 | } 96 | 97 | return this.loc.Get(map.nameID); 98 | } 99 | 100 | return ""; 101 | } 102 | 103 | GetDescription() { 104 | if (this.type === QuestType.EitherOrMission) { 105 | // Put these as description 106 | return this.subQuests.map(q => q.GetGoal()).join(" or "); 107 | } else if (this.quest.loc_description) { 108 | let desc = this.loc.Get(this.quest.loc_description); 109 | let parts = desc.split(" in "); 110 | if (parts.length > 1) { 111 | parts.pop(); 112 | } 113 | return parts.join(" in ").trim(); 114 | } else { 115 | return ""; 116 | } 117 | } 118 | 119 | GetRewardText() { 120 | if (this.quest.points.includes(",")) { 121 | // You get "operational_points" per this many points reached 122 | let parts = this.quest.points.split(",").sort((a, b) => { 123 | return parseInt(a) - parseInt(b); 124 | }); 125 | return `${this.quest.operational_points}x for ${parts.reduce((prev, cur, index) => { 126 | if (index === 0) { 127 | return cur; 128 | } else if ((index + 1) >= parts.length) { 129 | if (index === 1) { 130 | // There are only two options, so don't include a comma 131 | return prev + " and " + cur + " objectives completed"; 132 | } else { 133 | return prev + ", and " + cur + " objectives completed"; 134 | } 135 | } else { 136 | return prev + ", " + cur; 137 | } 138 | }, "")}`; 139 | } else { 140 | // You get "operational_points" once all points have been reached 141 | // Eg: 50 kills (Points), 1 match win (Points) 142 | return `${new Array(parseInt(this.quest.operational_points)).fill("★").join(" ")}`; 143 | } 144 | } 145 | 146 | GetDetails() { 147 | if (this.type === QuestType.EitherOrMission || this.type === QuestType.SingleNormalMission) { 148 | // These have no details 149 | return ""; 150 | } 151 | 152 | let joinString = { 153 | [QuestType.AnyOrderMission]: ", ", 154 | [QuestType.SpecificOrder]: " -> " 155 | }; 156 | let subMissionGoals = this.subQuests.map((quest) => { 157 | return quest.GetGoal(); 158 | }); 159 | 160 | // Try and filter out all the prefixes such as "Get a kill from" or "Apply graffiti at" 161 | // Maybe try and detect this automatically? 162 | let prefixes = [ 163 | "Get a kill from", 164 | "Apply graffiti (at|on|in) (the|)", 165 | "Get a streak of", 166 | "Get a kill at", 167 | "Spray graffiti (at|on|in) (the|)" 168 | ]; 169 | for (let prefix of prefixes) { 170 | for (let i = 0; i < subMissionGoals.length; i++) { 171 | subMissionGoals[i] = subMissionGoals[i].replace(new RegExp(`^${prefix}`, "i"), "").trim(); 172 | subMissionGoals[i] = `${subMissionGoals[i][0].toUpperCase()}${subMissionGoals[i].slice(1)}`; 173 | } 174 | } 175 | return subMissionGoals.join(joinString[this.type]); 176 | } 177 | 178 | ToColumn() { 179 | return [ 180 | "", 181 | ` ${this.GetName()}`, 182 | ` ${this.GetMode()}${this.GetMap() ? `: ${this.GetMap()}` : ""}`, 183 | ` ${this.GetDescription()}`, 184 | ` ${this.GetRewardText()}`, 185 | ` ${this.GetDetails()}`, 186 | "" 187 | ].map(l => "\t\t" + l).join("\n"); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import VDF from "vdf-parser"; 3 | import CommandLine from "./helpers/CommandLine.js"; 4 | import Cache from "./helpers/Cache.js"; 5 | import Translation from "./helpers/Translation.js"; 6 | import Card from "./components/Card.js"; 7 | 8 | let urls = { 9 | itemsGame: "https://raw.githubusercontent.com/SteamDatabase/GameTracking-CSGO/master/csgo/scripts/items/items_game.txt", 10 | translation: "https://raw.githubusercontent.com/SteamDatabase/GameTracking-CSGO/master/csgo/resource/" + (CommandLine.Get("--language") || "csgo_english.txt"), 11 | english: "https://raw.githubusercontent.com/SteamDatabase/GameTracking-CSGO/master/csgo/resource/csgo_english.txt", 12 | gamemodes: "https://raw.githubusercontent.com/SteamDatabase/GameTracking-CSGO/master/csgo/gamemodes.txt" 13 | }; 14 | 15 | // Get all the required files 16 | if (CommandLine.Includes("--local")) { 17 | await Promise.all(Object.keys(urls).map((key) => { 18 | let fileName = urls[key].split("/").pop(); 19 | return Cache.GetFileLocal(key, fileName, VDF.parse, { 20 | arrayify: true, 21 | types: false, 22 | conditionals: [] 23 | }); 24 | })); 25 | } else { 26 | await fs.rm("cfg", { 27 | force: true, 28 | recursive: true 29 | }); 30 | await fs.mkdir("cfg"); 31 | 32 | console.log("Fetching files..."); 33 | await Promise.all(Object.keys(urls).map((key) => { 34 | return Cache.GetFile(urls[key], key, VDF.parse, { 35 | arrayify: true, 36 | types: false, 37 | conditionals: [] 38 | }); 39 | })); 40 | 41 | // Removed Guardian stuff here, maybe add it back later 42 | } 43 | 44 | console.log("Building..."); 45 | 46 | const itemsGame = Cache.GetFileNoFetch("itemsGame"); 47 | const translation = new Translation(Cache.GetFileNoFetch("translation"), Cache.GetFileNoFetch("english")); 48 | 49 | // Get the current operation 50 | let operationIndex = 0; 51 | for (let ops of itemsGame.items_game.seasonaloperations) { 52 | operationIndex = Math.max(Object.keys(ops).reduce((prev, cur) => { 53 | cur = parseInt(cur); 54 | return Math.max(prev, cur); 55 | }, 0), operationIndex); 56 | } 57 | let operationStart = itemsGame.items_game.quest_schedule.start * 1000; 58 | let operationName = `Operation ${translation.Get(`op${operationIndex + 1}_name`)}`; 59 | 60 | const operation = itemsGame.items_game.seasonaloperations.find(o => o[operationIndex])[operationIndex]; 61 | const cards = operation.quest_mission_card.map((c, i) => new Card(translation, c, operationStart + ((7 * 24 * 60 * 60 * 1000) * i), i + 1)); 62 | const xpRewardsList = operation.xp_reward.find(x => /^(\d+(,|$))+$/.test(x)).split(","); 63 | 64 | const HTML = [ 65 | "", 66 | ` CSGO ${operationName} Missions`, 67 | " ", 68 | " ", 69 | ` `, 70 | " ", 71 | " ", 72 | " ", 73 | "", 74 | " ", 137 | "", 138 | " ", 198 | "", 199 | "", 200 | "", 201 | `

All available CSGO ${operationName} missions

`, 202 | ` Created by BeepIsla. Gain a total of ${xpRewardsList.length} XP boosts by playing them!`, 203 | "

", 204 | "", 205 | "
    " 206 | ]; 207 | for (let card of cards) { 208 | HTML.push(...[ 209 | "
  • ", 210 | ` `, 211 | ` ${card.GetName()}`, 212 | " ", 213 | "", 214 | "
      ", 215 | card.ToTable(), 216 | "
    ", 217 | "
  • " 218 | ].map(l => `\t\t${l}`)); 219 | } 220 | 221 | HTML.push(...[ 222 | "
", 223 | "" 224 | ]); 225 | 226 | await fs.rm("out", { 227 | force: true, 228 | recursive: true 229 | }); 230 | await fs.mkdir("out"); 231 | await fs.writeFile("out/index.html", HTML.join("\n")); 232 | await fs.copyFile("logo.png", "out/logo.png"); 233 | await fs.copyFile("favicon.png", "out/favicon.png"); 234 | --------------------------------------------------------------------------------