Compare commits
	
		
			27 Commits 
		
	
	
		
			b881298231
			...
			2688e86b9d
		
	
	| Author | SHA1 | Date | 
|---|---|---|
| 
							
							
								 | 
						2688e86b9d | |
| 
							
							
								 | 
						162bd36f29 | |
| 
							
							
								 | 
						23b92f6f63 | |
| 
							
							
								 | 
						4503c26c42 | |
| 
							
							
								 | 
						a02d49782d | |
| 
							
							
								 | 
						38edf66a73 | |
| 
							
							
								 | 
						d673f7744e | |
| 
							
							
								 | 
						f214035dd6 | |
| 
							
							
								 | 
						b3e8562113 | |
| 
							
							
								 | 
						610df24eff | |
| 
							
							
								 | 
						9499709fca | |
| 
							
							
								 | 
						e41be64891 | |
| 
							
							
								 | 
						fa3810e735 | |
| 
							
							
								 | 
						0bde4ac648 | |
| 
							
							
								 | 
						175eed0f44 | |
| 
							
							
								 | 
						51e6f2ebaa | |
| 
							
							
								 | 
						f909347059 | |
| 
							
							
								 | 
						8a85790caf | |
| 
							
							
								 | 
						9b85d5d9b4 | |
| 
							
							
								 | 
						407716b11b | |
| 
							
							
								 | 
						421a0f3f02 | |
| 
							
							
								 | 
						14bf32b846 | |
| 
							
							
								 | 
						12a4a8e937 | |
| 
							
							
								 | 
						863d702471 | |
| 
							
							
								 | 
						f4fb7af669 | |
| 
							
							
								 | 
						6259f52b3e | |
| 
							
							
								 | 
						a59ca4355a | 
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
MIT License
 | 
			
		||||
 | 
			
		||||
Copyright (c) 2025 FX Client
 | 
			
		||||
 | 
			
		||||
Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
in the Software without restriction, including without limitation the rights
 | 
			
		||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
furnished to do so, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
copies or substantial portions of the Software.
 | 
			
		||||
 | 
			
		||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
SOFTWARE.
 | 
			
		||||
								
									
									
										
											139
										
									
									build.js
									
									
									
									
								
								
							
							
										
											139
										
									
									build.js
									
									
									
									
								| 
						 | 
				
			
			@ -1,17 +1,21 @@
 | 
			
		|||
// @ts-check
 | 
			
		||||
import beautifier from 'js-beautify';
 | 
			
		||||
const { js: beautify } = beautifier;
 | 
			
		||||
import UglifyJS from 'uglify-js';
 | 
			
		||||
import fs from 'fs';
 | 
			
		||||
import webpack from 'webpack';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import applyPatches from './patches.js';
 | 
			
		||||
import applyPatches from './patches/patches.js';
 | 
			
		||||
import ModUtils, { minifyCode } from './modUtils.js';
 | 
			
		||||
 | 
			
		||||
if (!fs.existsSync("./build")) fs.mkdirSync("./build");
 | 
			
		||||
fs.cpSync("./static/", "./build/", { recursive: true });
 | 
			
		||||
fs.cpSync("./assets/", "./build/assets/", { recursive: true });
 | 
			
		||||
fs.writeFileSync("./build/index.html", fs.readFileSync("./build/index.html").toString().replace(/buildTimestamp/g, Date.now()));
 | 
			
		||||
const buildTimestamp = Date.now().toString();
 | 
			
		||||
fs.writeFileSync("./build/index.html", fs.readFileSync("./build/index.html").toString().replace(/buildTimestamp/g, buildTimestamp));
 | 
			
		||||
fs.writeFileSync("./build/sw.js", fs.readFileSync("./build/sw.js").toString().replace("buildTimestamp", buildTimestamp));
 | 
			
		||||
 | 
			
		||||
