diff --git a/package-lock.json b/package-lock.json index 64b7ff97a..bdd1fd31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "acorn": "^8.11.3", "acorn-jsx-walk": "^2.0.0", "acorn-walk": "^8.3.2", + "ajv": "^8.17.1", "arg": "^5.0.2", "bcryptjs": "^2.4.3", "better-react-mathjax": "^2.0.3", @@ -2348,6 +2349,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2381,6 +2399,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3525,6 +3550,30 @@ "resolve": "~1.19.0" } }, + "node_modules/@microsoft/api-extractor-model/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@microsoft/api-extractor-model/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@microsoft/api-extractor-model/node_modules/resolve": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", @@ -3665,12 +3714,6 @@ "node": ">=8" } }, - "node_modules/@microsoft/api-extractor/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3780,12 +3823,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/@mui/base": { "version": "5.0.0-beta.18", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.18.tgz", @@ -5772,15 +5809,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5804,37 +5841,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -6185,22 +6191,6 @@ "webpack": ">=5" } }, - "node_modules/babel-loader/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/babel-loader/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -6213,12 +6203,6 @@ "ajv": "^8.8.2" } }, - "node_modules/babel-loader/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/babel-loader/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -7153,22 +7137,6 @@ "webpack": "^5.1.0" } }, - "node_modules/copy-webpack-plugin/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -7201,12 +7169,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/copy-webpack-plugin/node_modules/path-type": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", @@ -8566,6 +8528,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -8673,6 +8652,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8970,8 +8956,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -9016,8 +9001,7 @@ "node_modules/fast-uri": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", - "dev": true + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", @@ -12964,10 +12948,10 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -16216,7 +16200,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -16465,6 +16448,40 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -18213,22 +18230,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -18241,12 +18242,6 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -18325,22 +18320,6 @@ } } }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/webpack-dev-server/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -18353,12 +18332,6 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", diff --git a/package.json b/package.json index 47a8ce39f..0a8fdbc46 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "acorn": "^8.11.3", "acorn-jsx-walk": "^2.0.0", "acorn-walk": "^8.3.2", + "ajv": "^8.17.1", "arg": "^5.0.2", "bcryptjs": "^2.4.3", "better-react-mathjax": "^2.0.3", diff --git a/src/Gang/AllGangs.ts b/src/Gang/AllGangs.ts index 7115ae7bf..001098fe2 100644 --- a/src/Gang/AllGangs.ts +++ b/src/Gang/AllGangs.ts @@ -1,12 +1,14 @@ import { FactionName } from "@enums"; import { Reviver } from "../utils/GenericReviver"; +import { JsonSchemaValidator } from "../JsonSchema/JsonSchemaValidator"; +import { dialogBoxCreate } from "../ui/React/DialogBox"; interface GangTerritory { power: number; territory: number; } -function getDefaultAllGangs() { +export function getDefaultAllGangs() { return { [FactionName.SlumSnakes]: { power: 1, @@ -46,7 +48,26 @@ export function resetGangs(): void { } export function loadAllGangs(saveString: string): void { - AllGangs = JSON.parse(saveString, Reviver); + let allGangsData: unknown; + let validate; + try { + allGangsData = JSON.parse(saveString, Reviver); + validate = JsonSchemaValidator.AllGangs; + if (!validate(allGangsData)) { + console.error("validate.errors:", validate.errors); + // validate.errors is an array of objects, so we need to use JSON.stringify. + throw new Error(JSON.stringify(validate.errors)); + } + } catch (error) { + console.error(error); + console.error("Invalid AllGangsSave:", saveString); + resetGangs(); + setTimeout(() => { + dialogBoxCreate(`Cannot load data of AllGangs. AllGangs is reset. Error: ${error}.`); + }, 1000); + return; + } + AllGangs = allGangsData; } export function getClashWinChance(thisGang: string, otherGang: string): number { diff --git a/src/JsonSchema/Data/AllGangsSchema.ts b/src/JsonSchema/Data/AllGangsSchema.ts new file mode 100644 index 000000000..5f6baad04 --- /dev/null +++ b/src/JsonSchema/Data/AllGangsSchema.ts @@ -0,0 +1,35 @@ +import type { JSONSchemaType } from "ajv"; +import type { AllGangs } from "../../Gang/AllGangs"; +import { GangConstants } from "../../Gang/data/Constants"; + +/** + * If we add/remove gangs, we must change 4 things: + * - src\Gang\AllGangs.ts: getDefaultAllGangs + * - src\Gang\data\Constants.ts: GangConstants.Names + * - src\Gang\data\power.ts: PowerMultiplier + * - Save file migration code. + * + * Gang code assumes that save data contains exactly gangs defined in these places. + */ +export const AllGangsSchema: JSONSchemaType = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + patternProperties: { + ".*": { + type: "object", + properties: { + power: { + type: "number", + }, + territory: { + type: "number", + }, + }, + required: ["power", "territory"], + }, + }, + propertyNames: { + enum: GangConstants.Names, + }, + required: GangConstants.Names, +}; diff --git a/src/JsonSchema/Data/StockMarketSchema.ts b/src/JsonSchema/Data/StockMarketSchema.ts new file mode 100644 index 000000000..837166015 --- /dev/null +++ b/src/JsonSchema/Data/StockMarketSchema.ts @@ -0,0 +1,141 @@ +import { OrderType, PositionType } from "@enums"; +import { getKeyList } from "../../utils/helpers/getKeyList"; +import { Stock } from "../../StockMarket/Stock"; +import { Order } from "../../StockMarket/Order"; + +const stockObjectProperties = getKeyList(Stock); +const orderObjectProperties = getKeyList(Order); + +/** + * It's intentional to not use JSONSchemaType here. The data structure of StockMarket is not suitable for the usage of + * JSONSchemaType. These are 2 biggest problems: + * - IStockMarket is an intersection type. In our case, satisfying TS's type-checking for JSONSchemaType is too hard. + * - Stock and Order are classes, not interfaces or types. In those classes, we have functions, but functions are not + * supported by JSON (without ugly hacks). Let's use the "Order" class as an example. The Order class has the toJSON + * function, so ajv forces us to define the "toJSON" property. If we define it, we have to give it a "type". In + * JavaScript, a function is just an object, so the naive solution is to use {type:"object"}. However, the generated + * validation code uses "typeof" to check the data. "typeof functionName" is "function", not "object", so ajv sees it as + * invalid data. There are some ways to work around this problem, but all of them are complicated. In the end, + * JSONSchemaType is only a utility type. If our schema is designed and tested properly, after the validation, we can + * typecast the data. + */ +export const StockMarketSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + patternProperties: { + /** + * IStockMarket is an intersection type combining: + * - Record [1] + * - {lastUpdate: number;Orders: IOrderBook;storedCycles: number;ticksUntilCycle: number;} [2] + * + * StockMarketSchema contains: + * - patternProperties: Defines [1]. The following regex matches all properties that are not in [2]. It defines the + * map of "Full stock name -> Stock". + * - properties: Define [2]. + * + * Note that with [1], our code allows unknown stocks. Let's say the player loads a save file with this entry in + * [1]: UnknownCorp123 -> Stock with symbol UCP123. Although this stock is not in our list of "valid" stocks, we + * still process it normally. By "tolerating" unknown stocks, we allow loading a save file created in: + * - Old versions with unsupported stocks: In very old versions (v1.2.0 and older ones), the "full stock name" of + * "Joe's Guns" is "Joes Guns". + * - New versions with unknown stocks. + */ + "^(?!(lastUpdate|Orders|storedCycles|ticksUntilCycle))": { + type: "object", + properties: { + b: { + type: "boolean", + }, + cap: { + type: "number", + }, + lastPrice: { + type: "number", + }, + maxShares: { + type: "number", + }, + mv: { + type: "number", + }, + name: { + type: "string", + }, + otlkMag: { + type: "number", + }, + otlkMagForecast: { + type: "number", + }, + playerAvgPx: { + type: "number", + }, + playerAvgShortPx: { + type: "number", + }, + playerShares: { + type: "number", + }, + playerShortShares: { + type: "number", + }, + price: { + type: "number", + }, + shareTxForMovement: { + type: "number", + }, + shareTxUntilMovement: { + type: "number", + }, + spreadPerc: { + type: "number", + }, + symbol: { + type: "string", + }, + totalShares: { + type: "number", + }, + }, + required: [...stockObjectProperties], + }, + }, + properties: { + lastUpdate: { type: "number" }, + Orders: { + type: "object", + patternProperties: { + ".*": { + type: "array", + items: { + type: "object", + properties: { + pos: { + type: "string", + enum: [PositionType.Long, PositionType.Short], + }, + price: { + type: "number", + }, + shares: { + type: "number", + }, + stockSymbol: { + type: "string", + }, + type: { + type: "string", + enum: [OrderType.LimitBuy, OrderType.LimitSell, OrderType.StopBuy, OrderType.StopSell], + }, + }, + required: [...orderObjectProperties], + }, + }, + }, + }, + storedCycles: { type: "number" }, + ticksUntilCycle: { type: "number" }, + }, + required: ["lastUpdate", "Orders", "storedCycles", "ticksUntilCycle"], +}; diff --git a/src/JsonSchema/JsonSchemaValidator.ts b/src/JsonSchema/JsonSchemaValidator.ts new file mode 100644 index 000000000..9790b9781 --- /dev/null +++ b/src/JsonSchema/JsonSchemaValidator.ts @@ -0,0 +1,10 @@ +import Ajv from "ajv"; +import { AllGangsSchema } from "./Data/AllGangsSchema"; +import { StockMarketSchema } from "./Data/StockMarketSchema"; + +const ajv = new Ajv(); + +export const JsonSchemaValidator = { + AllGangs: ajv.compile(AllGangsSchema), + StockMarket: ajv.compile(StockMarketSchema), +}; diff --git a/src/StockMarket/StockMarket.tsx b/src/StockMarket/StockMarket.tsx index 1e2d016fa..e89f826c3 100644 --- a/src/StockMarket/StockMarket.tsx +++ b/src/StockMarket/StockMarket.tsx @@ -16,13 +16,19 @@ import { Reviver } from "../utils/GenericReviver"; import { NetscriptContext } from "../Netscript/APIWrapper"; import { helpers } from "../Netscript/NetscriptHelpers"; import { getRandomIntInclusive } from "../utils/helpers/getRandomIntInclusive"; +import { JsonSchemaValidator } from "../JsonSchema/JsonSchemaValidator"; +import { Player } from "../Player"; -export let StockMarket: IStockMarket = { - lastUpdate: 0, - Orders: {}, - storedCycles: 0, - ticksUntilCycle: 0, -} as IStockMarket; // Maps full stock name -> Stock object +export function getDefaultEmptyStockMarket(): IStockMarket { + return { + lastUpdate: 0, + Orders: {}, + storedCycles: 0, + ticksUntilCycle: 0, + } as IStockMarket; +} + +export let StockMarket = getDefaultEmptyStockMarket(); // Maps full stock name -> Stock object // Gross type, needs to be addressed export const SymbolToStockMap: Record = {}; // Maps symbol -> Stock object @@ -130,23 +136,35 @@ export function cancelOrder(params: ICancelOrderParams, ctx?: NetscriptContext): } export function loadStockMarket(saveString: string): void { - if (saveString === "") { - StockMarket = { - lastUpdate: 0, - Orders: {}, - storedCycles: 0, - ticksUntilCycle: 0, - } as IStockMarket; - } else StockMarket = JSON.parse(saveString, Reviver); + let stockMarketData: unknown; + let validate; + try { + stockMarketData = JSON.parse(saveString, Reviver); + validate = JsonSchemaValidator.StockMarket; + if (!validate(stockMarketData)) { + console.error("validate.errors:", validate.errors); + // validate.errors is an array of objects, so we need to use JSON.stringify. + throw new Error(JSON.stringify(validate.errors)); + } + } catch (error) { + console.error(error); + console.error("Invalid StockMarketSave:", saveString); + deleteStockMarket(); + if (Player.hasWseAccount) { + initStockMarket(); + } + const errorMessage = `Cannot load data of StockMarket. StockMarket is reset.`; + setTimeout(() => { + dialogBoxCreate(errorMessage); + }, 1000); + return; + } + // Typecasting here is fine because we validated the loaded data. + StockMarket = stockMarketData as IStockMarket; } export function deleteStockMarket(): void { - StockMarket = { - lastUpdate: 0, - Orders: {}, - storedCycles: 0, - ticksUntilCycle: 0, - } as IStockMarket; + StockMarket = getDefaultEmptyStockMarket(); } export function initStockMarket(): void { diff --git a/test/jest/JsonSchema/AllGangsSchema.test.ts b/test/jest/JsonSchema/AllGangsSchema.test.ts new file mode 100644 index 000000000..667405c1f --- /dev/null +++ b/test/jest/JsonSchema/AllGangsSchema.test.ts @@ -0,0 +1,41 @@ +import { getDefaultAllGangs } from "../../../src/Gang/AllGangs"; +import { JsonSchemaValidator } from "../../../src/JsonSchema/JsonSchemaValidator"; + +describe("Success", () => { + test("Default AllGangs", () => { + const defaultAllGangs = getDefaultAllGangs(); + expect(JsonSchemaValidator.AllGangs(defaultAllGangs)).toStrictEqual(true); + }); +}); + +describe("Failure", () => { + test("Do not have all gangs", () => { + const defaultAllGangs = getDefaultAllGangs() as Record; + delete defaultAllGangs["Slum Snakes"]; + expect(JsonSchemaValidator.AllGangs(defaultAllGangs)).toStrictEqual(false); + }); + test("Have an unexpected gang", () => { + const defaultAllGangs = getDefaultAllGangs() as Record; + defaultAllGangs["CyberSec"] = { + power: 1, + territory: 1 / 7, + }; + expect(JsonSchemaValidator.AllGangs(defaultAllGangs)).toStrictEqual(false); + }); + test("Have invalid power", () => { + const defaultAllGangs = getDefaultAllGangs() as Record; + defaultAllGangs["Slum Snakes"] = { + power: "1", + territory: 1 / 7, + }; + expect(JsonSchemaValidator.AllGangs(defaultAllGangs)).toStrictEqual(false); + }); + test("Have invalid territory", () => { + const defaultAllGangs = getDefaultAllGangs() as Record; + defaultAllGangs["Slum Snakes"] = { + power: 1, + territory: "1 / 7", + }; + expect(JsonSchemaValidator.AllGangs(defaultAllGangs)).toStrictEqual(false); + }); +}); diff --git a/test/jest/JsonSchema/StockMarketSchema.test.ts b/test/jest/JsonSchema/StockMarketSchema.test.ts new file mode 100644 index 000000000..eed35c7e8 --- /dev/null +++ b/test/jest/JsonSchema/StockMarketSchema.test.ts @@ -0,0 +1,140 @@ +import { JsonSchemaValidator } from "../../../src/JsonSchema/JsonSchemaValidator"; +import { + deleteStockMarket, + getDefaultEmptyStockMarket, + initStockMarket, + StockMarket, +} from "../../../src/StockMarket/StockMarket"; + +deleteStockMarket(); +initStockMarket(); +// Get the clone of StockMarket immediately after calling deleteStockMarket() and initStockMarket(). +const defaultStockMarket = structuredClone(StockMarket); + +/** + * We must call this function to get the data for testing. Do not use the module-scoped "StockMarket" from + * src/StockMarket/StockMarket. Jest tests run in parallel, so our tests may mutate that variable and result in wrong + * tests. + */ +function getCloneOfDefaultStockMarket(): Record { + return structuredClone(defaultStockMarket); +} + +function getCloneOfSampleStock() { + return { + name: "ECorp", + symbol: "ECP", + price: 24618.907733048673, + lastPrice: 24702.20549043557, + playerShares: 2, + playerAvgPx: 19893.251484918932, + playerShortShares: 0, + playerAvgShortPx: 0, + mv: 0.44, + b: true, + otlkMag: 17.783525574804138, + otlkMagForecast: 68.11871382128285, + cap: 148683699, + spreadPerc: 0.5, + shareTxForMovement: 76967, + shareTxUntilMovement: 76967, + totalShares: 140600000, + maxShares: 28100000, + }; +} + +function getCloneOfSampleOrders() { + return { + ECP: [ + { + stockSymbol: "ECP", + shares: 1, + price: 1000, + type: "Limit Buy Order", + pos: "L", + }, + ], + }; +} + +type SampleOrders = ReturnType; + +describe("Success", () => { + test("Default empty StockMarket", () => { + expect(JsonSchemaValidator.StockMarket(getDefaultEmptyStockMarket())).toStrictEqual(true); + }); + test("Default StockMarket", () => { + expect(JsonSchemaValidator.StockMarket(getCloneOfDefaultStockMarket())).toStrictEqual(true); + }); + test("StockMarket with Orders", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = getCloneOfSampleOrders(); + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(true); + }); +}); + +describe("Failure", () => { + test("Have unexpected property", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.test = ""; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + + test("Do not have lastUpdate property", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + delete stockMarket.lastUpdate; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + + describe("Invalid stock", () => { + test("Have invalid type of stock", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.ECorp = []; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + const sampleStock = getCloneOfSampleStock(); + for (const [key, value] of Object.entries(sampleStock)) { + if (typeof value === "string") { + continue; + } + test(`Have invalid ${key}`, () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.ECorp = getCloneOfSampleStock(); + (stockMarket.ECorp as { [_: string]: unknown })[key] = "test"; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + } + }); + + describe("Invalid Orders", () => { + test("Have invalid type of Orders", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = []; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + test("Have invalid Order: Invalid order type", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = getCloneOfSampleOrders(); + (stockMarket.Orders as SampleOrders).ECP[0].type = "Limit Buy Order1"; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + test("Have invalid Order: Invalid position type", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = getCloneOfSampleOrders(); + (stockMarket.Orders as SampleOrders).ECP[0].pos = "L1"; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + test("Have invalid Order: Invalid shares", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = getCloneOfSampleOrders(); + ((stockMarket.Orders as SampleOrders).ECP[0].shares as unknown) = "1"; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + test("Have invalid Order: Invalid price", () => { + const stockMarket = getCloneOfDefaultStockMarket(); + stockMarket.Orders = getCloneOfSampleOrders(); + ((stockMarket.Orders as SampleOrders).ECP[0].price as unknown) = "1000"; + expect(JsonSchemaValidator.StockMarket(stockMarket)).toStrictEqual(false); + }); + }); +});