diff --git a/Dockerfile b/Dockerfile index d28764cb8..80e8cba5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ FROM base COPY --from=dependencies / / ARG GIT_COMMIT=unknown -ENV GIT_COMMIT=$GIT_COMMIT +ENV GIT_COMMIT="$GIT_COMMIT" # Set the working directory in the container WORKDIR /usr/src/app @@ -35,7 +35,7 @@ COPY package*.json ./ # Install dependencies while bypassing Husky hooks ENV HUSKY=0 ENV NPM_CONFIG_IGNORE_SCRIPTS=1 -RUN mkdir -p .git && npm install +RUN mkdir -p .git && npm ci # Copy the rest of the application code COPY . . @@ -45,7 +45,7 @@ RUN npm run build-prod # So we can see which commit was used to build the container # https://openfront.io/commit.txt -RUN echo $GIT_COMMIT > static/commit.txt +RUN echo "$GIT_COMMIT" > static/commit.txt # Copy Nginx configuration and ensure it's used instead of the default COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/package-lock.json b/package-lock.json index f4f95a913..5d6c4e951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "jimp": "^0.22.12", "jose": "^6.0.10", "lit": "^3.2.1", + "lit-markdown": "^1.3.2", "msgpack5": "^6.0.2", "nanoid": "^3.3.6", "node-addon-api": "^8.1.0", @@ -97,6 +98,7 @@ "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", "binary-base64-loader": "^1.0.0", + "canvas": "^3.1.0", "chai": "^5.1.1", "concurrently": "^8.2.2", "cross-env": "^7.0.3", @@ -111,6 +113,7 @@ "html-loader": "^5.1.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.0.0-beta.3", "lint-staged": "^15.4.3", "mrmime": "^2.0.0", "postcss": "^8.5.1", @@ -160,6 +163,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2931,6 +2955,121 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -4382,6 +4521,228 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.0.0-beta.3.tgz", + "integrity": "sha512-+zbcQyBD2IhxWkv0koB1PnEpcsDRwlmTRQIz6/+mzdT3sit3nd15C8g4bH9kW2+EPA5WIthZ2GsXiV+dJO+igA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.0-beta.3", + "@jest/fake-timers": "30.0.0-beta.3", + "@jest/types": "30.0.0-beta.3", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.0.0-beta.3", + "jest-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.0-beta.3.tgz", + "integrity": "sha512-1qcDVc37nlItrkYXrWC9FFXU0CxNUe/PGYpHzOz6zIX7FKFv7i8Z0LKBs27iN6XIhczrmQtFzs9JUnHGARWRoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.0-beta.3", + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "jest-mock": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.0-beta.3.tgz", + "integrity": "sha512-bSYm4Q42Obgzs8tnDcd5zMsgNSZFTtydZJQxEFoaHOSnNuSnC03CvJbZuKs5Gcjqm94eHYoOL7HDvo1W5UMVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.0-beta.3", + "jest-mock": "30.0.0-beta.3", + "jest-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.0-beta.3.tgz", + "integrity": "sha512-tiT79EKOlJGT5v8fYr9UKLSyjlA3Ek+nk0cVZwJGnRqVp26EQSOTYXBCzj0dGMegkgnPTt3f7wP1kGGI8q/e0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.0-beta.3.tgz", + "integrity": "sha512-x7GyHD8rxZ4Ygmp4rea3uPDIPZ6Jglcglaav8wQNqXsVUAByapDwLF52Cp3wEYMPMnvH4BicEj56j8fqZx5jng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.0-beta.3", + "@jest/schemas": "30.0.0-beta.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.33", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.33.tgz", + "integrity": "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.0-beta.3.tgz", + "integrity": "sha512-AMfuhrcOs+Nlyk3HBywn1cIO5KusDxelLP6HTgMlggYWNODm2yNENVnYBuBw78x1uK5f/DQNYlTioq5ub6TXlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "30.0.0-beta.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.8", + "pretty-format": "30.0.0-beta.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.0-beta.3.tgz", + "integrity": "sha512-g7w/Wjxefrq7MNTGstemMP20PTiSpABJlkl/4L1lAAAy15ZM4BDEl1D9aBEz2qcfUJAS9690HVIB4bJ/V+5sTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "jest-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.0-beta.3.tgz", + "integrity": "sha512-kob8YNaO1UPrG0TgGdH5l0ciNGuXDX93Yn2b2VCkALuqOXbqzT2xCr6O7dBuwhM7tmzBbpM6CkcK7Qyf/JmLZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^4.0.0", + "graceful-fs": "^4.2.9", + "picomatch": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.0-beta.3.tgz", + "integrity": "sha512-MR29N2jVpfzRkuW6XbZsYYIqpoU/CzlsLwNO0h4/D5OZlu3c4WkIxaIiUxyuw25GEB8B6KNqOC6WQrqAzhkA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.0-beta.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -4443,6 +4804,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.0-beta.3.tgz", + "integrity": "sha512-IuB9mweyJI5ToVBRdptKb2w97LGnNHFI+V9/cGaYeFareL7BYD6KiUH022OC51K1841c6YzgYjyQmJHFxELZSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.0-beta.3.tgz", + "integrity": "sha512-kiDaZ35ogPivxgLEGJ1jNW2KBtvmPwGlPjy5ASHiVE3kjn3g80galEIcWC0hZV6g5BtTx15VKzSyfOTiKXPnxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -8283,6 +8668,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9097,13 +9494,10 @@ } }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -9981,6 +10375,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/canvas/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/centra": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", @@ -10857,6 +11273,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.4.0.tgz", + "integrity": "sha512-W0Y2HOXlPkb2yaKrCVRjinYKciu/qSLEmK0K9mcfDei3zwlnHFEHAs/Du3cIRwPqY+J4JsiBzUjoHyc8RsJ03A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -11258,6 +11688,57 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -11292,6 +11773,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -11317,6 +11821,16 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -11328,7 +11842,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11802,7 +12315,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -11957,7 +12469,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12454,6 +12965,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -13006,6 +13527,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -13228,6 +13756,13 @@ "omggif": "^1.0.10" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -13560,6 +14095,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", @@ -13827,12 +14375,12 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -14036,6 +14584,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -14252,6 +14807,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -14615,6 +15177,225 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.0.0-beta.3.tgz", + "integrity": "sha512-YLBmv46sn5CYQR/iX+seZJ7FsuUAM4tf7Pm5ymP8XQKzrKEPdDv1f1V/z2b9XSTniR+OlIuGpnP3G3RbpbsceA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.0-beta.3", + "@jest/environment-jsdom-abstract": "30.0.0-beta.3", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.0-beta.3.tgz", + "integrity": "sha512-1qcDVc37nlItrkYXrWC9FFXU0CxNUe/PGYpHzOz6zIX7FKFv7i8Z0LKBs27iN6XIhczrmQtFzs9JUnHGARWRoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.0-beta.3", + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "jest-mock": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.0-beta.3.tgz", + "integrity": "sha512-bSYm4Q42Obgzs8tnDcd5zMsgNSZFTtydZJQxEFoaHOSnNuSnC03CvJbZuKs5Gcjqm94eHYoOL7HDvo1W5UMVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.0-beta.3", + "jest-mock": "30.0.0-beta.3", + "jest-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.0-beta.3.tgz", + "integrity": "sha512-tiT79EKOlJGT5v8fYr9UKLSyjlA3Ek+nk0cVZwJGnRqVp26EQSOTYXBCzj0dGMegkgnPTt3f7wP1kGGI8q/e0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.0-beta.3.tgz", + "integrity": "sha512-x7GyHD8rxZ4Ygmp4rea3uPDIPZ6Jglcglaav8wQNqXsVUAByapDwLF52Cp3wEYMPMnvH4BicEj56j8fqZx5jng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.0-beta.3", + "@jest/schemas": "30.0.0-beta.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.33", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.33.tgz", + "integrity": "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.0-beta.3.tgz", + "integrity": "sha512-AMfuhrcOs+Nlyk3HBywn1cIO5KusDxelLP6HTgMlggYWNODm2yNENVnYBuBw78x1uK5f/DQNYlTioq5ub6TXlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "30.0.0-beta.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.8", + "pretty-format": "30.0.0-beta.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.0-beta.3.tgz", + "integrity": "sha512-g7w/Wjxefrq7MNTGstemMP20PTiSpABJlkl/4L1lAAAy15ZM4BDEl1D9aBEz2qcfUJAS9690HVIB4bJ/V+5sTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "jest-util": "30.0.0-beta.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.0-beta.3.tgz", + "integrity": "sha512-kob8YNaO1UPrG0TgGdH5l0ciNGuXDX93Yn2b2VCkALuqOXbqzT2xCr6O7dBuwhM7tmzBbpM6CkcK7Qyf/JmLZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.0-beta.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^4.0.0", + "graceful-fs": "^4.2.9", + "picomatch": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.0.0-beta.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.0-beta.3.tgz", + "integrity": "sha512-MR29N2jVpfzRkuW6XbZsYYIqpoU/CzlsLwNO0h4/D5OZlu3c4WkIxaIiUxyuw25GEB8B6KNqOC6WQrqAzhkA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.0-beta.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || >=22.0.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -15081,6 +15862,83 @@ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -15607,6 +16465,57 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/lit-markdown": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/lit-markdown/-/lit-markdown-1.3.2.tgz", + "integrity": "sha512-51M4QRR2UmJIKck3kRQClD8CAM2Ox7BBZ9qzQLA1ppkr5tFfyNJDXnM0A2xmpNNfFrELZrnTmD5ST6VEdcemPg==", + "license": "MIT", + "dependencies": { + "lit": "^2.6.1", + "marked": "^4.2.12", + "sanitize-html": "^2.9.0" + } + }, + "node_modules/lit-markdown/node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/lit-markdown/node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/lit-markdown/node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/lit-markdown/node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/load-bmfont": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz", @@ -15983,6 +16892,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -16122,6 +17043,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-document": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", @@ -16330,6 +17264,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -16432,6 +17373,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -16498,6 +17446,32 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.2.1.tgz", @@ -16712,6 +17686,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -17043,19 +18024,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse5": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.0.tgz", - "integrity": "sha512-ZkDsAOcxsUMZ4Lz5fVciOehNcJ+Gb8gTzcA4yl3wnc273BAybYWrQ+Ks/OjCjSEpjvQkDSeZbybK9qj2VHHdGA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -17435,7 +18435,6 @@ "version": "8.5.1", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -17734,6 +18733,33 @@ "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -17954,6 +18980,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -18138,6 +19175,32 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -18510,6 +19573,13 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -18582,12 +19652,110 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", @@ -18959,6 +20127,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -19145,7 +20360,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -19593,6 +20807,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.25.11", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.25.11.tgz", @@ -19703,6 +20924,95 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tar/node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -20015,6 +21325,26 @@ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -20060,6 +21390,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -20348,6 +21691,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/twemoji": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz", @@ -20683,6 +22039,19 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -21086,12 +22455,35 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -21331,6 +22723,16 @@ "xtend": "^4.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-parse-from-string": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", @@ -21359,6 +22761,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 516332ca9..ccbbf1a89 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", "binary-base64-loader": "^1.0.0", + "canvas": "^3.1.0", "chai": "^5.1.1", "concurrently": "^8.2.2", "cross-env": "^7.0.3", @@ -58,6 +59,7 @@ "html-loader": "^5.1.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.0.0-beta.3", "lint-staged": "^15.4.3", "mrmime": "^2.0.0", "postcss": "^8.5.1", @@ -123,6 +125,7 @@ "jimp": "^0.22.12", "jose": "^6.0.10", "lit": "^3.2.1", + "lit-markdown": "^1.3.2", "msgpack5": "^6.0.2", "nanoid": "^3.3.6", "node-addon-api": "^8.1.0", diff --git a/resources/changelog.md b/resources/changelog.md new file mode 100644 index 000000000..76e4aeec3 --- /dev/null +++ b/resources/changelog.md @@ -0,0 +1,3 @@ +# header + +changelog here diff --git a/resources/flags/1_Northern Uí Néill.svg b/resources/flags/1_Northern Ui Neill.svg similarity index 100% rename from resources/flags/1_Northern Uí Néill.svg rename to resources/flags/1_Northern Ui Neill.svg diff --git a/resources/flags/1_Southern Uí Néill.svg b/resources/flags/1_Southern Ui Neill.svg similarity index 100% rename from resources/flags/1_Southern Uí Néill.svg rename to resources/flags/1_Southern Ui Neill.svg diff --git a/resources/flags/Ceará.svg b/resources/flags/Ceara.svg similarity index 100% rename from resources/flags/Ceará.svg rename to resources/flags/Ceara.svg diff --git a/resources/flags/Māori flag.svg b/resources/flags/Maori flag.svg similarity index 99% rename from resources/flags/Māori flag.svg rename to resources/flags/Maori flag.svg index a04f55679..7ab870b4d 100644 --- a/resources/flags/Māori flag.svg +++ b/resources/flags/Maori flag.svg @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/resources/flags/Pará.svg b/resources/flags/Para.svg similarity index 100% rename from resources/flags/Pará.svg rename to resources/flags/Para.svg diff --git a/resources/flags/São Paulo.svg b/resources/flags/Sao Paulo.svg similarity index 100% rename from resources/flags/São Paulo.svg rename to resources/flags/Sao Paulo.svg diff --git a/resources/flags/para.svg b/resources/flags/para.svg deleted file mode 100644 index aae5eb835..000000000 --- a/resources/flags/para.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/lang/en.json b/resources/lang/en.json index 34d9b0d4b..d3b950215 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -400,12 +400,22 @@ "sams": "SAMs", "warships": "Warships", "health": "Health", - "attitude": "Attitude" + "attitude": "Attitude", + "levels": "Levels" }, "events_display": { "retreating": "retreating", "boat": "Boat" }, + "unit_info_modal": { + "structure_info": "Structure Info", + "unit_type_unknown": "Unknown", + "close": "Close", + "cooldown": "Cooldown", + "type": "Type", + "upgrade": "Upgrade", + "level": "Level" + }, "relation": { "hostile": "Hostile", "distrustful": "Distrustful", @@ -435,6 +445,10 @@ "none": "None", "alliances": "Alliances" }, + "replay_panel": { + "replay_speed": "Replay speed", + "game_speed": "Game speed" + }, "error_modal": { "crashed": "Game crashed!", "paste_discord": "Please paste the following in your bug report in Discord:", diff --git a/resources/maps/Britannia.json b/resources/maps/Britannia.json index 79c5533ac..614d3615a 100644 --- a/resources/maps/Britannia.json +++ b/resources/maps/Britannia.json @@ -115,7 +115,7 @@ "coordinates": [564, 845], "name": "Southern Uí Néill", "strength": 3, - "flag": "1_Southern Uí Néill" + "flag": "1_Southern Ui Neill" }, { "coordinates": [639, 680], @@ -133,7 +133,7 @@ "coordinates": [416, 678], "name": "Northern Uí Néill", "strength": 3, - "flag": "1_Northern Uí Néill" + "flag": "1_Northern Ui Neill" }, { "coordinates": [1869, 1308], diff --git a/resources/version.txt b/resources/version.txt new file mode 100644 index 000000000..00f522f66 --- /dev/null +++ b/resources/version.txt @@ -0,0 +1 @@ +v0.24.0-dev diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 1bde53319..401ea2653 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -268,8 +268,10 @@ export class ClientGameRunner { }); const worker = this.worker; const keepWorkerAlive = () => { - worker.sendHeartbeat(); - requestAnimationFrame(keepWorkerAlive); + if (this.isActive) { + worker.sendHeartbeat(); + requestAnimationFrame(keepWorkerAlive); + } }; requestAnimationFrame(keepWorkerAlive); @@ -329,8 +331,10 @@ export class ClientGameRunner { } public stop(saveFullGame: boolean = false) { - this.worker.cleanup(); + if (!this.isActive) return; + this.isActive = false; + this.worker.cleanup(); this.transport.leaveGame(saveFullGame); if (this.connectionCheckInterval) { clearInterval(this.connectionCheckInterval); @@ -516,12 +520,13 @@ export class ClientGameRunner { if (this.transport.isLocal) { return; } - const timeSinceLastMessage = Date.now() - this.lastMessageTime; + const now = Date.now(); + const timeSinceLastMessage = now - this.lastMessageTime; if (timeSinceLastMessage > 5000) { console.log( `No message from server for ${timeSinceLastMessage} ms, reconnecting`, ); - this.lastMessageTime = Date.now(); + this.lastMessageTime = now; this.transport.reconnect(); } } @@ -554,26 +559,25 @@ function showErrorModal( const button = document.createElement("button"); button.textContent = translateText("error_modal.copy_clipboard"); button.className = "copy-btn"; - button.addEventListener("click", () => { - navigator.clipboard - .writeText(content) - .then(() => (button.textContent = translateText("error_modal.copied"))) - .catch( - () => (button.textContent = translateText("error_modal.failed_copy")), - ); - }); - - const closeButton = document.createElement("button"); - closeButton.textContent = "X"; - closeButton.className = "close-btn"; - closeButton.addEventListener("click", () => { - modal.style.display = "none"; + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(content); + button.textContent = translateText("error_modal.copied"); + } catch { + button.textContent = translateText("error_modal.failed_copy"); + } }); // Add to modal modal.appendChild(pre); modal.appendChild(button); if (closable) { + const closeButton = document.createElement("button"); + closeButton.textContent = "X"; + closeButton.className = "close-btn"; + closeButton.addEventListener("click", () => { + modal.remove(); + }); modal.appendChild(closeButton); } diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index bd1807f59..ec53514b9 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query } from "lit/decorators.js"; -import { translateText } from "../client/Utils"; +import { getAltKey, getModifierKey, translateText } from "../client/Utils"; import "./components/Difficulties"; import "./components/Maps"; @@ -41,7 +41,7 @@ export class HelpModal extends LitElement {
- Shift + ⇧ Shift +
@@ -54,7 +54,7 @@ export class HelpModal extends LitElement {
- Ctrl + ${getModifierKey()} +
@@ -67,7 +67,7 @@ export class HelpModal extends LitElement {
- Alt + ${getAltKey()} +
@@ -99,7 +99,7 @@ export class HelpModal extends LitElement {
- Shift + ⇧ Shift +
@@ -116,7 +116,8 @@ export class HelpModal extends LitElement { - ALT + R + ${getAltKey()} + + R ${translateText("help_modal.action_reset_gfx")} @@ -139,6 +140,7 @@ export class HelpModal extends LitElement { alt="Leaderboard" title="Leaderboard" class="default-image" + loading="lazy" />
@@ -158,6 +160,7 @@ export class HelpModal extends LitElement { alt="Control panel" title="Control panel" class="default-image" + loading="lazy" />
@@ -188,12 +191,14 @@ export class HelpModal extends LitElement { alt="Event panel" title="Event panel" class="default-image" + loading="lazy" /> Event panel
@@ -225,6 +230,7 @@ export class HelpModal extends LitElement { alt="Options" title="Options" class="default-image" + loading="lazy" />
@@ -252,6 +258,7 @@ export class HelpModal extends LitElement { alt="Player info overlay" title="Player info overlay" class="default-image" + loading="lazy" />
@@ -274,12 +281,14 @@ export class HelpModal extends LitElement { alt="Radial menu" title="Radial menu" class="default-image" + loading="lazy" /> Radial menu ally
@@ -294,6 +303,7 @@ export class HelpModal extends LitElement { src="/images/InfoIcon.svg" class="inline-block icon" style="fill: white; background: transparent;" + loading="lazy" /> ${translateText("help_modal.radial_info")} @@ -330,6 +340,7 @@ export class HelpModal extends LitElement { alt="Enemy info panel" title="Enemy info panel" class="info-panel-img" + loading="lazy" />
@@ -373,6 +384,7 @@ export class HelpModal extends LitElement { alt="Ally info panel" title="Ally info panel" class="info-panel-img" + loading="lazy" />
@@ -482,6 +494,7 @@ export class HelpModal extends LitElement { alt="Number 1 player" title="Number 1 player" class="player-icon-img w-full" + loading="lazy" />
@@ -498,6 +511,7 @@ export class HelpModal extends LitElement { alt="Traitor" title="Traitor" class="player-icon-img w-full" + loading="lazy" />
@@ -514,6 +528,7 @@ export class HelpModal extends LitElement { alt="Ally" title="Ally" class="player-icon-img w-full" + loading="lazy" />
@@ -532,6 +547,7 @@ export class HelpModal extends LitElement { alt="Stopped trading" title="Stopped trading" class="player-icon-img w-full" + loading="lazy" />
@@ -548,6 +564,7 @@ export class HelpModal extends LitElement { alt="Alliance Request" title="Alliance Request" class="player-icon-img w-full" + loading="lazy" />
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 597733705..96fde5beb 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,6 +1,7 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; +import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class MouseUpEvent implements GameEvent { constructor( @@ -82,6 +83,10 @@ export class AttackRatioEvent implements GameEvent { constructor(public readonly attackRatio: number) {} } +export class ReplaySpeedChangeEvent implements GameEvent { + constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {} +} + export class CenterCameraEvent implements GameEvent { constructor() {} } @@ -103,6 +108,7 @@ export class InputHandler { private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); + private keybinds: Record = {}; private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; @@ -115,7 +121,7 @@ export class InputHandler { ) {} initialize() { - const keybinds = { + this.keybinds = { toggleView: "Space", centerCamera: "KeyC", moveUp: "KeyW", @@ -127,8 +133,17 @@ export class InputHandler { attackRatioDown: "Digit1", attackRatioUp: "Digit2", boatAttack: "KeyB", + modifierKey: "ControlLeft", + altKey: "AltLeft", ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), }; + + // Mac users might have different keybinds + const isMac = /Mac/.test(navigator.userAgent); + if (isMac) { + this.keybinds.modifierKey = "MetaLeft"; // Use Command key on Mac + } + this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); window.addEventListener("pointerup", (e) => this.onPointerUp(e)); this.canvas.addEventListener( @@ -154,22 +169,22 @@ export class InputHandler { let deltaY = 0; if ( - this.activeKeys.has(keybinds.moveUp) || + this.activeKeys.has(this.keybinds.moveUp) || this.activeKeys.has("ArrowUp") ) deltaY += this.PAN_SPEED; if ( - this.activeKeys.has(keybinds.moveDown) || + this.activeKeys.has(this.keybinds.moveDown) || this.activeKeys.has("ArrowDown") ) deltaY -= this.PAN_SPEED; if ( - this.activeKeys.has(keybinds.moveLeft) || + this.activeKeys.has(this.keybinds.moveLeft) || this.activeKeys.has("ArrowLeft") ) deltaX += this.PAN_SPEED; if ( - this.activeKeys.has(keybinds.moveRight) || + this.activeKeys.has(this.keybinds.moveRight) || this.activeKeys.has("ArrowRight") ) deltaX -= this.PAN_SPEED; @@ -182,13 +197,13 @@ export class InputHandler { const cy = window.innerHeight / 2; if ( - this.activeKeys.has(keybinds.zoomOut) || + this.activeKeys.has(this.keybinds.zoomOut) || this.activeKeys.has("Minus") ) { this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED)); } if ( - this.activeKeys.has(keybinds.zoomIn) || + this.activeKeys.has(this.keybinds.zoomIn) || this.activeKeys.has("Equal") ) { this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED)); @@ -196,7 +211,7 @@ export class InputHandler { }, 1); window.addEventListener("keydown", (e) => { - if (e.code === keybinds.toggleView) { + if (e.code === this.keybinds.toggleView) { e.preventDefault(); if (!this.alternateView) { this.alternateView = true; @@ -211,21 +226,21 @@ export class InputHandler { if ( [ - keybinds.moveUp, - keybinds.moveDown, - keybinds.moveLeft, - keybinds.moveRight, - keybinds.zoomOut, - keybinds.zoomIn, + this.keybinds.moveUp, + this.keybinds.moveDown, + this.keybinds.moveLeft, + this.keybinds.moveRight, + this.keybinds.zoomOut, + this.keybinds.zoomIn, "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight", "Minus", "Equal", - keybinds.attackRatioDown, - keybinds.attackRatioUp, - keybinds.centerCamera, + this.keybinds.attackRatioDown, + this.keybinds.attackRatioUp, + this.keybinds.centerCamera, "ControlLeft", "ControlRight", ].includes(e.code) @@ -234,7 +249,7 @@ export class InputHandler { } }); window.addEventListener("keyup", (e) => { - if (e.code === keybinds.toggleView) { + if (e.code === this.keybinds.toggleView) { e.preventDefault(); this.alternateView = false; this.eventBus.emit(new AlternateViewEvent(false)); @@ -245,22 +260,22 @@ export class InputHandler { this.eventBus.emit(new RefreshGraphicsEvent()); } - if (e.code === keybinds.boatAttack) { + if (e.code === this.keybinds.boatAttack) { e.preventDefault(); this.eventBus.emit(new DoBoatAttackEvent()); } - if (e.code === keybinds.attackRatioDown) { + if (e.code === this.keybinds.attackRatioDown) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(-10)); } - if (e.code === keybinds.attackRatioUp) { + if (e.code === this.keybinds.attackRatioUp) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(10)); } - if (e.code === keybinds.centerCamera) { + if (e.code === this.keybinds.centerCamera) { e.preventDefault(); this.eventBus.emit(new CenterCameraEvent()); } @@ -297,11 +312,11 @@ export class InputHandler { this.pointerDown = false; this.pointers.clear(); - if (event.ctrlKey) { + if (this.isModifierKeyPressed(event)) { this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); return; } - if (event.altKey) { + if (this.isAltKeyPressed(event)) { this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY)); return; } @@ -400,4 +415,22 @@ export class InputHandler { } this.activeKeys.clear(); } + + isModifierKeyPressed(event: PointerEvent): boolean { + return ( + (this.keybinds.modifierKey === "AltLeft" && event.altKey) || + (this.keybinds.modifierKey === "ControlLeft" && event.ctrlKey) || + (this.keybinds.modifierKey === "ShiftLeft" && event.shiftKey) || + (this.keybinds.modifierKey === "MetaLeft" && event.metaKey) + ); + } + + isAltKeyPressed(event: PointerEvent): boolean { + return ( + (this.keybinds.altKey === "AltLeft" && event.altKey) || + (this.keybinds.altKey === "ControlLeft" && event.ctrlKey) || + (this.keybinds.altKey === "ShiftLeft" && event.shiftKey) || + (this.keybinds.altKey === "MetaLeft" && event.metaKey) + ); + } } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 27b7f9399..ad1486b77 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -186,6 +186,7 @@ export class LangSelector extends LitElement { "game-starting-modal", "top-bar", "player-panel", + "replay-panel", "help-modal", "username-input", "public-lobby", diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index e3bc08dcf..73e4961d2 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,3 +1,4 @@ +import { EventBus } from "../core/EventBus"; import { AllPlayersStats, ClientMessage, @@ -12,7 +13,9 @@ import { } from "../core/Schemas"; import { createGameRecord, decompressGameRecord, replacer } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; +import { ReplaySpeedChangeEvent } from "./InputHandler"; import { getPersistentID } from "./Main"; +import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class LocalServer { // All turns from the game record on replay. @@ -24,6 +27,7 @@ export class LocalServer { private startedAt: number; private paused = false; + private replaySpeedMultiplier = defaultReplaySpeedMultiplier; private winner: ClientSendWinnerMessage | null = null; private allPlayersStats: AllPlayersStats = {}; @@ -38,23 +42,29 @@ export class LocalServer { private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void, private isReplay: boolean, + private eventBus: EventBus, ) {} start() { this.turnCheckInterval = setInterval(() => { - if (this.turnsExecuted === this.turns.length) { - if ( - this.isReplay || - Date.now() > - this.turnStartTime + this.lobbyConfig.serverConfig.turnIntervalMs() - ) { - this.turnStartTime = Date.now(); - // End turn on the server means the client will start processing the turn. - this.endTurn(); - } + const turnIntervalMs = + this.lobbyConfig.serverConfig.turnIntervalMs() * + this.replaySpeedMultiplier; + + if ( + this.turnsExecuted === this.turns.length && + Date.now() > this.turnStartTime + turnIntervalMs + ) { + this.turnStartTime = Date.now(); + // End turn on the server means the client will start processing the turn. + this.endTurn(); } }, 5); + this.eventBus.on(ReplaySpeedChangeEvent, (event) => { + this.replaySpeedMultiplier = event.replaySpeedMultiplier; + }); + this.startedAt = Date.now(); this.clientConnect(); if (this.lobbyConfig.gameRecord) { diff --git a/src/client/Main.ts b/src/client/Main.ts index 9e5c6dfc1..cab14d6a0 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -59,11 +59,19 @@ class Client { constructor() {} initialize(): void { + const gameVersion = document.getElementById( + "game-version", + ) as HTMLDivElement; + if (!gameVersion) { + console.warn("Game version element not found"); + } + fetch("/version.txt") + .then((response) => (response.ok ? response.text() : "Failed to load")) + .then((version) => (gameVersion.innerText = version)); + const newsModal = document.querySelector("news-modal") as NewsModal; if (!newsModal) { console.warn("News modal element not found"); - } else { - console.log("News modal element found"); } newsModal instanceof NewsModal; const newsButton = document.querySelector("news-button") as NewsButton; @@ -72,6 +80,9 @@ class Client { } else { console.log("News button element found"); } + fetch("/changelog.md") + .then((response) => (response.ok ? response.text() : "Failed to load")) + .then((changelog) => (newsModal.markdown = changelog)); // Comment out to show news button. // newsButton.hidden = true; @@ -79,13 +90,13 @@ class Client { const langSelector = document.querySelector( "lang-selector", ) as LangSelector; - const LanguageModal = document.querySelector( - "lang-selector", + const languageModal = document.querySelector( + "language-modal", ) as LanguageModal; if (!langSelector) { console.warn("Lang selector element not found"); } - if (!LanguageModal) { + if (!languageModal) { console.warn("Language modal element not found"); } diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 13ddb67bb..4f08b2ee1 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -1,5 +1,6 @@ import { LitElement, css, html } from "lit"; -import { customElement, query } from "lit/decorators.js"; +import { resolveMarkdown } from "lit-markdown"; +import { customElement, property, query } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; @@ -11,6 +12,8 @@ export class NewsModal extends LitElement { close: () => void; }; + @property({ type: String }) markdown = "Loading..."; + static styles = css` :host { display: block; @@ -51,22 +54,10 @@ export class NewsModal extends LitElement {
-

Main things to note:

-
-
    -
  • Workers reproduce faster than troops.
  • -
  • Defense = troops divided how much land you have.
  • -
  • Attacking troops count toward your population limit.
  • -
-
-
- See full changelog - here. + ${resolveMarkdown(this.markdown, { + includeImages: true, + includeCodeBlockClassNames: true, + })}
diff --git a/src/client/Transport.ts b/src/client/Transport.ts index f208a55a1..b1e73fded 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -44,6 +44,13 @@ export class SendBreakAllianceIntentEvent implements GameEvent { ) {} } +export class SendUpgradeStructureIntentEvent implements GameEvent { + constructor( + public readonly unitId: number, + public readonly unitType: UnitType, + ) {} +} + export class SendAllianceReplyIntentEvent implements GameEvent { constructor( // The original alliance requestor @@ -187,6 +194,9 @@ export class Transport { this.onSendSpawnIntentEvent(e), ); this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e)); + this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => + this.onSendUpgradeStructureIntent(e), + ); this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.onSendBoatAttackIntent(e), ); @@ -266,6 +276,7 @@ export class Transport { onconnect, onmessage, this.lobbyConfig.gameRecord !== undefined, + this.eventBus, ); this.localServer.start(); } @@ -426,6 +437,15 @@ export class Transport { }); } + private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { + this.sendIntent({ + type: "upgrade_structure", + unit: event.unitType, + clientID: this.lobbyConfig.clientID, + unitId: event.unitId, + }); + } + private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) { this.sendIntent({ type: "targetPlayer", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index b60dcaeb6..d8e782f16 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -150,3 +150,21 @@ export function getMessageTypeClasses(type: MessageType): string { return severityColors["white"]; } } + +export function getModifierKey(): string { + const isMac = /Mac/.test(navigator.userAgent); + if (isMac) { + return "⌘"; // Command key + } else { + return "Ctrl"; + } +} + +export function getAltKey(): string { + const isMac = /Mac/.test(navigator.userAgent); + if (isMac) { + return "⌥"; // Option key + } else { + return "Alt"; + } +} diff --git a/src/client/data/countries.json b/src/client/data/countries.json index 90d67d102..61b7c0251 100644 --- a/src/client/data/countries.json +++ b/src/client/data/countries.json @@ -404,7 +404,7 @@ "name": "Cayman Islands" }, { - "code": "Ceará", + "code": "Ceara", "continent": "South America", "name": "Ceará" }, @@ -1238,7 +1238,7 @@ "name": "Malta" }, { - "code": "Māori flag", + "code": "Maori flag", "continent": "Oceania", "name": "Māori Flag" }, @@ -1501,7 +1501,7 @@ "name": "Northern Mariana Islands" }, { - "code": "1_Northern Uí Néill", + "code": "1_Northern Ui Neill", "continent": "Europe", "name": "Northern Uí Néill" }, @@ -1566,7 +1566,7 @@ "name": "Papua New Guinea" }, { - "code": "Pará", + "code": "Para", "continent": "South America", "name": "Pará" }, @@ -1803,7 +1803,7 @@ "name": "Sao Tome and Principe" }, { - "code": "São Paulo", + "code": "Sao Paulo", "continent": "South America", "name": "São Paulo" }, @@ -1941,7 +1941,7 @@ "name": "South Sudan" }, { - "code": "1_Southern Uí Néill", + "code": "1_Southern Ui Neill", "continent": "Europe", "name": "Southern Uí Néill" }, diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index ee3bbc4b9..8699c2741 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -21,6 +21,7 @@ import { OptionsMenu } from "./layers/OptionsMenu"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { PlayerTeamLabel } from "./layers/PlayerTeamLabel"; +import { ReplayPanel } from "./layers/ReplayPanel"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; @@ -126,6 +127,13 @@ export function createRenderer( optionsMenu.eventBus = eventBus; optionsMenu.game = game; + const replayPanel = document.querySelector("replay-panel") as ReplayPanel; + if (!(replayPanel instanceof ReplayPanel)) { + console.error("ReplayPanel element not found in the DOM"); + } + replayPanel.eventBus = eventBus; + replayPanel.game = game; + const topBar = document.querySelector("top-bar") as TopBar; if (!(topBar instanceof TopBar)) { console.error("top bar not found"); @@ -215,6 +223,7 @@ export function createRenderer( playerInfo, winModel, optionsMenu, + replayPanel, teamStats, topBar, playerPanel, diff --git a/src/client/graphics/ProgressBar.ts b/src/client/graphics/ProgressBar.ts new file mode 100644 index 000000000..52500bb87 --- /dev/null +++ b/src/client/graphics/ProgressBar.ts @@ -0,0 +1,61 @@ +export class ProgressBar { + private static readonly CLEAR_PADDING = 2; + constructor( + private colors: string[] = [], + private ctx: CanvasRenderingContext2D, + private x: number, + private y: number, + private w: number, + private h: number, + private progress: number = 0, // Progress from 0 to 1 + ) { + this.setProgress(progress); + } + + setProgress(progress: number): void { + progress = Math.max(0, Math.min(1, progress)); + this.clear(); + // Draw the loading bar background + this.ctx.fillStyle = "rgba(0, 0, 0, 1)"; + this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h); + + // Draw the loading progress + if (this.colors.length === 0) { + this.ctx.fillStyle = "#808080"; // default gray + } else { + const idx = Math.min( + this.colors.length - 1, + Math.floor(progress * this.colors.length), + ); + this.ctx.fillStyle = this.colors[idx]; + } + this.ctx.fillRect( + this.x, + this.y, + Math.max(1, Math.floor(progress * (this.w - 2))), + this.h - 2, + ); + this.progress = progress; + } + + clear() { + this.ctx.clearRect( + this.x - ProgressBar.CLEAR_PADDING, + this.y - ProgressBar.CLEAR_PADDING, + this.w + ProgressBar.CLEAR_PADDING, + this.h + ProgressBar.CLEAR_PADDING, + ); + } + + getX(): number { + return this.x; + } + + getY(): number { + return this.y; + } + + getProgress(): number { + return this.progress; + } +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 23f596c01..d322f76f4 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -12,7 +12,7 @@ import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; +import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; @@ -516,7 +516,7 @@ export class NameLayer implements Layer { const isSendingNuke = render.player.id() === unit.owner().id(); const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id(); return ( - (nukeTypes as UnitType[]).includes(unit.type()) && + nukeTypes.includes(unit.type()) && isSendingNuke && notMyPlayer && unit.isActive() diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts index 56e71605a..79aa4dad5 100644 --- a/src/client/graphics/layers/OptionsMenu.ts +++ b/src/client/graphics/layers/OptionsMenu.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 099a865e2..261fd3f0f 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -240,18 +240,58 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
${translateText("player_info_overlay.ports")}: ${player.units(UnitType.Port).length} + ${player + .units(UnitType.Port) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.Port) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.cities")}: ${player.units(UnitType.City).length} + ${player + .units(UnitType.City) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.City) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.missile_launchers")}: ${player.units(UnitType.MissileSilo).length} + ${player + .units(UnitType.MissileSilo) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.MissileSilo) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.sams")}: ${player.units(UnitType.SAMLauncher).length} + ${player + .units(UnitType.SAMLauncher) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.SAMLauncher) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.warships")}: diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts new file mode 100644 index 000000000..622b233ff --- /dev/null +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -0,0 +1,128 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GameType } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { ReplaySpeedChangeEvent } from "../../InputHandler"; +import { + defaultReplaySpeedMultiplier, + ReplaySpeedMultiplier, +} from "../../utilities/ReplaySpeedMultiplier"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; + +@customElement("replay-panel") +export class ReplayPanel extends LitElement implements Layer { + public game: GameView | undefined; + public eventBus: EventBus | undefined; + + @state() + private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier; + private _isSinglePlayer: boolean = false; + + @state() + private _isVisible = false; + + init() { + this._isSinglePlayer = + this.game?.config().gameConfig().gameType === GameType.Singleplayer; + if (this._isSinglePlayer) { + this.setVisible(true); + } + } + + tick() { + if (!this._isVisible && this.game?.config().isReplay()) { + this.setVisible(true); + } + + this.requestUpdate(); + } + + onReplaySpeedChange(value: ReplaySpeedMultiplier) { + this._replaySpeedMultiplier = value; + this.eventBus?.emit(new ReplaySpeedChangeEvent(value)); + } + + renderLayer(context: CanvasRenderingContext2D) { + // Render any necessary canvas elements + } + + shouldTransform(): boolean { + return false; + } + + setVisible(visible: boolean) { + this._isVisible = visible; + this.requestUpdate(); + } + + render() { + if (!this._isVisible) { + return html``; + } + + return html` +
e.preventDefault()} + > + +
+ + + + +
+
+ `; + } + + createRenderRoot() { + return this; // Disable shadow DOM to allow Tailwind styles + } +} diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 180005d26..180e838ab 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -242,13 +242,13 @@ export class StructureLayer implements Layer { const config = this.unitConfigs[unitType]; let icon: ImageData | undefined; - if (unitType === UnitType.SAMLauncher && unit.isCooldown()) { + if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) { icon = this.unitIcons.get("reloadingSam"); } else { icon = this.unitIcons.get(iconType); } - if (unitType === UnitType.MissileSilo && unit.isCooldown()) { + if (unitType === UnitType.MissileSilo && unit.isInCooldown()) { icon = this.unitIcons.get("reloadingSilo"); } else { icon = this.unitIcons.get(iconType); @@ -268,13 +268,13 @@ export class StructureLayer implements Layer { if (!unit.isActive()) return; let borderColor = this.theme.borderColor(unit.owner()); - if (unitType === UnitType.SAMLauncher && unit.isCooldown()) { + if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) { borderColor = reloadingColor; } else if (unit.type() === UnitType.Construction) { borderColor = underConstructionColor; } - if (unitType === UnitType.MissileSilo && unit.isCooldown()) { + if (unitType === UnitType.MissileSilo && unit.isInCooldown()) { borderColor = reloadingColor; } else if (unit.type() === UnitType.Construction) { borderColor = underConstructionColor; @@ -391,6 +391,7 @@ export class StructureLayer implements Layer { const screenPos = this.transformHandler.worldToScreenCoordinates(cell); const unitTile = clickedUnit.tile(); this.unitInfoModal?.onOpenStructureModal({ + eventBus: this.eventBus, unit: clickedUnit, x: screenPos.x, y: screenPos.y, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index e8b8a54a0..c9fa49933 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -1,12 +1,24 @@ import { Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; import { Theme } from "../../../core/configuration/Config"; -import { UnitType } from "../../../core/game/Game"; +import { Tick, UnitType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { UnitSelectionEvent } from "../../InputHandler"; +import { ProgressBar } from "../ProgressBar"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +const COLOR_PROGRESSION = [ + "rgb(232, 25, 25)", + "rgb(240, 122, 25)", + "rgb(202, 231, 15)", + "rgb(44, 239, 18)", +]; +const HEALTHBAR_WIDTH = 11; // Width of the health bar +const LOADINGBAR_WIDTH = 18; // Width of the loading bar +const PROGRESSBAR_HEIGHT = 3; // Height of a bar + /** * Layer responsible for drawing UI elements that overlay the game * such as selection boxes, health bars, etc. @@ -17,7 +29,11 @@ export class UILayer implements Layer { private theme: Theme | null = null; private selectionAnimTime = 0; - + private allProgressBars: Map< + number, + { unit: UnitView; startTick: Tick; endTick: Tick; progressBar: ProgressBar } + > = new Map(); + private allHealthBars: Map = new Map(); // Keep track of currently selected unit private selectedUnit: UnitView | null = null; @@ -51,6 +67,16 @@ export class UILayer implements Layer { if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) { this.drawSelectionBox(this.selectedUnit); } + + this.game + .updatesSinceLastTick() + ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) + ?.forEach((unitView) => { + if (unitView === undefined) return; + this.onUnitEvent(unitView); + }); + + this.updateProgressBars(); } init() { @@ -76,6 +102,42 @@ export class UILayer implements Layer { this.canvas.height = this.game.height(); } + onUnitEvent(unit: UnitView) { + switch (unit.type()) { + case UnitType.Construction: { + const playerId = this.game.myPlayer()?.id(); + if ( + unit.isActive() && + playerId !== undefined && + unit.owner().id() === playerId + ) { + const constructionType = unit.constructionType(); + if (constructionType === undefined) { + // Skip units without construction type + return; + } + const endTick = + this.game.unitInfo(constructionType).constructionDuration || 0; + this.drawLoadingBar(unit, endTick); + } + break; + } + case UnitType.Warship: { + this.drawHealthBar(unit); + break; + } + case UnitType.SAMLauncher: + case UnitType.MissileSilo: + if (unit.isActive() && unit.isInCooldown()) { + const endTick = unit.ticksLeftInCooldown() || 0; + this.drawLoadingBar(unit, endTick); + } + break; + default: + return; + } + } + /** * Handle the unit selection event */ @@ -187,11 +249,71 @@ export class UILayer implements Layer { } /** - * Draw health bar for a unit (placeholder for future implementation) + * Draw health bar for a unit */ public drawHealthBar(unit: UnitView) { - // This is a placeholder for future health bar implementation - // It would draw a health bar above units that have health + const maxHealth = this.game.unitInfo(unit.type()).maxHealth; + if (maxHealth === undefined || this.context === null) { + return; + } + if ( + this.allHealthBars.has(unit.id()) && + (unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive()) + ) { + // full hp/dead warships dont need a hp bar + this.allHealthBars.get(unit.id())?.clear(); + this.allHealthBars.delete(unit.id()); + } else if (unit.health() < maxHealth && unit.health() > 0) { + this.allHealthBars.get(unit.id())?.clear(); + const healthBar = new ProgressBar( + COLOR_PROGRESSION, + this.context, + this.game.x(unit.tile()) - 4, + this.game.y(unit.tile()) - 6, + HEALTHBAR_WIDTH, + PROGRESSBAR_HEIGHT, + unit.health() / maxHealth, + ); + // keep track of units that have health bars for clearing purposes + this.allHealthBars.set(unit.id(), healthBar); + } + } + + private updateProgressBars() { + const currentTick = this.game.ticks(); + this.allProgressBars.forEach((progressBarInfo, unitId) => { + const progress = + (currentTick - progressBarInfo.startTick) / progressBarInfo.endTick; + if (progress >= 1 || !progressBarInfo.unit.isActive()) { + this.allProgressBars.get(unitId)?.progressBar.clear(); + this.allProgressBars.delete(unitId); + return; + } + progressBarInfo.progressBar.setProgress(progress); + }); + } + + public drawLoadingBar(unit: UnitView, endTick: Tick) { + if (!this.context) { + return; + } + if (!this.allProgressBars.has(unit.id())) { + const progressBar = new ProgressBar( + COLOR_PROGRESSION, + this.context, + this.game.x(unit.tile()) - 8, + this.game.y(unit.tile()) - 10, + LOADINGBAR_WIDTH, + PROGRESSBAR_HEIGHT, + 0, + ); + this.allProgressBars.set(unit.id(), { + unit, + startTick: this.game.ticks(), + endTick, + progressBar, + }); + } } paintCell(x: number, y: number, color: Colord, alpha: number) { diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts index 131f9c028..cee548768 100644 --- a/src/client/graphics/layers/UnitInfoModal.ts +++ b/src/client/graphics/layers/UnitInfoModal.ts @@ -1,7 +1,10 @@ import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../../../client/Utils"; +import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { GameView, UnitView } from "../../../core/game/GameView"; +import { SendUpgradeStructureIntentEvent } from "../../Transport"; import { Layer } from "./Layer"; import { StructureLayer } from "./StructureLayer"; @@ -14,6 +17,7 @@ export class UnitInfoModal extends LitElement implements Layer { public game: GameView; public structureLayer: StructureLayer | null = null; + private eventBus: EventBus; constructor() { super(); @@ -28,12 +32,14 @@ export class UnitInfoModal extends LitElement implements Layer { } public onOpenStructureModal = ({ + eventBus, unit, x, y, tileX, tileY, }: { + eventBus: EventBus; unit: UnitView; x: number; y: number; @@ -43,6 +49,7 @@ export class UnitInfoModal extends LitElement implements Layer { if (!this.game) return; this.x = x; this.y = y; + this.eventBus = eventBus; const targetRef = this.game.ref(tileX, tileY); const allUnitTypes = Object.values(UnitType); @@ -118,12 +125,44 @@ export class UnitInfoModal extends LitElement implements Layer { .close-button:hover { background: #a00; } + + .upgrade-button { + background: #3a0; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 6px 12px; + } + + .upgrade-button:hover { + background: #0a0; + } `; render() { if (!this.unit) return null; - const cooldown = this.unit.ticksLeftInCooldown() ?? 0; + const ticksLeftInCooldown = this.unit.ticksLeftInCooldown(); + let configTimer; + switch (this.unit.type()) { + case UnitType.MissileSilo: + configTimer = this.game.config().SiloCooldown(); + break; + case UnitType.SAMLauncher: + configTimer = this.game.config().SAMCooldown(); + break; + } + let cooldown = 0; + if (ticksLeftInCooldown !== undefined && configTimer !== undefined) { + cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown); + } const secondsLeft = Math.ceil(cooldown / 10); return html` @@ -133,17 +172,53 @@ export class UnitInfoModal extends LitElement implements Layer { .x}px; top: ${this.y}px; position: absolute;" >
- Structure Info + ${translateText("unit_info_modal.structure_info")}
- Type: ${this.unit.type?.() ?? "Unknown"} + ${translateText("unit_info_modal.type")}: + ${translateText(+"unit_type." + this.unit.type?.().toLowerCase()) ?? + translateText("unit_info_modal.unit_type_unknown")} + ${translateText("unit_info_modal.level")}: + ${this.game.unitInfo(this.unit.type()).upgradable && + this.unit.level?.() + ? this.unit.level?.() + : ""}
${secondsLeft > 0 ? html`
- Cooldown: ${secondsLeft}s + ${translateText("unit_info_modal.cooldown")} + ${secondsLeft}s
` : ""} -
+
+
diff --git a/src/client/index.html b/src/client/index.html index ae94e0ae7..1b9c2c46e 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -203,7 +203,9 @@ /> -
v23.0
+
+ Loading version... +
@@ -304,6 +306,7 @@ class="flex flex-column gap-2 fixed right-[10px] top-[10px] z-50 flex flex-col w-32 sm:w-32 lg:w-48" > +
; export type CancelAttackIntent = z.infer; @@ -55,6 +56,9 @@ export type TargetTroopRatioIntent = z.infer< typeof TargetTroopRatioIntentSchema >; export type BuildUnitIntent = z.infer; +export type UpgradeStructureIntent = z.infer< + typeof UpgradeStructureIntentSchema +>; export type MoveWarshipIntent = z.infer; export type QuickChatIntent = z.infer; export type MarkDisconnectedIntent = z.infer< @@ -178,6 +182,7 @@ const BaseIntentSchema = z.object({ "emoji", "troop_ratio", "build_unit", + "upgrade_structure", "embargo", "move_warship", ]), @@ -266,6 +271,12 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ y: z.number(), }); +export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ + type: z.literal("upgrade_structure"), + unit: z.nativeEnum(UnitType), + unitId: z.number(), +}); + export const CancelAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("cancel_attack"), attackID: z.string(), @@ -316,6 +327,7 @@ const IntentSchema = z.union([ DonateTroopIntentSchema, TargetTroopRatioIntentSchema, BuildUnitIntentSchema, + UpgradeStructureIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, QuickChatIntentSchema, diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index a9ef657b4..e9a643766 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -83,6 +83,7 @@ export const OTHER_INDEX_BUILT = 0; // Structures and warships built export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed export const OTHER_INDEX_CAPTURE = 2; // Structures captured export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others +export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded const BigIntStringSchema = z.preprocess((val) => { if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val); diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts index f2bbfd0fe..d09db865e 100644 --- a/src/core/configuration/Colors.ts +++ b/src/core/configuration/Colors.ts @@ -1,4 +1,6 @@ import { colord, Colord } from "colord"; +import { ColoredTeams, Team } from "../game/Game"; +import { simpleHash } from "../Util"; export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue @@ -9,7 +11,7 @@ export const orange = colord({ h: 25, s: 95, l: 53 }); export const green = colord({ h: 128, s: 49, l: 50 }); export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray -export const territoryColors: Colord[] = [ +export const nationColors: Colord[] = [ colord({ r: 230, g: 100, b: 100 }), // Bright Red colord({ r: 100, g: 180, b: 230 }), // Sky Blue colord({ r: 230, g: 180, b: 80 }), // Golden Yellow @@ -109,110 +111,71 @@ export const territoryColors: Colord[] = [ colord({ r: 170, g: 150, b: 170 }), // Dusty Rose ]; +// Bright pastel theme with 64 colors export const humanColors: Colord[] = [ - // Original set - colord({ r: 235, g: 75, b: 75 }), // Bright Red - colord({ r: 67, g: 190, b: 84 }), // Fresh Green - colord({ r: 59, g: 130, b: 246 }), // Royal Blue - colord({ r: 245, g: 158, b: 11 }), // Amber - colord({ r: 236, g: 72, b: 153 }), // Deep Pink - colord({ r: 48, g: 178, b: 180 }), // Teal - colord({ r: 168, g: 85, b: 247 }), // Vibrant Purple - colord({ r: 251, g: 191, b: 36 }), // Marigold - colord({ r: 74, g: 222, b: 128 }), // Mint - colord({ r: 239, g: 68, b: 68 }), // Crimson - colord({ r: 34, g: 197, b: 94 }), // Emerald - colord({ r: 96, g: 165, b: 250 }), // Sky Blue - colord({ r: 249, g: 115, b: 22 }), // Tangerine - colord({ r: 192, g: 132, b: 252 }), // Lavender - colord({ r: 45, g: 212, b: 191 }), // Turquoise - colord({ r: 244, g: 114, b: 182 }), // Rose - colord({ r: 132, g: 204, b: 22 }), // Lime - colord({ r: 56, g: 189, b: 248 }), // Light Blue - colord({ r: 234, g: 179, b: 8 }), // Sunflower - colord({ r: 217, g: 70, b: 239 }), // Fuchsia colord({ r: 16, g: 185, b: 129 }), // Sea Green - colord({ r: 251, g: 146, b: 60 }), // Light Orange - colord({ r: 147, g: 51, b: 234 }), // Bright Purple - colord({ r: 79, g: 70, b: 229 }), // Indigo - colord({ r: 245, g: 101, b: 101 }), // Coral - colord({ r: 134, g: 239, b: 172 }), // Light Green - colord({ r: 59, g: 130, b: 246 }), // Cerulean - colord({ r: 253, g: 164, b: 175 }), // Salmon Pink - colord({ r: 147, g: 197, b: 253 }), // Powder Blue - colord({ r: 252, g: 211, b: 77 }), // Golden - colord({ r: 190, g: 92, b: 251 }), // Amethyst - colord({ r: 82, g: 183, b: 136 }), // Jade - colord({ r: 248, g: 113, b: 113 }), // Warm Red - colord({ r: 99, g: 202, b: 253 }), // Azure - colord({ r: 240, g: 171, b: 252 }), // Orchid - colord({ r: 163, g: 230, b: 53 }), // Yellow Green - colord({ r: 234, g: 88, b: 12 }), // Burnt Orange - colord({ r: 125, g: 211, b: 252 }), // Crystal Blue - colord({ r: 251, g: 113, b: 133 }), // Watermelon + colord({ r: 34, g: 197, b: 94 }), // Emerald + colord({ r: 45, g: 212, b: 191 }), // Turquoise + colord({ r: 48, g: 178, b: 180 }), // Teal colord({ r: 52, g: 211, b: 153 }), // Spearmint - colord({ r: 167, g: 139, b: 250 }), // Periwinkle - colord({ r: 245, g: 158, b: 11 }), // Honey + colord({ r: 56, g: 189, b: 248 }), // Light Blue + colord({ r: 59, g: 130, b: 246 }), // Royal Blue + colord({ r: 67, g: 190, b: 84 }), // Fresh Green + colord({ r: 74, g: 222, b: 128 }), // Mint + colord({ r: 79, g: 70, b: 229 }), // Indigo + colord({ r: 82, g: 183, b: 136 }), // Jade + colord({ r: 96, g: 165, b: 250 }), // Sky Blue + colord({ r: 99, g: 202, b: 253 }), // Azure colord({ r: 110, g: 231, b: 183 }), // Seafoam - colord({ r: 233, g: 213, b: 255 }), // Light Lilac - colord({ r: 202, g: 138, b: 4 }), // Rich Gold - colord({ r: 151, g: 255, b: 187 }), // Fresh Mint - colord({ r: 220, g: 38, b: 38 }), // Ruby colord({ r: 124, g: 58, b: 237 }), // Royal Purple - colord({ r: 45, g: 212, b: 191 }), // Ocean - colord({ r: 252, g: 165, b: 165 }), // Peach - - // Additional 50 colors - colord({ r: 179, g: 136, b: 255 }), // Light Purple + colord({ r: 125, g: 211, b: 252 }), // Crystal Blue + colord({ r: 132, g: 204, b: 22 }), // Lime colord({ r: 133, g: 77, b: 14 }), // Chocolate - colord({ r: 52, g: 211, b: 153 }), // Aquamarine - colord({ r: 234, g: 179, b: 8 }), // Mustard - colord({ r: 236, g: 72, b: 153 }), // Hot Pink - colord({ r: 147, g: 197, b: 253 }), // Sky - colord({ r: 249, g: 115, b: 22 }), // Pumpkin - colord({ r: 167, g: 139, b: 250 }), // Iris - colord({ r: 16, g: 185, b: 129 }), // Pine - colord({ r: 251, g: 146, b: 60 }), // Mango - colord({ r: 192, g: 132, b: 252 }), // Wisteria - colord({ r: 79, g: 70, b: 229 }), // Sapphire - colord({ r: 245, g: 101, b: 101 }), // Salmon - colord({ r: 134, g: 239, b: 172 }), // Spring Green - colord({ r: 59, g: 130, b: 246 }), // Ocean Blue - colord({ r: 253, g: 164, b: 175 }), // Rose Gold - colord({ r: 16, g: 185, b: 129 }), // Forest - colord({ r: 252, g: 211, b: 77 }), // Sunshine - colord({ r: 190, g: 92, b: 251 }), // Grape - colord({ r: 82, g: 183, b: 136 }), // Eucalyptus - colord({ r: 248, g: 113, b: 113 }), // Cherry - colord({ r: 99, g: 202, b: 253 }), // Arctic - colord({ r: 240, g: 171, b: 252 }), // Lilac - colord({ r: 163, g: 230, b: 53 }), // Chartreuse - colord({ r: 234, g: 88, b: 12 }), // Rust - colord({ r: 125, g: 211, b: 252 }), // Ice Blue - colord({ r: 251, g: 113, b: 133 }), // Strawberry - colord({ r: 52, g: 211, b: 153 }), // Sage - colord({ r: 167, g: 139, b: 250 }), // Violet - colord({ r: 245, g: 158, b: 11 }), // Apricot - colord({ r: 110, g: 231, b: 183 }), // Mint Green - colord({ r: 233, g: 213, b: 255 }), // Thistle - colord({ r: 202, g: 138, b: 4 }), // Bronze - colord({ r: 151, g: 255, b: 187 }), // Pistachio - colord({ r: 220, g: 38, b: 38 }), // Fire Engine - colord({ r: 124, g: 58, b: 237 }), // Electric Purple - colord({ r: 45, g: 212, b: 191 }), // Caribbean - colord({ r: 252, g: 165, b: 165 }), // Melon - colord({ r: 168, g: 85, b: 247 }), // Byzantium - colord({ r: 74, g: 222, b: 128 }), // Kelly Green - colord({ r: 239, g: 68, b: 68 }), // Cardinal - colord({ r: 34, g: 197, b: 94 }), // Shamrock - colord({ r: 96, g: 165, b: 250 }), // Marina - colord({ r: 249, g: 115, b: 22 }), // Carrot - colord({ r: 192, g: 132, b: 252 }), // Heliotrope - colord({ r: 45, g: 212, b: 191 }), // Lagoon - colord({ r: 244, g: 114, b: 182 }), // Bubble Gum - colord({ r: 132, g: 204, b: 22 }), // Apple - colord({ r: 56, g: 189, b: 248 }), // Electric Blue - colord({ r: 234, g: 179, b: 8 }), // Daffodil + colord({ r: 134, g: 239, b: 172 }), // Light Green + colord({ r: 147, g: 51, b: 234 }), // Bright Purple + colord({ r: 147, g: 197, b: 253 }), // Powder Blue + colord({ r: 151, g: 255, b: 187 }), // Fresh Mint + colord({ r: 163, g: 230, b: 53 }), // Yellow Green + colord({ r: 167, g: 139, b: 250 }), // Periwinkle + colord({ r: 168, g: 85, b: 247 }), // Vibrant Purple + colord({ r: 179, g: 136, b: 255 }), // Light Purple + colord({ r: 186, g: 255, b: 201 }), // Pale Emerald + colord({ r: 190, g: 92, b: 251 }), // Amethyst + colord({ r: 192, g: 132, b: 252 }), // Lavender + colord({ r: 202, g: 138, b: 4 }), // Rich Gold + colord({ r: 202, g: 225, b: 255 }), // Baby Blue + colord({ r: 204, g: 204, b: 255 }), // Soft Lavender Blue + colord({ r: 217, g: 70, b: 239 }), // Fuchsia + colord({ r: 220, g: 38, b: 38 }), // Ruby + colord({ r: 220, g: 220, b: 255 }), // Meringue Blue + colord({ r: 220, g: 240, b: 250 }), // Ice Blue + colord({ r: 230, g: 250, b: 210 }), // Pastel Lime + colord({ r: 230, g: 255, b: 250 }), // Mint Whisper + colord({ r: 233, g: 213, b: 255 }), // Light Lilac + colord({ r: 234, g: 88, b: 12 }), // Burnt Orange + colord({ r: 234, g: 179, b: 8 }), // Sunflower + colord({ r: 235, g: 75, b: 75 }), // Bright Red + colord({ r: 236, g: 72, b: 153 }), // Deep Pink + colord({ r: 239, g: 68, b: 68 }), // Crimson + colord({ r: 240, g: 171, b: 252 }), // Orchid + colord({ r: 240, g: 240, b: 200 }), // Light Khaki + colord({ r: 244, g: 114, b: 182 }), // Rose + colord({ r: 245, g: 101, b: 101 }), // Coral + colord({ r: 245, g: 158, b: 11 }), // Amber + colord({ r: 248, g: 113, b: 113 }), // Warm Red + colord({ r: 249, g: 115, b: 22 }), // Tangerine + colord({ r: 250, g: 215, b: 225 }), // Cotton Candy + colord({ r: 250, g: 250, b: 210 }), // Pastel Lemon + colord({ r: 251, g: 113, b: 133 }), // Watermelon + colord({ r: 251, g: 146, b: 60 }), // Light Orange + colord({ r: 251, g: 191, b: 36 }), // Marigold + colord({ r: 251, g: 235, b: 245 }), // Rose Powder + colord({ r: 252, g: 165, b: 165 }), // Peach + colord({ r: 252, g: 211, b: 77 }), // Golden + colord({ r: 253, g: 164, b: 175 }), // Salmon Pink + colord({ r: 255, g: 204, b: 229 }), // Blush Pink + colord({ r: 255, g: 223, b: 186 }), // Apricot Cream + colord({ r: 255, g: 240, b: 200 }), // Vanilla ]; export const botColors: Colord[] = [ @@ -266,3 +229,156 @@ export const botColors: Colord[] = [ colord({ r: 150, g: 160, b: 140 }), // Muted Dark Olive Green colord({ r: 150, g: 140, b: 150 }), // Muted Dusty Rose ]; + +// Fallback colors for when the color palette is exhausted. Currently 100 colors. +export const fallbackColors: Colord[] = [ + colord({ r: 0, g: 5, b: 0 }), // Black Mint + colord({ r: 0, g: 15, b: 0 }), // Deep Forest + colord({ r: 0, g: 25, b: 0 }), // Jungle + colord({ r: 0, g: 35, b: 0 }), // Dark Emerald + colord({ r: 0, g: 45, b: 0 }), // Green Moss + colord({ r: 0, g: 55, b: 0 }), // Moss Shadow + colord({ r: 0, g: 65, b: 0 }), // Dark Meadow + colord({ r: 0, g: 75, b: 0 }), // Forest Fern + colord({ r: 0, g: 85, b: 0 }), // Pine Leaf + colord({ r: 0, g: 95, b: 0 }), // Shadow Grass + colord({ r: 0, g: 105, b: 0 }), // Classic Green + colord({ r: 0, g: 115, b: 0 }), // Deep Lime + colord({ r: 0, g: 125, b: 0 }), // Dense Leaf + colord({ r: 0, g: 135, b: 0 }), // Basil Green + colord({ r: 0, g: 145, b: 0 }), // Organic Green + colord({ r: 0, g: 155, b: 0 }), // Bitter Herb + colord({ r: 0, g: 165, b: 0 }), // Raw Spinach + colord({ r: 0, g: 175, b: 0 }), // Woodland + colord({ r: 0, g: 185, b: 0 }), // Spring Weed + colord({ r: 0, g: 195, b: 5 }), // Apple Stem + colord({ r: 0, g: 205, b: 10 }), // Crisp Lettuce + colord({ r: 0, g: 215, b: 15 }), // Vibrant Green + colord({ r: 0, g: 225, b: 20 }), // Bright Herb + colord({ r: 0, g: 235, b: 25 }), // Green Splash + colord({ r: 0, g: 245, b: 30 }), // Mint Leaf + colord({ r: 0, g: 255, b: 35 }), // Fresh Mint + colord({ r: 10, g: 255, b: 45 }), // Neon Grass + colord({ r: 20, g: 255, b: 55 }), // Lemon Balm + colord({ r: 30, g: 255, b: 65 }), // Juicy Green + colord({ r: 40, g: 255, b: 75 }), // Pear Tint + colord({ r: 50, g: 255, b: 85 }), // Avocado Pastel + colord({ r: 60, g: 255, b: 95 }), // Lime Glow + colord({ r: 70, g: 255, b: 105 }), // Light Leaf + colord({ r: 80, g: 255, b: 115 }), // Soft Fern + colord({ r: 90, g: 255, b: 125 }), // Pastel Green + colord({ r: 100, g: 255, b: 135 }), // Green Melon + colord({ r: 110, g: 255, b: 145 }), // Herbal Mist + colord({ r: 120, g: 255, b: 155 }), // Kiwi Foam + colord({ r: 130, g: 255, b: 165 }), // Aloe Fresh + colord({ r: 140, g: 255, b: 175 }), // Light Mint + colord({ r: 150, g: 200, b: 255 }), // Cornflower Mist + colord({ r: 150, g: 255, b: 185 }), // Green Sorbet + colord({ r: 160, g: 215, b: 255 }), // Powder Blue + colord({ r: 160, g: 255, b: 195 }), // Pastel Apple + colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice + colord({ r: 170, g: 225, b: 255 }), // Baby Sky + colord({ r: 170, g: 255, b: 205 }), // Aloe Breeze + colord({ r: 180, g: 180, b: 255 }), // Pale Indigo + colord({ r: 180, g: 235, b: 250 }), // Aqua Pastel + colord({ r: 180, g: 255, b: 215 }), // Pale Mint + colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint + colord({ r: 190, g: 245, b: 240 }), // Ice Mint + colord({ r: 190, g: 255, b: 225 }), // Mint Water + colord({ r: 195, g: 145, b: 200 }), // Dusky Rose + colord({ r: 200, g: 150, b: 205 }), // Plum Frost + colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom + colord({ r: 200, g: 255, b: 215 }), // Cool Aloe + colord({ r: 200, g: 255, b: 235 }), // Cool Mist + colord({ r: 205, g: 155, b: 210 }), // Berry Foam + colord({ r: 210, g: 160, b: 215 }), // Grape Cloud + colord({ r: 210, g: 255, b: 245 }), // Sea Mist + colord({ r: 215, g: 165, b: 220 }), // Light Bloom + colord({ r: 215, g: 255, b: 200 }), // Fresh Mint + colord({ r: 220, g: 160, b: 255 }), // Violet Mist + colord({ r: 220, g: 170, b: 225 }), // Cherry Blossom + colord({ r: 220, g: 255, b: 255 }), // Pale Aqua + colord({ r: 225, g: 175, b: 230 }), // Faded Rose + colord({ r: 225, g: 255, b: 175 }), // Soft Lime + colord({ r: 230, g: 180, b: 235 }), // Dreamy Mauve + colord({ r: 230, g: 250, b: 255 }), // Sky Haze + colord({ r: 235, g: 150, b: 255 }), // Orchid Glow + colord({ r: 235, g: 185, b: 240 }), // Powder Violet + colord({ r: 240, g: 190, b: 245 }), // Pastel Violet + colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac + colord({ r: 240, g: 250, b: 160 }), // Citrus Wash + colord({ r: 245, g: 160, b: 240 }), // Rose Lilac + colord({ r: 245, g: 195, b: 250 }), // Soft Magenta + colord({ r: 245, g: 245, b: 175 }), // Lemon Mist + colord({ r: 250, g: 200, b: 255 }), // Lilac Cream + colord({ r: 250, g: 230, b: 255 }), // Misty Mauve + colord({ r: 255, g: 170, b: 225 }), // Bubblegum Pink + colord({ r: 255, g: 185, b: 215 }), // Blush Mist + colord({ r: 255, g: 195, b: 235 }), // Faded Fuchsia + colord({ r: 255, g: 200, b: 220 }), // Cotton Rose + colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid + colord({ r: 255, g: 205, b: 255 }), // Violet Bloom + colord({ r: 255, g: 210, b: 230 }), // Pastel Blush + colord({ r: 255, g: 210, b: 250 }), // Lavender Mist + colord({ r: 255, g: 210, b: 255 }), // Orchid Mist + colord({ r: 255, g: 215, b: 195 }), // Apricot Glow + colord({ r: 255, g: 215, b: 245 }), // Rose Whisper + colord({ r: 255, g: 220, b: 235 }), // Pink Mist + colord({ r: 255, g: 220, b: 250 }), // Powder Petal + colord({ r: 255, g: 225, b: 180 }), // Butter Peach + colord({ r: 255, g: 225, b: 255 }), // Petal Mist + colord({ r: 255, g: 230, b: 245 }), // Light Rose + colord({ r: 255, g: 235, b: 200 }), // Cream Peach + colord({ r: 255, g: 235, b: 235 }), // Blushed Petal + colord({ r: 255, g: 240, b: 220 }), // Pastel Sand + colord({ r: 255, g: 245, b: 210 }), // Soft Banana +]; + +export class ColorAllocator { + private availableColors: Colord[]; + private fallbackColors: Colord[]; + private assigned = new Map(); + + constructor(colors: Colord[], fallback: Colord[]) { + this.availableColors = [...colors]; + this.fallbackColors = [...fallback]; + } + + assignColor(id: string): Colord { + if (this.assigned.has(id)) { + return this.assigned.get(id)!; + } + if (this.availableColors.length === 0) { + this.availableColors = [...this.fallbackColors]; + } + const index = 0; + const color = this.availableColors.splice(index, 1)[0]; + this.assigned.set(id, color); + return color; + } + + assignTeamColor(team: Team): Colord { + switch (team) { + case ColoredTeams.Blue: + return blue; + case ColoredTeams.Red: + return red; + case ColoredTeams.Teal: + return teal; + case ColoredTeams.Purple: + return purple; + case ColoredTeams.Yellow: + return yellow; + case ColoredTeams.Orange: + return orange; + case ColoredTeams.Green: + return green; + case ColoredTeams.Bot: + return botColor; + default: + return this.availableColors[ + simpleHash(team) % this.availableColors.length + ]; + } + } +} diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 23f28513a..d7a825e8d 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -359,6 +359,7 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, + upgradable: true, }; case UnitType.AtomBomb: return { @@ -402,6 +403,7 @@ export class DefaultConfig implements Config { : 1_000_000n, territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 10 * 10, + upgradable: true, }; case UnitType.DefensePost: return { @@ -418,6 +420,7 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, + upgradable: true, }; case UnitType.SAMLauncher: return { @@ -434,6 +437,7 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 30 * 10, + upgradable: true, }; case UnitType.City: return { @@ -451,6 +455,7 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, + upgradable: true, }; case UnitType.Construction: return { @@ -678,7 +683,11 @@ export class DefaultConfig implements Config { player.type() === PlayerType.Human && this.infiniteTroops() ? 1_000_000_000 : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) + - player.units(UnitType.City).length * this.cityPopulationIncrease(); + player + .units(UnitType.City) + .map((city) => city.level()) + .reduce((a, b) => a + b, 0) * + this.cityPopulationIncrease(); if (player.type() === PlayerType.Bot) { return maxPop / 2; @@ -764,7 +773,7 @@ export class DefaultConfig implements Config { } defaultNukeSpeed(): number { - return 4; + return 6; } // Humans can be population, soldiers attacking, soldiers in boat etc. @@ -773,8 +782,7 @@ export class DefaultConfig implements Config { } structureMinDist(): number { - // TODO: Increase this to ~15 once upgradable structures are implemented. - return 1; + return 15; } shellLifetime(): number { diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 8846348d0..c723d9bd3 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -1,21 +1,14 @@ import { Colord, colord } from "colord"; import { PseudoRandom } from "../PseudoRandom"; -import { simpleHash } from "../Util"; -import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game"; +import { PlayerType, Team, TerrainType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PlayerView } from "../game/GameView"; import { - blue, - botColor, botColors, - green, + ColorAllocator, + fallbackColors, humanColors, - orange, - purple, - red, - teal, - territoryColors, - yellow, + nationColors, } from "./Colors"; import { Theme } from "./Config"; @@ -24,9 +17,12 @@ type ColorCache = Map; export class PastelTheme implements Theme { private borderColorCache: ColorCache = new Map(); private rand = new PseudoRandom(123); + private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); + private botColorAllocator = new ColorAllocator(botColors, botColors); + private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors); + private nationColorAllocator = new ColorAllocator(nationColors, nationColors); private background = colord({ r: 60, g: 60, b: 60 }); - private land = colord({ r: 194, g: 193, b: 148 }); private shore = colord({ r: 204, g: 203, b: 158 }); private falloutColors = [ colord({ r: 120, g: 255, b: 71 }), // Original color @@ -45,26 +41,7 @@ export class PastelTheme implements Theme { private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 }); teamColor(team: Team): Colord { - switch (team) { - case ColoredTeams.Blue: - return blue; - case ColoredTeams.Red: - return red; - case ColoredTeams.Teal: - return teal; - case ColoredTeams.Purple: - return purple; - case ColoredTeams.Yellow: - return yellow; - case ColoredTeams.Orange: - return orange; - case ColoredTeams.Green: - return green; - case ColoredTeams.Bot: - return botColor; - default: - return humanColors[simpleHash(team) % humanColors.length]; - } + return this.teamColorAllocator.assignTeamColor(team); } territoryColor(player: PlayerView): Colord { @@ -73,12 +50,12 @@ export class PastelTheme implements Theme { return this.teamColor(team); } if (player.type() === PlayerType.Human) { - return humanColors[simpleHash(player.id()) % humanColors.length]; + return this.humanColorAllocator.assignColor(player.id()); } if (player.type() === PlayerType.Bot) { - return botColors[simpleHash(player.id()) % botColors.length]; + return this.botColorAllocator.assignColor(player.id()); } - return territoryColors[simpleHash(player.id()) % territoryColors.length]; + return this.nationColorAllocator.assignColor(player.id()); } textColor(player: PlayerView): string { diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index 3d428c447..467205cea 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -1,21 +1,14 @@ import { Colord, colord } from "colord"; import { PseudoRandom } from "../PseudoRandom"; -import { simpleHash } from "../Util"; -import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game"; +import { PlayerType, Team, TerrainType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PlayerView } from "../game/GameView"; import { - blue, - botColor, botColors, - green, + ColorAllocator, + fallbackColors, humanColors, - orange, - purple, - red, - teal, - territoryColors, - yellow, + nationColors, } from "./Colors"; import { Theme } from "./Config"; @@ -24,9 +17,12 @@ type ColorCache = Map; export class PastelThemeDark implements Theme { private borderColorCache: ColorCache = new Map(); private rand = new PseudoRandom(123); + private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); + private botColorAllocator = new ColorAllocator(botColors, botColors); + private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors); + private nationColorAllocator = new ColorAllocator(nationColors, nationColors); private background = colord({ r: 0, g: 0, b: 0 }); - private land = colord({ r: 194, g: 193, b: 148 }); private shore = colord({ r: 134, g: 133, b: 88 }); private falloutColors = [ colord({ r: 120, g: 255, b: 71 }), // Original color @@ -45,26 +41,7 @@ export class PastelThemeDark implements Theme { private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 }); teamColor(team: Team): Colord { - switch (team) { - case ColoredTeams.Blue: - return blue; - case ColoredTeams.Red: - return red; - case ColoredTeams.Teal: - return teal; - case ColoredTeams.Purple: - return purple; - case ColoredTeams.Yellow: - return yellow; - case ColoredTeams.Orange: - return orange; - case ColoredTeams.Green: - return green; - case ColoredTeams.Bot: - return botColor; - default: - return humanColors[simpleHash(team) % humanColors.length]; - } + return this.teamColorAllocator.assignTeamColor(team); } territoryColor(player: PlayerView): Colord { @@ -73,12 +50,12 @@ export class PastelThemeDark implements Theme { return this.teamColor(team); } if (player.type() === PlayerType.Human) { - return humanColors[simpleHash(player.id()) % humanColors.length]; + return this.humanColorAllocator.assignColor(player.id()); } if (player.type() === PlayerType.Bot) { - return botColors[simpleHash(player.id()) % botColors.length]; + return this.botColorAllocator.assignColor(player.id()); } - return territoryColors[simpleHash(player.id()) % territoryColors.length]; + return this.nationColorAllocator.assignColor(player.id()); } textColor(player: PlayerView): string { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index bc8c78dff..51c07c212 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -122,31 +122,15 @@ export class AttackExecution implements Execution { // Record stats this.mg.stats().attack(this._owner, this.target, this.startTroops); - for (const incoming of this._owner.incomingAttacks()) { - if (incoming.attacker() === this.target) { - // Target has opposing attack, cancel them out - if (incoming.troops() > this.attack.troops()) { - incoming.setTroops(incoming.troops() - this.attack.troops()); - this.attack.delete(); - this.active = false; - return; - } else { - this.attack.setTroops(this.attack.troops() - incoming.troops()); - incoming.delete(); - } - } - } for (const outgoing of this._owner.outgoingAttacks()) { if ( outgoing !== this.attack && outgoing.target() === this.attack.target() && - outgoing.sourceTile() === this.attack.sourceTile() + // Boat attacks (sourceTile is not null) are not combined with other attacks + this.attack.sourceTile() === null ) { - // Existing attack on same target, add troops - outgoing.setTroops(outgoing.troops() + this.attack.troops()); - this.active = false; - this.attack.delete(); - return; + this.attack.setTroops(this.attack.troops() + outgoing.troops()); + outgoing.delete(); } } diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 84d46768c..71141929d 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -58,11 +58,8 @@ export class BotExecution implements Execution { if (this.behavior === null) { throw new Error("not initialized"); } - const traitors = this.bot - .neighbors() - .filter((n) => n.isPlayer() && n.isTraitor()) as Player[]; - if (traitors.length > 0) { - const toAttack = this.random.randElement(traitors); + const toAttack = this.behavior.getNeighborTraitorToAttack(); + if (toAttack !== null) { const odds = this.bot.isFriendly(toAttack) ? 6 : 3; if (this.random.chance(odds)) { this.behavior.sendAttack(toAttack); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a333d95ec..9640b8d5e 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -24,6 +24,7 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TransportShipExecution } from "./TransportShipExecution"; +import { UpgradeStructureExecution } from "./UpgradeStructureExecution"; export class Executor { // private random = new PseudoRandom(999) @@ -114,6 +115,8 @@ export class Executor { this.mg.ref(intent.x, intent.y), intent.unit, ); + case "upgrade_structure": + return new UpgradeStructureExecution(player, intent.unitId); case "quick_chat": return new QuickChatExecution( player, diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index 7fd9459d8..8ff9fabc3 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -34,10 +34,20 @@ export class MissileSiloExecution implements Execution { } } - const cooldown = this.silo.ticksLeftInCooldown(); + const frontTime = this.silo.ticksLeftInCooldown(); + if (frontTime === undefined) { + return; + } + + const cooldown = + this.mg.config().SiloCooldown() - (this.mg.ticks() - frontTime); if (typeof cooldown === "number" && cooldown >= 0) { this.silo.touch(); } + + if (cooldown <= 0) { + this.silo.reloadMissile(); + } } isActive(): boolean { diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 783c2b1f4..e183c67cf 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -155,11 +155,6 @@ export class SAMLauncherExecution implements Execution { target = this.getSingleTarget(); } - const cooldown = this.sam.ticksLeftInCooldown(); - if (typeof cooldown === "number" && cooldown >= 0) { - this.sam.touch(); - } - const isSingleTarget = target && !target.targetedBySAM(); if ( (isSingleTarget || mirvWarheadTargets.length > 0) && @@ -204,6 +199,21 @@ export class SAMLauncherExecution implements Execution { } } } + + const frontTime = this.sam.ticksLeftInCooldown(); + if (frontTime === undefined) { + return; + } + + const cooldown = + this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime); + if (typeof cooldown === "number" && cooldown >= 0) { + this.sam.touch(); + } + + if (cooldown <= 0) { + this.sam.reloadMissile(); + } } isActive(): boolean { diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts new file mode 100644 index 000000000..19f14ff4c --- /dev/null +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -0,0 +1,44 @@ +import { Execution, Game, Player, Unit } from "../game/Game"; + +export class UpgradeStructureExecution implements Execution { + private structure: Unit | undefined; + private cost: bigint; + + constructor( + private player: Player, + private unitId: number, + ) {} + + init(mg: Game, ticks: number): void { + this.structure = this.player + .units() + .find((unit) => unit.id() === this.unitId); + + if (this.structure === undefined) { + console.warn(`structure is undefined`); + return; + } + if (!this.structure.info().upgradable) { + console.warn(`unit type ${this.structure} cannot be upgraded`); + return; + } + this.cost = this.structure.info().cost(this.player); + if (this.player.gold() < this.cost) { + return; + } + this.player.upgradeUnit(this.structure); + return; + } + + tick(ticks: number): void { + return; + } + + isActive(): boolean { + return false; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 39ff00907..5e393d967 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -43,22 +43,48 @@ export class BotBehavior { this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } + private setNewEnemy(newEnemy: Player | null) { + this.enemy = newEnemy; + this.enemyUpdated = this.game.ticks(); + } + + private clearEnemy() { + this.enemy = null; + } + forgetOldEnemies() { // Forget old enemies if (this.game.ticks() - this.enemyUpdated > 100) { - this.enemy = null; + this.clearEnemy(); } } + private hasSufficientTroops(): boolean { + const maxPop = this.game.config().maxPopulation(this.player); + const ratio = this.player.population() / maxPop; + return ratio >= this.triggerRatio; + } + private checkIncomingAttacks() { // Switch enemies if we're under attack const incomingAttacks = this.player.incomingAttacks(); - if (incomingAttacks.length > 0) { - this.enemy = incomingAttacks - .sort((a, b) => b.troops() - a.troops())[0] - .attacker(); - this.enemyUpdated = this.game.ticks(); + let largestAttack = 0; + let largestAttacker: Player | undefined; + for (const attack of incomingAttacks) { + if (attack.troops() <= largestAttack) continue; + largestAttack = attack.troops(); + largestAttacker = attack.attacker(); } + if (largestAttacker !== undefined) { + this.setNewEnemy(largestAttacker); + } + } + + getNeighborTraitorToAttack(): Player | null { + const traitors = this.player + .neighbors() + .filter((n): n is Player => n.isPlayer() && n.isTraitor()); + return traitors.length > 0 ? this.random.randElement(traitors) : null; } assistAllies() { @@ -79,8 +105,7 @@ export class BotBehavior { } // All checks passed, assist them this.player.updateRelation(ally, -20); - this.enemy = target; - this.enemyUpdated = this.game.ticks(); + this.setNewEnemy(target); this.emoji(ally, this.assistAcceptEmoji); break outer; } @@ -90,50 +115,57 @@ export class BotBehavior { selectEnemy(): Player | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio - const maxPop = this.game.config().maxPopulation(this.player); - const ratio = this.player.population() / maxPop; - if (ratio < this.triggerRatio) return null; - } + if (!this.hasSufficientTroops()) return null; - // Prefer neighboring bots - if (this.enemy === null) { + // Prefer neighboring bots const bots = this.player .neighbors() - .filter((n) => n.isPlayer() && n.type() === PlayerType.Bot) as Player[]; + .filter( + (n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot, + ); if (bots.length > 0) { const density = (p: Player) => p.troops() / p.numTilesOwned(); - this.enemy = bots.sort((a, b) => density(a) - density(b))[0]; - this.enemyUpdated = this.game.ticks(); + let lowestDensityBot: Player | undefined; + let lowestDensity = Infinity; + + for (const bot of bots) { + const currentDensity = density(bot); + if (currentDensity < lowestDensity) { + lowestDensity = currentDensity; + lowestDensityBot = bot; + } + } + + if (lowestDensityBot !== undefined) { + this.setNewEnemy(lowestDensityBot); + } } - } - // Retaliate against incoming attacks - if (this.enemy === null) { - this.checkIncomingAttacks(); - } + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } - // Select the most hated player - if (this.enemy === null) { - const mostHated = this.player.allRelationsSorted()[0]; - if (mostHated !== undefined && mostHated.relation === Relation.Hostile) { - this.enemy = mostHated.player; - this.enemyUpdated = this.game.ticks(); + // Select the most hated player + if (this.enemy === null) { + const mostHated = this.player.allRelationsSorted()[0]; + if ( + mostHated !== undefined && + mostHated.relation === Relation.Hostile + ) { + this.setNewEnemy(mostHated.player); + } } } // Sanity check, don't attack our allies or teammates - if (this.enemy && this.player.isFriendly(this.enemy)) { - this.enemy = null; - } - return this.enemy; + return this.enemySanityCheck(); } selectRandomEnemy(): Player | TerraNullius | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio - const maxPop = this.game.config().maxPopulation(this.player); - const ratio = this.player.population() / maxPop; - if (ratio < this.triggerRatio) return null; + if (!this.hasSufficientTroops()) return null; // Choose a new enemy randomly const neighbors = this.player.neighbors(); @@ -145,34 +177,32 @@ export class BotBehavior { continue; } } - this.enemy = neighbor; - this.enemyUpdated = this.game.ticks(); + this.setNewEnemy(neighbor); } - } - // Retaliate against incoming attacks - if (this.enemy === null) { - this.checkIncomingAttacks(); - } + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } - // Select a traitor as an enemy - if (this.enemy === null) { - const traitors = this.player - .neighbors() - .filter((n) => n.isPlayer() && n.isTraitor()) as Player[]; - if (traitors.length > 0) { - const toAttack = this.random.randElement(traitors); - const odds = this.player.isFriendly(toAttack) ? 6 : 3; - if (this.random.chance(odds)) { - this.enemy = toAttack; - this.enemyUpdated = this.game.ticks(); + // Select a traitor as an enemy + if (this.enemy === null) { + const toAttack = this.getNeighborTraitorToAttack(); + if (toAttack !== null) { + if (!this.player.isFriendly(toAttack) && this.random.chance(3)) { + this.setNewEnemy(toAttack); + } } } } // Sanity check, don't attack our allies or teammates + return this.enemySanityCheck(); + } + + private enemySanityCheck(): Player | null { if (this.enemy && this.player.isFriendly(this.enemy)) { - this.enemy = null; + this.clearEnemy(); } return this.enemy; } @@ -200,12 +230,17 @@ export class BotBehavior { } function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) { - const isTraitor = request.requestor().isTraitor(); - const hasMalice = player.relation(request.requestor()) < Relation.Neutral; - const requestorIsMuchLarger = - request.requestor().numTilesOwned() > player.numTilesOwned() * 3; - const tooManyAlliances = request.requestor().alliances().length >= 3; - return ( - !isTraitor && !hasMalice && (requestorIsMuchLarger || !tooManyAlliances) - ); + if (player.relation(request.requestor()) < Relation.Neutral) { + return false; // Reject if hasMalice + } + if (request.requestor().isTraitor()) { + return false; // Reject if isTraitor + } + if (request.requestor().numTilesOwned() > player.numTilesOwned() * 3) { + return true; // Accept if requestorIsMuchLarger + } + if (request.requestor().alliances().length >= 3) { + return false; // Reject if tooManyAlliances + } + return true; // Accept otherwise } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 6e89dee63..8e30cdafb 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -130,6 +130,7 @@ export interface UnitInfo { maxHealth?: number; damage?: number; constructionDuration?: number; + upgradable?: boolean; } export enum UnitType { @@ -385,8 +386,9 @@ export interface Unit { // SAMs & Missile Silos launch(): void; - ticksLeftInCooldown(): Tick | undefined; + reloadMissile(): void; isInCooldown(): boolean; + ticksLeftInCooldown(): Tick | undefined; // Trade Ships setSafeFromPirates(): void; // Only for trade ships @@ -396,6 +398,10 @@ export interface Unit { constructionType(): UnitType | null; setConstructionType(type: UnitType): void; + // Upgradable Structures + level(): number; + increaseLevel(): void; + // Warships setPatrolTile(tile: TileRef): void; patrolTile(): TileRef | undefined; @@ -471,6 +477,7 @@ export interface Player { spawnTile: TileRef, params: UnitParams, ): Unit; + upgradeUnit(unit: Unit): void; captureUnit(unit: Unit): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 4a67f55e5..3bcbf9ce8 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -80,7 +80,9 @@ export interface UnitUpdate { targetTile?: TileRef; // Only for nukes health?: number; constructionType?: UnitType; - ticksLeftInCooldown?: Tick; + missileTimerQueue: number[]; + readyMissileCount: number; + level: number; } export interface AttackUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index aaaaa591c..4b7b8df41 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -112,11 +112,13 @@ export class UnitView { return this.data.targetTile; } ticksLeftInCooldown(): Tick | undefined { - return this.data.ticksLeftInCooldown; + return this.data.missileTimerQueue?.[0]; } - isCooldown(): boolean { - if (this.data.ticksLeftInCooldown === undefined) return false; - return this.data.ticksLeftInCooldown > 0; + isInCooldown(): boolean { + return this.data.readyMissileCount === 0; + } + level(): number { + return this.data.level; } } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 580e38bb6..3cbc07ae0 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -157,7 +157,7 @@ export class PlayerImpl implements Player { troops: a.troops(), id: a.id(), retreating: a.retreating(), - } as AttackUpdate; + } satisfies AttackUpdate; }), incomingAttacks: this._incomingAttacks.map((a) => { return { @@ -166,7 +166,7 @@ export class PlayerImpl implements Player { troops: a.troops(), id: a.id(), retreating: a.retreating(), - } as AttackUpdate; + } satisfies AttackUpdate; }), outgoingAllianceRequests: outgoingAllianceRequests, hasSpawned: this.hasSpawned(), @@ -252,7 +252,9 @@ export class PlayerImpl implements Player { if (this.mg.map().isLand(neighbor)) { const owner = this.mg.map().ownerID(neighbor); if (owner !== this.smallID()) { - ns.add(this.mg.playerBySmallID(owner) as Player | TerraNullius); + ns.add( + this.mg.playerBySmallID(owner) satisfies Player | TerraNullius, + ); } } } @@ -394,7 +396,7 @@ export class PlayerImpl implements Player { if (this.isAlliedWith(recipient)) { throw new Error(`cannot create alliance request, already allies`); } - return this.mg.createAllianceRequest(this, recipient as Player); + return this.mg.createAllianceRequest(this, recipient satisfies Player); } relation(other: Player): Relation { @@ -481,7 +483,7 @@ export class PlayerImpl implements Player { .map((a) => a.other(this)) .flatMap((ally) => ally.targets()); ts.push(...this.targets()); - return [...new Set(ts)] as Player[]; + return [...new Set(ts)] satisfies Player[]; } sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void { @@ -752,6 +754,12 @@ export class PlayerImpl implements Player { return b; } + upgradeUnit(unit: Unit) { + const cost = this.mg.unitInfo(unit.type()).cost(this); + this.removeGold(cost); + unit.increaseLevel(); + } + public buildableUnits(tile: TileRef): BuildableUnit[] { const validTiles = this.validStructureSpawnTiles(tile); return Object.values(UnitType).map((u) => { @@ -1007,8 +1015,8 @@ export class PlayerImpl implements Player { if (this.mg.owner(tile) === this) { return false; } - if (this.mg.hasOwner(tile)) { - const other = this.mg.owner(tile) as Player; + const other = this.mg.owner(tile); + if (other.isPlayer()) { if (this.isFriendly(other)) { return false; } @@ -1018,7 +1026,7 @@ export class PlayerImpl implements Player { return false; } if (this.mg.hasOwner(tile)) { - return this.sharesBorderWith(this.mg.owner(tile)); + return this.sharesBorderWith(other); } else { for (const t of this.mg.bfs( tile, diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index e4f95758b..c3af26c7e 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -85,6 +85,9 @@ export interface Stats { // Player captures a unit of type unitCapture(player: Player, type: OtherUnitType): void; + // Player upgrades a unit of type + unitUpgrade(player: Player, type: OtherUnitType): void; + // Player destroys a unit of type unitDestroy(player: Player, type: OtherUnitType): void; diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index 1a1a7618d..88469b7b8 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -20,6 +20,7 @@ import { OTHER_INDEX_CAPTURE, OTHER_INDEX_DESTROY, OTHER_INDEX_LOST, + OTHER_INDEX_UPGRADE, OtherUnitType, PlayerStats, unitTypeToBombUnit, @@ -234,6 +235,10 @@ export class StatsImpl implements Stats { this._addOtherUnit(player, type, OTHER_INDEX_CAPTURE, 1); } + unitUpgrade(player: Player, type: OtherUnitType): void { + this._addOtherUnit(player, type, OTHER_INDEX_UPGRADE, 1); + } + unitDestroy(player: Player, type: OtherUnitType): void { this._addOtherUnit(player, type, OTHER_INDEX_DESTROY, 1); } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index e68178eee..85ff736c7 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -26,8 +26,10 @@ export class UnitImpl implements Unit { private _constructionType: UnitType | undefined; private _lastOwner: PlayerImpl | null = null; private _troops: number; - private _cooldownStartTick: Tick | null = null; + private _missileTimerQueue: number[] = []; + private _readyMissileCount: number = 1; private _patrolTile: TileRef | undefined; + private _level: number = 1; constructor( private _type: UnitType, private mg: GameImpl, @@ -104,7 +106,9 @@ export class UnitImpl implements Unit { constructionType: this._constructionType, targetUnitId: this._targetUnit?.id() ?? undefined, targetTile: this.targetTile() ?? undefined, - ticksLeftInCooldown: this.ticksLeftInCooldown() ?? undefined, + missileTimerQueue: this._missileTimerQueue, + readyMissileCount: this._readyMissileCount, + level: this.level(), }; } @@ -267,30 +271,23 @@ export class UnitImpl implements Unit { } launch(): void { - this._cooldownStartTick = this.mg.ticks(); + this._missileTimerQueue.push(this.mg.ticks()); + this._readyMissileCount--; this.mg.addUpdate(this.toUpdate()); } ticksLeftInCooldown(): Tick | undefined { - let cooldownDuration = 0; - if (this.type() === UnitType.SAMLauncher) { - cooldownDuration = this.mg.config().SAMCooldown(); - } else if (this.type() === UnitType.MissileSilo) { - cooldownDuration = this.mg.config().SiloCooldown(); - } else { - return undefined; - } - - if (!this._cooldownStartTick) { - return undefined; - } - - return cooldownDuration - (this.mg.ticks() - this._cooldownStartTick); + return this._missileTimerQueue[0]; } isInCooldown(): boolean { - const ticksLeft = this.ticksLeftInCooldown(); - return ticksLeft !== undefined && ticksLeft > 0; + return this._readyMissileCount === 0; + } + + reloadMissile(): void { + this._missileTimerQueue.shift(); + this._readyMissileCount++; + this.mg.addUpdate(this.toUpdate()); } setTargetTile(targetTile: TileRef | undefined) { @@ -335,4 +332,16 @@ export class UnitImpl implements Unit { this.mg.config().safeFromPiratesCooldownMax() ); } + + level(): number { + return this._level; + } + + increaseLevel(): void { + this._level++; + if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) { + this._readyMissileCount++; + } + this.mg.addUpdate(this.toUpdate()); + } } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 88db909dd..57fced752 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -182,6 +182,7 @@ export class GameServer { this.allClients.set(client.clientID, client); + client.ws.removeAllListeners("message"); client.ws.on( "message", gatekeeper.wsHandler(client.ip, async (message: string) => { @@ -239,6 +240,7 @@ export class GameServer { } }), ); + client.ws.removeAllListeners("close"); client.ws.on("close", () => { this.log.info("client disconnected", { clientID: client.clientID, @@ -248,6 +250,7 @@ export class GameServer { (c) => c.clientID !== client.clientID, ); }); + client.ws.removeAllListeners("error"); client.ws.on("error", (error: Error) => { if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { client.ws.close(1002); diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts new file mode 100644 index 000000000..eb04cea0c --- /dev/null +++ b/tests/Colors.test.ts @@ -0,0 +1,81 @@ +import { colord, Colord } from "colord"; +import { + blue, + botColor, + ColorAllocator, + red, + teal, +} from "../src/core/configuration/Colors"; +import { ColoredTeams } from "../src/core/game/Game"; + +const mockColors: Colord[] = [ + colord({ r: 255, g: 0, b: 0 }), + colord({ r: 0, g: 255, b: 0 }), + colord({ r: 0, g: 0, b: 255 }), +]; + +const fallbackMockColors: Colord[] = [ + colord({ r: 0, g: 0, b: 0 }), + colord({ r: 255, g: 255, b: 255 }), +]; + +describe("ColorAllocator", () => { + let allocator: ColorAllocator; + + beforeEach(() => { + allocator = new ColorAllocator(mockColors, fallbackMockColors); + }); + + test("returns a unique color for each new ID", () => { + const c1 = allocator.assignColor("a"); + const c2 = allocator.assignColor("b"); + const c3 = allocator.assignColor("c"); + + expect(c1.isEqual(c2)).toBe(false); + expect(c1.isEqual(c3)).toBe(false); + expect(c2.isEqual(c3)).toBe(false); + }); + + test("returns the same color for the same ID", () => { + const c1 = allocator.assignColor("a"); + const c2 = allocator.assignColor("a"); + + expect(c1.isEqual(c2)).toBe(true); + }); + + test("falls back when colors are exhausted", () => { + allocator.assignColor("1"); + allocator.assignColor("2"); + allocator.assignColor("3"); + const fallback = allocator.assignColor("4"); + const fallback2 = allocator.assignColor("5"); + + const match = fallbackMockColors.some((color) => color.isEqual(fallback)); + expect(match).toBe(true); + + const match2 = fallback.isEqual(fallback2); + expect(match2).toBe(false); + }); + + test("assignBotColor returns deterministic color from botColors", () => { + const allocator = new ColorAllocator(mockColors, mockColors); + + const id1 = "bot123"; + const id2 = "bot456"; + + const c1 = allocator.assignColor(id1); + const c2 = allocator.assignColor(id2); + const c1Again = allocator.assignColor(id1); + const c2Again = allocator.assignColor(id2); + + expect(c1.isEqual(c1Again)).toBe(true); + expect(c2.isEqual(c2Again)).toBe(true); + }); + + test("assignTeamColor returns the expected static color for known teams", () => { + expect(allocator.assignTeamColor(ColoredTeams.Blue)).toEqual(blue); + expect(allocator.assignTeamColor(ColoredTeams.Red)).toEqual(red); + expect(allocator.assignTeamColor(ColoredTeams.Teal)).toEqual(teal); + expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor); + }); +}); diff --git a/tests/MessageTypeClasses.test.ts b/tests/MessageTypeClasses.test.ts index 706600a03..3b7368fc6 100644 --- a/tests/MessageTypeClasses.test.ts +++ b/tests/MessageTypeClasses.test.ts @@ -15,8 +15,8 @@ describe("getMessageTypeClasses", () => { it("should return a valid CSS class for every MessageType", () => { const messageTypes = Object.values(MessageType).filter( - (value) => typeof value === "number", - ) as MessageType[]; + (value): value is MessageType => typeof value === "number", + ); messageTypes.forEach((messageType) => { const result = getMessageTypeClasses(messageType); @@ -30,8 +30,8 @@ describe("getMessageTypeClasses", () => { it("should not trigger console.warn for any MessageType", () => { const messageTypes = Object.values(MessageType).filter( - (value) => typeof value === "number", - ) as MessageType[]; + (value): value is MessageType => typeof value === "number", + ); messageTypes.forEach((messageType) => { getMessageTypeClasses(messageType); diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index 04d708546..02e5875e6 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -1,5 +1,6 @@ import { NukeExecution } from "../src/core/execution/NukeExecution"; import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { UpgradeStructureExecution } from "../src/core/execution/UpgradeStructureExecution"; import { Game, Player, @@ -9,7 +10,7 @@ import { } from "../src/core/game/Game"; import { TileRef } from "../src/core/game/GameMap"; import { setup } from "./util/Setup"; -import { constructionExecution } from "./util/utils"; +import { constructionExecution, executeTicks } from "./util/utils"; let game: Game; let attacker: Player; @@ -85,7 +86,21 @@ describe("MissileSilo", () => { ).toBeTruthy(); } - game.executeNextTick(); + executeTicks(game, 2); + expect(attacker.units(UnitType.MissileSilo)[0].isInCooldown()).toBeFalsy(); }); + + test("missilesilo should have increased level after upgrade", async () => { + expect(attacker.units(UnitType.MissileSilo)[0].level()).toEqual(1); + + const upgradeStructureExecution = new UpgradeStructureExecution( + attacker, + attacker.units(UnitType.MissileSilo)[0].id(), + ); + game.addExecution(upgradeStructureExecution); + executeTicks(game, 2); + + expect(attacker.units(UnitType.MissileSilo)[0].level()).toEqual(2); + }); }); diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index d3f209673..ef3458940 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -1,6 +1,7 @@ import { NukeExecution } from "../src/core/execution/NukeExecution"; import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution"; import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { UpgradeStructureExecution } from "../src/core/execution/UpgradeStructureExecution"; import { Game, Player, @@ -94,6 +95,7 @@ describe("SAM", () => { test("sam should cooldown as long as configured", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); expect(sam.isInCooldown()).toBeFalsy(); const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { @@ -103,6 +105,7 @@ describe("SAM", () => { executeTicks(game, 3); expect(nuke.isActive()).toBeFalsy(); + for (let i = 0; i < game.config().SAMCooldown() - 3; i++) { game.executeNextTick(); expect(sam.isInCooldown()).toBeTruthy(); @@ -161,4 +164,18 @@ describe("SAM", () => { expect(sam1.isInCooldown()).toBeFalsy(); expect(sam2.isInCooldown()).toBeTruthy(); }); + + test("SAM should have increased level after upgrade", async () => { + defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + expect(defender.units(UnitType.SAMLauncher)[0].level()).toEqual(1); + + const upgradeStructureExecution = new UpgradeStructureExecution( + defender, + defender.units(UnitType.SAMLauncher)[0].id(), + ); + game.addExecution(upgradeStructureExecution); + executeTicks(game, 2); + + expect(defender.units(UnitType.SAMLauncher)[0].level()).toEqual(2); + }); }); diff --git a/tests/client/graphics/ProgressBar.test.ts b/tests/client/graphics/ProgressBar.test.ts new file mode 100644 index 000000000..50189a9e3 --- /dev/null +++ b/tests/client/graphics/ProgressBar.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment jsdom + */ +import { ProgressBar } from "../../../src/client/graphics/ProgressBar"; + +describe("ProgressBar", () => { + let ctx: CanvasRenderingContext2D; + let canvas: HTMLCanvasElement; + + beforeEach(() => { + canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 20; + ctx = canvas.getContext("2d")!; + }); + + it("should initialize and draw the background", () => { + const spyClearRect = jest.spyOn(ctx, "clearRect"); + const spyFillRect = jest.spyOn(ctx, "fillRect"); + const spyFillStyle = jest.spyOn(ctx, "fillStyle", "set"); + const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5); + expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12); + expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10); + expect(spyFillStyle).toHaveBeenCalledWith("#00ff00"); + expect(bar.getX()).toBe(2); + expect(bar.getY()).toBe(2); + }); + + it("should set progress and draw the progress bar", () => { + const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10); + const spyFillRect = jest.spyOn(ctx, "fillRect"); + bar.setProgress(0.5); + expect(bar.getProgress()).toBe(0.5); + expect(spyFillRect).toHaveBeenCalledWith( + 2, + 2, + Math.floor(0.5 * (80 - 2)), + 8, + ); + expect(ctx.fillStyle).toBe("#00ff00"); + + bar.setProgress(0.1); + expect(ctx.fillStyle).toBe("#ff0000"); + }); + + it("should clamp progress between 0 and 1 on init", () => { + const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1); + expect(bar.getProgress()).toBe(0); + const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2); + expect(bar2.getProgress()).toBe(1); + }); + + it("should handle empty colors array gracefully", () => { + const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5); + expect(() => bar.setProgress(0.5)).not.toThrow(); + expect(ctx.fillStyle).toBe("#808080"); + }); +}); diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts new file mode 100644 index 000000000..dbc131cec --- /dev/null +++ b/tests/client/graphics/UILayer.test.ts @@ -0,0 +1,136 @@ +/** + * @jest-environment jsdom + */ +import { UILayer } from "../../../src/client/graphics/layers/UILayer"; +import { UnitSelectionEvent } from "../../../src/client/InputHandler"; +import { UnitView } from "../../../src/core/game/GameView"; + +describe("UILayer", () => { + let game: any; + let eventBus: any; + let transformHandler: any; + + beforeEach(() => { + game = { + width: () => 100, + height: () => 100, + config: () => ({ + theme: () => ({ + territoryColor: () => ({ + lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }), + }), + }), + }), + x: () => 10, + y: () => 10, + unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }), + myPlayer: () => ({ id: () => 1 }), + ticks: () => 1, + updatesSinceLastTick: () => undefined, + }; + eventBus = { on: jest.fn() }; + transformHandler = {}; + }); + + it("should initialize and redraw canvas", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + expect(ui["canvas"].width).toBe(100); + expect(ui["canvas"].height).toBe(100); + expect(ui["context"]).not.toBeNull(); + }); + + it("should handle unit selection event", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + type: () => "Warship", + isActive: () => true, + tile: () => ({}), + owner: () => ({}), + }; + const event = { isSelected: true, unit }; + ui.drawSelectionBox = jest.fn(); + ui["onUnitSelection"](event as UnitSelectionEvent); + expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit); + }); + + it("should add and clear health bars", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + id: () => 1, + type: () => "Warship", + health: () => 5, + tile: () => ({}), + owner: () => ({}), + isActive: () => true, + } as unknown as UnitView; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(true); + + // a full hp unit doesnt have a health bar + unit.health = () => 10; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(false); + + // a dead unit doesnt have a health bar + unit.health = () => 5; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(true); + unit.health = () => 0; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(false); + }); + + it("should add loading bar for unit", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + id: () => 2, + tile: () => ({}), + isActive: () => true, + } as unknown as UnitView; + ui.drawLoadingBar(unit, 5); + expect(ui["allProgressBars"].has(2)).toBe(true); + }); + + it("should remove loading bar for inactive unit", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + id: () => 2, + type: () => "Construction", + constructionType: () => "City", + owner: () => ({ id: () => 1 }), + tile: () => ({}), + isActive: () => true, + } as unknown as UnitView; + ui.onUnitEvent(unit); + expect(ui["allProgressBars"].has(2)).toBe(true); + + // an inactive unit should not have a loading bar + unit.isActive = () => false; + ui.tick(); + expect(ui["allProgressBars"].has(2)).toBe(false); + }); + + it("should remove loading bar for a finished progress bar", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + id: () => 2, + type: () => "Construction", + constructionType: () => "City", + owner: () => ({ id: () => 1 }), + tile: () => ({}), + isActive: () => true, + } as unknown as UnitView; + ui.onUnitEvent(unit); + expect(ui["allProgressBars"].has(2)).toBe(true); + + game.ticks = () => 6; // simulate enough ticks for completion + ui.tick(); + expect(ui["allProgressBars"].has(2)).toBe(false); + }); +});