const buildClientCode = () => new Promise((resolve, reject) => {
 | 
			
		||||
const buildClientCode = () => /** @type {Promise<void>} */(new Promise((resolve, reject) => {
 | 
			
		||||
	webpack({
 | 
			
		||||
		mode: 'production',
 | 
			
		||||
		entry: { fxClient: "./src/main.js" },
 | 
			
		||||
| 
						 | 
				
			
			@ -32,9 +36,9 @@ const buildClientCode = () => new Promise((resolve, reject) => {
 | 
			
		|||
		}
 | 
			
		||||
		else resolve();
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
let script = fs.readFileSync('./game/latest.js', { encoding: 'utf8' }).replace("\n", "").trim();
 | 
			
		||||
let script = fs.readFileSync('./game/latest.js', { encoding: 'utf8' }).trim();
 | 
			
		||||
 | 
			
		||||
const exposeVarsToGlobalScope = true;
 | 
			
		||||
// need to first remove the iife wrapper so the top-level functions aren't inlined
 | 
			
		||||
| 
						 | 
				
			
			@ -43,93 +47,35 @@ if (exposeVarsToGlobalScope && script.startsWith("\"use strict\";    (function (
 | 
			
		|||
if (exposeVarsToGlobalScope && script.startsWith("(function () {") && script.endsWith("})();"))
 | 
			
		||||
	script = script.slice("(function () {".length, -"})();".length);
 | 
			
		||||
 | 
			
		||||
// uncompress strings
 | 
			
		||||
// this will break if there is a closing square bracket ("]") in one of the strings
 | 
			
		||||
const stringArrayRaw = script.match(/var S=(\[[^\]]+\]);/)?.[1];
 | 
			
		||||
if (stringArrayRaw === undefined) throw new Error("cannot find the string array");
 | 
			
		||||
const stringArray = JSON.parse(stringArrayRaw);
 | 
			
		||||
script = script.replace(/\bS\[(\d+)\]/g, (_match, index) => `"${stringArray[index]}"`);
 | 
			
		||||
 | 
			
		||||
const modUtils = new ModUtils(minifyCode(script));
 | 
			
		||||
 | 
			
		||||
import customLobbyPatches from './patches/customLobby.js';
 | 
			
		||||
customLobbyPatches(modUtils);
 | 
			
		||||
 | 
			
		||||
// for versions ^1.99.5.2
 | 
			
		||||
const minificationResult = UglifyJS.minify(script, {
 | 
			
		||||
const minificationResult = UglifyJS.minify(modUtils.script, {
 | 
			
		||||
	"compress": { "arrows": false },
 | 
			
		||||
	"mangle": false
 | 
			
		||||
});
 | 
			
		||||
if (minificationResult.error) console.log(minificationResult.error);
 | 
			
		||||
if (minificationResult.error) {
 | 
			
		||||
	console.log("error while passing through UglifyJS, replaceCode replacements might have caused errors");
 | 
			
		||||
	throw minificationResult.error;
 | 
			
		||||
}
 | 
			
		||||
if (minificationResult.warnings) console.log(minificationResult.warnings);
 | 
			
		||||
script = minificationResult.code;
 | 
			
		||||
modUtils.script = minificationResult.code;
 | 
			
		||||
 | 
			
		||||
const replaceOne = (expression, replaceValue) => {
 | 
			
		||||
    const result = matchOne(expression);
 | 
			
		||||
    // this (below) works correctly because expression.lastIndex gets reset above in matchOne when there is no match
 | 
			
		||||
    script = script.replace(expression, replaceValue);
 | 
			
		||||
    return result;
 | 
			
		||||
}
 | 
			
		||||
const replace = (...args) => script = script.replace(...args);
 | 
			
		||||
const matchOne = (expression) => {
 | 
			
		||||
	const result = expression.exec(script);
 | 
			
		||||
    if (result === null) throw new Error("no match for: ") + expression;
 | 
			
		||||
	if (expression.exec(script) !== null) throw new Error("more than one match for: " + expression);
 | 
			
		||||
	return result;
 | 
			
		||||
}
 | 
			
		||||
// https://stackoverflow.com/a/63838890
 | 
			
		||||
const escapeRegExp = (string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
 | 
			
		||||
 | 
			
		||||
//const dictionary = { __dictionaryVersion: '1.90.0   4 Feb 2024', playerId: 'bB', playerNames: 'hA', playerBalances: 'bC', playerTerritories: 'bj', gIsSingleplayer: 'fc', gIsTeamGame: 'cH' };
 | 
			
		||||
//if (!script.includes(`"${dictionary.__dictionaryVersion}"`)) throw new Error("Dictionary is outdated.");
 | 
			
		||||
const dictionary = {};
 | 
			
		||||
 | 
			
		||||
const matchDictionaryExpression = expression => {
 | 
			
		||||
	const result = expression.exec(script);
 | 
			
		||||
	if (result === null) throw new Error("no match for ") + expression;
 | 
			
		||||
	if (expression.exec(script) !== null) throw new Error("more than one match for: ") + expression;
 | 
			
		||||
	for (let [key, value] of Object.entries(result.groups)) dictionary[key] = value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Return value example:
 | 
			
		||||
// When replaceRawCode or matchRawCode are called with "var1=var2+1;" as the code
 | 
			
		||||
// and this matches "a=b+1;", the returned value will be the object: { var1: "a", var2: "b" }
 | 
			
		||||
const replaceRawCode = (/** @type {string} */ raw, /** @type {string} */ result, nameMappings) => {
 | 
			
		||||
	const { expression, groups } = generateRegularExpression(raw, false, nameMappings);
 | 
			
		||||
	let localizerCount = 0;
 | 
			
		||||
	let replacementString = result.replaceAll("$", "$$").replaceAll("__L()", "__L)").replaceAll("__L(", "__L,")
 | 
			
		||||
	.replace(/\w+/g, match => {
 | 
			
		||||
		// these would get stored as "___localizer1", "___localizer2", ...
 | 
			
		||||
		if (match === "__L") match = "___localizer" + (++localizerCount);
 | 
			
		||||
		return groups.hasOwnProperty(match) ? "$" + groups[match] : match;
 | 
			
		||||
	});
 | 
			
		||||
	//console.log(replacementString);
 | 
			
		||||
	let expressionMatchResult;
 | 
			
		||||
	try { expressionMatchResult = replaceOne(expression, replacementString); }
 | 
			
		||||
	catch (e) {
 | 
			
		||||
		throw new Error("replaceRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
 | 
			
		||||
	}
 | 
			
		||||
	return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
 | 
			
		||||
}
 | 
			
		||||
const matchRawCode = (/** @type {string} */ raw, nameMappings) => {
 | 
			
		||||
	const { expression, groups } = generateRegularExpression(raw, false, nameMappings);
 | 
			
		||||
	const expressionMatchResult = matchOne(expression);
 | 
			
		||||
	return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
 | 
			
		||||
}
 | 
			
		||||
const generateRegularExpression = (/** @type {string} */ code, /** @type {boolean} */ isForDictionary, nameMappings) => {
 | 
			
		||||
	const groups = {};
 | 
			
		||||
	let groupNumberCounter = 1;
 | 
			
		||||
	let localizerCount = 0;
 | 
			
		||||
	let raw = escapeRegExp(code).replaceAll("__L\\(\\)", "___localizer\\)")
 | 
			
		||||
		// when there is a parameter, add a comma to separate it from the added number
 | 
			
		||||
		.replaceAll("__L\\(", "___localizer,");
 | 
			
		||||
	raw = raw.replace(isForDictionary ? /(?:@@)*(@?)(\w+)/g : /()(\w+)/g, (_match, modifier, word) => {
 | 
			
		||||
		// if a substitution string for the "word" is specified in the nameMappings, use it
 | 
			
		||||
		if (nameMappings && nameMappings.hasOwnProperty(word)) return nameMappings[word];
 | 
			
		||||
		// if the "word" is a number or is one of these specific words, ingore it
 | 
			
		||||
		if (/^\d/.test(word) || ["return", "this", "var", "function", "Math"].includes(word)) return word;
 | 
			
		||||
		// for easy localizer function matching
 | 
			
		||||
		else if (word === "___localizer") {
 | 
			
		||||
			groups[word + (++localizerCount)] = groupNumberCounter++;
 | 
			
		||||
			return "\\b(L\\(\\d+)"; // would match "L(123", "L(50" and etc. when using "__L("
 | 
			
		||||
		}
 | 
			
		||||
		else if (groups.hasOwnProperty(word)) return "\\" + groups[word]; // regex numeric reference to the group
 | 
			
		||||
		else {
 | 
			
		||||
			groups[word] = groupNumberCounter++;
 | 
			
		||||
			return modifier === "@" ? `(?<${word}>\\w+)` : "(\\w+)";
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
	let expression = new RegExp(isForDictionary ? raw.replaceAll("@@", "@") : raw, "g");
 | 
			
		||||
	return { expression, groups };
 | 
			
		||||
}
 | 
			
		||||
const {
 | 
			
		||||
    matchDictionaryExpression,
 | 
			
		||||
    generateRegularExpression
 | 
			
		||||
} = modUtils;
 | 
			
		||||
const dictionary = modUtils.dictionary;
 | 
			
		||||
 | 
			
		||||
[
 | 
			
		||||
	/,this\.(?<gIsTeamGame>\w+)=this\.\w+<7\|\|9===this\.\w+,/g,
 | 
			
		||||
| 
						 | 
				
			
			@ -140,11 +86,12 @@ const generateRegularExpression = (/** @type {string} */ code, /** @type {boolea
 | 
			
		|||
].forEach(matchDictionaryExpression);
 | 
			
		||||
 | 
			
		||||
const rawCodeSegments = [
 | 
			
		||||
	`aR.f1(fy)?aR.fB(fy)?a0z=__L([a0z]):(player=aR.fA(fy),oM=__L([b1.t9.xw(@playerData.@rawPlayerNames[player],b1.kx.l2(0,10),150)])+"   ",a0z=(oM+=__L([b1.l5.l6(playerData.@playerBalances[player])])+"   ")+(__L([b1.l5.l6(playerData.@playerTerritories[player])])+"   ")+`,
 | 
			
		||||
	"this.@gIsSingleplayer?this.@gLobbyMaxJoin=@SingleplayerMenu.@getSingleplayerPlayerCount():this.gLobbyMaxJoin=this.@gMaxPlayers,this.@gBots=this.gLobbyMaxJoin-this.@gHumans,this.sg=0,",
 | 
			
		||||
	`aQ.eI(e0)?aQ.eE(e0)?a38=__L([a38]):(player=aQ.eF(e0),oq=__L([b0.uS.zG(@playerData.@rawPlayerNames[player],b0.p9.qQ(0,10),150)])+"   ",oq=(oq+=__L([b0.wx.a07(playerData.@playerBalances[player])])+"   ")+__L([b0.wx.a07(playerData.@playerTerritories[player])])+"   ",`,
 | 
			
		||||
	"this.@gLobbyMaxJoin=1===dg?this.@gHumans:this.@data.@playerCount,this.tZ=this.gLobbyMaxJoin,this.@gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,",
 | 
			
		||||
	"[0]=__L(),@strs[1]=@game.@gIsSingleplayer?__L():__L(),",
 | 
			
		||||
	"?(this.gB=Math.floor(.066*aK.fw),g5=aK.g5-4*@uiSizes.@gap-this.gB):",
 | 
			
		||||
	`for(a0L=new Array(@game.@gMaxPlayers),a0A.font=a07,@i=game.gMaxPlayers-1;0<=i;i--)a0L[i]=i+1+".",@playerData.@playerNames[i]=aY.qW.tm(playerData.@rawPlayerNames[i],a07,a0W),a0K[i]=Math.floor(a0A.measureText(playerData.playerNames[i]).width);`,
 | 
			
		||||
	`var dt=@MenuManager.@getState();if(6===dt){if(4211===d)`
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
rawCodeSegments.forEach(code => {
 | 
			
		||||
| 
						 | 
				
			
			@ -153,18 +100,24 @@ rawCodeSegments.forEach(code => {
 | 
			
		|||
	matchDictionaryExpression(expression);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
applyPatches({ replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp });
 | 
			
		||||
modUtils.executePostMinifyHandlers();
 | 
			
		||||
applyPatches(modUtils);
 | 
			
		||||
script = modUtils.script;
 | 
			
		||||
 | 
			
		||||
await buildClientCode();
 | 
			
		||||
// the dictionary should maybe get embedded into one of the files in the bundle
 | 
			
		||||
fs.writeFileSync("./build/fx.bundle.js", `const dictionary = ${JSON.stringify(dictionary)};\n` + fs.readFileSync("./build/fx.bundle.js").toString());
 | 
			
		||||
fs.writeFileSync(
 | 
			
		||||
	"./build/fx.bundle.js",
 | 
			
		||||
	`const buildTimestamp = "${buildTimestamp}"; const dictionary = ${JSON.stringify(dictionary)};\n`
 | 
			
		||||
	+ fs.readFileSync("./build/fx.bundle.js").toString()
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
console.log("Formatting code...");
 | 
			
		||||
 | 
			
		||||
script = beautify(script, {
 | 
			
		||||
	"indent_size": "1",
 | 
			
		||||
	"indent_size": 1,
 | 
			
		||||
	"indent_char": "\t",
 | 
			
		||||
	"max_preserve_newlines": "5",
 | 
			
		||||
	"max_preserve_newlines": 5,
 | 
			
		||||
	"preserve_newlines": true,
 | 
			
		||||
	"keep_array_indentation": false,
 | 
			
		||||
	"break_chained_methods": false,
 | 
			
		||||
| 
						 | 
				
			
			@ -175,7 +128,7 @@ script = beautify(script, {
 | 
			
		|||
	"unescape_strings": false,
 | 
			
		||||
	"jslint_happy": false,
 | 
			
		||||
	"end_with_newline": false,
 | 
			
		||||
	"wrap_line_length": "250",
 | 
			
		||||
	"wrap_line_length": 250,
 | 
			
		||||
	"indent_inner_html": false,
 | 
			
		||||
	"comma_first": false,
 | 
			
		||||
	"e4x": false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,134 @@
 | 
			
		|||
// @ts-check
 | 
			
		||||
import UglifyJS from 'uglify-js';
 | 
			
		||||
 | 
			
		||||
// https://stackoverflow.com/a/63838890
 | 
			
		||||
const escapeRegExp = (/** @type {string} */ string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
 | 
			
		||||
 | 
			
		||||
export function minifyCode(/** @type {string} */ script) {
 | 
			
		||||
    const output = UglifyJS.minify(script, {
 | 
			
		||||
        compress: false,
 | 
			
		||||
        mangle: false
 | 
			
		||||
    });
 | 
			
		||||
    if (output.error) throw output.error;
 | 
			
		||||
    if (output.warnings) throw (output.warnings);
 | 
			
		||||
    if (output.warnings) console.warn(output.warnings);
 | 
			
		||||
    return output.code;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ModUtils {
 | 
			
		||||
    script = "";
 | 
			
		||||
    /** @type {{[key: string]: string}} */
 | 
			
		||||
    dictionary = {};
 | 
			
		||||
    /** @type {Function[]} */
 | 
			
		||||
    postMinifyHandlers = [];
 | 
			
		||||
 | 
			
		||||
    constructor(/** @type {string} */ script) {
 | 
			
		||||
        this.script = script;
 | 
			
		||||
        // Bind methods
 | 
			
		||||
        this.matchDictionaryExpression = this.matchDictionaryExpression.bind(this);
 | 
			
		||||
        this.generateRegularExpression = this.generateRegularExpression.bind(this);
 | 
			
		||||
        this.replace = this.replace.bind(this);
 | 
			
		||||
        this.replaceOne = this.replaceOne.bind(this);
 | 
			
		||||
        this.replaceRawCode = this.replaceRawCode.bind(this);
 | 
			
		||||
        this.matchOne = this.matchOne.bind(this);
 | 
			
		||||
        this.matchRawCode = this.matchRawCode.bind(this);
 | 
			
		||||
        this.replaceCode = this.replaceCode.bind(this);
 | 
			
		||||
        this.waitForMinification = this.waitForMinification.bind(this);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /** @param {RegExp} expression */
 | 
			
		||||
    matchDictionaryExpression(expression) {
 | 
			
		||||
        const result = this.matchOne(expression);
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        for (let [key, value] of Object.entries(result.groups)) this.addToDictionary(key, value);
 | 
			
		||||
    }
 | 
			
		||||
    replace(/** @type {Parameters<typeof String.prototype.replace>} */ ...args) {
 | 
			
		||||
        return this.script = this.script.replace(...args);
 | 
			
		||||
    };
 | 
			
		||||
    /** Expressions passed to this function must have the global flag set. */
 | 
			
		||||
    matchOne(/** @type {RegExp} */ expression) {
 | 
			
		||||
        const result = expression.exec(this.script);
 | 
			
		||||
        if (result === null) throw new Error("no match for: " + expression.toString());
 | 
			
		||||
        if (expression.exec(this.script) !== null) throw new Error("more than one match for: " + expression.toString());
 | 
			
		||||
        return result;
 | 
			
		||||
    };
 | 
			
		||||
    addToDictionary(/** @type {string} */ key, /** @type {string} */ value) {
 | 
			
		||||
        if (this.dictionary[key] !== undefined && this.dictionary[key] !== value)
 | 
			
		||||
            throw new Error("name different from existing one:\n  KEY: " + key + "\n  VALUE: " + value + "\n  Value in dictionary: " + this.dictionary[key]);
 | 
			
		||||
        this.dictionary[key] = value;
 | 
			
		||||
    };
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {RegExp} expression
 | 
			
		||||
     * @param {string} replaceValue
 | 
			
		||||
     */
 | 
			
		||||
    replaceOne(expression, replaceValue) {
 | 
			
		||||
        const result = this.matchOne(expression);
 | 
			
		||||
        // this (below) works correctly because expression.lastIndex gets reset above in matchOne when there is no match
 | 
			
		||||
        this.script = this.script.replace(expression, replaceValue);
 | 
			
		||||
        return result;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Return value example:
 | 
			
		||||
    // When replaceRawCode or matchRawCode are called with "var1=var2+1;" as the code
 | 
			
		||||
    // and this matches "a=b+1;", the returned value will be the object: { var1: "a", var2: "b" }
 | 
			
		||||
    replaceRawCode(/** @type {string} */ raw, /** @type {string} */ result, nameMappings) {
 | 
			
		||||
        const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
 | 
			
		||||
        let localizerCount = 0;
 | 
			
		||||
        let replacementString = result.replaceAll("$", "$$").replaceAll("__L()", "__L)").replaceAll("__L(", "__L,")
 | 
			
		||||
            .replace(/\w+/g, match => {
 | 
			
		||||
                // these would get stored as "___localizer1", "___localizer2", ...
 | 
			
		||||
                if (match === "__L") match = "___localizer" + (++localizerCount);
 | 
			
		||||
                return groups.hasOwnProperty(match) ? "$" + groups[match] : match;
 | 
			
		||||
            });
 | 
			
		||||
        //console.log(replacementString);
 | 
			
		||||
        let expressionMatchResult;
 | 
			
		||||
        try { expressionMatchResult = this.replaceOne(expression, replacementString); }
 | 
			
		||||
        catch (e) {
 | 
			
		||||
            throw new Error("replaceRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
 | 
			
		||||
        }
 | 
			
		||||
        return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
 | 
			
		||||
    }
 | 
			
		||||
    matchRawCode(/** @type {string} */ raw, nameMappings) {
 | 
			
		||||
        const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
 | 
			
		||||
        const expressionMatchResult = this.matchOne(expression);
 | 
			
		||||
        return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
 | 
			
		||||
    }
 | 
			
		||||
    generateRegularExpression(/** @type {string} */ code, /** @type {boolean} */ isForDictionary, nameMappings) {
 | 
			
		||||
        const groups = {};
 | 
			
		||||
        let groupNumberCounter = 1;
 | 
			
		||||
        let localizerCount = 0;
 | 
			
		||||
        let raw = escapeRegExp(code).replaceAll("__L\\(\\)", "___localizer\\)")
 | 
			
		||||
            // when there is a parameter, add a comma to separate it from the added number
 | 
			
		||||
            .replaceAll("__L\\(", "___localizer,");
 | 
			
		||||
        raw = raw.replace(isForDictionary ? /(?:@@)*(@?)(\w+)/g : /()(\w+)/g, (_match, modifier, word) => {
 | 
			
		||||
            // if a substitution string for the "word" is specified in the nameMappings, use it
 | 
			
		||||
            if (nameMappings && nameMappings.hasOwnProperty(word)) return nameMappings[word];
 | 
			
		||||
            // if the "word" is a number or is one of these specific words, ingore it
 | 
			
		||||
            if (/^\d/.test(word) || ["return", "this", "var", "function", "new", "Math", "WebSocket"].includes(word)) return word;
 | 
			
		||||
            // for easy localizer function matching
 | 
			
		||||
            else if (word === "___localizer") {
 | 
			
		||||
                groups[word + (++localizerCount)] = groupNumberCounter++;
 | 
			
		||||
                return "\\b(L\\(\\d+)"; // would match "L(123", "L(50" and etc. when using "__L("
 | 
			
		||||
            }
 | 
			
		||||
            else if (groups.hasOwnProperty(word)) return "\\" + groups[word]; // regex numeric reference to the group
 | 
			
		||||
            else {
 | 
			
		||||
                groups[word] = groupNumberCounter++;
 | 
			
		||||
                return modifier === "@" ? `(?<${word}>\\w+)` : "(\\w+)";
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        let expression = new RegExp(isForDictionary ? raw.replaceAll("@@", "@") : raw, "g");
 | 
			
		||||
        return { expression, groups };
 | 
			
		||||
    }
 | 
			
		||||
    replaceCode(code, replacement, options) {
 | 
			
		||||
        return this.replaceRawCode(minifyCode(code), replacement);
 | 
			
		||||
    }
 | 
			
		||||
    waitForMinification(/** @type {Function} */ handler) {
 | 
			
		||||
        this.postMinifyHandlers.push(handler);
 | 
			
		||||
    }
 | 
			
		||||
    executePostMinifyHandlers() {
 | 
			
		||||
        this.postMinifyHandlers.forEach(handler => handler());
 | 
			
		||||
    }
 | 
			
		||||
    escapeRegExp = escapeRegExp
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ModUtils;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
import ModUtils from '../modUtils.js';
 | 
			
		||||
 | 
			
		||||
// Custom lobby patches
 | 
			
		||||
export default (/** @type {ModUtils} */ { replaceCode, replaceRawCode, dictionary: dict, waitForMinification }) => {
 | 
			
		||||
    
 | 
			
		||||
    // set player id correctly
 | 
			
		||||
    replaceCode(`function aBG(aBE) {
 | 
			
		||||
		if (!Lobby.aAl) { return -1; }
 | 
			
		||||
		var s = aBE.length;
 | 
			
		||||
		var qu = Lobby.aAl.qu;
 | 
			
		||||
		for (var i = 0; i < s; i++) { if (aBE[i].qu === qu) { return i; } }
 | 
			
		||||
		return -1;
 | 
			
		||||
	}`, `function aBG(aBE) {
 | 
			
		||||
		if (!Lobby.aAl) { return -1; }
 | 
			
		||||
        if (__fx.customLobby.isActive()) return __fx.customLobby.getPlayerId();
 | 
			
		||||
		var s = aBE.length;
 | 
			
		||||
		var qu = Lobby.aAl.qu;
 | 
			
		||||
		for (var i = 0; i < s; i++) { if (aBE[i].qu === qu) { return i; } }
 | 
			
		||||
		return -1;
 | 
			
		||||
	}`);
 | 
			
		||||
 | 
			
		||||
    waitForMinification(() => {
 | 
			
		||||
        replaceRawCode("this.aHm=function(){i___.rX(),aM.a7U(0),aM.init()}",
 | 
			
		||||
            `this.aHm=function(){i___.rX(),aM.a7U(0),aM.init()},
 | 
			
		||||
            __fx.customLobby.setJoinFunction(() => { i___.rX(); aM.a7U(0); aM.init(); })`
 | 
			
		||||
        )
 | 
			
		||||
        replaceRawCode(`(socketId-aq.kt.a82)+"/",(socket=new WebSocket(url)`,
 | 
			
		||||
            `(socketId-aq.kt.a82)+"/",(socket=new WebSocket(__fx.customLobby.isActive() && socketId === 1 ? __fx.customLobby.getSocketURL() : url)`)
 | 
			
		||||
        replaceRawCode("this.send=function(socketId,data){aJE(socketId),aJ4[socketId].send(data)}",
 | 
			
		||||
            "this.send=function(socketId,data){aJE(socketId),aJ4[socketId].send(data)},__fx.customLobby.setSendFunction(this.send)")
 | 
			
		||||
        replaceRawCode("b7.dH(a0),0===b7.size?aq.kt.aJJ(wR,3205):",
 | 
			
		||||
            "b7.dH(a0),0===b7.size?aq.kt.aJJ(wR,3205):__fx.customLobby.isCustomMessage(a0)||")
 | 
			
		||||
        // set the custom lobby to inactive when clicking the "Back" button on the connection screen or leaving the lobby
 | 
			
		||||
        replaceRawCode("this.xZ=function(){Sockets.kt.wf(3260),i___.kt.we()}",
 | 
			
		||||
            "this.xZ=function(){Sockets.kt.wf(3260),__fx.customLobby.setActive(false),i___.kt.we()}")
 | 
			
		||||
        replaceRawCode("function(){n.r(),bl.zf(),Sockets.s.ze(3240),n.o(5,5)}",
 | 
			
		||||
            `(__fx.customLobby.setLeaveFunction(() => {n.r(),bl.zf(),Sockets.s.ze(3240),__fx.customLobby.setActive(false),n.o(5,5)}),
 | 
			
		||||
            function(){n.r(),bl.zf(),Sockets.s.ze(3240),__fx.customLobby.setActive(false),n.o(5,5)})`)
 | 
			
		||||
        // when a socket error occurs on the custom lobby socket
 | 
			
		||||
        replaceRawCode("this.wQ=function(wR,d){if(8===i.pz&&0===wR)if(4211===d)wS(d);",
 | 
			
		||||
            `this.wQ=function(wR,d){
 | 
			
		||||
            wR===1 && __fx.customLobby.isActive() && ${dict.MenuManager}.${dict.getState}() !== 6 && __fx.customLobby.setActive(false);
 | 
			
		||||
            if(8===i.pz&&0===wR)if(4211===d)wS(d);`)
 | 
			
		||||
        // when leaving a game
 | 
			
		||||
        replaceRawCode("this.wl=function(zs){a1.gZ||az.oO.a11.length||(az.oO.a11=az.a12.vd()),ap.ky.zt(),this.vH=0,bU.zu(),m.n.setState(0),zs||bJ.df.show(),aN.setState(0),2===this.a3D?i.ky.a3U():1===this.a3D?i.j(19):i.j(5,5)}",
 | 
			
		||||
            `this.wl=function(zs){a1.gZ||az.oO.a11.length||(az.oO.a11=az.a12.vd()),
 | 
			
		||||
            __fx.customLobby.isActive() === false && ap.ky.zt(),
 | 
			
		||||
            this.vH=0,bU.zu(),m.n.setState(0),zs||bJ.df.show(),aN.setState(0);
 | 
			
		||||
            if (__fx.customLobby.isActive()) __fx.customLobby.rejoinLobby(); else 2===this.a3D?i.ky.a3U():1===this.a3D?i.j(19):i.j(5,5)}`)
 | 
			
		||||
        // do not display lobby UI
 | 
			
		||||
        replaceRawCode(`(sV.style.backdropFilter="blur(4px)",sV.style.webkitBackdropFilter="blur(4px)"),`,
 | 
			
		||||
            `(sV.style.backdropFilter="blur(4px)",sV.style.webkitBackdropFilter="blur(4px)"),
 | 
			
		||||
            __fx.customLobby.isActive() && (sV.style.display = "none"),`);
 | 
			
		||||
        // allow games with one player
 | 
			
		||||
        replaceRawCode("if((t3=bk.t1.t3[e0])<2)return!1;", "if((t3=bk.t1.t3[e0])<2 && !__fx.customLobby.isActive())return!1;")
 | 
			
		||||
        // if the server is unreachable
 | 
			
		||||
        replaceRawCode("{g.wc(3249)}", "{__fx.customLobby.isActive()?(g.wc(3249),__fx.customLobby.setActive(false)):g.wc(3249)}")
 | 
			
		||||
        // error descriptions
 | 
			
		||||
        const errors = { 3249: "No servers found", 4705: "Lobby not found", 4730: "Kicked from lobby" };
 | 
			
		||||
        replaceRawCode(`m.n___(4,5,new o(__L(),xT(e),!0))`,
 | 
			
		||||
            `m.n___(4,5,new o(__L(),${JSON.stringify(errors)}[e] ?? xT(e),!0))`)
 | 
			
		||||
        // map info (for the map selection menu)
 | 
			
		||||
        replaceRawCode("this.info=new Array(Maps.totalMapCount+1),this.info[0]={name:__L(),",
 | 
			
		||||
            "this.info=new Array(Maps.totalMapCount+1),__fx.customLobby.setMapInfo(this.info),this.info[0]={name:__L(),")
 | 
			
		||||
        // to not set custom lobby games as singleplayer
 | 
			
		||||
        replaceRawCode("this.vK=this.jS=this.data.a0f,this.gameIsSingleplayer=1===this.vK,",
 | 
			
		||||
            "this.vK=this.jS=this.data.a0f,this.gameIsSingleplayer=1===this.vK&&!__fx.customLobby.isActive(),")
 | 
			
		||||
        // custom difficulty
 | 
			
		||||
        replaceRawCode("if(9===a1.jq)this.jr();else if(a1.js)if(3===a1.data.jv)for(z=a1.ju-1;0<=z;z--){var jw=z+jp;this.ie[jw]=",
 | 
			
		||||
            `if(9===a1.jq)this.jr();
 | 
			
		||||
            else if (__fx.customLobby.isActive()) for(z=a1.ju-1;0<=z;z--) this.ie[z+jp] = __fx.customLobby.gameInfo.difficulty;
 | 
			
		||||
            else if(a1.js)if(3===a1.data.jv)for(z=a1.ju-1;0<=z;z--){var jw=z+jp;this.ie[jw]=`)
 | 
			
		||||
        // spawn selection
 | 
			
		||||
        replaceRawCode(":50,this.a=this.b=this.data.c,this.d=this.b?new e:null,",
 | 
			
		||||
            ":50,this.a=this.b=__fx.customLobby.isActive() ? __fx.customLobby.gameInfo.spawnSelection : this.data.c,this.d=this.b?new e:null,")
 | 
			
		||||
        // bot count
 | 
			
		||||
        replaceRawCode(",this.gLobbyMaxJoin=1===dg?this.gHumans:this.data.playerCount,this.maxPlayers=this.gLobbyMaxJoin,this.gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,",
 | 
			
		||||
            `,this.gLobbyMaxJoin = __fx.customLobby.isActive() ? Math.max(Math.min(__fx.customLobby.gameInfo.botCount, this.data.playerCount), this.gHumans) : 1===dg?this.gHumans:this.data.playerCount,
 | 
			
		||||
            this.maxPlayers=this.gLobbyMaxJoin,this.gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,`)
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import assets from './assets.js';
 | 
			
		||||
export default ({ replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp }) => {
 | 
			
		||||
import assets from '../assets.js';
 | 
			
		||||
import ModUtils from '../modUtils.js';
 | 
			
		||||
export default (/** @type {ModUtils} */ { replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp }) => {
 | 
			
		||||
 | 
			
		||||
    // Constants for easy usage of otherwise long variable access expressions
 | 
			
		||||
    const dict = dictionary;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,10 +29,10 @@ export default ({ replace, replaceOne, replaceRawCode, dictionary, matchOne, mat
 | 
			
		|||
    // TODO: test this; it might cause issues with new boat mechanics?
 | 
			
		||||
 | 
			
		||||
    { // Add Troop Density and Maximum Troops in side panel
 | 
			
		||||
        const { valuesArray } = replaceRawCode(`,labels[5]=__L(),labels[6]=__L(),labels[7]=__L(),(valuesArray=new Array(labels.length))[0]=game.io?`,
 | 
			
		||||
            `,labels[5]=__L(),labels[6]=__L(),labels[7]=__L(),
 | 
			
		||||
        const { valuesArray } = replaceRawCode(`,labels[5]=__L(0,"Interest"),labels[6]=__L(),labels[7]=__L(),(truncatedLabels=new Array(labels.length)).fill(""),(valuesArray=new Array(labels.length))[0]=game.io?`,
 | 
			
		||||
            `,labels[5]=__L(0,"Interest"),labels[6]=__L(),labels[7]=__L(),
 | 
			
		||||
		labels.push("Max Troops", "Density"), // add labels
 | 
			
		||||
		(valuesArray=new Array(labels.length))[0]=game.io?`);
 | 
			
		||||
		(truncatedLabels=new Array(labels.length)).fill(""),(valuesArray=new Array(labels.length))[0]=game.io?`);
 | 
			
		||||
        replaceOne(new RegExp(/(:(?<valueIndex>\w+)<7\?\w+\.\w+\.\w+\(valuesArray\[\2\]\)):(\w+\.\w+\(valuesArray\[7\]\))}/
 | 
			
		||||
            .source.replace(/valuesArray/g, valuesArray), "g"),
 | 
			
		||||
            '$1 : $<valueIndex> === 7 ? $3 '
 | 
			
		||||
| 
						 | 
				
			
			@ -42,22 +43,25 @@ export default ({ replace, replaceOne, replaceRawCode, dictionary, matchOne, mat
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    // Increment win counter on wins
 | 
			
		||||
    replaceRawCode(`=function(sE){o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0)`,
 | 
			
		||||
    replaceRawCode(`=function(sE){a8.gD[sE]&&(o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0))`,
 | 
			
		||||
        `=function(sE){
 | 
			
		||||
		if (${playerId} === sE && !${gIsSingleplayer})
 | 
			
		||||
			__fx.wins.count++, window.localStorage.setItem("fx_winCount", __fx.wins.count),
 | 
			
		||||
			xD(0,"Your Win Count is now " + __fx.wins.count,3,sE,ad.gN,ad.kl,-1,!0);
 | 
			
		||||
		o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0)`);
 | 
			
		||||
		a8.gD[sE]&&(o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0))`);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    { // Add settings button and win count
 | 
			
		||||
        // add settings button
 | 
			
		||||
    { // Add settings button, custom lobby button and win count
 | 
			
		||||
        // add buttons
 | 
			
		||||
        replaceRawCode(`,new nQ("☰<br>"+__L(),function(){aD6(3)},aa.ks),new nQ("",function(){at.d5(12)},aa.kg,!1)]`,
 | 
			
		||||
            `,new nQ("☰<br>"+__L(),function(){aD6(3)},aa.ks),new nQ("",function(){at.d5(12)},aa.kg,!1),
 | 
			
		||||
            new nQ("FX Client settings", function() { __fx.WindowManager.openWindow("settings"); }, "rgba(0, 0, 20, 0.5")]`)
 | 
			
		||||
        // set settings button position
 | 
			
		||||
            new nQ("FX Client settings", function() { __fx.WindowManager.openWindow("settings"); }, "rgba(0, 0, 20, 0.5)"),
 | 
			
		||||
            new nQ("Join/Create custom lobby", function() { __fx.customLobby.showJoinPrompt(); }, "rgba(20, 9, 77, 0.5)")]`)
 | 
			
		||||
        // set position
 | 
			
		||||
        replaceRawCode(`aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h);`,
 | 
			
		||||
            `aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h); aZ.g5.vO(aD3[5].button, x, a3X + h * 2 + gap * 2, a0S * 2 + gap, h / 3);`);
 | 
			
		||||
            `aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h);
 | 
			
		||||
            aZ.g5.vO(aD3[5].button, x, a3X + h * 2 + gap * 2, a0S * 2 + gap, h / 3);
 | 
			
		||||
            aZ.g5.vO(aD3[6].button, x, a3X + h * 2.33 + gap * 3, a0S * 2 + gap, h / 3);`);
 | 
			
		||||
        // render win count
 | 
			
		||||
        replaceRawCode(`if(_y.a4l(),_r.gI(),_m.gI(),aw.gI(),a0.g8()){ctx.imageSmoothingEnabled=!1;var iQ=a0.a4o("territorial.io"),kL=.84*aD4.gA/iQ.width;`,
 | 
			
		||||
            `if(_y.a4l(),_r.gI(),_m.gI(),aw.gI(),a0.g8()){
 | 
			
		||||
| 
						 | 
				
			
			@ -94,7 +98,7 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
 | 
			
		|||
 | 
			
		||||
    { // Keybinds
 | 
			
		||||
        // match required variables
 | 
			
		||||
        const { 0: match, groups: { attackBarObject, setRelative } } = matchOne(/:"."===(\w+\.key)\?(?<attackBarObject>\w+)\.(?<setRelative>\w+)\(31\/32\):"."===\1\?\2\.\3\(32\/31\):/g,);
 | 
			
		||||
        const { 0: match, groups: { attackBarObject, setRelative } } = matchOne(/:\w+\.\w+\(\w+,8\)\?(?<attackBarObject>\w+)\.(?<setRelative>\w+)\(32\/31\):/g);
 | 
			
		||||
        // create a setAbsolutePercentage function on the attack percentage bar object,
 | 
			
		||||
        // and also register the keybind handler functions
 | 
			
		||||
        replaceOne(/}(function \w+\((\w+)\){return!\(1<\2&&1===(?<attackPercentage>\w+)\|\|\(1<\2&&\2\*\3-\3<1\/1024\?\2=\(\3\+1\/1024\)\/\3:\2<1)/g,
 | 
			
		||||
| 
						 | 
				
			
			@ -137,12 +141,14 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
 | 
			
		|||
        `, ${dict.game}.${dict.gIsTeamGame} && __fx.donationsTracker.displayHistory($2, ${rawPlayerNames}, ${gIsSingleplayer}), $1 && !isEmptySpace $3`);
 | 
			
		||||
 | 
			
		||||
    // Reset donation history and leaderboard filter when a new game is started
 | 
			
		||||
    replaceOne(new RegExp(`,this\\.${dictionary.playerBalances}.fill\\(0\\),`, "g"), "$& __fx.donationsTracker.reset(), __fx.leaderboardFilter.reset(), ");
 | 
			
		||||
    replaceRawCode(",ab.dP(),ad.a10(),b5.nZ.oJ=[],bc.dP(),this.wE=1,",
 | 
			
		||||
        `,ab.dP(),ad.a10(),b5.nZ.oJ=[],bc.dP(),this.wE=1,
 | 
			
		||||
        __fx.donationsTracker.reset(), __fx.leaderboardFilter.reset(), __fx.customLobby.isActive() && __fx.customLobby.hideWindow(),`)
 | 
			
		||||
 | 
			
		||||
    { // Player list and leaderboard filter tabs
 | 
			
		||||
        // Draw player list button
 | 
			
		||||
        const uiOffset = dictionary.uiSizes + "." + dictionary.gap;
 | 
			
		||||
        const { groups: { drawFunction, topBarHeight } } = replaceOne(/(=1;function (?<drawFunction>\w+)\(\){[^}]+?(?<canvas>\w+)\.fillRect\(0,(?<topBarHeight>\w+),\w+,1\),(?:\3\.fillRect\([^()]+\),)+\3\.font=\w+,(\w+\.\w+)\.textBaseline\(\3,1\),\5\.textAlign\(\3,1\),\3\.fillText\(\w+\(\d+\),Math\.floor\()(\w+)\/2\),(Math\.floor\(\w+\+\w+\/2\)\));/g,
 | 
			
		||||
        const { groups: { drawFunction, topBarHeight } } = replaceOne(/(="";function (?<drawFunction>\w+)\(\){[^}]+?(?<canvas>\w+)\.fillRect\(0,(?<topBarHeight>\w+),\w+,1\),(?:\3\.fillRect\([^()]+\),)+\3\.font=\w+,(\w+\.\w+)\.textBaseline\(\3,1\),\5\.textAlign\(\3,1\),\3\.fillText\(\w+,Math\.floor\()(\w+)\/2\),(Math\.floor\(\w+\+\w+\/2\)\));/g,
 | 
			
		||||
            "$1($6 + $<topBarHeight> - 22) / 2), $7; __fx.playerList.drawButton($<canvas>, 12, 12, $<topBarHeight> - 22);");
 | 
			
		||||
        const buttonBoundsCheck = `__fx.utils.isPointInRectangle($<x>, $<y>, ${uiOffset} + 12, ${uiOffset} + 12, ${topBarHeight} - 22, ${topBarHeight} - 22)`
 | 
			
		||||
        // Handle player list button and leaderboard tabs mouseDown
 | 
			
		||||
| 
						 | 
				
			
			@ -161,19 +167,23 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
 | 
			
		|||
	)) return; $4`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    { // Display density of other players
 | 
			
		||||
        const r = matchRawCode(`bD.dO.data[7].value?a9W(i,jm,jk,jl,ctx):a9V(ctx,i,jm,jk,jl,a9S)))`);
 | 
			
		||||
        const settingsSwitchNameAndBalance = `${r.bD}.${r.dO}.${r.data}[7].${r.value}`;
 | 
			
		||||
        //console.log(settingsSwitchNameAndBalance);
 | 
			
		||||
        // Applies when the "Reverse Name/Balance" setting is off
 | 
			
		||||
    { // Name rendering patches - Display density of other players & Hide bot names features
 | 
			
		||||
        const { placeBalanceAbove } = matchRawCode(`,aGH+=Math.floor(.78*fontSize),placeBalanceAbove?aGN(a7,aGJ,aGG,aGH,hT):aGM(hT,a7,aGJ,aGG,aGH,aGI)`);
 | 
			
		||||
        // Balance rendering; Renders density when the "Reverse Name/Balance" setting is off
 | 
			
		||||
        replaceRawCode("function a9V(ctx,i,fontSize,x,y,a9S){i=ac.jv.formatNumber(playerData.playerBalances[i]);a9S>>1&1?(ctx.lineWidth=.05*fontSize,ctx.strokeStyle=a9U(fontSize,a9S%2),ctx.strokeText(i,x,y)):(1<a9S&&(ctx.lineWidth=.12*fontSize,ctx.strokeStyle=a9U(fontSize,a9S),ctx.strokeText(i,x,y)),ctx.fillText(i,x,y))}",
 | 
			
		||||
            `function a9V(ctx,i,fontSize,x,y,a9S){
 | 
			
		||||
		var ___id = i;
 | 
			
		||||
		i=ac.jv.formatNumber(playerData.playerBalances[i]);a9S>>1&1?(ctx.lineWidth=.05*fontSize,ctx.strokeStyle=a9U(fontSize,a9S%2),ctx.strokeText(i,x,y)):(1<a9S&&(ctx.lineWidth=.12*fontSize,ctx.strokeStyle=a9U(fontSize,a9S),ctx.strokeText(i,x,y)),ctx.fillText(i,x,y));
 | 
			
		||||
		${settingsSwitchNameAndBalance} || __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && (ctx.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), ctx.fillText(__fx.utils.getDensity(___id), x, y + fontSize))}`)
 | 
			
		||||
        // Applies when the "Reverse Name/Balance" setting is on (default)
 | 
			
		||||
        replaceOne(/(function \w+\((\w+),(?<fontSize>\w+),(?<x>\w+),(?<y>\w+),(?<canvas>\w+)\){)(\6\.fillText\((?<playerData>\w+)\.(?<playerNames>\w+)\[\2\],\4,\5\)),(\2<(?<game>\w+)\.(?<gHumans>\w+)&&2!==\8\.(?<playerStates>\w+)\[[^}]+)}/g,
 | 
			
		||||
            `$1 var ___id = $2; $7, $10; ${settingsSwitchNameAndBalance} && __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && ($<canvas>.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), $<canvas>.fillText(__fx.utils.getDensity(___id), $<x>, $<y> + $<fontSize>)); }`);
 | 
			
		||||
		${placeBalanceAbove} || __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && (ctx.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), ctx.fillText(__fx.utils.getDensity(___id), x, y + fontSize))}`)
 | 
			
		||||
        // Name rendering; Renders density when the "Reverse Name/Balance" setting is on (default)
 | 
			
		||||
        replaceOne(/(function \w+\((?<i>\w+),(?<fontSize>\w+),(?<x>\w+),(?<y>\w+),(?<canvas>\w+)\){)(\6\.fillText\((?<playerData>\w+)\.(?<playerNames>\w+)\[\2\],\4,\5\)),(\2<(?<game>\w+)\.(?<gHumans>\w+)&&2!==\8\.(?<playerStates>\w+)\[[^}]+)}/g,
 | 
			
		||||
            `$1 var ___id = $2;
 | 
			
		||||
            var showName = $<i> < $<game>.$<gHumans> || !__fx.settings.hideBotNames;
 | 
			
		||||
            if (showName) $7, $10;
 | 
			
		||||
            ${placeBalanceAbove} && __fx.settings.showPlayerDensity && (
 | 
			
		||||
                __fx.settings.coloredDensity && ($<canvas>.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)),
 | 
			
		||||
                $<canvas>.fillText(__fx.utils.getDensity(___id), $<x>, showName ? $<y> + $<fontSize> : $<y>)
 | 
			
		||||
            ); }`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    { // Leaderboard filter
 | 
			
		||||
| 
						 | 
				
			
			@ -293,6 +303,11 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
 | 
			
		|||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Detailed team pie chart percentage
 | 
			
		||||
    replaceRawCode(`qr=Math.floor(100*f0+.5)+"%"`,
 | 
			
		||||
        `qr = (__fx.settings.detailedTeamPercentage ? (100*f0).toFixed(2) : Math.floor(100*f0+.5)) + "%"`)
 | 
			
		||||
    replaceRawCode(",fontSize=+dz*Math.min(f0,.37);", ",fontSize=(__fx.settings.detailedTeamPercentage ? 0.75 : 1)*dz*Math.min(f0,.37);")
 | 
			
		||||
 | 
			
		||||
    // Invalid hostname detection avoidance
 | 
			
		||||
    replaceRawCode(`,hostnameIsValid=0<=window.location.hostname.toLowerCase().indexOf("territorial.io"),`,
 | 
			
		||||
        `,hostnameIsValid=true,`)
 | 
			
		||||
								
									
									
										
											17
										
									
									readme.md
									
									
									
									
								
								
							
							
										
											17
										
									
									readme.md
									
									
									
									
								| 
						 | 
				
			
			@ -25,17 +25,18 @@ FX Client is the first Territorial.io client, offering a better User Interface a
 | 
			
		|||
4. Displays your troop density and maximum troops
 | 
			
		||||
5. Displays the density of players and bots
 | 
			
		||||
6. Adds a "Clan" tab on the leaderboard, allowing you to easily see your clanmates
 | 
			
		||||
7. Hovering tooltip: makes the territory map information (normally visible on right click) be visible constantly (on hover)
 | 
			
		||||
8. Adds a player list
 | 
			
		||||
9. Adds the ability to view the history of who donated to a player during a team game by clicking on their name in the leaderboard or the player list
 | 
			
		||||
10. Adds a win counter
 | 
			
		||||
11. Can be installed as a PWA (progressive web app) ensuring maximum enjoyment on consoles, phones and even desktop devices
 | 
			
		||||
7. Adds custom lobbies
 | 
			
		||||
8. Hovering tooltip: makes the territory map information (normally visible on right click) be visible constantly (on hover)
 | 
			
		||||
9. Adds a player list
 | 
			
		||||
10. Adds the ability to view the history of who donated to a player during a team game by clicking on their name in the leaderboard or the player list
 | 
			
		||||
11. Adds a win counter
 | 
			
		||||
12. Can be installed as a PWA (progressive web app) ensuring maximum enjoyment on consoles, phones and even desktop devices
 | 
			
		||||
 | 
			
		||||
#### The client has a settings menu, from which you can:
 | 
			
		||||
 | 
			
		||||
12. Make fullscreen mode trigger automatically
 | 
			
		||||
13. Set a custom main menu background
 | 
			
		||||
14. Create custom attack percentage keybinds
 | 
			
		||||
13. Make fullscreen mode trigger automatically
 | 
			
		||||
14. Set a custom main menu background
 | 
			
		||||
15. Create custom attack percentage keybinds
 | 
			
		||||
 | 
			
		||||
## Building Locally
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,306 @@
 | 
			
		|||
import WindowManager from "./windowManager.js";
 | 
			
		||||
 | 
			
		||||
//const socketURL = "ws://localhost:8080/";
 | 
			
		||||
const socketURL = "wss://fx.peshomir.workers.dev/";
 | 
			
		||||
const customMessageMarker = 120;
 | 
			
		||||
let isActive = false;
 | 
			
		||||
let currentCode = "";
 | 
			
		||||
let joinLobby = () => { };
 | 
			
		||||
let leaveLobby = () => { };
 | 
			
		||||
let sendRaw = (socketId, data) => { };
 | 
			
		||||
const textEncoder = new TextEncoder();
 | 
			
		||||
const textDecoder = new TextDecoder();
 | 
			
		||||
 | 
			
		||||
WindowManager.add({
 | 
			
		||||
    name: "lobbyJoinMenu",
 | 
			
		||||
    element: document.getElementById("customLobbyJoinMenu")
 | 
			
		||||
})
 | 
			
		||||
const windowElement = WindowManager.create({
 | 
			
		||||
    name: "customLobby",
 | 
			
		||||
    classes: "scrollable selectable flex-column text-align-center",
 | 
			
		||||
    closable: false
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const header = document.createElement("h2");
 | 
			
		||||
header.textContent = "Custom Lobby";
 | 
			
		||||
 | 
			
		||||
const main = document.createElement("div");
 | 
			
		||||
main.className = "customlobby-main";
 | 
			
		||||
 | 
			
		||||
const playerListContainer = document.createElement("div");
 | 
			
		||||
const playerCount = document.createElement("p");
 | 
			
		||||
playerCount.textContent = "0 Players";
 | 
			
		||||
const playerListDiv = document.createElement("div");
 | 
			
		||||
playerListContainer.append(playerCount, playerListDiv);
 | 
			
		||||
 | 
			
		||||
const optionsContainer = document.createElement("div");
 | 
			
		||||
optionsContainer.className = "text-align-left";
 | 
			
		||||
 | 
			
		||||
const optionsStructure = {
 | 
			
		||||
    mode: {
 | 
			
		||||
        label: "Mode:", type: "selectMenu", options: [
 | 
			
		||||
            { value: 0, label: "2 Teams" },
 | 
			
		||||
            { value: 1, label: "3 Teams" },
 | 
			
		||||
            { value: 2, label: "4 Teams" },
 | 
			
		||||
            { value: 3, label: "5 Teams" },
 | 
			
		||||
            { value: 4, label: "6 Teams" },
 | 
			
		||||
            { value: 5, label: "7 Teams" },
 | 
			
		||||
            { value: 6, label: "8 Teams" },
 | 
			
		||||
            { value: 7, label: "Battle Royale" },
 | 
			
		||||
            { value: 10, label: "No Fullsend Battle Royale" },
 | 
			
		||||
            { value: 9, label: "Zombie mode" }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    map: { label: "Map:", type: "selectMenu" },
 | 
			
		||||
    difficulty: { label: "Difficulty:", type: "selectMenu", options: [
 | 
			
		||||
        { value: 0, label: "Very Easy (Default)" },
 | 
			
		||||
        { value: 1, label: "Easy (1v1)" },
 | 
			
		||||
        { value: 2, label: "Normal" },
 | 
			
		||||
        { value: 3, label: "Hard" },
 | 
			
		||||
        { value: 4, label: "Very Hard" },
 | 
			
		||||
        { value: 5, label: "Impossible" }
 | 
			
		||||
    ]},
 | 
			
		||||
    spawnSelection: { label: "Spawn selection", type: "checkbox" },
 | 
			
		||||
    botCount: { label: "Bot & player count:", type: "numberInput", attributes: { min: "1", max: "512" } }
 | 
			
		||||
}
 | 
			
		||||
const optionsElements = {};
 | 
			
		||||
const optionsValues = {};
 | 
			
		||||
 | 
			
		||||
function updateOption(option, value) {
 | 
			
		||||
    if (optionsStructure[option].type === "checkbox")
 | 
			
		||||
        optionsElements[option].checked = (value === 0 ? false : true);
 | 
			
		||||
    else optionsElements[option].value = value.toString();
 | 
			
		||||
    optionsValues[option] = value;
 | 
			
		||||
}
 | 
			
		||||
function inputUpdateHandler(key, e) {
 | 
			
		||||
    sendMessage("options", [key, parseInt(e.target.value)])
 | 
			
		||||
}
 | 
			
		||||
function checkboxUpdateHandler(key, e) {
 | 
			
		||||
    sendMessage("options", [key, e.target.checked ? 1 : 0])
 | 
			
		||||
}
 | 
			
		||||
Object.entries(optionsStructure).forEach(([key, item]) => {
 | 
			
		||||
    const label = document.createElement("label");
 | 
			
		||||
    if (item.tooltip) label.title = item.tooltip;
 | 
			
		||||
    const isValueInput = item.type.endsWith("Input");
 | 
			
		||||
    const element = document.createElement(
 | 
			
		||||
        isValueInput || item.type === "checkbox" ? "input"
 | 
			
		||||
            : item.type === "selectMenu" ? "select"
 | 
			
		||||
                : "button"
 | 
			
		||||
    );
 | 
			
		||||
    optionsElements[key] = element;
 | 
			
		||||
    if (item.type === "textInput") element.type = "text";
 | 
			
		||||
    if (item.type === "numberInput") element.type = "number";
 | 
			
		||||
    if (item.placeholder) element.placeholder = item.placeholder;
 | 
			
		||||
    if (isValueInput || item.type === "selectMenu")
 | 
			
		||||
        element.addEventListener("change", inputUpdateHandler.bind(undefined, key))
 | 
			
		||||
    if (item.text) element.innerText = item.text;
 | 
			
		||||
    if (item.action) element.addEventListener("click", item.action);
 | 
			
		||||
    if (item.label) label.append(item.label + " ");
 | 
			
		||||
    if (item.note) {
 | 
			
		||||
        const note = document.createElement("small");
 | 
			
		||||
        note.innerText = item.note;
 | 
			
		||||
        label.append(document.createElement("br"), note);
 | 
			
		||||
    }
 | 
			
		||||
    if (item.options) setSelectMenuOptions(item.options, element);
 | 
			
		||||
    if (item.attributes) Object.entries(item.attributes).forEach(
 | 
			
		||||
        ([name, value]) => element.setAttribute(name, value)
 | 
			
		||||
    );
 | 
			
		||||
    label.append(element);
 | 
			
		||||
    if (item.type === "checkbox") {
 | 
			
		||||
        element.type = "checkbox";
 | 
			
		||||
        const checkmark = document.createElement("span");
 | 
			
		||||
        checkmark.className = "checkmark";
 | 
			
		||||
        label.className = "checkbox";
 | 
			
		||||
        label.append(checkmark);
 | 
			
		||||
        //checkboxFields[item.for] = element;
 | 
			
		||||
        element.addEventListener("change", checkboxUpdateHandler.bind(undefined, key))
 | 
			
		||||
    } else label.append(document.createElement("br"));
 | 
			
		||||
    optionsContainer.append(label/*, document.createElement("br")*/);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function setMapInfo(maps) {
 | 
			
		||||
    setTimeout(() => setSelectMenuOptions(maps.map((info, index) => ({ value: index.toString(), label: info.name })), optionsElements["map"]), 0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main.append(playerListContainer, optionsContainer);
 | 
			
		||||
 | 
			
		||||
const footer = document.createElement("footer");
 | 
			
		||||
footer.style.marginTop = "10px";
 | 
			
		||||
const startButton = document.createElement("button");
 | 
			
		||||
const leaveButton = document.createElement("button");
 | 
			
		||||
startButton.textContent = "Start game";
 | 
			
		||||
leaveButton.textContent = "Leave lobby";
 | 
			
		||||
startButton.addEventListener("click", startGame);
 | 
			
		||||
leaveButton.addEventListener("click", () => leaveLobby());
 | 
			
		||||
footer.append(startButton, leaveButton);
 | 
			
		||||
 | 
			
		||||
windowElement.append(header, main, footer);
 | 
			
		||||
 | 
			
		||||
/** @param {HTMLSelectElement} element  */
 | 
			
		||||
function setSelectMenuOptions(options, element) {
 | 
			
		||||
    options.forEach(data => {
 | 
			
		||||
        const option = document.createElement("option");
 | 
			
		||||
        option.setAttribute("value", data.value);
 | 
			
		||||
        option.textContent = data.label;
 | 
			
		||||
        element.append(option);
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function showJoinPrompt() {
 | 
			
		||||
    WindowManager.openWindow("lobbyJoinMenu");
 | 
			
		||||
}
 | 
			
		||||
document.getElementById("lobbyCode").addEventListener("input", ({ target: input }) => {
 | 
			
		||||
    if (input.value.length !== 5) return;
 | 
			
		||||
    currentCode = input.value.toLowerCase();
 | 
			
		||||
    input.value = "";
 | 
			
		||||
    WindowManager.closeWindow("lobbyJoinMenu");
 | 
			
		||||
    isActive = true;
 | 
			
		||||
    joinLobby();
 | 
			
		||||
});
 | 
			
		||||
document.getElementById("createLobbyButton").addEventListener("click", () => {
 | 
			
		||||
    currentCode = "";
 | 
			
		||||
    WindowManager.closeWindow("lobbyJoinMenu");
 | 
			
		||||
    isActive = true;
 | 
			
		||||
    joinLobby();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function sendMessage(type, data) {
 | 
			
		||||
    const message = data !== undefined ? { t: type, d: data } : { t: type }
 | 
			
		||||
    const originalArray = textEncoder.encode(JSON.stringify(message));
 | 
			
		||||
    const buffer = new ArrayBuffer(originalArray.length + 1);
 | 
			
		||||
    const view = new DataView(buffer);
 | 
			
		||||
    // Set the first byte to the custom message marker
 | 
			
		||||
    view.setUint8(0, customMessageMarker);
 | 
			
		||||
    // Copy the original array starting from the second byte
 | 
			
		||||
    const uint8ArrayView = new Uint8Array(buffer, 1);
 | 
			
		||||
    uint8ArrayView.set(originalArray);
 | 
			
		||||
    sendRaw(1, buffer);
 | 
			
		||||
}
 | 
			
		||||
let playerIsHost = false;
 | 
			
		||||
/** @param {Uint8Array} raw */
 | 
			
		||||
function isCustomMessage(raw) {
 | 
			
		||||
    if (raw[0] !== customMessageMarker) return false;
 | 
			
		||||
    if (raw.length === 1) return true; // ping
 | 
			
		||||
    const subArray = new Uint8Array(raw.buffer, 1);
 | 
			
		||||
    const message = JSON.parse(textDecoder.decode(subArray));
 | 
			
		||||
    const { t: type, d: data } = message;
 | 
			
		||||
    if (type === "lobby") {
 | 
			
		||||
        WindowManager.openWindow("customLobby");
 | 
			
		||||
        header.textContent = "Custom Lobby " + data.code;
 | 
			
		||||
        currentCode = data.code;
 | 
			
		||||
        playerIsHost = data.isHost;
 | 
			
		||||
        startButton.disabled = !playerIsHost;
 | 
			
		||||
        if (playerIsHost) optionsContainer.classList.remove("disabled");
 | 
			
		||||
        else optionsContainer.classList.add("disabled");
 | 
			
		||||
        Object.entries(data.options).forEach(([option, value]) => updateOption(option, value));
 | 
			
		||||
        displayPlayers(data.players);
 | 
			
		||||
    } else if (type === "addPlayer") {
 | 
			
		||||
        addPlayer({ name: data.name, inGame: false, isHost: false });
 | 
			
		||||
        updatePlayerCount();
 | 
			
		||||
    } else if (type === "removePlayer") {
 | 
			
		||||
        const index = data;
 | 
			
		||||
        playerList[index].element.remove();
 | 
			
		||||
        playerList.splice(index, 1);
 | 
			
		||||
        updatePlayerCount();
 | 
			
		||||
    } else if (type === "inLobby") {
 | 
			
		||||
        const index = data;
 | 
			
		||||
        playerList[index].inGameBadge.className = "d-none";
 | 
			
		||||
    } else if (type === "options") {
 | 
			
		||||
        const [option, value] = data;
 | 
			
		||||
        updateOption(option, value);
 | 
			
		||||
    } else if (type === "setHost") {
 | 
			
		||||
        const index = data;
 | 
			
		||||
        playerList[index].isHost = true;
 | 
			
		||||
        playerList[index].hostBadge.className = "";
 | 
			
		||||
    } else if (type === "host") {
 | 
			
		||||
        playerIsHost = true;
 | 
			
		||||
        startButton.disabled = false;
 | 
			
		||||
        optionsContainer.classList.remove("disabled");
 | 
			
		||||
        playerList.forEach(p => { if (!p.isHost) p.kickButton.className = "" });
 | 
			
		||||
    } else if (type === "serverMessage") alert(data);
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** @typedef {{ element: HTMLDivElement, hostBadge: HTMLSpanElement, inGameBadge: HTMLSpanElement, kickButton: HTMLButtonElement, isHost: boolean, inGame: boolean }} PlayerListEntry */
 | 
			
		||||
 | 
			
		||||
/** @type {PlayerListEntry[]} */
 | 
			
		||||
let playerList = [];
 | 
			
		||||
/** @type {PlayerListEntry} */
 | 
			
		||||
let thisPlayer;
 | 
			
		||||
 | 
			
		||||
function createBadge(text, visible) {
 | 
			
		||||
    const badge = document.createElement("span");
 | 
			
		||||
    badge.textContent = text;
 | 
			
		||||
    badge.className = visible ? "" : "d-none";
 | 
			
		||||
    return badge;
 | 
			
		||||
}
 | 
			
		||||
/** @typedef {{ name: string, isHost: boolean, inGame: boolean }} PlayerInfo */
 | 
			
		||||
/** @param {PlayerInfo} player */
 | 
			
		||||
function addPlayer(player) {
 | 
			
		||||
    const div = document.createElement("div");
 | 
			
		||||
    div.className = "lobby-player";
 | 
			
		||||
    div.textContent = player.name;
 | 
			
		||||
    const kickButton = document.createElement("button");
 | 
			
		||||
    kickButton.textContent = "Kick";
 | 
			
		||||
    kickButton.className = playerIsHost && !player.isHost ? "" : "d-none";
 | 
			
		||||
    kickButton.addEventListener("click", kickButtonHandler);
 | 
			
		||||
    const hostBadge = createBadge("Host", player.isHost);
 | 
			
		||||
    const inGameBadge = createBadge("In Game", player.inGame);
 | 
			
		||||
    div.append(hostBadge, inGameBadge, kickButton);
 | 
			
		||||
    playerListDiv.append(div);
 | 
			
		||||
    playerList.push({ element: div, hostBadge, inGameBadge, kickButton, isHost: player.isHost, inGame: player.inGame });
 | 
			
		||||
}
 | 
			
		||||
function kickButtonHandler(event) {
 | 
			
		||||
    const button = event.target;
 | 
			
		||||
    for (let index = 0; index < playerList.length; index++) {
 | 
			
		||||
        if (playerList[index].kickButton === button) {
 | 
			
		||||
            sendMessage("kick", index);
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
/** @param {PlayerInfo[]} players */
 | 
			
		||||
function displayPlayers(players) {
 | 
			
		||||
    playerList = [];
 | 
			
		||||
    playerListDiv.innerHTML = "";
 | 
			
		||||
    players.forEach(addPlayer);
 | 
			
		||||
    thisPlayer = playerList[playerList.length - 1];
 | 
			
		||||
    updatePlayerCount();
 | 
			
		||||
}
 | 
			
		||||
function updatePlayerCount() {
 | 
			
		||||
    playerCount.textContent = `${playerList.length} Player${playerList.length === 1 ? "" : "s"}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSocketURL() {
 | 
			
		||||
    return socketURL + (currentCode === "" ? "create" : "join?" + currentCode)
 | 
			
		||||
}
 | 
			
		||||
function getPlayerId() {
 | 
			
		||||
    let id = 0;
 | 
			
		||||
    for (let i = 0; i < playerList.length; i++) {
 | 
			
		||||
        const player = playerList[i];
 | 
			
		||||
        if (player === thisPlayer) return id;
 | 
			
		||||
        if (player.inGame === false) id++;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
function startGame() {
 | 
			
		||||
    WindowManager.closeWindow("customLobby");
 | 
			
		||||
    sendMessage("startGame");
 | 
			
		||||
}
 | 
			
		||||
function rejoinLobby() {
 | 
			
		||||
    joinLobby();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setJoinFunction(f) { joinLobby = f; }
 | 
			
		||||
function setLeaveFunction(f) { leaveLobby = f; }
 | 
			
		||||
function setSendFunction(f) { sendRaw = f; }
 | 
			
		||||
function setActive(active) {
 | 
			
		||||
    isActive = active;
 | 
			
		||||
    if (active === false) WindowManager.closeWindow("customLobby");
 | 
			
		||||
}
 | 
			
		||||
function hideWindow() {
 | 
			
		||||
    WindowManager.closeWindow("customLobby");
 | 
			
		||||
}
 | 
			
		||||
const gameInterface = { gameInfo: optionsValues, showJoinPrompt, isCustomMessage, getSocketURL, getPlayerId, setJoinFunction, setLeaveFunction, setSendFunction, setMapInfo, rejoinLobby, hideWindow, isActive: () => isActive, setActive }
 | 
			
		||||
 | 
			
		||||
const customLobby = gameInterface
 | 
			
		||||
export default customLobby
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
const playerDataProperties = ["playerTerritories", "playerBalances", "rawPlayerNames"];
 | 
			
		||||
const gameObjectProperties = ["playerId", "gIsTeamGame", "gHumans", "gLobbyMaxJoin", "gameState", "gIsSingleplayer"];
 | 
			
		||||
export const getVar = varName => {
 | 
			
		||||
    if (playerDataProperties.includes(varName)) return window[dictionary.playerData][dictionary[varName]];
 | 
			
		||||
    if (gameObjectProperties.includes(varName)) return window[dictionary.game][dictionary[varName]];
 | 
			
		||||
    if (playerDataProperties.includes(varName)) return window[dictionary.playerData]?.[dictionary[varName]];
 | 
			
		||||
    if (gameObjectProperties.includes(varName)) return window[dictionary.game]?.[dictionary[varName]];
 | 
			
		||||
    return window[dictionary[varName]]
 | 
			
		||||
};
 | 
			
		||||
								
									
									
										
											17
										
									
									src/main.js
									
									
									
									
								
								
							
							
										
											17
										
									
									src/main.js
									
									
									
									
								| 
						 | 
				
			
			@ -1,5 +1,16 @@
 | 
			
		|||
const fx_version = '0.6.5.6'; // FX Client Version
 | 
			
		||||
const fx_update = 'Oct 3'; // FX Client Last Updated
 | 
			
		||||
const fx_version = '0.6.7'; // FX Client Version
 | 
			
		||||
const fx_update = 'Feb 8'; // FX Client Last Updated
 | 
			
		||||
 | 
			
		||||
if ("serviceWorker" in navigator) {
 | 
			
		||||
  navigator.serviceWorker.addEventListener("message", (e) => {
 | 
			
		||||
    const message = e.data;
 | 
			
		||||
    if (message.event === "activate" && buildTimestamp !== message.version) {
 | 
			
		||||
      // worker was updated in the background
 | 
			
		||||
      document.getElementById("updateNotification").style.display = "block";
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  navigator.serviceWorker.register("./sw.js");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
import settingsManager from './settings.js';
 | 
			
		||||
import { clanFilter, leaderboardFilter } from "./clanFilters.js";
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +21,7 @@ import playerList from "./playerList.js";
 | 
			
		|||
import gameScriptUtils from "./gameScriptUtils.js";
 | 
			
		||||
import hoveringTooltip from "./hoveringTooltip.js";
 | 
			
		||||
import { keybindFunctions, keybindHandler } from "./keybinds.js";
 | 
			
		||||
import customLobby from './customLobby.js';
 | 
			
		||||
 | 
			
		||||
window.__fx = window.__fx || {};
 | 
			
		||||
const __fx = window.__fx;
 | 
			
		||||
| 
						 | 
				
			
			@ -26,5 +38,6 @@ __fx.playerList = playerList;
 | 
			
		|||
__fx.hoveringTooltip = hoveringTooltip;
 | 
			
		||||
__fx.clanFilter = clanFilter;
 | 
			
		||||
__fx.wins = winCounter;
 | 
			
		||||
__fx.customLobby = customLobby;
 | 
			
		||||
 | 
			
		||||
console.log('Successfully loaded FX Client');
 | 
			
		||||
								
									
									
										
											443
										
									
									src/settings.js
									
									
									
									
								
								
							
							
										
											443
										
									
									src/settings.js
									
									
									
									
								| 
						 | 
				
			
			@ -6,204 +6,301 @@ window.__fx = window.__fx || {};
 | 
			
		|||
const __fx = window.__fx;
 | 
			
		||||
 | 
			
		||||
var settings = {
 | 
			
		||||
    //"fontName": "Trebuchet MS",
 | 
			
		||||
    //"showBotDonations": false,
 | 
			
		||||
    "displayWinCounter": true,
 | 
			
		||||
    "useFullscreenMode": false,
 | 
			
		||||
    "hoveringTooltip": true,
 | 
			
		||||
    //"hideAllLinks": false,
 | 
			
		||||
    "realisticNames": false,
 | 
			
		||||
    "showPlayerDensity": true,
 | 
			
		||||
    "coloredDensity": true,
 | 
			
		||||
    "densityDisplayStyle": "percentage",
 | 
			
		||||
    "highlightClanSpawns": false,
 | 
			
		||||
    //"customMapFileBtn": true
 | 
			
		||||
    "customBackgroundUrl": "",
 | 
			
		||||
    "attackPercentageKeybinds": [],
 | 
			
		||||
  //"fontName": "Trebuchet MS",
 | 
			
		||||
  //"showBotDonations": false,
 | 
			
		||||
  displayWinCounter: true,
 | 
			
		||||
  useFullscreenMode: false,
 | 
			
		||||
  hoveringTooltip: true,
 | 
			
		||||
  //"hideAllLinks": false,
 | 
			
		||||
  realisticNames: false,
 | 
			
		||||
  showPlayerDensity: true,
 | 
			
		||||
  coloredDensity: true,
 | 
			
		||||
  densityDisplayStyle: "percentage",
 | 
			
		||||
  hideBotNames: false,
 | 
			
		||||
  highlightClanSpawns: false,
 | 
			
		||||
  detailedTeamPercentage: false,
 | 
			
		||||
  //"customMapFileBtn": true
 | 
			
		||||
  customBackgroundUrl: "",
 | 
			
		||||
  attackPercentageKeybinds: [],
 | 
			
		||||
};
 | 
			
		||||
__fx.settings = settings;
 | 
			
		||||
const discontinuedSettings = [ "hideAllLinks", "fontName" ];
 | 
			
		||||
const discontinuedSettings = ["hideAllLinks", "fontName"];
 | 
			
		||||
__fx.makeMainMenuTransparent = false;
 | 
			
		||||
 | 
			
		||||
/*var settingsGearIcon = document.createElement('img');
 | 
			
		||||
settingsGearIcon.setAttribute('src', 'assets/geari_white.png');*/
 | 
			
		||||
 | 
			
		||||
const settingsManager = new (function() {
 | 
			
		||||
    const settingsStructure = [
 | 
			
		||||
        { for: "displayWinCounter", type: "checkbox", label: "Display win counter",
 | 
			
		||||
            note: "The win counter tracks multiplayer solo wins (not in team games)" },
 | 
			
		||||
        { type: "button", text: "Reset win counter", action: winCounter.removeWins },
 | 
			
		||||
        { for: "useFullscreenMode", type: "checkbox", label: "Use fullscreen mode",
 | 
			
		||||
        note: "Note: fullscreen mode will trigger after you click anywhere on the page due to browser policy restrictions." },
 | 
			
		||||
        { for: "hoveringTooltip", type: "checkbox", label: "Hovering tooltip",
 | 
			
		||||
        note: "Display map territory info constantly (on mouse hover) instead of only when right clicking on the map" },
 | 
			
		||||
        //{ for: "hideAllLinks", type: "checkbox", label: "Hide Links option also hides app store links" },
 | 
			
		||||
        { for: "realisticNames", type: "checkbox", label: "Realistic Bot Names" },
 | 
			
		||||
        { for: "showPlayerDensity", type: "checkbox", label: "Show player density" },
 | 
			
		||||
        { for: "coloredDensity", type: "checkbox", label: "Colored density", note: "Display the density with a color between red and green depending on the density value" },
 | 
			
		||||
        { for: "densityDisplayStyle", type: "selectMenu", label: "Density value display style:", tooltip: "Controls how the territorial density value should be rendered", options: [
 | 
			
		||||
            { value: "percentage", label: "Percentage" },
 | 
			
		||||
            { value: "absoluteQuotient", label: "Value from 0 to 150 (BetterTT style)" }
 | 
			
		||||
        ]},
 | 
			
		||||
        { for: "highlightClanSpawns", type: "checkbox", label: "Highlight clan spawnpoints",
 | 
			
		||||
            note: "Increases the spawnpoint glow size for members of your clan" },
 | 
			
		||||
        { for: "customBackgroundUrl", type: "textInput", label: "Custom main menu background:", placeholder: "Enter an image URL here", tooltip: "A custom image to be shown as the main menu background instead of the currently selected map." },
 | 
			
		||||
        KeybindsInput
 | 
			
		||||
    ];
 | 
			
		||||
    const settingsContainer = document.querySelector(".settings .scrollable");
 | 
			
		||||
    var inputFields = {}; // (includes select menus)
 | 
			
		||||
    var checkboxFields = {};
 | 
			
		||||
    var customElements = [];
 | 
			
		||||
    settingsStructure.forEach(item => {
 | 
			
		||||
        if (typeof item === "function") {
 | 
			
		||||
            const container = document.createElement("div");
 | 
			
		||||
            customElements.push(new item(container));
 | 
			
		||||
            return settingsContainer.append(container);
 | 
			
		||||
        }
 | 
			
		||||
        const label = document.createElement("label");
 | 
			
		||||
        if (item.tooltip) label.title = item.tooltip;
 | 
			
		||||
        const isValueInput = item.type.endsWith("Input");
 | 
			
		||||
        const element = document.createElement(isValueInput || item.type === "checkbox" ? "input" : item.type === "selectMenu" ? "select" : "button");
 | 
			
		||||
        if (item.type === "textInput") element.type = "text";
 | 
			
		||||
        if (item.placeholder) element.placeholder = item.placeholder;
 | 
			
		||||
        if (isValueInput || item.type === "selectMenu") inputFields[item.for] = element;
 | 
			
		||||
        if (item.text) element.innerText = item.text;
 | 
			
		||||
        if (item.action) element.addEventListener("click", item.action);
 | 
			
		||||
        if (item.label) label.append(item.label + " ");
 | 
			
		||||
        if (item.note) {
 | 
			
		||||
            const note = document.createElement("small");
 | 
			
		||||
            note.innerText = item.note;
 | 
			
		||||
            label.append(document.createElement("br"), note)
 | 
			
		||||
        }
 | 
			
		||||
        if (item.options) item.options.forEach(option => {
 | 
			
		||||
            const optionElement = document.createElement("option");
 | 
			
		||||
            optionElement.setAttribute("value", option.value);
 | 
			
		||||
            optionElement.innerText = option.label;
 | 
			
		||||
            element.append(optionElement);
 | 
			
		||||
        });
 | 
			
		||||
        label.append(element);
 | 
			
		||||
        if (item.type === "checkbox") {
 | 
			
		||||
            element.type = "checkbox";
 | 
			
		||||
            const checkmark = document.createElement("span");
 | 
			
		||||
            checkmark.className = "checkmark";
 | 
			
		||||
            label.className = "checkbox";
 | 
			
		||||
            label.append(checkmark);
 | 
			
		||||
            checkboxFields[item.for] = element;
 | 
			
		||||
        } else label.append(document.createElement("br"));
 | 
			
		||||
        settingsContainer.append(label, document.createElement("br"));
 | 
			
		||||
const settingsManager = new (function () {
 | 
			
		||||
  const settingsStructure = [
 | 
			
		||||
    {
 | 
			
		||||
      for: "displayWinCounter",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Display win counter",
 | 
			
		||||
      note: "The win counter tracks multiplayer solo wins (not in team games)",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: "button",
 | 
			
		||||
      text: "Reset win counter",
 | 
			
		||||
      action: winCounter.removeWins,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "useFullscreenMode",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Use fullscreen mode",
 | 
			
		||||
      note: "Note: fullscreen mode will trigger after you click anywhere on the page due to browser policy restrictions.",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "hoveringTooltip",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Hovering tooltip",
 | 
			
		||||
      note: "Display map territory info constantly (on mouse hover) instead of only when right clicking on the map",
 | 
			
		||||
    },
 | 
			
		||||
    //{ for: "hideAllLinks", type: "checkbox", label: "Hide Links option also hides app store links" },
 | 
			
		||||
    { for: "realisticNames", type: "checkbox", label: "Realistic Bot Names" },
 | 
			
		||||
    {
 | 
			
		||||
      for: "showPlayerDensity",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Show player density",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "coloredDensity",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Colored density",
 | 
			
		||||
      note: "Display the density with a color between red and green depending on the density value",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "densityDisplayStyle",
 | 
			
		||||
      type: "selectMenu",
 | 
			
		||||
      label: "Density value display style:",
 | 
			
		||||
      tooltip: "Controls how the territorial density value should be rendered",
 | 
			
		||||
      options: [
 | 
			
		||||
        { value: "percentage", label: "Percentage" },
 | 
			
		||||
        {
 | 
			
		||||
          value: "absoluteQuotient",
 | 
			
		||||
          label: "Value from 0 to 150 (BetterTT style)",
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    { for: "hideBotNames", type: "checkbox", label: "Hide bot names" },
 | 
			
		||||
    {
 | 
			
		||||
      for: "highlightClanSpawns",
 | 
			
		||||
      type: "checkbox",
 | 
			
		||||
      label: "Highlight clan spawnpoints",
 | 
			
		||||
      note: "Increases the spawnpoint glow size for members of your clan",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "detailedTeamPercentage", type: "checkbox",
 | 
			
		||||
      label: "Detailed team pie chart percentage",
 | 
			
		||||
      note: "For example: this would show 25.82% instead of 26% on the pie chart in team games"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      for: "customBackgroundUrl",
 | 
			
		||||
      type: "textInput",
 | 
			
		||||
      label: "Custom main menu background:",
 | 
			
		||||
      placeholder: "Enter an image URL here",
 | 
			
		||||
      tooltip:
 | 
			
		||||
        "A custom image to be shown as the main menu background instead of the currently selected map.",
 | 
			
		||||
    },
 | 
			
		||||
    KeybindsInput,
 | 
			
		||||
  ];
 | 
			
		||||
  const settingsContainer = document.querySelector(".settings .scrollable");
 | 
			
		||||
  var inputFields = {}; // (includes select menus)
 | 
			
		||||
  var checkboxFields = {};
 | 
			
		||||
  var customElements = [];
 | 
			
		||||
  settingsStructure.forEach((item) => {
 | 
			
		||||
    if (typeof item === "function") {
 | 
			
		||||
      const container = document.createElement("div");
 | 
			
		||||
      customElements.push(new item(container));
 | 
			
		||||
      return settingsContainer.append(container);
 | 
			
		||||
    }
 | 
			
		||||
    const label = document.createElement("label");
 | 
			
		||||
    if (item.tooltip) label.title = item.tooltip;
 | 
			
		||||
    const isValueInput = item.type.endsWith("Input");
 | 
			
		||||
    const element = document.createElement(
 | 
			
		||||
      isValueInput || item.type === "checkbox"
 | 
			
		||||
        ? "input"
 | 
			
		||||
        : item.type === "selectMenu"
 | 
			
		||||
        ? "select"
 | 
			
		||||
        : "button"
 | 
			
		||||
    );
 | 
			
		||||
    if (item.type === "textInput") element.type = "text";
 | 
			
		||||
    if (item.placeholder) element.placeholder = item.placeholder;
 | 
			
		||||
    if (isValueInput || item.type === "selectMenu")
 | 
			
		||||
      inputFields[item.for] = element;
 | 
			
		||||
    if (item.text) element.innerText = item.text;
 | 
			
		||||
    if (item.action) element.addEventListener("click", item.action);
 | 
			
		||||
    if (item.label) label.append(item.label + " ");
 | 
			
		||||
    if (item.note) {
 | 
			
		||||
      const note = document.createElement("small");
 | 
			
		||||
      note.innerText = item.note;
 | 
			
		||||
      label.append(document.createElement("br"), note);
 | 
			
		||||
    }
 | 
			
		||||
    if (item.options)
 | 
			
		||||
      item.options.forEach((option) => {
 | 
			
		||||
        const optionElement = document.createElement("option");
 | 
			
		||||
        optionElement.setAttribute("value", option.value);
 | 
			
		||||
        optionElement.innerText = option.label;
 | 
			
		||||
        element.append(optionElement);
 | 
			
		||||
      });
 | 
			
		||||
    label.append(element);
 | 
			
		||||
    if (item.type === "checkbox") {
 | 
			
		||||
      element.type = "checkbox";
 | 
			
		||||
      const checkmark = document.createElement("span");
 | 
			
		||||
      checkmark.className = "checkmark";
 | 
			
		||||
      label.className = "checkbox";
 | 
			
		||||
      label.append(checkmark);
 | 
			
		||||
      checkboxFields[item.for] = element;
 | 
			
		||||
    } else label.append(document.createElement("br"));
 | 
			
		||||
    settingsContainer.append(label, document.createElement("br"));
 | 
			
		||||
  });
 | 
			
		||||
  this.save = function () {
 | 
			
		||||
    Object.keys(inputFields).forEach(function (key) {
 | 
			
		||||
      settings[key] = inputFields[key].value.trim();
 | 
			
		||||
    });
 | 
			
		||||
    this.save = function() {
 | 
			
		||||
        Object.keys(inputFields).forEach(function(key) { settings[key] = inputFields[key].value.trim(); });
 | 
			
		||||
        Object.keys(checkboxFields).forEach(function(key) { settings[key] = checkboxFields[key].checked; });
 | 
			
		||||
        this.applySettings();
 | 
			
		||||
        WindowManager.closeWindow("settings");
 | 
			
		||||
        discontinuedSettings.forEach(settingName => delete settings[settingName]);
 | 
			
		||||
    Object.keys(checkboxFields).forEach(function (key) {
 | 
			
		||||
      settings[key] = checkboxFields[key].checked;
 | 
			
		||||
    });
 | 
			
		||||
    this.applySettings();
 | 
			
		||||
    WindowManager.closeWindow("settings");
 | 
			
		||||
    discontinuedSettings.forEach((settingName) => delete settings[settingName]);
 | 
			
		||||
    localStorage.setItem("fx_settings", JSON.stringify(settings));
 | 
			
		||||
    // should probably firgure out a way to do this without reloading - // You can't do it, localstorages REQUIRE you to reload
 | 
			
		||||
    window.location.reload();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const fileInput = document.createElement("input");
 | 
			
		||||
  fileInput.type = "file";
 | 
			
		||||
  function handleFileSelect(event) {
 | 
			
		||||
    const input = event.target;
 | 
			
		||||
    /** @type {File} */
 | 
			
		||||
    const selectedFile = input.files[0];
 | 
			
		||||
    if (!selectedFile) return;
 | 
			
		||||
 | 
			
		||||
    input.removeEventListener("change", handleFileSelect);
 | 
			
		||||
    input.value = "";
 | 
			
		||||
    if (!selectedFile.name.endsWith(".json"))
 | 
			
		||||
      return alert("Invalid file format");
 | 
			
		||||
    const fileReader = new FileReader();
 | 
			
		||||
    fileReader.onload = function () {
 | 
			
		||||
      let result;
 | 
			
		||||
      try {
 | 
			
		||||
        result = JSON.parse(fileReader.result);
 | 
			
		||||
        if (
 | 
			
		||||
          confirm(
 | 
			
		||||
            'Warning: This will override all current settings, click "OK" to confirm'
 | 
			
		||||
          )
 | 
			
		||||
        )
 | 
			
		||||
          __fx.settings = settings = result;
 | 
			
		||||
        localStorage.setItem("fx_settings", JSON.stringify(settings));
 | 
			
		||||
        // should probably firgure out a way to do this without reloading - // You can't do it, localstorages REQUIRE you to reload
 | 
			
		||||
        window.location.reload();
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        alert("Error\n" + error);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    fileReader.readAsText(selectedFile);
 | 
			
		||||
  }
 | 
			
		||||
  this.importFromFile = function () {
 | 
			
		||||
    fileInput.click();
 | 
			
		||||
    fileInput.addEventListener("change", handleFileSelect);
 | 
			
		||||
  };
 | 
			
		||||
  // https://stackoverflow.com/a/34156339
 | 
			
		||||
  function saveFile(content, fileName, contentType) {
 | 
			
		||||
    var a = document.createElement("a");
 | 
			
		||||
    var file = new Blob([content], { type: contentType });
 | 
			
		||||
    a.href = URL.createObjectURL(file);
 | 
			
		||||
    a.download = fileName;
 | 
			
		||||
    a.click();
 | 
			
		||||
    URL.revokeObjectURL(a.href);
 | 
			
		||||
  }
 | 
			
		||||
  this.exportToFile = function () {
 | 
			
		||||
    saveFile(
 | 
			
		||||
      JSON.stringify(settings),
 | 
			
		||||
      "FX_client_settings.json",
 | 
			
		||||
      "application/json"
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    const fileInput = document.createElement("input");
 | 
			
		||||
    fileInput.type = "file";
 | 
			
		||||
    function handleFileSelect(event) {
 | 
			
		||||
        const input = event.target;
 | 
			
		||||
        /** @type {File} */
 | 
			
		||||
        const selectedFile = input.files[0];
 | 
			
		||||
        if (!selectedFile) return;
 | 
			
		||||
  this.syncFields = function () {
 | 
			
		||||
    Object.keys(inputFields).forEach(function (key) {
 | 
			
		||||
      inputFields[key].value = settings[key];
 | 
			
		||||
    });
 | 
			
		||||
    Object.keys(checkboxFields).forEach(function (key) {
 | 
			
		||||
      checkboxFields[key].checked = settings[key];
 | 
			
		||||
    });
 | 
			
		||||
    customElements.forEach((element) => element.update(settings));
 | 
			
		||||
  };
 | 
			
		||||
  this.resetAll = function () {
 | 
			
		||||
    if (
 | 
			
		||||
      !confirm(
 | 
			
		||||
        "Are you Really SURE you want to RESET ALL SETTINGS back to the default?"
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
      return;
 | 
			
		||||
    localStorage.removeItem("fx_settings");
 | 
			
		||||
    window.location.reload();
 | 
			
		||||
  };
 | 
			
		||||
  this.applySettings = function () {
 | 
			
		||||
    //setVarByName("bu", "px " + settings.fontName);
 | 
			
		||||
 | 
			
		||||
        input.removeEventListener("change", handleFileSelect);
 | 
			
		||||
        input.value = "";
 | 
			
		||||
        if (!selectedFile.name.endsWith(".json")) return alert("Invalid file format");
 | 
			
		||||
        const fileReader = new FileReader();
 | 
			
		||||
        fileReader.onload = function() {
 | 
			
		||||
            let result;
 | 
			
		||||
            try {    
 | 
			
		||||
                result = JSON.parse(fileReader.result);
 | 
			
		||||
                if (confirm("Warning: This will override all current settings, click \"OK\" to confirm")) __fx.settings = settings = result;
 | 
			
		||||
                localStorage.setItem("fx_settings", JSON.stringify(settings));
 | 
			
		||||
                window.location.reload();
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                alert("Error\n" + error)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        fileReader.readAsText(selectedFile);
 | 
			
		||||
    if (settings.customBackgroundUrl !== "") {
 | 
			
		||||
      document.body.style.backgroundImage =
 | 
			
		||||
        "url(" + settings.customBackgroundUrl + ")";
 | 
			
		||||
      document.body.style.backgroundSize = "cover";
 | 
			
		||||
      document.body.style.backgroundPosition = "center";
 | 
			
		||||
    }
 | 
			
		||||
    this.importFromFile = function() {
 | 
			
		||||
        fileInput.click();
 | 
			
		||||
        fileInput.addEventListener('change', handleFileSelect);
 | 
			
		||||
    };
 | 
			
		||||
    // https://stackoverflow.com/a/34156339
 | 
			
		||||
    function saveFile(content, fileName, contentType) {
 | 
			
		||||
        var a = document.createElement("a");
 | 
			
		||||
        var file = new Blob([content], {type: contentType});
 | 
			
		||||
        a.href = URL.createObjectURL(file);
 | 
			
		||||
        a.download = fileName;
 | 
			
		||||
        a.click();
 | 
			
		||||
        URL.revokeObjectURL(a.href);
 | 
			
		||||
    }
 | 
			
		||||
    this.exportToFile = function() {
 | 
			
		||||
        saveFile(JSON.stringify(settings), 'FX_client_settings.json', 'application/json');
 | 
			
		||||
    };
 | 
			
		||||
    __fx.makeMainMenuTransparent = settings.customBackgroundUrl !== "";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    this.syncFields = function() {
 | 
			
		||||
        Object.keys(inputFields).forEach(function(key) { inputFields[key].value = settings[key]; });
 | 
			
		||||
        Object.keys(checkboxFields).forEach(function(key) { checkboxFields[key].checked = settings[key]; });
 | 
			
		||||
        customElements.forEach(element => element.update(settings));
 | 
			
		||||
    };
 | 
			
		||||
    this.resetAll = function() {
 | 
			
		||||
        if (!confirm("Are you Really SURE you want to RESET ALL SETTINGS back to the default?")) return;
 | 
			
		||||
        localStorage.removeItem("fx_settings");
 | 
			
		||||
        window.location.reload();
 | 
			
		||||
    };
 | 
			
		||||
    this.applySettings = function() {
 | 
			
		||||
        //setVarByName("bu", "px " + settings.fontName);
 | 
			
		||||
        if (settings.useFullscreenMode && document.fullscreenEnabled) {
 | 
			
		||||
            function tryEnterFullscreen() {
 | 
			
		||||
                if (document.fullscreenElement !== null) return;
 | 
			
		||||
                document.documentElement.requestFullscreen({ navigationUI: "hide" })
 | 
			
		||||
                    .then(() => { console.log('Fullscreen mode activated'); })
 | 
			
		||||
                    .catch((error) => { console.warn('Could not enter fullscreen mode:', error); });
 | 
			
		||||
            }
 | 
			
		||||
            document.addEventListener('mousedown', tryEnterFullscreen, { once: true });
 | 
			
		||||
            document.addEventListener('click', tryEnterFullscreen, { once: true });
 | 
			
		||||
        }
 | 
			
		||||
        if (settings.customBackgroundUrl !== "") {
 | 
			
		||||
            document.body.style.backgroundImage = "url(" + settings.customBackgroundUrl + ")";
 | 
			
		||||
            document.body.style.backgroundSize = "cover";
 | 
			
		||||
            document.body.style.backgroundPosition = "center";
 | 
			
		||||
        }
 | 
			
		||||
        __fx.makeMainMenuTransparent = settings.customBackgroundUrl !== "";
 | 
			
		||||
    };
 | 
			
		||||
});
 | 
			
		||||
  if (settings.useFullscreenMode) tryEnterFullscreen();
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
export function tryEnterFullscreen() {
 | 
			
		||||
  if (document.fullscreenElement !== null || !document.fullscreenEnabled) return;
 | 
			
		||||
  document.documentElement
 | 
			
		||||
    .requestFullscreen({ navigationUI: "hide" })
 | 
			
		||||
    .then(() => {
 | 
			
		||||
      console.log("Fullscreen mode activated");
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
      console.warn("Could not enter fullscreen mode:", error);
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const openCustomBackgroundFilePicker = () => {
 | 
			
		||||
    const fileInput = document.getElementById("customBackgroundFileInput");
 | 
			
		||||
    fileInput.click();
 | 
			
		||||
    fileInput.addEventListener('change', handleFileSelect);
 | 
			
		||||
}
 | 
			
		||||
  const fileInput = document.getElementById("customBackgroundFileInput");
 | 
			
		||||
  fileInput.click();
 | 
			
		||||
  fileInput.addEventListener("change", handleFileSelect);
 | 
			
		||||
};
 | 
			
		||||
function handleFileSelect(event) {
 | 
			
		||||
    const fileInput = event.target;
 | 
			
		||||
    const selectedFile = fileInput.files[0];
 | 
			
		||||
    console.log(fileInput.files);
 | 
			
		||||
    console.log(fileInput.files[0]);
 | 
			
		||||
    if (selectedFile) {
 | 
			
		||||
        const fileUrl = URL.createObjectURL(selectedFile);
 | 
			
		||||
        console.log("File URL:", fileUrl);
 | 
			
		||||
        fileInput.value = "";
 | 
			
		||||
        fileInput.removeEventListener("change", handleFileSelect);
 | 
			
		||||
    }
 | 
			
		||||
  const fileInput = event.target;
 | 
			
		||||
  const selectedFile = fileInput.files[0];
 | 
			
		||||
  console.log(fileInput.files);
 | 
			
		||||
  console.log(fileInput.files[0]);
 | 
			
		||||
  if (selectedFile) {
 | 
			
		||||
    const fileUrl = URL.createObjectURL(selectedFile);
 | 
			
		||||
    console.log("File URL:", fileUrl);
 | 
			
		||||
    fileInput.value = "";
 | 
			
		||||
    fileInput.removeEventListener("change", handleFileSelect);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
WindowManager.add({
 | 
			
		||||
    name: "settings",
 | 
			
		||||
    element: document.querySelector(".settings"),
 | 
			
		||||
    beforeOpen: function() { settingsManager.syncFields(); }
 | 
			
		||||
  name: "settings",
 | 
			
		||||
  element: document.querySelector(".settings"),
 | 
			
		||||
  beforeOpen: function () {
 | 
			
		||||
    settingsManager.syncFields();
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
if (localStorage.getItem("fx_settings") !== null) {
 | 
			
		||||
    __fx.settings = settings = {...settings, ...JSON.parse(localStorage.getItem("fx_settings"))};
 | 
			
		||||
  __fx.settings = settings = {
 | 
			
		||||
    ...settings,
 | 
			
		||||
    ...JSON.parse(localStorage.getItem("fx_settings")),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
settingsManager.applySettings();
 | 
			
		||||
 | 
			
		||||
export default settingsManager;
 | 
			
		||||
export function getSettings() { return settings; };
 | 
			
		||||
export function getSettings() {
 | 
			
		||||
  return settings;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,27 +1,63 @@
 | 
			
		|||
var windows = {};
 | 
			
		||||
function add(newWindow) {
 | 
			
		||||
    windows[newWindow.name] = newWindow;
 | 
			
		||||
    windows[newWindow.name].isOpen = false;
 | 
			
		||||
};
 | 
			
		||||
function openWindow(windowName, ...args) {
 | 
			
		||||
    if (windows[windowName].isOpen === true) return;
 | 
			
		||||
    if (windows[windowName].beforeOpen !== undefined) windows[windowName].beforeOpen(...args);
 | 
			
		||||
    windows[windowName].isOpen = true;
 | 
			
		||||
    windows[windowName].element.style.display = null;
 | 
			
		||||
};
 | 
			
		||||
function closeWindow(windowName) {
 | 
			
		||||
    if (windows[windowName].isOpen === false) return;
 | 
			
		||||
    windows[windowName].isOpen = false;
 | 
			
		||||
    windows[windowName].element.style.display = "none";
 | 
			
		||||
    if (windows[windowName].onClose !== undefined) windows[windowName].onClose();
 | 
			
		||||
};
 | 
			
		||||
function closeAll() {
 | 
			
		||||
    Object.values(windows).forEach(function (windowObj) {
 | 
			
		||||
        closeWindow(windowObj.name);
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
document.getElementById("canvasA").addEventListener("mousedown", closeAll);
 | 
			
		||||
document.getElementById("canvasA").addEventListener("touchstart", closeAll, { passive: true });
 | 
			
		||||
document.addEventListener("keydown", event => { if (event.key === "Escape") closeAll(); });
 | 
			
		||||
import { getSettings, tryEnterFullscreen } from "./settings.js";
 | 
			
		||||
 | 
			
		||||
export default { add, openWindow, closeWindow, closeAll }
 | 
			
		||||
var windows = {};
 | 
			
		||||
 | 
			
		||||
const container = document.getElementById("windowContainer");
 | 
			
		||||
function create(info) {
 | 
			
		||||
  const window = document.createElement("div");
 | 
			
		||||
  info.element = window;
 | 
			
		||||
  window.className =
 | 
			
		||||
    "window" +
 | 
			
		||||
    (info.classes !== undefined
 | 
			
		||||
      ? " " + info.classes
 | 
			
		||||
      : " scrollable selectable");
 | 
			
		||||
  window.style.display = "none";
 | 
			
		||||
  container.appendChild(window);
 | 
			
		||||
  add(info);
 | 
			
		||||
  return window;
 | 
			
		||||
}
 | 
			
		||||
function add(newWindow) {
 | 
			
		||||
  windows[newWindow.name] = newWindow;
 | 
			
		||||
  windows[newWindow.name].isOpen = false;
 | 
			
		||||
}
 | 
			
		||||
function openWindow(windowName, ...args) {
 | 
			
		||||
  if (windows[windowName].isOpen === true) return;
 | 
			
		||||
  if (windows[windowName].beforeOpen !== undefined)
 | 
			
		||||
    windows[windowName].beforeOpen(...args);
 | 
			
		||||
  windows[windowName].isOpen = true;
 | 
			
		||||
  windows[windowName].element.style.display = null;
 | 
			
		||||
}
 | 
			
		||||
function closeWindow(windowName) {
 | 
			
		||||
  if (windows[windowName].isOpen === false) return;
 | 
			
		||||
  windows[windowName].isOpen = false;
 | 
			
		||||
  windows[windowName].element.style.display = "none";
 | 
			
		||||
  if (windows[windowName].onClose !== undefined) windows[windowName].onClose();
 | 
			
		||||
}
 | 
			
		||||
function closeAll() {
 | 
			
		||||
  Object.values(windows).forEach(function (windowObj) {
 | 
			
		||||
    if (windowObj.closable !== false) closeWindow(windowObj.name);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
document.addEventListener(
 | 
			
		||||
  "mousedown",
 | 
			
		||||
  (e) => {
 | 
			
		||||
    // when clicking outside a window
 | 
			
		||||
    if (!container.contains(e.target)) closeAll();
 | 
			
		||||
 | 
			
		||||
    const isFullScreenEnabled = getSettings().useFullscreenMode;
 | 
			
		||||
 | 
			
		||||
    if (isFullScreenEnabled) {
 | 
			
		||||
      tryEnterFullscreen();
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { passive: true, capture: true }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
document
 | 
			
		||||
  .getElementById("canvasA")
 | 
			
		||||
  .addEventListener("touchstart", closeAll, { passive: true });
 | 
			
		||||
document.addEventListener("keydown", (event) => {
 | 
			
		||||
  if (event.key === "Escape") closeAll();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default { create, add, openWindow, closeWindow, closeAll };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,14 +2,14 @@
 | 
			
		|||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
    <!-- Google tag (gtag.js) -->
 | 
			
		||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-WYYDMY13BG"></script>
 | 
			
		||||
<!--<script async src="https://www.googletagmanager.com/gtag/js?id=G-WYYDMY13BG"></script>
 | 
			
		||||
<script>
 | 
			
		||||
  window.dataLayer = window.dataLayer || [];
 | 
			
		||||
  function gtag(){dataLayer.push(arguments);}
 | 
			
		||||
  gtag('js', new Date());
 | 
			
		||||
 | 
			
		||||
  gtag('config', 'G-WYYDMY13BG');
 | 
			
		||||
</script>
 | 
			
		||||
</script>-->
 | 
			
		||||
    <meta charset="utf-8" />
 | 
			
		||||
    <title>FX Client</title>
 | 
			
		||||
    <meta name="description" content="Modified Version of Territorial.io - FX Client">
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +59,7 @@
 | 
			
		|||
 | 
			
		||||
<body onload="aiCommand746(0);">
 | 
			
		||||
    <canvas id="canvasA" width="128" height="128"></canvas>
 | 
			
		||||
    <span><div class="window flex settings" style="display:none">
 | 
			
		||||
    <span id="windowContainer"><div class="window flex-column settings" style="display:none">
 | 
			
		||||
        <h1>Settings</h1>
 | 
			
		||||
        <div class="scrollable"></div>
 | 
			
		||||
        <hr>
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +70,11 @@
 | 
			
		|||
            <button onclick="__fx.settingsManager.exportToFile()">Export</button>
 | 
			
		||||
        </footer>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="window flex-column" id="customLobbyJoinMenu" style="display: none">
 | 
			
		||||
      <input type="text" id="lobbyCode" placeholder="Enter lobby code">
 | 
			
		||||
      or
 | 
			
		||||
      <button id="createLobbyButton">Create new lobby</button>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="window scrollable selectable" id="playerlist" style="display: none;">
 | 
			
		||||
        <h1>Player List</h1>
 | 
			
		||||
        <table><tbody id="playerlist_content"></tbody></table>
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +83,11 @@
 | 
			
		|||
        <h1>Donation history for </h1>
 | 
			
		||||
        <p id="donationhistory_note">Note: donations from bots are not shown here</p>
 | 
			
		||||
        <table><tbody id="donationhistory_content"></tbody></table>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="window" style="display: none" id="updateNotification">
 | 
			
		||||
      <h3>A new version of FX is available! Reload to update</h3>
 | 
			
		||||
      <button onclick="window.location.reload()">Reload</button>
 | 
			
		||||
      <button onclick="document.getElementById('updateNotification').style.display = 'none'">Dismiss</button>
 | 
			
		||||
    </div></span>
 | 
			
		||||
    <script src="variables.js?buildTimestamp"></script>
 | 
			
		||||
    <script src="fx.bundle.js?buildTimestamp"></script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,15 +34,6 @@
 | 
			
		|||
	z-index         : 10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.window.flex {
 | 
			
		||||
	display       : flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
hr {
 | 
			
		||||
	width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.window button,
 | 
			
		||||
.window input,
 | 
			
		||||
.window select {
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +45,77 @@ hr {
 | 
			
		|||
	transition      : 0.2s;
 | 
			
		||||
	border          : 1px solid #fff;
 | 
			
		||||
	border-radius   : 5px;
 | 
			
		||||
	margin          : 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.window :disabled, .window .disabled {
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	opacity: 0.65;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.window.settings button,
 | 
			
		||||
.window.settings input,
 | 
			
		||||
.window.settings select {
 | 
			
		||||
	margin: 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex {
 | 
			
		||||
	display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-column {
 | 
			
		||||
	display       : flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#customLobbyJoinMenu {
 | 
			
		||||
	align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.customlobby-main {
 | 
			
		||||
	display: flex;
 | 
			
		||||
    justify-content: space-evenly;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
	gap: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.lobby-player {
 | 
			
		||||
	margin: 5px;
 | 
			
		||||
	width: 15rem;
 | 
			
		||||
	display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.lobby-player span {
 | 
			
		||||
	margin: 0px 5px;
 | 
			
		||||
    font-size: .7em;
 | 
			
		||||
    border-style: solid;
 | 
			
		||||
    border-width: 1px;
 | 
			
		||||
    padding: 3px 5px;
 | 
			
		||||
    border-color: #ffffff7d;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.lobby-player button {
 | 
			
		||||
	font-size: 0.7em;
 | 
			
		||||
	margin: 0px 5px;
 | 
			
		||||
	padding: 3px 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.d-none {
 | 
			
		||||
	display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-align-center {
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
.text-align-left {
 | 
			
		||||
	text-align: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
hr {
 | 
			
		||||
	width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
| 
						 | 
				
			
			@ -96,16 +158,16 @@ td {
 | 
			
		|||
#playerlist_content.clickable td:hover { background-color: #00ff0040; }
 | 
			
		||||
 | 
			
		||||
tr.new {
 | 
			
		||||
    animation: flashAnimation 0.4s ease-out;
 | 
			
		||||
	animation: flashAnimation 0.4s ease-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes flashAnimation {
 | 
			
		||||
    0% {
 | 
			
		||||
        background-color: #ffffffaa;
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
        background-color: transparent;
 | 
			
		||||
    }
 | 
			
		||||
	0% {
 | 
			
		||||
		background-color: #ffffffaa;
 | 
			
		||||
	}
 | 
			
		||||
	100% {
 | 
			
		||||
		background-color: transparent;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
const cacheName = "buildTimestamp"; // this gets replaced by the build script
 | 
			
		||||
 | 
			
		||||
self.addEventListener("install", (e) => {
 | 
			
		||||
  console.log("[Service Worker] Install");
 | 
			
		||||
  self.skipWaiting();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
self.addEventListener("fetch", (e) => {
 | 
			
		||||
  const url = e.request.url;
 | 
			
		||||
  // Cache http and https only, skip unsupported chrome-extension:// and file://...
 | 
			
		||||
  if (!(url.startsWith('http:') || url.startsWith('https:'))) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  e.respondWith(
 | 
			
		||||
    (async () => {
 | 
			
		||||
      const r = await caches.match(e.request);
 | 
			
		||||
      console.log(`[Service Worker] Fetching resource: ${url}`);
 | 
			
		||||
      if (r) {
 | 
			
		||||
        return r;
 | 
			
		||||
      }
 | 
			
		||||
      const response = await fetch(e.request);
 | 
			
		||||
      const cache = await caches.open(cacheName);
 | 
			
		||||
      console.log(`[Service Worker] Caching new resource: ${url}`);
 | 
			
		||||
      cache.put(e.request, response.clone());
 | 
			
		||||
      return response;
 | 
			
		||||
    })(),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
self.addEventListener("activate", (e) => {
 | 
			
		||||
  console.log("[Service Worker] Activated", cacheName);
 | 
			
		||||
  self.clients.matchAll().then(clients => {
 | 
			
		||||
    clients.forEach(client => client.postMessage({ event: "activate", version: cacheName }));
 | 
			
		||||
  });
 | 
			
		||||
  e.waitUntil(
 | 
			
		||||
    caches.keys().then((keyList) => {
 | 
			
		||||
      return Promise.all(
 | 
			
		||||
        keyList.map((key) => {
 | 
			
		||||
          if (key === cacheName) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          return caches.delete(key);
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
		Reference in New Issue