├── .prettierrc ├── presentation └── bwi.png ├── logs └── README.md ├── game_files └── README.md ├── localStorage └── README.md ├── CODE ├── caracAL │ ├── tests │ │ ├── script_load_test__.js │ │ ├── script_load_test.js │ │ ├── on_destroy_test.js │ │ ├── cm_test.js │ │ ├── shutdown_test.js │ │ ├── localStorage_test.js │ │ └── deploy_test.js │ └── examples │ │ └── crabs.js └── README.md ├── standalones ├── test_log_formatting.js ├── regenerate_config_example.js ├── test_connectivity.js ├── test_logging.js ├── LogPrinter.js └── CharacterCoordinator.js ├── TYPECODE └── caracAL │ ├── tests │ └── caracAL_bindings_present.ts │ └── examples │ └── crabs_with_tophats.ts ├── src ├── CONSTANTS.js ├── ts_defs │ └── caracAL.d.ts ├── StreamMultiplexer.js ├── LogUtils.js ├── FileStoredKeyValues.js ├── ConfigUtil.js └── CharacterThread.js ├── .gitignore ├── .github └── workflows │ └── prettier.yml ├── start_on_boot.sh ├── webpack.config.js ├── package.json ├── LICENSE ├── main.js ├── account_info.js ├── config.EXAMPLE.js ├── ipcStorage.js ├── html_vars.js ├── CHANGELOG.md ├── game_files.js ├── monitoring_util.js ├── tsconfig.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /presentation/bwi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numbereself/caracAL/HEAD/presentation/bwi.png -------------------------------------------------------------------------------- /logs/README.md: -------------------------------------------------------------------------------- 1 | # logs 2 | 3 | This is where pino-based logs are stored in newer versions of caracAL 4 | -------------------------------------------------------------------------------- /game_files/README.md: -------------------------------------------------------------------------------- 1 | # game_files 2 | 3 | In this directory caracAL will store the versions it downloads. 4 | -------------------------------------------------------------------------------- /localStorage/README.md: -------------------------------------------------------------------------------- 1 | # localStorage 2 | 3 | In this directory caracAL will store the localStorage data 4 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/script_load_test__.js: -------------------------------------------------------------------------------- 1 | globalThis.carac_message = "sucessfully"; 2 | console.log("script load test 2nd phase"); 3 | -------------------------------------------------------------------------------- /CODE/README.md: -------------------------------------------------------------------------------- 1 | # CODE 2 | 3 | Place the scripts to be run on your bots in this folder, then edit config.js to make the bots use the respective script. 4 | A basic crab farming script can be found in `caracAL/examples/crabs.js` 5 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/script_load_test.js: -------------------------------------------------------------------------------- 1 | console.log("begin script load test"); 2 | parent.caracAL 3 | .load_scripts(["tests/script_load_test__.js"]) 4 | .then((x) => console.log("loaded script ", globalThis.carac_message)); 5 | -------------------------------------------------------------------------------- /standalones/test_log_formatting.js: -------------------------------------------------------------------------------- 1 | const StreamMultiplexer = require("../src/StreamMultiplexer"); 2 | 3 | StreamMultiplexer.setup_log_pipes( 4 | [["node", "./standalones/LogPrinter.js"]], 5 | "./standalones/test_logging", 6 | ); 7 | -------------------------------------------------------------------------------- /TYPECODE/caracAL/tests/caracAL_bindings_present.ts: -------------------------------------------------------------------------------- 1 | // If this file compile the test is passed 2 | setInterval( 3 | () => 4 | console.log( 5 | "All my friends are " + (parent.caracAL?.siblings || []).join(", "), 6 | ), 7 | 5000, 8 | ); 9 | -------------------------------------------------------------------------------- /standalones/regenerate_config_example.js: -------------------------------------------------------------------------------- 1 | const ConfigUtil = require("../src/ConfigUtil"); 2 | const fs = require("node:fs"); 3 | const { CONFIG_EXAMPLE_PATH } = require("../src/CONSTANTS"); 4 | 5 | fs.writeFileSync(CONFIG_EXAMPLE_PATH, ConfigUtil.make_cfg_string(), { 6 | encoding: "utf8", 7 | }); 8 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/on_destroy_test.js: -------------------------------------------------------------------------------- 1 | const { deploy } = parent.caracAL; 2 | 3 | //relog to asia after delay 4 | setTimeout(() => deploy(), 37e3); 5 | 6 | function on_destroy() { 7 | // called just before the CODE is destroyed 8 | console.log("%s was absolutely demolished", character.name); 9 | } 10 | -------------------------------------------------------------------------------- /src/CONSTANTS.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | COORDINATOR_MODULE_PATH: "./standalones/CharacterCoordinator.js", 3 | CONFIG_EXAMPLE_PATH: "./config.EXAMPLE.js", 4 | LOCALSTORAGE_PATH: "./localStorage/caraGarage.jsonl", 5 | LOCALSTORAGE_ROTA_PATH: "./localStorage/caraGarage.other.jsonl", 6 | STAT_BEAT_INTERVAL: 500, 7 | }; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /caracAL.log* 2 | /logs/* 3 | !/logs/README.md 4 | /config.js 5 | /caracAL.service 6 | /node_modules/ 7 | /game_files/* 8 | !/game_files/README.md 9 | /localStorage/* 10 | !/localStorage/README.md 11 | .idea/ 12 | /CODE/* 13 | !/CODE/README.md 14 | !/CODE/caracAL/ 15 | /TYPECODE/* 16 | !/TYPECODE/README.md 17 | !/TYPECODE/caracAL/ 18 | /TYPECODE.out/* -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | prettier: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | ref: ${{ github.head_ref }} 17 | 18 | - name: Prettify code 19 | uses: creyD/prettier_action@v4.3 20 | with: 21 | prettier_options: --write **/*.{js,md,html,css,json} 22 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/cm_test.js: -------------------------------------------------------------------------------- 1 | character.on("cm", (m) => 2 | console.log("Received a cm on character %s: %s", character.name, m), 3 | ); 4 | 5 | function sleep(ms) { 6 | return new Promise((resolve) => setTimeout(resolve, ms)); 7 | } 8 | 9 | async function behold_my_spam() { 10 | await sleep(5000 + 5000 * Math.random()); 11 | const trgs = parent.X.characters.map((x) => x.name); 12 | send_cm(trgs, `${character.name} sends his regards`); 13 | await behold_my_spam(); 14 | } 15 | 16 | behold_my_spam(); 17 | -------------------------------------------------------------------------------- /standalones/test_connectivity.js: -------------------------------------------------------------------------------- 1 | const io = require("socket.io-client"); 2 | 3 | (async () => { 4 | const socket = io("wss://" + "eud1.adventure.land" + ":" + 2053, { 5 | secure: true, 6 | transports: ["websocket"], 7 | query: undefined, 8 | }); 9 | 10 | socket.on("connect", function () { 11 | console.log("connected"); 12 | }); 13 | 14 | socket.on("welcome", (x) => { 15 | console.log("welcome", Object.keys(x)); 16 | }); 17 | socket.on("server_info", (x) => console.log("serv_info", Object.keys(x))); 18 | console.log("socket initialized"); 19 | })().catch((e) => { 20 | console.error(`exception ${e}`); 21 | }); 22 | -------------------------------------------------------------------------------- /start_on_boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # file: start_on_boot.sh 3 | node_loc=$(which node) 4 | cur_dir=$(pwd) 5 | envsubst >./caracAL.service < x.name != character.name) 14 | .sort((a, b) => (a.name > b.name ? 1 : -1)); 15 | 16 | setTimeout(() => { 17 | const second_character = all_chars[0]; 18 | console.log(`deploy ${second_character.name}`); 19 | deploy(second_character.name, null); 20 | 21 | setTimeout(() => { 22 | console.log(`shutdown ${second_character.name}`); 23 | shutdown(second_character.name); 24 | }, 5000); 25 | }, 5000); 26 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("node:path"); 2 | const WebpackWatchedGlobEntries = require("webpack-watched-glob-entries-plugin"); 3 | 4 | module.exports = { 5 | mode: "none", 6 | entry: WebpackWatchedGlobEntries.getEntries( 7 | [ 8 | // Your path(s) 9 | path.resolve(__dirname, "TYPECODE/**/*.ts"), 10 | ], 11 | { 12 | // Optional glob options that are passed to glob.sync() 13 | ignore: "**/*.lib.ts", 14 | }, 15 | ), 16 | plugins: [new WebpackWatchedGlobEntries()], 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | include: [path.resolve(__dirname, "TYPECODE")], 22 | use: "ts-loader", 23 | }, 24 | ], 25 | }, 26 | resolve: { 27 | extensions: [".ts", ".tsx", ".js"], 28 | }, 29 | output: { 30 | path: path.resolve(__dirname, "TYPECODE.out"), 31 | clean: true, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caracal", 3 | "version": "0.3.0", 4 | "description": "caracAL - Node client for adventure.land", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "number_e", 10 | "license": "MIT", 11 | "dependencies": { 12 | "bot-web-interface": "^2.0.14", 13 | "cloneable-readable": "^3.0.0", 14 | "inquirer": "^8.2.6", 15 | "jquery": "^3.7.1", 16 | "jsdom": "^24.0.0", 17 | "logrotate-stream": "^0.2.9", 18 | "node-fetch": "^3.3.2", 19 | "pino": "^8.16.1", 20 | "pino-pretty": "^10.2.3", 21 | "pngjs": "^6.0.0", 22 | "pretty-ms": "^7.0.1", 23 | "socket.io-client": "^4.7.2", 24 | "ts-loader": "^9.5.0", 25 | "typed-adventureland": "^0.0.43", 26 | "typescript": "^5.2.2", 27 | "webpack": "^5.89.0", 28 | "webpack-cli": "^5.1.4", 29 | "webpack-watched-glob-entries-plugin": "^2.2.6" 30 | }, 31 | "devDependencies": { 32 | "prettier": "3.0.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 numbereself 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/localStorage_test.js: -------------------------------------------------------------------------------- 1 | character.on("cm", (m) => show_storages()); 2 | 3 | function sleep(ms) { 4 | return new Promise((resolve) => setTimeout(resolve, ms)); 5 | } 6 | 7 | function notify_others() { 8 | const others = parent.X.characters 9 | .filter((x) => x.name != character.name) 10 | .map((x) => x.name); 11 | send_cm(others, "Check your storage bro!"); 12 | } 13 | 14 | function show_storages() { 15 | console.log(`Showing storage data for ${character.name}:`); 16 | Object.entries(localStorage).forEach(([k, v]) => { 17 | console.log(`ls: ${k} = ${v}`); 18 | }); 19 | Object.entries(sessionStorage).forEach(([k, v]) => { 20 | console.log(`ss: ${k} = ${v}`); 21 | }); 22 | } 23 | 24 | show_storages(); 25 | 26 | localStorage.clear(); 27 | sessionStorage.clear(); 28 | 29 | sessionStorage.setItem(character.name, "Big Flopppa"); 30 | 31 | for (let key in sessionStorage) { 32 | console.log(`sessionStorage has key ${key} : ${sessionStorage.getItem(key)}`); 33 | } 34 | 35 | notify_others(); 36 | 37 | (async () => { 38 | await sleep(5000); 39 | delete sessionStorage[character.name]; 40 | localStorage[character.name + "blorp"] = {}; 41 | localStorage[character.name + "blurp"] = Math.random(); 42 | show_storages(); 43 | notify_others(); 44 | })(); 45 | -------------------------------------------------------------------------------- /CODE/caracAL/examples/crabs.js: -------------------------------------------------------------------------------- 1 | const mon_type = "crab"; 2 | 3 | setInterval(function () { 4 | if (character.rip) { 5 | respawn(); 6 | } 7 | }, 2e3); 8 | 9 | setInterval(function () { 10 | if (character.rip) { 11 | return; 12 | } 13 | if (can_use("hp")) { 14 | if ( 15 | character.hp / character.max_hp <= 0.5 || 16 | character.max_hp - character.hp >= 200 17 | ) { 18 | use("hp"); 19 | } 20 | if ( 21 | character.mp / character.max_mp <= 0.5 || 22 | character.max_mp - character.mp >= 300 23 | ) { 24 | use("mp"); 25 | } 26 | } 27 | loot(); 28 | 29 | const target = get_nearest_monster({ type: mon_type }); 30 | 31 | if (target) { 32 | change_target(target); 33 | if (can_attack(target)) { 34 | attack(target); 35 | } else { 36 | const dist = simple_distance(target, character); 37 | if (!is_moving(character) && dist > character.range - 10) { 38 | if (can_move_to(target.real_x, target.real_y)) { 39 | move( 40 | (target.real_x + character.real_x) / 2, 41 | (target.real_y + character.real_y) / 2, 42 | ); 43 | } else { 44 | smart_move(target); 45 | } 46 | } 47 | } 48 | } else if (!is_moving(character)) { 49 | smart_move(mon_type); 50 | } 51 | }, 100); 52 | -------------------------------------------------------------------------------- /CODE/caracAL/tests/deploy_test.js: -------------------------------------------------------------------------------- 1 | //start this test on one char in EU 2 | 3 | console.log( 4 | `I am ${character.name} from ${parent.server_region + parent.server_identifier}`, 5 | ); 6 | const { deploy, shutdown } = parent.caracAL; 7 | if (parent.server_region != "ASIA") { 8 | //relog to asia after delay 9 | setTimeout(() => deploy(null, "ASIAI"), 37e3); 10 | } else { 11 | //log out after delay 12 | setTimeout(() => shutdown(), 33e3); 13 | } 14 | 15 | setTimeout(() => { 16 | console.log(`${character.name}: My siblings are ${parent.caracAL.siblings}`); 17 | }, 10e3); 18 | 19 | //deliberating bringing in lodash 20 | //tho i dont even know what that is. 21 | function partition(a, fun) { 22 | const ret = [[], []]; 23 | for (let i = 0; i < a.length; i++) 24 | if (fun(a[i])) ret[0].push(a[i]); 25 | else ret[1].push(a[i]); 26 | return ret; 27 | } 28 | 29 | setTimeout(() => { 30 | const all_chars = parent.X.characters 31 | .filter((x) => x.type != "merchant") 32 | .sort((a, b) => (a.name > b.name ? 1 : -1)); 33 | const [onliners, offliners] = partition( 34 | all_chars, 35 | (x) => x.server || parent.caracAL.siblings.includes(x.name), 36 | ); 37 | if (onliners.length < 3) { 38 | //deploy a char after delay 39 | const to_deploy = offliners[0].name; 40 | console.log(`${character.name} is starting ${to_deploy}`); 41 | deploy(to_deploy, "EUI"); 42 | } 43 | }, 23e3); 44 | -------------------------------------------------------------------------------- /src/ts_defs/caracAL.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Credit to @thmsn 3 | You rock! 4 | */ 5 | 6 | export {}; 7 | declare global { 8 | /** When you access parent via game code, this is what you have access to. */ 9 | interface Window { 10 | /** 11 | * Contains an object if we are running the character in caracAL 12 | * https://github.com/numbereself/caracAL 13 | */ 14 | caracAL?: { 15 | /** 16 | * 17 | * @param characterName 18 | * @param serverRealm 19 | * @param scriptPath 20 | * @example runs other char with script farm_snakes.js in current realm 21 | * parent.caracAL.deploy(another_char_name,null,"farm_snakes.js"); 22 | * @example runs current char with current script in US 2 23 | * parent.caracAL.deploy(null,"USII"); 24 | */ 25 | deploy( 26 | characterName: string | null | undefined, 27 | serverRealm: string | null | undefined, 28 | scriptPath: string | null | undefined, 29 | ): void; 30 | 31 | /** 32 | * Shuts down the current character 33 | */ 34 | shutdown(): void; 35 | 36 | /** 37 | * All the characters running in our current caracAL instance 38 | */ 39 | siblings: string[]; 40 | 41 | /** 42 | * load one or more additional scripts 43 | * @param scripts 44 | * @example get a promise for loading the script ./CODE/bonus_script.js 45 | * parent.caracAL.load_scripts(["bonus_script.js"]) 46 | * .then(()=>console.log("the new script is loaded")); 47 | */ 48 | load_scripts(scripts: string[]): Promise; 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/StreamMultiplexer.js: -------------------------------------------------------------------------------- 1 | //so much pain just because windows doesnt have bash ;-; 2 | const { fork, spawn } = require("node:child_process"); 3 | const { pipeline } = require("node:stream"); 4 | const cloneable = require("cloneable-readable"); 5 | 6 | function spawn_sink(command, ...args) { 7 | if (command === "node") { 8 | return fork(args[0], args.slice(1), { 9 | stdio: ["pipe", "inherit", "inherit", "ipc"], 10 | }); 11 | } else { 12 | return spawn(command, args, { 13 | stdio: ["pipe", "inherit", "inherit", "ipc"], 14 | }); 15 | } 16 | } 17 | 18 | function redirect_into_process(stdout, stderr, target_proc) { 19 | function err_handler(err) { 20 | if (err) throw err; 21 | } 22 | if (target_proc) { 23 | pipeline(stdout, target_proc.stdin, err_handler); 24 | pipeline(stderr, target_proc.stdin, err_handler); 25 | } else { 26 | pipeline(stdout, process.stdout, err_handler); 27 | pipeline(stderr, process.stderr, err_handler); 28 | } 29 | } 30 | 31 | function setup_log_pipes(log_sinks, module_path, ...args) { 32 | const log_sink_processes = log_sinks.map((command) => 33 | command ? spawn_sink(...command) : null, 34 | ); 35 | 36 | const runner = fork(module_path, args, { 37 | stdio: ["inherit", "pipe", "pipe", "ipc"], 38 | }); 39 | const stdout_clone = cloneable(runner.stdout); 40 | const stderr_clone = cloneable(runner.stderr); 41 | 42 | if (log_sink_processes.length < 1) { 43 | log_sink_processes = [null]; 44 | } 45 | 46 | for (let i = 1; i < log_sink_processes.length; i++) { 47 | redirect_into_process( 48 | stdout_clone.clone(), 49 | stderr_clone.clone(), 50 | log_sink_processes[i], 51 | ); 52 | } 53 | redirect_into_process(stdout_clone, stderr_clone, log_sink_processes[0]); 54 | return runner; 55 | } 56 | 57 | module.exports = { setup_log_pipes, spawn_sink }; 58 | -------------------------------------------------------------------------------- /standalones/test_logging.js: -------------------------------------------------------------------------------- 1 | const { log, ctype_to_clid, fakePinoConsole } = require("../src/LogUtils"); 2 | 3 | //basic test 4 | console.log("I am not jsonl, so I dont get formatted"); 5 | log.info({ type: "hello world" }, "I am jsonl so I get formatted"); 6 | log.debug("I usually dont get logged because my level is too low"); 7 | log.error({ type: "self important message" }, "my level is high on life"); 8 | //error formatting 9 | log.warn(new Error("kaboom")); 10 | 11 | //clid color formatting test 12 | log.info( 13 | { cname: "CodeGorm", clid: ctype_to_clid["warrior"] }, 14 | "Can I have a cookie", 15 | ); 16 | 17 | log.info({ cname: "CodeAnna", clid: ctype_to_clid["merchant"] }, "No."); 18 | 19 | log.info({ cname: "CodeGorm", clid: ctype_to_clid["warrior"] }, "Please?"); 20 | 21 | log.info({ cname: "CodeAnna", clid: ctype_to_clid["merchant"] }, "Okay."); 22 | 23 | log.info( 24 | { cname: "CodeTrin", clid: ctype_to_clid["rogue"] }, 25 | "Can I also have a cookie?", 26 | ); 27 | 28 | log.info({ cname: "CodeFake" }, "You guys are getting cookies?"); 29 | 30 | log.info({ cname: "CodeDad", clid: 1924 }, "Hello getting cookies im Dad."); 31 | 32 | log.info({ val: 42 }); 33 | 34 | log.info({ val: 43 }, "But what is the question?"); 35 | 36 | log.info({ type: "50 shades of", val: "green", col: "green" }); 37 | 38 | log.info({ type: "animals", col: "red" }, "foxes are"); 39 | 40 | log.info({ type: "big if true", col: "bold" }, "im dummy thicc"); 41 | 42 | log.info({ type: "node executable location", val: process.execPath }); 43 | 44 | log.info( 45 | { type: "what happens if msg is an object?", col: "green" }, 46 | { hallo: "welt" }, 47 | ); 48 | 49 | const conn = fakePinoConsole(log); 50 | 51 | conn.log(process.execPath); 52 | conn.info("the floor is lava"); 53 | conn.warn("the floor is %s", "gooey"); 54 | conn.warn("the floor is %s", "sort of %s", "idunno"); 55 | conn.warn(); 56 | conn.table({ hallo: "welt" }); 57 | 58 | log.warn({ type: "finish" }, "thats all folks"); 59 | -------------------------------------------------------------------------------- /standalones/LogPrinter.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const pump = require("pump"); 4 | const pino_pretty = require("pino-pretty"); 5 | const fs = require("fs"); 6 | const colorette = require("colorette"); 7 | 8 | const clid_to_colors = { 9 | 1: colorette.gray, 10 | 2: colorette.red, 11 | 3: colorette.cyan, 12 | 4: colorette.magenta, 13 | 5: colorette.yellow, 14 | 6: colorette.green, 15 | 7: colorette.blue, 16 | }; 17 | 18 | const supported_msg_colors = { 19 | gray: colorette.gray, 20 | red: colorette.red, 21 | cyan: colorette.cyan, 22 | magenta: colorette.magenta, 23 | yellow: colorette.yellow, 24 | green: colorette.green, 25 | blue: colorette.blue, 26 | }; //no black or white, these are assumed to be console-theme-preference 27 | 28 | const messageFormat = (log_object, messageKey) => { 29 | const { clid, cname, id, type, func, val, col } = log_object; 30 | const msg = log_object[messageKey]; 31 | const msg_colors = supported_msg_colors[col]; 32 | const char_colors = clid_to_colors[clid]; 33 | const char_raw = ((cname || "caracAL") + " ").slice(0, 12); 34 | const char_string = !cname 35 | ? colorette.bold(char_raw) 36 | : char_colors 37 | ? char_colors(char_raw) 38 | : char_raw; 39 | const message_string = 40 | "msg" in log_object 41 | ? "> " + (msg_colors ? msg_colors(msg) : msg) 42 | : "val" in log_object 43 | ? " = " + (msg_colors ? msg_colors(val) : val) 44 | : ""; 45 | return `${id} ${char_string} ${"type" in log_object ? type : "unspecified"}${func ? `.${func}()` : ""}${message_string}`; 46 | }; 47 | 48 | const opts = { 49 | singleLine: true, 50 | errorLikeObjectKeys: "err,error", 51 | errorProps: "", 52 | ignore: "id,type,func,cname,clid", 53 | messageFormat, 54 | }; 55 | 56 | const res = pino_pretty(opts); 57 | pump(process.stdin, res); 58 | 59 | if (!process.stdin.isTTY && !fs.fstatSync(process.stdin.fd).isFile()) { 60 | process.once("SIGINT", function noOp() {}); 61 | } 62 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { COORDINATOR_MODULE_PATH } = require("./src/CONSTANTS"); 2 | const ConfigUtil = require("./src/ConfigUtil"); 3 | const StreamMultiplexer = require("./src/StreamMultiplexer"); 4 | const fs = require("fs"); 5 | 6 | async function get_webpack_started(cfg) { 7 | fs.rmSync("./TYPECODE.out", { recursive: true }); 8 | if (cfg.enable_TYPECODE) { 9 | //TODO make this configurable 10 | const webpack_log_output = [ 11 | [ 12 | "node", 13 | "./node_modules/logrotate-stream/bin/logrotate-stream", 14 | "./logs/webpack.log", 15 | "--keep", 16 | "3", 17 | "--size", 18 | "1500000", 19 | ], 20 | null, 21 | ]; 22 | //Run webpack once so we are guaranteed to have the compiled files 23 | let wp_seed; 24 | console.log("Starting Initial WebPack"); 25 | //initially we do not use log_pipes because when the process terminates it will close our stdin/out 26 | wp_seed = StreamMultiplexer.spawn_sink( 27 | "node", 28 | "./node_modules/webpack/bin/webpack", 29 | "--no-color", 30 | ); 31 | 32 | await new Promise((resolve) => { 33 | wp_seed.on("close", resolve); 34 | }); 35 | console.log("Initial WebPack is done"); 36 | wp_seed = StreamMultiplexer.setup_log_pipes( 37 | webpack_log_output, 38 | "./node_modules/webpack/bin/webpack", 39 | "--no-color", 40 | "--watch", 41 | ); 42 | } 43 | } 44 | 45 | (async () => { 46 | await ConfigUtil.interactive(); 47 | 48 | const cfg = require("./config"); 49 | await get_webpack_started(cfg); 50 | 51 | const log_sinks = cfg.log_sinks || [ 52 | [ 53 | "node", 54 | "./node_modules/logrotate-stream/bin/logrotate-stream", 55 | "./logs/caracAL.log.jsonl", 56 | "--keep", 57 | "3", 58 | "--size", 59 | "4500000", 60 | ], 61 | ["node", "./standalones/LogPrinter.js"], 62 | ]; 63 | StreamMultiplexer.setup_log_pipes(log_sinks, COORDINATOR_MODULE_PATH); 64 | })(); 65 | -------------------------------------------------------------------------------- /account_info.js: -------------------------------------------------------------------------------- 1 | const fetch = (...args) => 2 | import("node-fetch").then(({ default: fetch }) => fetch(...args)); 3 | const { console } = require("./src/LogUtils"); 4 | class Info { 5 | constructor() {} 6 | 7 | async updateInfo() { 8 | console.log("updating account info"); 9 | const raw = await fetch( 10 | "https://adventure.land/api/servers_and_characters", 11 | { 12 | method: "POST", 13 | headers: { 14 | Cookie: "auth=" + this.session, 15 | "Content-Type": "application/x-www-form-urlencoded", 16 | }, 17 | body: "method=servers_and_characters", 18 | }, 19 | ); 20 | if (!raw.ok) { 21 | throw new Error(`failed to update account info: ${raw.statusText}`); 22 | } 23 | const res = (await raw.json())[0]; 24 | console.log( 25 | `found ${res.servers.length} servers and ${res.characters.length} characters`, 26 | ); 27 | this.listeners.forEach((func) => func(res)); 28 | return (this.response = res); 29 | } 30 | 31 | add_listener(func) { 32 | this.listeners.push(func); 33 | } 34 | remove_listener(func) { 35 | const index = this.listeners.indexOf(func); 36 | if (index > -1) { 37 | this.listeners.splice(index, 1); 38 | } 39 | } 40 | 41 | static async build(session) { 42 | const result = new Info(); 43 | result.session = session; 44 | result.listeners = []; 45 | result.auto_update = true; 46 | await result.updateInfo(); 47 | result.task = setInterval(async () => { 48 | if (result.auto_update) { 49 | await result 50 | .updateInfo() 51 | .catch((e) => console.warn("failed to update account info: %s", e)); 52 | } 53 | }, 6e3); 54 | return result; 55 | } 56 | 57 | destroy() { 58 | if (this.task) { 59 | clearInterval(this.task); 60 | } 61 | } 62 | 63 | resolve_char(char_name) { 64 | return this.response.characters.find((char) => char.name == char_name); 65 | } 66 | 67 | resolve_realm(realm_key) { 68 | return this.response.servers.find((serv) => serv.key == realm_key); 69 | } 70 | } 71 | module.exports = Info.build; 72 | -------------------------------------------------------------------------------- /config.EXAMPLE.js: -------------------------------------------------------------------------------- 1 | //DO NOT SHARE THIS WITH ANYONE 2 | //the session key can be used to take over your account 3 | //and I would know.(<3 u Nex) 4 | module.exports = { 5 | //to obtain a session: show_json(parent.user_id+"-"+parent.user_auth) 6 | //or just delete the config file and restart caracAL 7 | session: "1111111111111111-abc123ABCabc123ABCabc", 8 | //delete all versions except the two latest ones 9 | cull_versions: true, 10 | //If you want caracAL to compile TypeScript and use the TYPECODE folder 11 | //This works by running Webpack in the background 12 | enable_TYPECODE: true, 13 | //how much logging you want 14 | //set to "debug" for more logging and "warn" for less logging 15 | log_level: "info", 16 | //where to log to 17 | //the lines are commands which use stdin stream and write it somwehere 18 | //default is a logrotate file and colorful stdout formatting 19 | //advanced linuxers: keep in mind that file redirects (>) and pipes (|) are a shell feature 20 | //so if you wanna use them you have to prefix your command with "bash", "-c" 21 | log_sinks: [ 22 | [ 23 | "node", 24 | "./node_modules/logrotate-stream/bin/logrotate-stream", 25 | "./logs/caracAL.log.jsonl", 26 | "--keep", 27 | "3", 28 | "--size", 29 | "4500000", 30 | ], 31 | ["node", "./standalones/LogPrinter.js"], 32 | ], 33 | web_app: { 34 | //enables the monitoring dashboard 35 | enable_bwi: false, 36 | //enables the minimap in dashboard 37 | //setting this to true implicitly 38 | //enables the dashboard 39 | enable_minimap: false, 40 | //exposes the CODE directory via http 41 | //this lets you load and debug outside of caracAL 42 | expose_CODE: false, 43 | //exposes the TYPECODE.out directory via http 44 | //this lets you load and debug outside of caracAL 45 | //useful if you want to dev in regular client 46 | expose_TYPECODE: false, 47 | //which port to run webservices on 48 | port: 924, 49 | }, 50 | characters: { 51 | Wizard: { 52 | realm: "EUPVP", 53 | script: "caracAL/examples/crabs.js", 54 | enabled: true, 55 | version: 0, 56 | }, 57 | MERC: { 58 | realm: "USIII", 59 | script: "caracAL/tests/deploy_test.js", 60 | enabled: true, 61 | version: "halflife3", 62 | }, 63 | GG: { 64 | realm: "ASIAI", 65 | typescript: "caracAL/examples/crabs_with_tophats.js", 66 | enabled: false, 67 | version: 0, 68 | }, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /TYPECODE/caracAL/examples/crabs_with_tophats.ts: -------------------------------------------------------------------------------- 1 | const mon_type = "crab"; 2 | 3 | function smart_potion_logic() { 4 | if (character.rip) return; 5 | 6 | if (is_on_cooldown("use_hp")) return; 7 | 8 | function can_consume_fully(pot: String) { 9 | if ("regen_hp" === pot) return character.max_hp - character.hp >= 50; 10 | if ("regen_mp" === pot) return character.max_mp - character.mp >= 100; 11 | if (pot.startsWith("hp")) { 12 | return character.max_hp - character.hp >= G.items[pot].gives[0][1]; 13 | } else { 14 | return character.max_mp - character.mp >= G.items[pot].gives[0][1]; 15 | } 16 | } 17 | function choose_potion(priorities: Array, fallback: String = "") { 18 | let using_slot; 19 | for (let pot of priorities) { 20 | if (can_consume_fully(pot) && (using_slot = locate_item(pot)) >= 0) { 21 | equip(using_slot); 22 | return; 23 | } 24 | } 25 | if (fallback && can_consume_fully(fallback)) use_skill(fallback); 26 | } 27 | const hp_critical = character.hp / character.max_hp <= 0.5; 28 | const mp_critical = character.mp / character.max_mp <= 0.2; 29 | const priest_present = parent.party_list.some( 30 | (name) => "priest" === get_player(name)?.ctype, 31 | ); 32 | if (mp_critical) { 33 | //force restore mp 34 | choose_potion(["mpot1", "mpot0"], "regen_mp"); 35 | } else if (hp_critical) { 36 | //force restore hp 37 | choose_potion(["hpot1", "hpot0"], "regen_hp"); 38 | } else if (priest_present) { 39 | //heavily prefer mp 40 | choose_potion(["mpot1", "mpot0", "hpot1", "hpot0"]); 41 | } else { 42 | //prefer hp 43 | choose_potion(["hpot1", "mpot1", "hpot0", "mpot0"]); 44 | } 45 | } 46 | 47 | setInterval(function () { 48 | if (character.rip) { 49 | respawn(); 50 | } 51 | }, 2e3); 52 | 53 | setInterval(function () { 54 | if (character.rip) { 55 | return; 56 | } 57 | smart_potion_logic(); 58 | loot(); 59 | 60 | const target = get_nearest_monster({ type: mon_type }); 61 | 62 | if (target) { 63 | change_target(target); 64 | if (can_attack(target)) { 65 | attack(target); 66 | } else { 67 | const dist = simple_distance(target, character); 68 | if (!is_moving(character) && dist > character.range - 10) { 69 | if (can_move_to(target.real_x, target.real_y)) { 70 | move( 71 | (target.real_x + character.real_x) / 2, 72 | (target.real_y + character.real_y) / 2, 73 | ); 74 | } else { 75 | smart_move(target); 76 | } 77 | } 78 | } 79 | } else if (!is_moving(character)) { 80 | smart_move(mon_type); 81 | } 82 | }, 100); 83 | -------------------------------------------------------------------------------- /ipcStorage.js: -------------------------------------------------------------------------------- 1 | function make_IPC_storage(ident) { 2 | const items = new Map(); 3 | const mock_parent = { 4 | setItem(_key, _val) { 5 | const key = String(_key); 6 | const val = String(_val); 7 | process.send({ 8 | type: "stor", 9 | op: "set", 10 | ident, 11 | data: { [key]: val }, 12 | }); 13 | return items.set(key, val); 14 | }, 15 | getItem(_key) { 16 | const key = String(_key); 17 | return items.get(key); 18 | }, 19 | removeItem(_key) { 20 | const key = String(_key); 21 | process.send({ 22 | type: "stor", 23 | op: "del", 24 | ident, 25 | data: [key], 26 | }); 27 | items.delete(key); 28 | }, 29 | clear() { 30 | process.send({ 31 | type: "stor", 32 | op: "clear", 33 | ident, 34 | }); 35 | items.clear(); 36 | }, 37 | key(n) { 38 | return Array.from(items.keys())[n]; 39 | }, 40 | get length() { 41 | return items.size; 42 | }, 43 | }; 44 | process.on("message", (m) => { 45 | if (m.type == "stor" && m.ident == ident) { 46 | if (m.op == "set") { 47 | for (let key in m.data) { 48 | items.set(key, m.data[key]); 49 | } 50 | } 51 | if (m.op == "del") { 52 | for (let key of m.data) { 53 | items.delete(key); 54 | } 55 | } 56 | if (m.op == "clear") { 57 | items.clear(); 58 | } 59 | } 60 | }); 61 | 62 | process.send({ 63 | type: "stor", 64 | op: "init", 65 | ident, 66 | }); 67 | 68 | return new Proxy(Object.create(mock_parent), { 69 | get: function (oTarget, sKey) { 70 | return mock_parent[sKey] || mock_parent.getItem(sKey) || undefined; 71 | }, 72 | set: function (oTarget, sKey, vValue) { 73 | //length cannot be set in this manner 74 | if (sKey == "length") return vValue; 75 | return mock_parent.setItem(sKey, vValue); 76 | }, 77 | deleteProperty: function (oTarget, sKey) { 78 | //base keys can only be deleted with removeItem 79 | if (sKey in mock_parent) { 80 | return true; 81 | } 82 | return mock_parent.removeItem(sKey); 83 | }, 84 | //return contents but not base keys 85 | ownKeys: function (oTarget, sKey) { 86 | return Array.from(items.keys()).filter((key) => !(key in mock_parent)); 87 | }, 88 | has: function (oTarget, sKey) { 89 | return sKey in mock_parent || items.has(sKey); 90 | }, 91 | getOwnPropertyDescriptor: function (oTarget, sKey) { 92 | if (sKey in mock_parent) { 93 | return undefined; 94 | } 95 | if (!items.has(sKey)) { 96 | return undefined; 97 | } 98 | return { 99 | value: mock_parent.getItem(sKey), 100 | writable: true, 101 | enumerable: true, 102 | configurable: true, 103 | }; 104 | }, 105 | }); 106 | } 107 | 108 | exports.make_IPC_storage = make_IPC_storage; 109 | -------------------------------------------------------------------------------- /html_vars.js: -------------------------------------------------------------------------------- 1 | var inside = "login"; 2 | var user_id = "", 3 | user_auth = ""; 4 | var base_url = "https://adventure.land"; 5 | var server_addr = "", 6 | server_port = ""; 7 | var server_names = { US: "Americas", EU: "Europas", ASIA: "Eastlands" }; 8 | var sound_music = "", 9 | sound_sfx = "", 10 | xmas_tunes = false, 11 | music_level = 0.3; 12 | var perfect_pixels = "1"; 13 | var screenshot_mode = ""; 14 | var pro_mode = "1"; 15 | var tutorial_ui = "1"; 16 | var new_attacks = "1"; 17 | var recording_mode = ""; 18 | var cached_map = "1", 19 | scale = "2"; 20 | var d_lines = "1"; 21 | var sd_lines = "1"; 22 | var is_sdk = ""; 23 | var is_electron = "", 24 | electron_data = {}; 25 | var is_comm = false; 26 | var no_eval = false; 27 | var VERSION = ""; 28 | var platform = "web"; 29 | var engine_mode = ""; 30 | var no_graphics = "1"; 31 | var border_mode = ""; // use after adding a new monster 32 | var no_html = "bot"; 33 | var is_bot = "1"; 34 | var is_cli = "", 35 | harakiri = ""; 36 | var explicit_slot = ""; 37 | var is_mobile = false; 38 | var is_bold = false; 39 | var c_enabled = "1", 40 | stripe_enabled = ""; 41 | var auto_reload = "auto", 42 | reload_times = "0", 43 | character_to_load = "", 44 | mstand_to_load = null; 45 | // It's pretty complicated but there are 2 persistence, auto login routines, the above one is the first, the below one is the second, second one uses the URL data 46 | var url_ip = "", 47 | url_port = "", 48 | url_character = ""; 49 | var update_notes = []; 50 | var server_regions = { US: "Americas", EU: "Europas", ASIA: "Eastlands" }; 51 | var X = {}; 52 | function payment_logic() {} 53 | 54 | if (!is_sdk) { 55 | for (var f in log_flags) log_flags[f] = 0; 56 | } 57 | 58 | X.servers = [ 59 | { 60 | name: "I", 61 | region: "EU", 62 | players: 10, 63 | key: "EUI", 64 | port: 2053, 65 | addr: "eu1.adventure.land", 66 | }, 67 | { 68 | name: "II", 69 | region: "EU", 70 | players: 23, 71 | key: "EUII", 72 | port: 2083, 73 | addr: "eu2.adventure.land", 74 | }, 75 | { 76 | name: "PVP", 77 | region: "EU", 78 | players: 3, 79 | key: "EUPVP", 80 | port: 2087, 81 | addr: "eupvp.adventure.land", 82 | }, 83 | { 84 | name: "I", 85 | region: "US", 86 | players: 18, 87 | key: "USI", 88 | port: 2053, 89 | addr: "us1.adventure.land", 90 | }, 91 | { 92 | name: "II", 93 | region: "US", 94 | players: 10, 95 | key: "USII", 96 | port: 2083, 97 | addr: "us2.adventure.land", 98 | }, 99 | { 100 | name: "III", 101 | region: "US", 102 | players: 51, 103 | key: "USIII", 104 | port: 2053, 105 | addr: "us3.adventure.land", 106 | }, 107 | { 108 | name: "PVP", 109 | region: "US", 110 | players: 14, 111 | key: "USPVP", 112 | port: 2087, 113 | addr: "uspvp.adventure.land", 114 | }, 115 | { 116 | name: "I", 117 | region: "ASIA", 118 | players: 12, 119 | key: "ASIAI", 120 | port: 2053, 121 | addr: "asia1.adventure.land", 122 | }, 123 | ]; 124 | X.characters = []; 125 | X.tutorial = { step: 0, completed: [] }; 126 | X.unread = 0; 127 | X.codes = {}; 128 | 129 | code_logic(); 130 | -------------------------------------------------------------------------------- /src/LogUtils.js: -------------------------------------------------------------------------------- 1 | const fs = require("node:fs"); 2 | const os = require("os"); 3 | const crypto = require("crypto"); 4 | const pino = require("pino"); 5 | const { Writable } = require("stream"); 6 | const console = require("console"); 7 | const { resolve } = require("path"); 8 | const util = require("node:util"); 9 | 10 | //3 byte random ident base64 to 4 characters 11 | //should suffice if you assume that instances arent changed often 12 | const process_hash = crypto.randomBytes(3).toString("base64"); 13 | 14 | function get_git_revision() { 15 | try { 16 | const rev = fs.readFileSync(".git/HEAD").toString().trim(); 17 | if (rev.indexOf(":") === -1) { 18 | return rev; 19 | } else { 20 | return fs 21 | .readFileSync(".git/" + rev.substring(5)) 22 | .toString() 23 | .trim(); 24 | } 25 | } catch (err) { 26 | //did you know that windows still cant display unicode in console? 27 | //but tbf, this shouldnt even happen. 28 | return "🤷‍♂️"; 29 | } 30 | } 31 | 32 | let config = null; 33 | try { 34 | config = require("../config"); 35 | } catch (err) {} 36 | let level = config ? config.log_level || "info" : "silent"; 37 | 38 | let log = pino({ 39 | base: { id: process_hash }, 40 | level, 41 | }); 42 | 43 | log.warn( 44 | { 45 | type: "log_init", 46 | pid: process.pid, 47 | node_executable: process.execPath, 48 | node_versions: process.versions, 49 | cwd: resolve("."), 50 | hostname: os.hostname(), 51 | os_release: os.release(), 52 | os_platform: os.platform(), 53 | git_revision: get_git_revision(), 54 | }, 55 | "initializing logging", 56 | ); 57 | 58 | class NullWritableStream extends Writable { 59 | constructor(options) { 60 | super(options); 61 | } 62 | 63 | _write(chunk, encoding, callback) { 64 | // Do nothing with the written data 65 | callback(); 66 | } 67 | } 68 | 69 | function fakePinoConsole(baseLogger) { 70 | //if no config exists we do not provide structured logging 71 | if (baseLogger == null && !config) { 72 | return console; 73 | } 74 | const result = new console.Console(new NullWritableStream()); 75 | 76 | const console_levels_to_pino = { 77 | debug: "debug", 78 | info: "info", 79 | log: "info", 80 | warn: "warn", 81 | error: "error", 82 | }; 83 | const fallback_level = "info"; 84 | 85 | for (let key of Object.keys(result)) { 86 | const mapped_pino_level = console_levels_to_pino[key]; 87 | result[key] = mapped_pino_level 88 | ? function (template, ...args) { 89 | (baseLogger || log)[mapped_pino_level]( 90 | { type: "console", func: key, args: args }, 91 | util.format(template, ...args), 92 | ); 93 | } 94 | : function (...args) { 95 | (baseLogger || log)[fallback_level]({ 96 | type: "console", 97 | func: key, 98 | args: args, 99 | }); 100 | }; 101 | } 102 | return result; 103 | } 104 | 105 | const ctype_to_clid = { 106 | merchant: 1, 107 | warrior: 2, 108 | paladin: 3, 109 | priest: 4, 110 | ranger: 5, 111 | rogue: 6, 112 | mage: 7, 113 | }; 114 | 115 | module.exports = { 116 | get log() { 117 | return log; 118 | }, 119 | set log(val) { 120 | return (log = val); 121 | }, 122 | get console() { 123 | return fakePinoConsole(); 124 | }, 125 | fakePinoConsole, 126 | ctype_to_clid, 127 | }; 128 | -------------------------------------------------------------------------------- /src/FileStoredKeyValues.js: -------------------------------------------------------------------------------- 1 | const fs = require("node:fs"); 2 | const { entries } = Object; 3 | //TODO skip unchanged keys on client layer directly 4 | class FileStoredKeyValues { 5 | #main_path; 6 | #replacer_path; 7 | #backend; 8 | #handle; 9 | #refactorTask; 10 | 11 | constructor( 12 | main_path = "garage.jsonl", 13 | replacer_path = "garage.new.jsonl", 14 | refactor_interval = 30e3, 15 | ) { 16 | if (main_path === replacer_path) { 17 | throw new Error( 18 | `Please choose different main path and replacer path: ${main_path}`, 19 | ); 20 | } 21 | this.#main_path = main_path; 22 | this.#replacer_path = replacer_path; 23 | this.#backend = new Map(); 24 | this.#initializeHandle(); 25 | this.#refactorTask = setInterval(() => this.refactor(), refactor_interval); 26 | this.refactor(); 27 | } 28 | 29 | #checkFileExistence(path) { 30 | let handle = null; 31 | try { 32 | handle = fs.openSync(path, "r"); 33 | } catch (err) { 34 | return false; 35 | } finally { 36 | if (handle !== null) { 37 | fs.closeSync(handle); 38 | } 39 | } 40 | return true; 41 | } 42 | 43 | #initializeHandle() { 44 | if ( 45 | this.#checkFileExistence(this.#replacer_path) && 46 | !this.#checkFileExistence(this.#main_path) 47 | ) { 48 | fs.renameSync(this.#replacer_path, this.#main_path); 49 | } 50 | this.#handle = fs.openSync(this.#main_path, "a+"); 51 | const handle_contents = fs.readFileSync(this.#handle, "utf8").split("\n"); 52 | for (let line of handle_contents) { 53 | if (line.length > 0) { 54 | const [key, value] = entries(JSON.parse(line))[0]; 55 | if (value === null) { 56 | this.#backend.delete(key); 57 | } else { 58 | this.#backend.set(key, value); 59 | } 60 | } 61 | } 62 | } 63 | 64 | refactor() { 65 | //this method writes the current contents without history 66 | //it writes to a temp file then replaces original 67 | //this guarantees that data will never be lost 68 | const k_v_list = []; 69 | for (let [key, value] of this.#backend.entries()) { 70 | k_v_list.push(JSON.stringify({ [key]: value }) + "\n"); 71 | } 72 | fs.writeFileSync(this.#replacer_path, k_v_list.join(""), { 73 | encoding: "utf8", 74 | }); 75 | fs.closeSync(this.#handle); 76 | fs.unlinkSync(this.#main_path); 77 | fs.renameSync(this.#replacer_path, this.#main_path); 78 | this.#handle = fs.openSync(this.#main_path, "a"); 79 | } 80 | 81 | close() { 82 | clearInterval(this.#refactorTask); 83 | fs.closeSync(this.#handle); 84 | } 85 | 86 | //i dont guarantee functionality if you set values that are not strings 87 | set(key, value) { 88 | if (this.get(key) !== value) { 89 | fs.writeFileSync(this.#handle, JSON.stringify({ [key]: value }) + "\n", { 90 | encoding: "utf8", 91 | }); 92 | this.#backend.set(key, value); 93 | } 94 | return this; 95 | } 96 | 97 | delete(key) { 98 | fs.writeFileSync(this.#handle, JSON.stringify({ [key]: null }) + "\n", { 99 | encoding: "utf8", 100 | }); 101 | return this.#backend.delete(key); 102 | } 103 | 104 | get(key) { 105 | return this.#backend.get(key); 106 | } 107 | 108 | entries() { 109 | return this.#backend.entries(); 110 | } 111 | } 112 | 113 | module.exports = FileStoredKeyValues; 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | Here you can review the historic development of caracAL. 4 | 5 | I do not bundle releases but with the timestamp you can usually find the relevant git commit. 6 | 7 | #### 2024-07-12 8 | 9 | Fixed missing typeScript bindings for caracAL added types 10 | 11 | #### 2024-06-07 12 | 13 | Improve performance when switching servers 14 | 15 | ### Typescript 16 | 17 | #### 2024-03-10 18 | 19 | ##### npm install required 20 | 21 | This update introduces the ability to run TypeScript directly in caracAL. 22 | 23 | TypeScript is much similar to JavaScript but allows code to be much safer in development and enables better autocompletion in IDEs. 24 | 25 | TypeScript is opt-in. If you would like to continue working with JavaScript you can. If you already have a working pipeline with TypeScript it will continue to work. 26 | 27 | caracAL also allows you to expose your TypeScript creations via webserver. 28 | You can use this capability to load fully bundled versions of your code into other places, such as the official client while developing. 29 | 30 | ### Better 🌲 **logging** 31 | 32 | #### 2023-09-09 33 | 34 | ##### npm install required 35 | 36 | ##### BREAKING CHANGES 37 | 38 | This update updates the internal logging to use JSON format powered by [pino](https://www.npmjs.com/package/pino "the logging framework pino"). 39 | 40 | This enables significantly better formatting of console output, including which character sent a log and colored logs. 41 | 42 | The logfiles were moved into a separate logs folder but are still subject to logrotate. 43 | 44 | For more details see the README about [🌲Logging](./README.md#🌲logging "logging section of README.md"). 45 | 46 | Also, I updated localStorage. 47 | 48 | It is no longer stored as a single json file but as newline-delimited json (ndjson/jsonl) with one key per line. 49 | The filename is now `localStorage/caraGarage.jsonl`. 50 | Existing localStorage should get migrated automatically. 51 | 52 | Large files should no longer lock up the application, but it will take more storage space in the process. 53 | 54 | Behind the scenes I have done a lot of refactoring to make the codebase more maintainable. I will continue to do so as I add new features. 55 | Notably I will migrate many console calls in the codebase to the new logging. 56 | 57 | #### 2023-02-01 58 | 59 | Fix a bug where parent.X may be empty when starting new characters. 60 | 61 | #### 2022-10-24 62 | 63 | Add support for graceful shutdown. This enables the client function on_destroy to be called before a client is regenerated (shutdown as well as server switches). 64 | The calculations have a 500 ms window, after which they will be force terminated. 65 | 66 | #### 2022-09-27 67 | 68 | Fixes for storage.json sometimes being completely empty. 69 | If caracAL worked for you and you werent getting `Unexpected end of JSON input` you dont need to update. 70 | 71 | #### 2022-09-08 72 | 73 | Minor bugfixes with a larger impact. 74 | smart_move should now be usable again. 75 | Also special characters are now respected in login process 76 | 77 | ### The localStorage update 78 | 79 | #### 2021-10-17 80 | 81 | ##### npm install required 82 | 83 | This update finally introduces localStorage and sessionStorage. However, in order to provide these technologies we need to bring in additional dependencies. Follow the steps below to upgrade to the most recent version. This version renames the default scripts. The default code file `example.js` now resides in `caracAL/examples/crabs.js`. If you were running the default crab script please edit or regenerate your config file in order to match the changed filename. 84 | -------------------------------------------------------------------------------- /game_files.js: -------------------------------------------------------------------------------- 1 | const base_url = "https://adventure.land"; 2 | const fs = require("fs").promises; 3 | const { createWriteStream } = require("fs"); 4 | const { pipeline } = require("stream"); 5 | const { promisify } = require("util"); 6 | const streamPipeline = promisify(pipeline); 7 | const fetch = (...args) => 8 | import("node-fetch").then(({ default: fetch }) => fetch(...args)); 9 | const path = require("path"); 10 | const { console } = require("./src/LogUtils"); 11 | 12 | function get_runner_files() { 13 | return [ 14 | "/js/common_functions.js", 15 | "/js/runner_functions.js", 16 | "/js/runner_compat.js", 17 | ]; 18 | } 19 | function get_game_files() { 20 | return [ 21 | "/js/pixi/fake/pixi.min.js", 22 | "/js/libraries/combined.js", 23 | "/js/codemirror/fake/codemirror.js", 24 | 25 | "/js/common_functions.js", 26 | "/js/functions.js", 27 | "/js/game.js", 28 | "/js/html.js", 29 | "/js/payments.js", 30 | "/js/keyboard.js", 31 | "/data.js", 32 | ]; 33 | } 34 | 35 | async function cull_versions(exclusions) { 36 | const all_versions = await available_versions(); 37 | const target_culls = all_versions.filter( 38 | (x, i) => i >= 2 && !exclusions.includes(x), 39 | ); 40 | for (let cull of target_culls) { 41 | try { 42 | console.log("culling version " + cull); 43 | await fs.rmdir("./game_files/" + cull, { recursive: true }); 44 | } catch (e) { 45 | console.warn("failed to cull version " + cull, e); 46 | } 47 | } 48 | } 49 | 50 | async function available_versions() { 51 | return (await fs.readdir("./game_files", { withFileTypes: true })) 52 | .filter((dirent) => dirent.isDirectory()) 53 | .map((dirent) => dirent.name) 54 | .filter((x) => x.match(/^\d+$/)) 55 | .map((x) => parseInt(x)) 56 | .sort() 57 | .reverse(); 58 | } 59 | 60 | async function download_file(url, file_p) { 61 | const response = await fetch(url); 62 | 63 | if (!response.ok) { 64 | throw new Error(`failed to download ${url}: ${response.statusText}`); 65 | } 66 | 67 | return await streamPipeline(response.body, createWriteStream(file_p)); 68 | } 69 | 70 | async function get_latest_version() { 71 | const raw = await fetch(base_url); 72 | if (!raw.ok) { 73 | throw new Error(`failed to check version: ${raw.statusText}`); 74 | } 75 | const html = await raw.text(); 76 | const match = /game\.js\?v=([0-9]+)"/.exec(html); 77 | if (!match) { 78 | throw new Error(`malformed version response`); 79 | } 80 | return parseInt(match[1]); 81 | } 82 | 83 | function locate_game_file(resource, version) { 84 | return `./game_files/${version}/${path.posix.basename(resource)}`; 85 | } 86 | 87 | async function ensure_latest() { 88 | const version = await get_latest_version(); 89 | if ((await available_versions()).includes(version)) { 90 | console.log(`version ${version} is already downloaded`); 91 | } else { 92 | console.log(`downloading version ${version}`); 93 | const fpath = "./game_files/" + version; 94 | try { 95 | await fs.mkdir(fpath); 96 | const target_files = get_game_files() 97 | .concat(get_runner_files()) 98 | //remove duplicates 99 | .filter(function (item, pos, self) { 100 | return self.indexOf(item) == pos; 101 | }); 102 | const tasks = target_files.map((itm) => 103 | download_file(base_url + itm, locate_game_file(itm, version)), 104 | ); 105 | await Promise.all(tasks); 106 | } catch (e) { 107 | await fs.rmdir(fpath, { recursive: true }); 108 | throw e; 109 | } 110 | } 111 | return version; 112 | } 113 | exports.cull_versions = cull_versions; 114 | exports.available_versions = available_versions; 115 | exports.ensure_latest = ensure_latest; 116 | exports.locate_game_file = locate_game_file; 117 | exports.get_runner_files = get_runner_files; 118 | exports.get_game_files = get_game_files; 119 | -------------------------------------------------------------------------------- /src/ConfigUtil.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var inquirer = require("inquirer"); 3 | const fetch = (...args) => 4 | import("node-fetch").then(({ default: fetch }) => fetch(...args)); 5 | const account_info = require("../account_info"); 6 | const fs = require("fs").promises; 7 | const { constants } = require("fs"); 8 | 9 | async function make_auth(email, password) { 10 | const raw = await fetch("https://adventure.land/api/signup_or_login", { 11 | method: "POST", 12 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 13 | body: 14 | 'arguments={"email":"' + 15 | encodeURIComponent(email) + 16 | '","password":"' + 17 | encodeURIComponent(password) + 18 | '","only_login":true}&method=signup_or_login', 19 | }); 20 | if (!raw.ok) { 21 | throw new Error(`failed to call login api: ${raw.statusText}`); 22 | } 23 | const msg = (await raw.json()).find((x) => x.message); 24 | if (!msg) { 25 | throw new Error(`unexpected login api response`); 26 | } 27 | 28 | function find_auth(req) { 29 | let match; 30 | req.headers 31 | .raw() 32 | [ 33 | "set-cookie" 34 | ].find((x) => (match = /auth=([0-9]+-[a-zA-Z0-9]+)/.exec(x))); 35 | return match[1]; 36 | } 37 | if (msg.message == "Logged In!") { 38 | return find_auth(raw); 39 | } 40 | 41 | return null; 42 | } 43 | 44 | async function prompt_chars(all_chars) { 45 | const enabled_chars = []; 46 | while (true) { 47 | const char_question = { 48 | type: "list", 49 | message: `Which characters do you want to run? 50 | Select 'Deploy' when you are done choosing`, 51 | name: "chara", 52 | choices: [ 53 | { name: `Deploy ${enabled_chars.length} characters`, value: -1 }, 54 | ].concat( 55 | all_chars.map((x, i) => ({ 56 | value: i, 57 | name: (enabled_chars.includes(i) && `Disable ${x}`) || `Enable ${x}`, 58 | })), 59 | ), 60 | default: 1, 61 | }; 62 | const { chara } = await inquirer.prompt([char_question]); 63 | if (chara == -1) { 64 | break; 65 | } 66 | if (enabled_chars.includes(chara)) { 67 | enabled_chars.splice(enabled_chars.indexOf(chara), 1); 68 | } else { 69 | enabled_chars.push(chara); 70 | } 71 | } 72 | return enabled_chars; 73 | } 74 | 75 | async function prompt_new_cfg() { 76 | let session = null; 77 | while (!session) { 78 | console.log("please enter your credentials"); 79 | const { email, password } = await inquirer.prompt([ 80 | { 81 | type: "input", 82 | name: "email", 83 | message: "Email", 84 | }, 85 | { 86 | type: "password", 87 | message: "Password", 88 | name: "password", 89 | mask: "*", 90 | }, 91 | ]); 92 | session = await make_auth(email, password); 93 | if (!session) { 94 | console.warn("email or password seems to be wrong."); 95 | } 96 | } 97 | const my_acc = await account_info(session); 98 | my_acc.auto_update = false; 99 | const all_realms = my_acc.response.servers.map((x) => x.key); 100 | const all_chars = my_acc.response.characters.map((x) => x.name); 101 | my_acc.destroy(); 102 | const yesno_choice = [ 103 | { value: true, name: "Yes" }, 104 | { value: false, name: "No" }, 105 | ]; 106 | const enabled_chars = await prompt_chars(all_chars); 107 | if (enabled_chars.length <= 0) { 108 | console.warn("you did not enable any chars"); 109 | console.log("but you can change that manually in the config file"); 110 | } 111 | const { realm, use_bwi, use_minimap, port, typescript_enabled } = 112 | await inquirer.prompt([ 113 | { 114 | type: "list", 115 | name: "realm", 116 | message: "Which realm do you want your chars to run in", 117 | choices: all_realms, 118 | }, 119 | { 120 | type: "list", 121 | name: "typescript_enabled", 122 | message: "Do you want to enable TypeScript Integration?", 123 | choices: yesno_choice, 124 | }, 125 | { 126 | type: "list", 127 | name: "use_bwi", 128 | message: "Do you want to use the web monitoring panel?", 129 | choices: yesno_choice, 130 | }, 131 | { 132 | type: "list", 133 | name: "use_minimap", 134 | message: `Do you also want to enable the minimap? 135 | If you want max performance you should choose no.`, 136 | choices: yesno_choice, 137 | when(answers) { 138 | return answers.use_bwi; 139 | }, 140 | }, 141 | { 142 | type: "number", 143 | name: "port", 144 | message: "What port would you like to run the web panel on?", 145 | when(answers) { 146 | return answers.use_bwi; 147 | }, 148 | default: 924, 149 | }, 150 | ]); 151 | //console.log({realm,use_bwi,use_minimap,port}); 152 | 153 | const conf_object = { 154 | session: session, 155 | cull_versions: true, 156 | enable_TYPECODE: typescript_enabled, 157 | log_level: "info", 158 | log_sinks: [ 159 | [ 160 | "node", 161 | "./node_modules/logrotate-stream/bin/logrotate-stream", 162 | "./logs/caracAL.log.jsonl", 163 | "--keep", 164 | "3", 165 | "--size", 166 | "1500000", 167 | ], 168 | ["node", "./standalones/LogPrinter.js"], 169 | ], 170 | web_app: { 171 | enable_bwi: use_bwi, 172 | enable_minimap: use_minimap || false, 173 | expose_CODE: false, 174 | port: port || 924, 175 | }, 176 | characters: all_chars.reduce((acc, c_name, i) => { 177 | acc[c_name] = { 178 | realm: realm, 179 | enabled: enabled_chars.includes(i), 180 | version: 0, 181 | }; 182 | if (typescript_enabled) { 183 | acc[c_name].typescript = "caracAL/examples/crabs_with_tophats.js"; 184 | } else { 185 | acc[c_name].script = "caracAL/examples/crabs.js"; 186 | } 187 | return acc; 188 | }, {}), 189 | }; 190 | 191 | await fs.writeFile("./config.js", make_cfg_string(conf_object)); 192 | } 193 | 194 | const fallback = (first, second) => 195 | JSON.stringify(first !== undefined ? first : second, null, 2); 196 | 197 | function make_cfg_string(conf_object = {}) { 198 | const ezpz = (key, substitute) => 199 | `${key.split(".").pop()}: ${fallback( 200 | key 201 | .split(".") 202 | .reduce( 203 | (prev, curr) => (prev == undefined ? undefined : prev[curr]), 204 | conf_object, 205 | ), 206 | substitute, 207 | )}`; 208 | const characters = conf_object.characters || { 209 | Wizard: { 210 | realm: "EUPVP", 211 | script: "caracAL/examples/crabs.js", 212 | enabled: true, 213 | version: 0, 214 | }, 215 | MERC: { 216 | realm: "USIII", 217 | script: "caracAL/tests/deploy_test.js", 218 | enabled: true, 219 | version: "halflife3", 220 | }, 221 | GG: { 222 | realm: "ASIAI", 223 | typescript: "caracAL/examples/crabs_with_tophats.js", 224 | enabled: false, 225 | version: 0, 226 | }, 227 | }; 228 | return `//DO NOT SHARE THIS WITH ANYONE 229 | //the session key can be used to take over your account 230 | //and I would know.(<3 u Nex) 231 | module.exports = { 232 | //to obtain a session: show_json(parent.user_id+"-"+parent.user_auth) 233 | //or just delete the config file and restart caracAL 234 | ${ezpz("session", "1111111111111111-abc123ABCabc123ABCabc")}, 235 | //delete all versions except the two latest ones 236 | ${ezpz("cull_versions", true)}, 237 | //If you want caracAL to compile TypeScript and use the TYPECODE folder 238 | //This works by running Webpack in the background 239 | ${ezpz("enable_TYPECODE", true)}, 240 | //how much logging you want 241 | //set to "debug" for more logging and "warn" for less logging 242 | ${ezpz("log_level", "info")}, 243 | //where to log to 244 | //the lines are commands which use stdin stream and write it somwehere 245 | //default is a logrotate file and colorful stdout formatting 246 | //advanced linuxers: keep in mind that file redirects (>) and pipes (|) are a shell feature 247 | //so if you wanna use them you have to prefix your command with "bash", "-c" 248 | ${ezpz("log_sinks", [ 249 | [ 250 | "node", 251 | "./node_modules/logrotate-stream/bin/logrotate-stream", 252 | "./logs/caracAL.log.jsonl", 253 | "--keep", 254 | "3", 255 | "--size", 256 | "4500000", 257 | ], 258 | ["node", "./standalones/LogPrinter.js"], 259 | ])}, 260 | web_app: { 261 | //enables the monitoring dashboard 262 | ${ezpz("web_app.enable_bwi", false)}, 263 | //enables the minimap in dashboard 264 | //setting this to true implicitly 265 | //enables the dashboard 266 | ${ezpz("web_app.enable_minimap", false)}, 267 | //exposes the CODE directory via http 268 | //this lets you load and debug outside of caracAL 269 | ${ezpz("web_app.expose_CODE", false)}, 270 | //exposes the TYPECODE.out directory via http 271 | //this lets you load and debug outside of caracAL 272 | //useful if you want to dev in regular client 273 | ${ezpz("web_app.expose_TYPECODE", false)}, 274 | //which port to run webservices on 275 | ${ezpz("web_app.port", 924)} 276 | }, 277 | characters: { 278 | ${Object.entries(characters) 279 | .map( 280 | ([cname, cconf]) => ` ${JSON.stringify(cname)}:{ 281 | realm: ${fallback(cconf.realm, "EUI")}, 282 | ${ 283 | cconf.typescript 284 | ? "typescript: " + JSON.stringify(cconf.typescript) 285 | : "script: " + fallback(cconf.script, "caracAL/examples/crabs.js") 286 | }, 287 | enabled: ${fallback(cconf.enabled, false)}, 288 | version: ${fallback(cconf.version, 0)} 289 | }`, 290 | ) 291 | .join(",\n")} 292 | } 293 | }; 294 | `; 295 | } 296 | 297 | async function interactive() { 298 | try { 299 | await fs.access("./config.js", constants.R_OK); 300 | console.log("config file exists. lets get started!"); 301 | } catch (e) { 302 | console.warn("config file does not exist. lets fix that!"); 303 | console.log("first we need to log you in"); 304 | await prompt_new_cfg(); 305 | console.log("config file created. lets get started!"); 306 | console.log("(you can change any choices you made in config.js)"); 307 | } 308 | } 309 | 310 | module.exports = { interactive, prompt_new_cfg, make_cfg_string }; 311 | -------------------------------------------------------------------------------- /src/CharacterThread.js: -------------------------------------------------------------------------------- 1 | const vm = require("vm"); 2 | const io = require("socket.io-client"); 3 | const fs = require("fs").promises; 4 | const { JSDOM } = require("jsdom"); 5 | const node_query = require("jquery"); 6 | const game_files = require("../game_files"); 7 | const fetch = (...args) => 8 | import("node-fetch").then(({ default: fetch }) => fetch(...args)); 9 | const monitoring_util = require("../monitoring_util"); 10 | const ipc_storage = require("../ipcStorage"); 11 | 12 | const LogUtils = require("./LogUtils"); 13 | const { console } = LogUtils; 14 | 15 | process.on("unhandledRejection", function (exception) { 16 | console.warn("promise rejected: \n", exception); 17 | }); 18 | 19 | const html_spoof = ` 20 | 21 | 22 | Adventure Land 23 | 24 | 25 | 26 | `; 27 | 28 | function make_context(upper = null) { 29 | const result = new JSDOM(html_spoof, { url: "https://adventure.land/" }) 30 | .window; 31 | //jsdom maked globalThis point to Node global 32 | //but we want it to be window instead 33 | result.globalThis = result; 34 | result.fetch = fetch; 35 | result.$ = result.jQuery = node_query(result); 36 | result.require = require; 37 | result.console = console; 38 | if (upper) { 39 | Object.defineProperty(result, "parent", { value: upper }); 40 | result._localStorage = upper._localStorage; 41 | result._sessionStorage = upper._sessionStorage; 42 | } else { 43 | result._localStorage = ipc_storage.make_IPC_storage("ls"); 44 | result._sessionStorage = ipc_storage.make_IPC_storage("ss"); 45 | } 46 | vm.createContext(result); 47 | 48 | result.eval = function (arg) { 49 | return vm.runInContext(arg, result); 50 | }; 51 | 52 | return result; 53 | } 54 | 55 | async function ev_files(locations, context) { 56 | for (let location of locations) { 57 | let text = await fs.readFile(location, "utf8"); 58 | vm.runInContext(text + "\n//# sourceURL=file://" + location, context); 59 | } 60 | } 61 | 62 | async function make_runner(upper, CODE_file, version, is_typescript) { 63 | const runner_sources = game_files 64 | .get_runner_files() 65 | .map((f) => game_files.locate_game_file(f, version)); 66 | console.log("constructing runner instance"); 67 | console.debug("source files:\n%s", runner_sources); 68 | const runner_context = make_context(upper); 69 | //contents of adventure.land/runner 70 | //its an html file but not labeled as such 71 | //TODO in the future i should consider parsing the relevant parts out of the html files directly 72 | //for the runners as well as the instances 73 | vm.runInContext( 74 | "var active=false,catch_errors=true,is_code=1,is_server=0,is_game=0,is_bot=parent.is_bot,is_cli=parent.is_cli,is_sdk=parent.is_sdk;", 75 | runner_context, 76 | ); 77 | await ev_files(runner_sources, runner_context); 78 | runner_context.send_cm = function (to, data) { 79 | process.send({ 80 | type: "cm", 81 | to, 82 | data, 83 | }); 84 | }; 85 | //we need to do this here because of scoping 86 | upper.caracAL.load_scripts = async function (locations) { 87 | if (!is_typescript) { 88 | return await ev_files( 89 | locations.map((x) => "./CODE/" + x), 90 | runner_context, 91 | ); 92 | } else { 93 | throw new Exception( 94 | "Runtime Loading Code is not supported in Typescript Mode.\nUse an import instead", 95 | ); 96 | } 97 | }; 98 | vm.runInContext( 99 | "active = true;parent.code_active = true;set_message('Code Active');if (character.rip) character.trigger('death', {past: true});", 100 | runner_context, 101 | ); 102 | 103 | process.on("message", (m) => { 104 | switch (m.type) { 105 | case "closing_client": 106 | console.log("terminating self"); 107 | vm.runInContext("on_destroy()", runner_context); 108 | process.exit(); 109 | //vscode says this is unreachable. 110 | //with how whack node is better be safe 111 | break; 112 | } 113 | }); 114 | 115 | //so. 116 | //these should send a shutdown to parent 117 | //parent deletes instance and marks them inactive 118 | //if its duplicate then no instance and no double shutdown 119 | ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => 120 | process.on(signal, async () => { 121 | console.log(`Received ${signal} on client. Requesting termination`); 122 | process.send({ 123 | type: "shutdown", 124 | }); 125 | }), 126 | ); 127 | 128 | //awaits the arrival of a message from parent process 129 | //indicating the servers_and_characters proxy that we use 130 | const connected_signoff = new Promise((resolve) => { 131 | process.on("message", (m) => { 132 | switch (m.type) { 133 | case "siblings_and_acc": 134 | resolve(); 135 | break; 136 | } 137 | }); 138 | }); 139 | 140 | process.send({ type: "connected" }); 141 | 142 | console.log("runner instance constructed"); 143 | monitoring_util.register_stat_beat(upper); 144 | //Fix a bug where parent.X is initially empty 145 | await connected_signoff; 146 | await ev_files([CODE_file], runner_context); 147 | //TODO put a process end handler here 148 | 149 | return runner_context; 150 | } 151 | 152 | async function make_game(proc_args) { 153 | const game_sources = game_files 154 | .get_game_files() 155 | .map((f) => game_files.locate_game_file(f, proc_args.version)) 156 | .concat(["./html_vars.js"]); 157 | console.log("constructing game instance"); 158 | console.debug("source files:\n%s", game_sources); 159 | const game_context = make_context(); 160 | game_context.io = io; 161 | game_context.bowser = {}; 162 | await ev_files(game_sources, game_context); 163 | game_context.VERSION = "" + game_context.G.version; 164 | game_context.server_addr = proc_args.realm_addr; 165 | game_context.server_port = proc_args.realm_port; 166 | game_context.user_id = proc_args.sess.split("-")[0]; 167 | game_context.user_auth = proc_args.sess.split("-")[1]; 168 | game_context.character_to_load = proc_args.cid; 169 | 170 | //expose the block under parent.caracAL 171 | const extensions = {}; 172 | 173 | extensions.log = LogUtils.log; 174 | 175 | extensions.deploy = function (char_name, realm, script_file, game_version) { 176 | process.send({ 177 | type: "deploy", 178 | ...(char_name && { character: char_name }), 179 | ...(realm && { realm }), 180 | ...(script_file && { script: script_file }), 181 | ...(game_version && { version: game_version }), 182 | }); 183 | }; 184 | extensions.shutdown = function (char_name) { 185 | process.send({ 186 | type: "shutdown", 187 | character: char_name, 188 | }); 189 | }; 190 | extensions.map_enabled = function () { 191 | return proc_args.enable_map; 192 | }; 193 | 194 | game_context.caracAL = extensions; 195 | 196 | const old_ng_logic = game_context.new_game_logic; 197 | game_context.new_game_logic = function () { 198 | old_ng_logic(); 199 | clearTimeout(reload_task); 200 | //people reported bad performance when switching maps 201 | //and this allegedly fixes it. 202 | vm.runInContext("pause()", game_context); 203 | 204 | const is_typescript = 205 | proc_args.typescript_file && proc_args.typescript_file.length > 0; 206 | const target_script = is_typescript 207 | ? "./TYPECODE.out/" + proc_args.typescript_file 208 | : "./CODE/" + proc_args.script_file; 209 | (async function () { 210 | const runner_context = await make_runner( 211 | game_context, 212 | target_script, 213 | proc_args.version, 214 | is_typescript, 215 | ); 216 | extensions.runner = runner_context; 217 | })(); 218 | }; 219 | const old_dc = game_context.disconnect; 220 | game_context.disconnect = function () { 221 | old_dc(); 222 | extensions.deploy(); 223 | }; 224 | const old_api = game_context.api_call; 225 | game_context.api_call = function (method, args, r_args) { 226 | //servers and characters are handled centrally 227 | if (method != "servers_and_characters") { 228 | return old_api(method, args, r_args); 229 | } else { 230 | console.debug("filtered s&c call"); 231 | } 232 | }; 233 | game_context.get_code_function = function (f_name) { 234 | return (extensions.runner && extensions.runner[f_name]) || function () {}; 235 | }; 236 | //call_code_function("trigger_character_event","cm",{name:data.name,message:JSON.parse(data.message)}); 237 | 238 | vm.runInContext( 239 | ` 240 | (function() { 241 | const old_add_log = add_log; 242 | add_log = function(msg, col) { 243 | old_add_log(msg,col); 244 | for(let [msg, col] of game_logs) { 245 | caracAL.log.info({col:col, type:"game_logs"}, msg); 246 | } 247 | game_logs = []; 248 | } 249 | })(); 250 | `, 251 | game_context, 252 | ); 253 | //show_json causes a popup so it must be important 254 | //therefore we use warn level here 255 | vm.runInContext( 256 | 'show_json = function(json) {caracAL.log.warn({data:json, type:"AL", func:"show_json"});}', 257 | game_context, 258 | ); 259 | process.send({ type: "initialized" }); 260 | process.on("message", (m) => { 261 | switch (m.type) { 262 | case "siblings_and_acc": 263 | extensions.siblings = m.siblings; 264 | game_context.handle_information([m.account]); 265 | break; 266 | case "receive_cm": 267 | game_context.call_code_function("trigger_character_event", "cm", { 268 | name: m.name, 269 | message: m.data, 270 | caracAL: true, 271 | }); 272 | break; 273 | case "send_cm": 274 | game_context.send_code_message(m.to, m.data); 275 | break; 276 | } 277 | }); 278 | vm.runInContext("the_game()", game_context); 279 | const reload_timeout = 14; 280 | const reload_task = setTimeout( 281 | function () { 282 | console.warn( 283 | `game not loaded after ${reload_timeout} seconds, reloading`, 284 | ); 285 | extensions.deploy(); 286 | }, 287 | reload_timeout * 1000 + 100, 288 | ); 289 | console.log("game instance constructed"); 290 | return game_context; 291 | } 292 | //have to use on, localstorage may send messages 293 | process.on("message", async (msg) => { 294 | if (msg.type == "process_args") { 295 | const { cname, clid } = msg.arguments; 296 | console.debug( 297 | "starting character thread with arguments: %O", 298 | msg.arguments, 299 | ); 300 | const new_log = LogUtils.log.child({ cname, clid }); 301 | LogUtils.log = new_log; 302 | await make_game(msg.arguments); 303 | } 304 | }); 305 | 306 | process.send({ 307 | type: "process_ready", 308 | }); 309 | -------------------------------------------------------------------------------- /monitoring_util.js: -------------------------------------------------------------------------------- 1 | const prettyMilliseconds = require("pretty-ms"); 2 | const { PNG } = require("pngjs"); 3 | const { STAT_BEAT_INTERVAL } = require("./src/CONSTANTS.js"); 4 | const { max, min, abs, round, floor } = Math; 5 | 6 | function humanize_int(num, digits) { 7 | num = round(num); 8 | const lookup = [ 9 | { value: 1e3, symbol: "" }, 10 | { value: 1e6, symbol: "k" }, 11 | { value: 1e9, symbol: "Mil" }, 12 | { value: 1e12, symbol: "Bil" }, 13 | { value: 1e15, symbol: "Tril" }, 14 | ]; 15 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; 16 | var item = lookup.find(function (item) { 17 | return abs(num) < item.value; 18 | }); 19 | return item 20 | ? ((num * 1e3) / item.value).toFixed(digits).replace(rx, "$1") + item.symbol 21 | : num.toExponential(digits); 22 | } 23 | 24 | function register_stat_beat(g_con) { 25 | g_con.caracAL.stat_beat = setInterval(() => { 26 | const character = g_con.character; 27 | const result = { type: "stat_beat" }; 28 | [ 29 | "rip", 30 | "hp", 31 | "max_hp", 32 | "mp", 33 | "max_mp", 34 | "level", 35 | "xp", 36 | "max_xp", 37 | "gold", 38 | "party", 39 | "isize", 40 | "esize", 41 | ].forEach((x) => (result[x] = character[x])); 42 | const targeting = g_con.entities[character.target]; 43 | result.t_mtype = (targeting && targeting.mtype) || null; 44 | result.t_name = (targeting && targeting.name) || null; 45 | result.current_status = g_con.current_status; 46 | if (g_con.caracAL.map_enabled()) { 47 | result.mmap = 48 | "data:image/png;base64," + generate_minimap(g_con).toString("base64"); 49 | } 50 | process.send(result); 51 | }, STAT_BEAT_INTERVAL); 52 | } 53 | 54 | //g_child is a child process with a stat beat 55 | function create_monitor_ui(bwi, char_name, child_block, enable_map) { 56 | let xp_histo = []; 57 | let xp_ph = 0; 58 | let gold_histo = []; 59 | let last_beat = null; 60 | child_block.instance.on("message", (m) => { 61 | if (m.type == "stat_beat") { 62 | gold_histo.push(m.gold); 63 | gold_histo = gold_histo.slice(-100); 64 | if (last_beat && last_beat.level != m.level) { 65 | xp_histo = []; 66 | } 67 | xp_histo.push(m.xp); 68 | xp_histo = xp_histo.slice(-100); 69 | xp_ph = val_ph(xp_histo); 70 | last_beat = m; 71 | } 72 | }); 73 | function quick_bar_val(num, denom, humanize = false) { 74 | let modif = (x) => x; 75 | if (humanize) { 76 | modif = (x) => humanize_int(x, 1); 77 | } 78 | return [(100 * num) / denom, `${modif(num)}/${modif(denom)}`]; 79 | } 80 | function val_ph(arr) { 81 | if (arr.length < 2) { 82 | return 0; 83 | } 84 | return ( 85 | ((arr[arr.length - 1] - arr[0]) * 3600000) / 86 | (arr.length - 1) / 87 | STAT_BEAT_INTERVAL 88 | ); 89 | } 90 | const schema = [ 91 | { name: "name", type: "text", label: "Name", getter: () => char_name }, 92 | { 93 | name: "realm", 94 | type: "text", 95 | label: "Realm", 96 | getter: () => child_block.realm, 97 | }, 98 | { 99 | name: "not_rip", 100 | type: "text", 101 | label: "Alive", 102 | getter: () => (last_beat.rip && "No") || "Yes", 103 | }, 104 | { 105 | name: "level", 106 | type: "text", 107 | label: "Level", 108 | getter: () => last_beat.level, 109 | }, 110 | { 111 | name: "health", 112 | type: "labelProgressBar", 113 | label: "Health", 114 | options: { color: "red" }, 115 | getter: () => quick_bar_val(last_beat.hp, last_beat.max_hp), 116 | }, 117 | { 118 | name: "mana", 119 | type: "labelProgressBar", 120 | label: "Mana", 121 | options: { color: "blue" }, 122 | getter: () => quick_bar_val(last_beat.mp, last_beat.max_mp), 123 | }, 124 | { 125 | name: "xp", 126 | type: "labelProgressBar", 127 | label: "XP", 128 | options: { color: "green" }, 129 | getter: () => quick_bar_val(last_beat.xp, last_beat.max_xp, true), 130 | }, 131 | { 132 | name: "inv", 133 | type: "labelProgressBar", 134 | label: "Inventory", 135 | options: { color: "brown" }, 136 | getter: () => 137 | quick_bar_val(last_beat.isize - last_beat.esize, last_beat.isize), 138 | }, 139 | { 140 | name: "gold", 141 | type: "text", 142 | label: "Gold", 143 | getter: () => humanize_int(last_beat.gold, 1), 144 | }, 145 | { 146 | name: "party_leader", 147 | type: "text", 148 | label: "Chief", 149 | getter: () => last_beat.party || "N/A", 150 | }, 151 | { 152 | name: "current_status", 153 | type: "text", 154 | label: "Status", 155 | getter: () => last_beat.current_status, 156 | }, 157 | { 158 | name: "target", 159 | type: "text", 160 | label: "Target", 161 | getter: () => 162 | (last_beat.t_name && 163 | (last_beat.mtype ? "Player " : "") + last_beat.t_name) || 164 | "None", 165 | }, 166 | { 167 | name: "gph", 168 | type: "text", 169 | label: "Gold/h", 170 | getter: () => humanize_int(val_ph(gold_histo), 1), 171 | }, 172 | { 173 | name: "xpph", 174 | type: "text", 175 | label: "XP/h", 176 | getter: () => humanize_int(xp_ph, 1), 177 | }, 178 | { 179 | name: "ttlu", 180 | type: "text", 181 | label: "TTLU", 182 | getter: () => 183 | (xp_ph <= 0 && "N/A") || 184 | prettyMilliseconds( 185 | ((last_beat.max_xp - last_beat.xp) * 3600000) / xp_ph, 186 | { unitCount: 2 }, 187 | ), 188 | }, 189 | ]; 190 | if (enable_map) { 191 | schema.push({ 192 | name: "minimap", 193 | type: "image", 194 | label: "Map", 195 | options: { width: mmap_w, height: mmap_h }, 196 | getter: () => last_beat.mmap, 197 | }); 198 | } 199 | const ui = bwi.publisher.createInterface( 200 | schema.map((x) => ({ 201 | name: x.name, 202 | type: x.type, 203 | label: x.label, 204 | options: x.options, 205 | })), 206 | ); 207 | ui.setDataSource(() => { 208 | if (!last_beat) { 209 | return { 210 | name: char_name, 211 | realm: child_block.realm, 212 | not_rip: "Hopefully", 213 | current_status: "Loading...", 214 | }; 215 | } 216 | const result = {}; 217 | schema.forEach((x) => (result[x.name] = x.getter())); 218 | return result; 219 | }); 220 | return ui; 221 | } 222 | 223 | const mmap_cols = { 224 | //transparent 225 | background: [0, 0, 0, 0], 226 | //brown 227 | monster: [0xb1, 0x4f, 0x1d, 255], 228 | //light red 229 | monster_engaged: [0xc1, 0x00, 0x37, 255], 230 | //dark blue 231 | character: [50, 177, 245, 255], 232 | //light blue 233 | player: [40, 74, 244, 255], 234 | //gray 235 | wall: [200, 200, 200, 255], 236 | }; 237 | const mmap_w = 200; 238 | const mmap_h = 150; 239 | const mmap_scale = 1 / 3; 240 | 241 | function generate_minimap(game_context) { 242 | var png = new PNG({ 243 | width: mmap_w, 244 | height: mmap_h, 245 | filterType: -1, 246 | }); 247 | const i_data = png.data; 248 | function fill_rect(x1, y1, x2, y2, col) { 249 | for (let i = x1; i < x2; i++) { 250 | for (let j = y1; j < y2; j++) { 251 | const idd = (mmap_w * j + i) << 2; 252 | i_data[idd] = col[0]; 253 | i_data[idd + 1] = col[1]; 254 | i_data[idd + 2] = col[2]; 255 | i_data[idd + 3] = col[3]; 256 | } 257 | } 258 | } 259 | function safe_fill_rect(x1, y1, x2, y2, col) { 260 | x1 = max(0, min(x1, mmap_w)); 261 | x2 = max(0, min(x2, mmap_w)); 262 | y1 = max(0, min(y1, mmap_h)); 263 | y2 = max(0, min(y2, mmap_h)); 264 | fill_rect(x1, y1, x2, y2, col); 265 | } 266 | const g_char = game_context.character; 267 | const c_x = g_char.real_x; 268 | const c_y = g_char.real_y; 269 | function relative_coords(x, y) { 270 | return [ 271 | (x - c_x) * mmap_scale + mmap_w / 2, 272 | (y - c_y) * mmap_scale + mmap_h / 2, 273 | ]; 274 | } 275 | 276 | //fill with bg data 277 | fill_rect(0, 0, mmap_w, mmap_h, mmap_cols.background); 278 | 279 | const geom = game_context.GEO; 280 | //draw horizontal collision 281 | for (let i = 0; i < geom.x_lines.length; i++) { 282 | //raw line data 283 | const [r_x, r_y1, r_y2] = geom.x_lines[i]; 284 | const l_x = floor((r_x - c_x) * mmap_scale + mmap_w / 2); 285 | if (l_x < 0) continue; 286 | if (l_x >= mmap_w) break; 287 | safe_fill_rect( 288 | l_x, 289 | floor((r_y1 - c_y) * mmap_scale + mmap_h / 2), 290 | l_x + 1, 291 | floor((r_y2 - c_y) * mmap_scale + mmap_h / 2) + 1, 292 | mmap_cols.wall, 293 | ); 294 | } 295 | //draw vertical collision 296 | for (let i = 0; i < geom.y_lines.length; i++) { 297 | //raw line data 298 | const [r_y, r_x1, r_x2] = geom.y_lines[i]; 299 | const l_y = floor((r_y - c_y) * mmap_scale + mmap_h / 2); 300 | if (l_y < 0) continue; 301 | if (l_y >= mmap_h) break; 302 | 303 | safe_fill_rect( 304 | floor((r_x1 - c_x) * mmap_scale + mmap_w / 2), 305 | l_y, 306 | floor((r_x2 - c_x) * mmap_scale + mmap_w / 2) + 1, 307 | l_y + 1, 308 | mmap_cols.wall, 309 | ); 310 | } 311 | 312 | function draw_blip(ent, col) { 313 | const rel = relative_coords(ent.real_x, ent.real_y); 314 | const r_x = floor(rel[0]); 315 | const r_y = floor(rel[1]); 316 | safe_fill_rect(r_x - 1, r_y, r_x + 2, r_y + 1, col); 317 | safe_fill_rect(r_x, r_y - 1, r_x + 1, r_y + 2, col); 318 | } 319 | function pixel_circle(ent, col) { 320 | const rel = relative_coords(ent.real_x, ent.real_y); 321 | const r_x = floor(rel[0]); 322 | const r_y = floor(rel[1]); 323 | safe_fill_rect(r_x - 1, r_y - 3, r_x + 2, r_y - 2, col); 324 | safe_fill_rect(r_x - 1, r_y + 3, r_x + 2, r_y + 4, col); 325 | safe_fill_rect(r_x - 3, r_y - 1, r_x - 2, r_y + 2, col); 326 | safe_fill_rect(r_x + 3, r_y - 1, r_x + 4, r_y + 2, col); 327 | 328 | safe_fill_rect(r_x - 2, r_y - 2, r_x - 1, r_y - 1, col); 329 | safe_fill_rect(r_x + 2, r_y + 2, r_x + 3, r_y + 3, col); 330 | safe_fill_rect(r_x + 2, r_y - 2, r_x + 3, r_y - 1, col); 331 | safe_fill_rect(r_x - 2, r_y + 2, r_x - 1, r_y + 3, col); 332 | } 333 | 334 | //draw entities 335 | for (let ent_id in game_context.entities) { 336 | const ent = game_context.entities[ent_id]; 337 | if (ent.npc || ent.dead) { 338 | continue; 339 | } 340 | let color; 341 | if (ent.mtype) { 342 | color = 343 | (ent.target == g_char.name && mmap_cols.monster_engaged) || 344 | mmap_cols.monster; 345 | } else { 346 | color = mmap_cols.player; 347 | } 348 | draw_blip(ent, color); 349 | } 350 | 351 | const trg = game_context.entities[g_char.target]; 352 | if (trg && !trg.npc && !trg.dead) { 353 | pixel_circle(trg, mmap_cols.monster_engaged); 354 | } 355 | draw_blip(g_char, mmap_cols.character); 356 | 357 | return PNG.sync.write(png); 358 | } 359 | 360 | exports.create_monitor_ui = create_monitor_ui; 361 | exports.register_stat_beat = register_stat_beat; 362 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./TYPECODE/", 4 | "./node_modules/typed-adventureland", 5 | "./src/ts_defs" 6 | ], 7 | "compilerOptions": { 8 | /* Visit https://aka.ms/tsconfig to read more about this file */ 9 | 10 | /* Projects */ 11 | "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, 12 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 13 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 14 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 15 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 16 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 17 | 18 | /* Language and Environment */ 19 | "target": "es2015" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 20 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 21 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 22 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 23 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 24 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 25 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 26 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 27 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 28 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 29 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 30 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 31 | 32 | /* Modules */ 33 | "module": "es2015" /* Specify what module code is generated. */, 34 | "rootDir": "./TYPECODE/" /* Specify the root folder within your source files. */, 35 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 36 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 37 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 38 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 39 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 40 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 41 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 42 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 43 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 44 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 45 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 46 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 47 | // "resolveJsonModule": true, /* Enable importing .json files. */ 48 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 49 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 50 | 51 | /* JavaScript Support */ 52 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 53 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 54 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 55 | 56 | /* Emit */ 57 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 58 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 59 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 60 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 61 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 62 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 63 | "outDir": "./TYPECODE.out/" /* Specify an output folder for all emitted files. */, 64 | "removeComments": true /* Disable emitting comments. */, 65 | // "noEmit": true, /* Disable emitting files from a compilation. */ 66 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 67 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 68 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 69 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 70 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 71 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 72 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 73 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 74 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 75 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 76 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 77 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 78 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 79 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 80 | 81 | /* Interop Constraints */ 82 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 83 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 84 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 85 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 86 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 87 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 88 | 89 | /* Type Checking */ 90 | "strict": true /* Enable all strict type-checking options. */, 91 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 92 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 93 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 94 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 95 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 96 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 97 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 98 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 99 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 100 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 101 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 102 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 103 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 104 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 105 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 106 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 107 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 108 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 109 | 110 | /* Completeness */ 111 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 112 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /standalones/CharacterCoordinator.js: -------------------------------------------------------------------------------- 1 | const child_process = require("node:child_process"); 2 | const account_info = require("../account_info"); 3 | const game_files = require("../game_files"); 4 | const bwi = require("bot-web-interface"); 5 | const monitoring_util = require("../monitoring_util"); 6 | const express = require("express"); 7 | const fs_regular = require("node:fs"); 8 | const { 9 | LOCALSTORAGE_PATH, 10 | LOCALSTORAGE_ROTA_PATH, 11 | STAT_BEAT_INTERVAL, 12 | } = require("../src/CONSTANTS"); 13 | const { log, console, ctype_to_clid } = require("../src/LogUtils"); 14 | 15 | const FileStoredKeyValues = require("../src/FileStoredKeyValues"); 16 | 17 | //TODO check for invalid session 18 | //TODO improve termination 19 | //MAYBE improve linux service 20 | //MAYBE exclude used versions 21 | 22 | function partition(a, fun) { 23 | const ret = [[], []]; 24 | for (let i = 0; i < a.length; i++) 25 | if (fun(a[i])) ret[0].push(a[i]); 26 | else ret[1].push(a[i]); 27 | return ret; 28 | } 29 | 30 | //note to self: how to promisify event emitter(once) 31 | //const someAsyncFunction = util.promisify(myEmitter.once).bind(myEmitter); 32 | 33 | function migrate_old_storage(path, localStorage) { 34 | let file_contents; 35 | try { 36 | file_contents = fs_regular.readFileSync(path, "utf8"); 37 | } catch (err) { 38 | log.info( 39 | { type: "ls_migration_none", path }, 40 | "localStorage migration unnecessary", 41 | ); 42 | return; 43 | } 44 | if (file_contents.length > 0) { 45 | const json_object = JSON.parse(file_contents); 46 | for (let [key, value] of Object.entries(json_object)) { 47 | localStorage.set(key, value); 48 | } 49 | log.info( 50 | { type: "ls_migration", path, value: Object.keys(json_object).length }, 51 | "localStorage migrated", 52 | ); 53 | } 54 | fs_regular.unlinkSync(path); 55 | log.info({ type: "ls_migration_done", path }, "old localStorage deleted"); 56 | return; 57 | } 58 | 59 | (async () => { 60 | const localStorage = new FileStoredKeyValues( 61 | LOCALSTORAGE_PATH, 62 | LOCALSTORAGE_ROTA_PATH, 63 | ); 64 | 65 | //migrate from old library which stored everything in single file 66 | migrate_old_storage("./localStorage/storage.json", localStorage); 67 | 68 | const sessionStorage = new Map(); 69 | localStorage.set("caracAL", "Yeah"); 70 | sessionStorage.set("caracAL", "Yup"); 71 | 72 | const version = await game_files.ensure_latest(); 73 | 74 | const cfg = require("../config"); 75 | if (cfg.cull_versions) { 76 | await game_files.cull_versions([version]); 77 | } 78 | const sess = process.env.AL_SESSION || cfg.session; 79 | const my_acc = await account_info(sess); 80 | const default_realm = my_acc.response.servers[0]; 81 | 82 | const character_manage = cfg.characters; 83 | 84 | //TODO right now this server wont terminate. 85 | //this is fine atm because caracAL does not terminate when all chars stop. 86 | //when I change this in the future this might change as well. 87 | let bwi_instance = {}; 88 | try { 89 | if (cfg.web_app && (cfg.web_app.enable_bwi || cfg.web_app.enable_minimap)) { 90 | bwi_instance = new bwi({ 91 | port: cfg.web_app.port, 92 | password: null, 93 | updateRate: STAT_BEAT_INTERVAL, 94 | }); 95 | } 96 | let express_inst = bwi_instance.router; 97 | if (cfg.web_app && cfg.web_app.expose_CODE) { 98 | if (!express_inst) { 99 | express_inst = express(); 100 | express_inst.listen(cfg.web_app.port); 101 | } 102 | log.info( 103 | { type: "CODE_exposed", src_path: __dirname + "/../CODE" }, 104 | "Serving CODE statically", 105 | ); 106 | express_inst.use("/CODE", express.static(__dirname + "/../CODE")); 107 | } 108 | if (cfg.web_app && cfg.web_app.expose_TYPECODE && cfg.enable_TYPECODE) { 109 | if (!express_inst) { 110 | express_inst = express(); 111 | express_inst.listen(cfg.web_app.port); 112 | } 113 | log.info( 114 | { type: "TYPECODE_exposed", src_path: __dirname + "/../TYPECODE.out" }, 115 | "Serving TYPECODE statically", 116 | ); 117 | express_inst.use( 118 | "/TYPECODE", 119 | express.static(__dirname + "/../TYPECODE.out"), 120 | ); 121 | } 122 | } catch (e) { 123 | console.error(`failed to start web services.`, e); 124 | console.error(`no web services will be available`); 125 | } 126 | 127 | function safe_send(target, data) { 128 | if (target) { 129 | target.send(data, undefined, undefined, (e) => { 130 | //This can occur due to node closing ipc 131 | //before firing its close handlers 132 | if (e) { 133 | //console.error(`failed to send ipc`); 134 | //console.error(`target: `,target); 135 | } 136 | }); 137 | } 138 | } 139 | 140 | function sleep(ms) { 141 | return new Promise((resolve) => setTimeout(resolve, ms)); 142 | } 143 | //attempts to softkill child processes 144 | //by sending an ipc if the client is connected and giving some timeout 145 | //why not actual SIGTERM? cause windows cant even 146 | async function softkill_block(char_block) { 147 | const proc = char_block.instance; 148 | char_block.instance = null; 149 | if (proc) { 150 | if (char_block.connected) { 151 | console.log("telling client to self-terminate"); 152 | safe_send(proc, { 153 | type: "closing_client", 154 | }); 155 | const ended_graceful = await Promise.race([ 156 | sleep(500), 157 | new Promise((resolve) => { 158 | proc.on("exit", function () { 159 | console.log("Client terminated gracefully"); 160 | resolve(true); 161 | }); 162 | }), 163 | ]); 164 | if (ended_graceful) { 165 | return; 166 | } 167 | } 168 | console.log("Hard-terminating client"); 169 | proc.kill("SIGKILL"); 170 | } 171 | } 172 | 173 | function update_siblings_and_acc(info) { 174 | const sib_names = Object.keys(character_manage) 175 | .filter((x) => character_manage[x].connected) 176 | .sort(); 177 | 178 | sib_names.forEach((char) => { 179 | safe_send(character_manage[char].instance, { 180 | type: "siblings_and_acc", 181 | account: info, 182 | siblings: sib_names, 183 | }); 184 | }); 185 | } 186 | 187 | function start_char(char_name) { 188 | const char_block = character_manage[char_name]; 189 | let realm = my_acc.resolve_realm(char_block.realm); 190 | if (!realm) { 191 | console.warn( 192 | `could not find realm ${char_block.realm},`, 193 | `falling back to realm ${default_realm.key}`, 194 | ); 195 | char_block.realm = default_realm.key; 196 | realm = default_realm; 197 | } 198 | const char = my_acc.resolve_char(char_name); 199 | //class is char.type 200 | if (!char) { 201 | console.error( 202 | `could not resolve character ${char_name}`, 203 | `this character will not be started`, 204 | ); 205 | console.error( 206 | "are you sure you own this character and have not deleted it?", 207 | ); 208 | char_block.enabled = false; 209 | return; 210 | } 211 | const g_version = char_block.version || version; 212 | console.log( 213 | `starting ${char_name} running version ${g_version} in ${char_block.realm}`, 214 | ); 215 | const args = { 216 | version: g_version, 217 | realm_addr: realm.addr, 218 | realm_port: realm.port, 219 | sess: sess, 220 | cid: char.id, 221 | script_file: char_block.script, 222 | enable_map: !!(cfg.web_app && cfg.web_app.enable_minimap), 223 | cname: char_name, 224 | clid: ctype_to_clid[char.type] || -1, 225 | }; 226 | if (cfg.enable_TYPECODE) { 227 | args.typescript_file = char_block.typescript; 228 | } 229 | 230 | const result = child_process.fork("./src/CharacterThread.js", [], { 231 | stdio: ["ignore", "pipe", "pipe", "ipc"], 232 | }); 233 | 234 | result.stdout.pipe(process.stdout); 235 | result.stderr.pipe(process.stderr); 236 | char_block.instance = result; 237 | result.on("exit", () => { 238 | if (char_block.monitor) { 239 | //close monitor 240 | char_block.monitor.destroy(); 241 | char_block.monitor = null; 242 | } 243 | char_block.connected = false; 244 | char_block.instance = null; 245 | if (char_block.enabled) { 246 | start_char(char_name); 247 | } 248 | }); 249 | result.on("message", (m) => { 250 | switch (m.type) { 251 | case "process_ready": 252 | safe_send(result, { 253 | type: "process_args", 254 | arguments: args, 255 | }); 256 | break; 257 | case "initialized": 258 | break; 259 | case "connected": 260 | char_block.connected = true; 261 | update_siblings_and_acc(my_acc.response); 262 | break; 263 | case "deploy": 264 | //check for existing charblock, adjust parameters and kill it 265 | //or not find any, make a new one and start it 266 | const new_char_name = m.character || char_name; 267 | const candidate = character_manage[new_char_name] || {}; 268 | character_manage[new_char_name] = candidate; 269 | candidate.enabled = true; 270 | candidate.realm = m.realm || char_block.realm; 271 | if (char_block.typescript && char_block.typescript.length > 0) { 272 | candidate.typescript = m.script || char_block.typescript; 273 | } else { 274 | candidate.script = m.script || char_block.script; 275 | candidate.typescript = null; 276 | } 277 | candidate.script = m.script || char_block.script; 278 | candidate.version = m.version || char_block.version; 279 | if (candidate.instance) { 280 | softkill_block(candidate); 281 | candidate.connected = false; //TODO i need to refractor lifecycle management 282 | } else { 283 | candidate.connected = false; 284 | start_char(new_char_name); 285 | } 286 | break; 287 | case "shutdown": 288 | if (m.character) { 289 | const candidate = character_manage[m.character] || {}; 290 | 291 | console.log( 292 | `shutdown requested for ${m.character} from ${char_name}`, 293 | ); 294 | candidate.enabled = false; 295 | softkill_block(candidate); 296 | } else { 297 | console.log("shutdown requested from " + char_name); 298 | char_block.enabled = false; 299 | softkill_block(char_block); 300 | } 301 | break; 302 | case "cm": 303 | let recipients = m.to; 304 | if (!Array.isArray(recipients)) { 305 | recipients = [recipients]; 306 | } 307 | const [locs, globs] = partition( 308 | recipients, 309 | (x) => character_manage[x] && character_manage[x].connected, 310 | ); 311 | if (globs.length > 0) { 312 | safe_send(char_block.instance, { 313 | type: "send_cm", 314 | to: globs, 315 | data: m.data, 316 | }); 317 | } 318 | locs.forEach((blk) => { 319 | safe_send(character_manage[blk].instance, { 320 | type: "receive_cm", 321 | name: char_name, 322 | data: m.data, 323 | }); 324 | }); 325 | break; 326 | //localStorage and sessionStorage related 327 | case "stor": 328 | const trg_store = m.ident == "ls" ? localStorage : sessionStorage; 329 | switch (m.op) { 330 | case "set": 331 | for (let key in m.data) { 332 | trg_store.set(key, m.data[key]); 333 | } 334 | break; 335 | case "del": 336 | for (let key of m.data) { 337 | trg_store.delete(key); 338 | } 339 | break; 340 | case "clear": 341 | for (let [key, value] of trg_store.entries()) { 342 | trg_store.delete(key); 343 | } 344 | break; 345 | case "init": 346 | const catchup_data = {}; 347 | for (let [key, value] of trg_store.entries()) { 348 | catchup_data[key] = value; 349 | } 350 | safe_send(char_block.instance, { 351 | type: "stor", 352 | op: "set", 353 | ident: m.ident, 354 | data: catchup_data, 355 | }); 356 | break; 357 | default: 358 | break; 359 | } 360 | if (m.op != "init") { 361 | //forward to other running processes 362 | Object.values(character_manage) 363 | .filter((x) => x.instance) 364 | .forEach((block) => { 365 | safe_send(block.instance, m); 366 | }); 367 | } 368 | break; 369 | default: 370 | break; 371 | } 372 | }); 373 | if (bwi_instance.publisher) { 374 | char_block.monitor = monitoring_util.create_monitor_ui( 375 | bwi_instance, 376 | char_name, 377 | char_block, 378 | cfg.web_app.enable_minimap, 379 | ); 380 | } 381 | 382 | return result; 383 | } 384 | //TODO beta new logic for #5 385 | //i need to implement decent lifecycle-handling 386 | ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => 387 | process.on(signal, async () => { 388 | console.log(`Received ${signal} on master. Rounding up clients`); 389 | //softkill all chars, giving them chance to shutdown 390 | await Promise.all( 391 | Object.values(character_manage).map((char_block) => { 392 | char_block.enabled = false; 393 | return softkill_block(char_block); 394 | }), 395 | ); 396 | console.log("now truly exiting"); 397 | process.exit(); 398 | }), 399 | ); 400 | 401 | const tasks = Object.keys(character_manage).forEach((c_name) => { 402 | const char = character_manage[c_name]; 403 | char.connected = false; 404 | if (char.enabled) { 405 | start_char(c_name); 406 | } 407 | }); 408 | my_acc.add_listener(update_siblings_and_acc); 409 | })().catch((e) => { 410 | console.error("failed to start caracAL", e); 411 | }); 412 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # caracAL 2 | 3 | A Node.js client for [adventure.land](https://adventure.land/ "Adventure Land") 4 | 5 | ## Recent Versions 6 | 7 | #### 2024-07-12 8 | 9 | Fixed missing typeScript bindings for caracAL added types 10 | 11 | ### Typescript 12 | 13 | #### 2024-03-10 14 | 15 | ##### npm install required 16 | 17 | This update introduces the ability to run TypeScript directly in caracAL. 18 | 19 | TypeScript is much similar to JavaScript but allows code to be much safer in development and enables better autocompletion in IDEs. 20 | 21 | TypeScript is opt-in. If you would like to continue working with JavaScript you can. If you already have a working pipeline with TypeScript it will continue to work. 22 | 23 | caracAL also allows you to expose your TypeScript creations via webserver. 24 | You can use this capability to load fully bundled versions of your code into other places, such as the official client while developing. 25 | 26 | ### Upgrading from a git installation 27 | 28 | If you have installed caracAL from cloning this repository you can upgrade by entering the following commands into a terminal: 29 | 30 | ```bash 31 | git pull 32 | npm install 33 | ``` 34 | 35 | ### Simpler version 36 | 37 | Simply reinstall caracAL by following the steps below. You can keep the config.js file from your old installation for ease of use. 38 | 39 | ### Full Changelog 40 | 41 | The full changelog that used to be here has moved to [CHANGELOG.md](./CHANGELOG.md "the CHANGELOG.md file") 42 | 43 | ## Installation on Debian/Ubuntu 44 | 45 | ```bash 46 | #update packages 47 | sudo apt-get update 48 | sudo apt-get upgrade 49 | sudo apt-get install git curl 50 | #install node version manager(nvm) 51 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash 52 | #you need to restart your terminal here 53 | #so the nvm command can be recognized 54 | #install node 14 55 | #latest(16) does not like socket.io for some reason. 56 | nvm install 14 57 | #download caracAL 58 | git clone https://github.com/numbereself/caracAL.git 59 | #switch to directory 60 | cd caracAL 61 | #use npm to download dependencies 62 | npm install 63 | #if you want caracAL to autostart run 64 | #chmod +x ./start_on_boot.sh 65 | #./start_on_boot.sh 66 | #run caracAL 67 | node main.js 68 | ``` 69 | 70 | ## Installation on Windows 10 71 | 72 | First download Node.js version 14 and npm from 73 | 74 | [https://nodejs.org/en/download/](https://nodejs.org/en/download/ "Node.js homepage") 75 | 76 | Run the installer you just downloaded. MAKE SURE THAT YOU ENABLE THE "Add to PATH" OPTION DURING INSTALLATION. This option will allow you to refer to the Node.js and npm binarys with a shorthand. 77 | 78 | Next hit WINDOWS+R and type "powershell" in the window that opens, and hit enter. You should now be presented with the windows powershell. Copy and paste the following script 79 | 80 | ``` 81 | #download caracAL 82 | wget https://github.com/numbereself/caracAL/archive/refs/heads/main.zip -OutFile caracAL.zip 83 | #unzip archive 84 | tar -xf caracAL.zip caracAL-main 85 | #rename output 86 | ren "caracAL-main" "caracAL" 87 | #switch to directory 88 | cd caracAL 89 | #use npm to download dependencies 90 | npm install 91 | #run caracAL 92 | node main.js 93 | ``` 94 | 95 | ## First run and configuration 96 | 97 | When you first run caracAL it will ask you for your login credentials to adventure.land. After you have sucessfully entered them it will ask you which characters to start and in which realm. Finally it will ask you if you want to use the bot monitoring panel, and if you answer that with yes also if you want a minimap and what port you want it to be on. Once these questions are answered caracAL will generate a file config.js for you, containing the information you just entered. The characters you specified will immediately be loaded into caracAL and start farming crabs using the script example.js or crabs_with_tophats.js if you chose to enable TypeScript. 98 | 99 | ### config.js 100 | 101 | The session key represents you login into adventure.land. This is the reason why you should not share this file. 102 | 103 | caracAL stores versions of the game client on your disk, in the game_files folder. 104 | If set, the cull_versions key makes caracAL delete these versions, except the two most recent ones. 105 | Versions which are not numeric will not be considered for culling. 106 | 107 | enable_TYPECODE determines if you want to enable the TypeScript integration. 108 | Enabling this will start a WebPack process and allow you to specify the typescript property on your characters. 109 | 110 | log_level specifies how much logging you want. For more details refer to section [log_level](./README.md#log_level "log_level"). 111 | 112 | log_sinks specifies where your logging goes. More details in the section [log_sinks](./README.md#log_sinks "log_sinks"). 113 | 114 | The web_app section contains configuration that enables caracAL to host a webserver. The port option herein allows to choose which port the webserver should be hosted on. 115 | The enable_bwi option opens a monitoring panel that displays the status of the characters running within caracAL if set to true. 116 | The enable_minimap option configures if caracAL should generate a minimap summarizing your current game state. The minimap is located in the monitoring panel. Therefore, if the minimap is enabled, the monitoring panel will always be served and ignore the previous setting. 117 | The expose_CODE option shares the CODE directory, where your scripts are located, via the webserver. This is useful if you i.e. want to load the scripts you are using in caracAL from the steam client. Scripts shared in this manner will be available i.e. under the URL `localhost:924/CODE/caracAL/tests/cm_test.js`. 118 | The expose_TYPECODE option is the TypeScript analogon of expose_CODE. It requires enable_TYPECODE to be true. Scripts will be reachable under `http://localhost:924/TYPECODE/caracAL/examples/crabs_with_tophats.js`. Take special note that even though you developed in TypeScript, the output will be compiled and in .js form. 119 | If you do not enable either of these options no webserver will be opened. config.js files which do not have the web_app section, i.e. those, which were created before the update, will not open a webserver either. 120 | 121 | The characters key contains information about which characters to run. 122 | Each character has five fields: 123 | 124 | - realm: Which server the character should run on 125 | - enabled: caracAL will only run characters who have this field set to true 126 | - version: Which version of the game client this character should run. A value of 0 represents the latest version 127 | 128 | - script: Which JavaScript script the character should run. Scripts are located in the CODE folder. Will be ignored in a TypeScript setup. 129 | - typescript: Which TypeScript script the character should run. Scripts are located in the TYPECODE folder. Requires enable_TYPESCRIPT to be set to true. Take note that the file ending specified still needs to be .js and not .ts . 130 | 131 | ### Running your own code 132 | 133 | The default code located is at `./CODE/caracAL/examples/crabs.js` or `./TYPECODE/caracAL/examples/crabs_with_tophats.ts`, as specified by the character properties `script` or `typescript`, depending on wether or not TypeScript is enabled. These scripts make your characters farm tiny crabs on the beach. 134 | You can create your own scripts in the `./CODE/` or `./TYPECODE` directory. Since caracAL runs the same files as the game client you should be able to use the exact same files in caracAL. 135 | 136 | ## So why should I use caracAL? 137 | 138 | There is one big reason and that is 139 | 140 | ### Parity with the regular game client 141 | 142 | caracAL runs the same files as the regular client. You can use the same scripts in caracAL and in the normal client. This means that you can develop your scripts in the game and later deploy them with caracAL. This is the key selling point over competitors like ALclient, who develops a completely new client entirely and ALbot, which has only recently switched to an architecture quite similar to caracAL. 143 | 144 | Keep in mind that some functionality does not make sense in a headless client. This notably concerns the lack of a HTML document and a renderer. 145 | Most HTML routines do not throw an error, but do not expect them to do anything. Calls to PIXI routines should really be mostly avoided. 146 | If you want to implement HTML or PIXI functionality, check that `parent.no_graphics` is set to false. caracAL sets this to true. 147 | 148 | ### It is fast 149 | 150 | Compiling the game sources directly into Node.js V8 engine yields performance which is actually serviceable for smaller devices. 151 | The CLI aimed to do the same thing, but it ended up poorly emulating a webpage, which led to atrocious performance. 152 | 153 | ### Painless TypeScript setup 154 | 155 | Getting started with TypeScript can be challenging because there you need to find a source for bindings, setup a compiler among many other things. 156 | caracAL has some sensible defaults that allow you get started with TypeScript in a very quick manner. 157 | 158 | In order to accomplish running TypeScript files, caracAL starts a webpack instance. 159 | 160 | The WebPack instance logs directly into the console. You will notice it is not formatted. 161 | 162 | The WebPack logs can be viewed at `./logs/webpack.log` 163 | 164 | ## Additional features 165 | 166 | Some functionality is available through browser-based apis. Notably character deployment and script loading is realized through `