diff --git a/.github/workflows/build_package.yml b/.github/workflows/build_package.yml
index 0892711b..19d55d63 100644
--- a/.github/workflows/build_package.yml
+++ b/.github/workflows/build_package.yml
@@ -33,6 +33,6 @@ jobs:
- name: Upload a Build Artifact
uses: actions/upload-artifact@v3.1.3
with:
- name: latest
+ name: development_build_not_a_release
path: ./package
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 2a1a7568..40b18322 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -23,7 +23,7 @@ jobs:
uses: actions/setup-node@v2
- name: Install dependencies
- run: npm ci
+ run: npm install
working-directory: ./client
- name: Create the docs directory locally in CI
diff --git a/README.md b/README.md
index 261255e9..61760bc7 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Even better it requires no client mods be installed if used on a server
The full feature list is simply too long to enumerate in a short summary but needless to say Olympus offers up a lot of unique gameplay that has previously not existed, and enhances many other elements of DCS in exciting ways
### Installing DCS Olympus
-A prebuilt installer will soon be released and available here
+Check the [Wiki](https://github.com/Pax1601/DCSOlympus/wiki) for installation instructions
# Frequently Asked Questions
### Can I join up and help out with the project? ###
diff --git a/client/Dockerfile b/client/Dockerfile
new file mode 100644
index 00000000..23fc6e3e
--- /dev/null
+++ b/client/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:20-alpine AS appbuild
+
+WORKDIR /usr/src/app
+COPY package.json ./
+COPY package-lock.json ./
+RUN npm install
+COPY . .
+RUN npm run build-release-linux
+
+FROM node:20-alpine
+WORKDIR /usr/src/app
+COPY package.json ./
+COPY package-lock.json ./
+RUN npm install --omit=dev
+COPY . .
+COPY --from=appbuild /usr/src/app/public ./public
+EXPOSE 3000
+CMD npm start
diff --git a/client/configurator.js b/client/configurator.js
new file mode 100644
index 00000000..fce7f9e5
--- /dev/null
+++ b/client/configurator.js
@@ -0,0 +1,194 @@
+const fs = require('fs')
+const path = require('path')
+const yargs = require('yargs');
+const prompt = require('prompt-sync')({sigint: true});
+const sha256 = require('sha256');
+var jsonPath = path.join('..', 'olympus.json');
+var regedit = require('regedit')
+
+const shellFoldersKey = 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders'
+const saveGamesKey = '{4C5C32FF-BB9D-43B0-B5B4-2D72E54EAAA4}'
+
+/* Set the acceptable values */
+yargs.alias('a', 'address').describe('a', 'Backend address').string('a');
+yargs.alias('b', 'backendPort').describe('b', 'Backend port').number('b');
+yargs.alias('c', 'clientPort').describe('c', 'Client port').number('c');
+yargs.alias('p', 'gameMasterPassword').describe('p', 'Game Master password').string('p');
+yargs.alias('bp', 'blueCommanderPassword').describe('bp', 'Blue Commander password').string('bp');
+yargs.alias('rp', 'redCommanderPassword').describe('rp', 'Red Commander password').string('rp');
+yargs.alias('d', 'directory').describe('d', 'Directory where the DCS Olympus configurator is located').string('rp');
+args = yargs.argv;
+
+async function run() {
+ /* Check that we can read the json */
+ if (fs.existsSync(jsonPath)) {
+ var json = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
+
+ var address = args.address ?? json["server"]["address"];
+ var clientPort = args.clientPort ?? json["client"]["port"];
+ var backendPort = args.backendPort ?? json["server"]["port"];
+ var gameMasterPassword = args.gameMasterPassword? sha256(args.gameMasterPassword): json["authentication"]["gameMasterPassword"];
+ var blueCommanderPassword = args.blueCommanderPassword? sha256(args.blueCommanderPassword): json["authentication"]["blueCommanderPassword"];
+ var redCommanderPassword = args.redCommanderPassword? sha256(args.redCommanderPassword): json["authentication"]["redCommanderPassword"];
+
+ /* Run in interactive mode */
+ if (args.address === undefined && args.clientPort === undefined && args.backendPort === undefined &&
+ args.gameMasterPassword === undefined && args.blueCommanderPassword === undefined && args.redCommanderPassword === undefined) {
+
+ var newValue;
+ var result;
+
+ /* Get the new address */
+ newValue = prompt(`Insert an address or press Enter to keep current value ${address}. Use * for any address: `);
+ address = newValue !== ""? newValue: address;
+
+ /* Get the new client port */
+ while (true) {
+ newValue = prompt(`Insert a client port or press Enter to keep current value ${clientPort}. Integers between 1025 and 65535: `);
+ if (newValue === "")
+ break;
+ result = Number(newValue);
+
+ if (!isNaN(result) && Number.isInteger(result) && result > 1024 && result <= 65535)
+ break;
+ }
+ clientPort = newValue? result: clientPort;
+
+ /* Get the new backend port */
+ while (true) {
+ newValue = prompt(`Insert a backend port or press Enter to keep current value ${backendPort}. Integers between 1025 and 65535: `);
+ if (newValue === "")
+ break;
+ result = Number(newValue);
+
+ if (!isNaN(result) && Number.isInteger(result) && result > 1024 && result <= 65535 && result != clientPort)
+ break;
+
+ if (result === clientPort)
+ console.log("Client port and backend port must be different.");
+ }
+ backendPort = newValue? result: backendPort;
+
+ /* Get the new Game Master password */
+ while (true) {
+ newValue = prompt(`Insert a new Game Master password or press Enter to keep current value: `, {echo: "*"});
+ gameMasterPassword = newValue !== ""? sha256(newValue): gameMasterPassword;
+
+ // Check if Game Master password is unique
+ if (gameMasterPassword === blueCommanderPassword || gameMasterPassword === redCommanderPassword) {
+ console.log("Game Master password must be different from other passwords. Please try again.");
+ continue;
+ }
+ break;
+ }
+
+ /* Get the new Blue Commander password */
+ while (true) {
+ newValue = prompt(`Insert a new Blue Commander password or press Enter to keep current value: `, {echo: "*"});
+ blueCommanderPassword = newValue !== ""? sha256(newValue): blueCommanderPassword;
+
+ // Check if Blue Commander password is unique
+ if (blueCommanderPassword === gameMasterPassword || blueCommanderPassword === redCommanderPassword) {
+ console.log("Blue Commander password must be different from other passwords. Please try again.");
+ continue;
+ }
+ break;
+ }
+
+ /* Get the new Red Commander password */
+ while (true) {
+ newValue = prompt(`Insert a new Red Commander password or press Enter to keep current value: `, {echo: "*"});
+ redCommanderPassword = newValue !== ""? sha256(newValue): redCommanderPassword;
+
+ // Check if Red Commander password is unique
+ if (redCommanderPassword === gameMasterPassword || redCommanderPassword === blueCommanderPassword) {
+ console.log("Red Commander password must be different from other passwords. Please try again.");
+ continue;
+ }
+ break;
+ }
+ }
+
+ /* Apply the inputs */
+ json["server"]["address"] = address;
+ json["client"]["port"] = clientPort;
+ json["server"]["port"] = backendPort;
+ json["authentication"]["gameMasterPassword"] = gameMasterPassword;
+ json["authentication"]["blueCommanderPassword"] = blueCommanderPassword;
+ json["authentication"]["redCommanderPassword"] = redCommanderPassword;
+
+ /* Write the result to disk */
+ const serialized = JSON.stringify(json, null, 4);
+ fs.writeFileSync(jsonPath, serialized, 'utf8');
+ console.log("Olympus.json updated correctly, goodbye!");
+ }
+ else {
+ console.error("Error, could not read olympus.json file!")
+ }
+
+ /* Wait a bit before closing the window */
+ await new Promise(resolve => setTimeout(resolve, 3000));
+}
+
+console.log('\x1b[36m%s\x1b[0m', "*********************************************************************");
+console.log('\x1b[36m%s\x1b[0m', "* _____ _____ _____ ____ _ *");
+console.log('\x1b[36m%s\x1b[0m', "* | __ \\ / ____|/ ____| / __ \\| | *");
+console.log('\x1b[36m%s\x1b[0m', "* | | | | | | (___ | | | | |_ _ _ __ ___ _ __ _ _ ___ *");
+console.log('\x1b[36m%s\x1b[0m', "* | | | | | \\___ \\ | | | | | | | | '_ ` _ \\| '_ \\| | | / __| *");
+console.log('\x1b[36m%s\x1b[0m', "* | |__| | |____ ____) | | |__| | | |_| | | | | | | |_) | |_| \\__ \\ *");
+console.log('\x1b[36m%s\x1b[0m', "* |_____/ \\_____|_____/ \\____/|_|\\__, |_| |_| |_| .__/ \\__,_|___/ *");
+console.log('\x1b[36m%s\x1b[0m', "* __/ | | | *");
+console.log('\x1b[36m%s\x1b[0m', "* |___/ |_| *");
+console.log('\x1b[36m%s\x1b[0m', "*********************************************************************");
+console.log('\x1b[36m%s\x1b[0m', "");
+
+console.log("DCS Olympus configurator {{OLYMPUS_VERSION_NUMBER}}.{{OLYMPUS_COMMIT_HASH}}");
+console.log("");
+
+/* Run the configurator */
+if (args.directory) {
+ jsonPath = path.join(args.directory, "olympus.json");
+}
+else {
+ /* Automatically detect possible DCS installation folders */
+ regedit.list(shellFoldersKey, function(err, result) {
+ if (err) {
+ console.log(err);
+ }
+ else {
+ if (result[shellFoldersKey] !== undefined && result[shellFoldersKey]["exists"] && result[shellFoldersKey]['values'][saveGamesKey] !== undefined && result[shellFoldersKey]['values'][saveGamesKey]['value'] !== undefined)
+ {
+ const searchpath = result[shellFoldersKey]['values'][saveGamesKey]['value'];
+ const folders = fs.readdirSync(searchpath);
+ var options = [];
+ folders.forEach((folder) => {
+ if (fs.existsSync(path.join(searchpath, folder, "Logs", "dcs.log"))) {
+ options.push(folder);
+ }
+ })
+ console.log("The following DCS Saved Games folders have been automatically detected.")
+ options.forEach((folder, index) => {
+ console.log(`(${index + 1}) ${folder}`)
+ });
+ while (true) {
+ var newValue = prompt(`Please choose a folder onto which the configurator shall operate by typing the associated number: `)
+ result = Number(newValue);
+
+ if (!isNaN(result) && Number.isInteger(result) && result > 0 && result <= options.length) {
+ jsonPath = path.join(searchpath, options[result - 1], "Config", "olympus.json");
+ break;
+ }
+ else {
+ console.log(`Please type a number between 1 and ${options.length}`);
+ }
+ }
+
+ } else {
+ console.error("An error occured while trying to fetch the location of the DCS folder. Please type the folder location manually.")
+ jsonPath = path.join(prompt(`DCS Saved Games folder location: `), "olympus.json");
+ }
+ console.log(`Configurator will run on ${jsonPath}, if this is incorrect please restart the configurator`)
+ run();
+ }
+ })
+}
diff --git a/client/copy.sh b/client/copy.sh
new file mode 100755
index 00000000..b809b1fc
--- /dev/null
+++ b/client/copy.sh
@@ -0,0 +1,4 @@
+cp ./node_modules/leaflet/dist/leaflet.css ./public/stylesheets/leaflet/leaflet.css
+cp ./node_modules/leaflet-gesture-handling/dist/leaflet-gesture-handling.css ./public/stylesheets/leaflet/leaflet-gesture-handling.css
+cp ./node_modules/leaflet.nauticscale/dist/leaflet.nauticscale.js ./public/javascripts/leaflet.nauticscale.js
+cp ./node_modules/leaflet-path-drag/dist/L.Path.Drag.js ./public/javascripts/L.Path.Drag.js
diff --git a/client/package.json b/client/package.json
index 633bdc15..7928d875 100644
--- a/client/package.json
+++ b/client/package.json
@@ -44,6 +44,7 @@
"@types/node": "^18.16.1",
"@types/sortablejs": "^1.15.0",
"@types/svg-injector": "^0.0.29",
+ "ajv": "^8.12.0",
"babelify": "^10.0.0",
"browserify": "^17.0.0",
"concurrently": "^7.6.0",
diff --git a/client/public/stylesheets/leaflet/leaflet.css b/client/public/stylesheets/leaflet/leaflet.css
index 9ade8dc4..1981009f 100644
--- a/client/public/stylesheets/leaflet/leaflet.css
+++ b/client/public/stylesheets/leaflet/leaflet.css
@@ -60,11 +60,6 @@
padding: 0;
}
-.leaflet-container img.leaflet-tile {
- /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
- mix-blend-mode: plus-lighter;
-}
-
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
@@ -651,7 +646,7 @@ svg.leaflet-image-layer.leaflet-interactive path {
}
/* Printing */
-
+
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
diff --git a/client/public/stylesheets/other/contextmenus.css b/client/public/stylesheets/other/contextmenus.css
index 172afdac..fd816bb7 100644
--- a/client/public/stylesheets/other/contextmenus.css
+++ b/client/public/stylesheets/other/contextmenus.css
@@ -8,24 +8,20 @@
z-index: 9999;
}
-#map-contextmenu>div:nth-child(2) {
- align-items: center;
+
+
+/* #map-contextmenu>div:nth-child(n+4)>div {
+ width: 100%;
+} */
+
+#map-contextmenu .spawn-mode {
display: flex;
- flex-direction: row;
+ flex-direction: column;
justify-content: space-between;
- padding-right: 0px;
+ row-gap: 5px;
}
-#map-contextmenu>div:nth-child(3) {
- align-items: center;
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- padding-right: 0px;
-}
-
-#map-contextmenu>div:nth-child(n+4) {
- align-items: center;
+.ol-context-menu-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -33,10 +29,6 @@
padding: 20px;
}
-#map-contextmenu>div:nth-child(n+4)>div {
- width: 100%;
-}
-
.contextmenu-advanced-options,
.contextmenu-metadata {
align-items: center;
@@ -143,20 +135,6 @@
padding: 2px 5px;
}
-/*
-.ol-tag-CA {
- background-color: #FF000022;
-}
-
-.ol-tag-Radar {
- background-color: #00FF0022;
-}
-
-.ol-tag-IR {
- background-color: #0000FF22;
-}
-*/
-
.unit-loadout-list {
min-width: 0;
}
@@ -187,7 +165,65 @@
content: " (" attr(data-points) " points)";
}
-.upper-bar svg>* {
+#spawn-mode-tabs {
+ align-items: center;
+ column-gap: 6px;
+ display: flex;
+ position: absolute;
+ right: 0;
+ top:0;
+ translate: -6px -100%;
+ z-index: 9998;
+}
+
+#spawn-mode-tabs button {
+ align-items: center;
+ border-bottom:2px solid transparent;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-top-left-radius: var(--border-radius-sm);
+ border-top-right-radius: var(--border-radius-sm);
+ display: flex;
+ height:32px;
+ justify-content: center;
+ margin:0;
+ width:38px;
+}
+
+#spawn-mode-tabs button:hover {
+ background-color: var(--background-steel);
+}
+
+[data-coalition="blue"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-blue);
+}
+
+
+[data-coalition="red"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-red);
+}
+
+
+[data-coalition="neutral"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-neutral);
+}
+
+#spawn-mode-tabs button svg {
+ height:24px;
+ margin:6px;
+ width:24px;
+}
+
+.upper-bar {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding-right: 0px;
+}
+
+.upper-bar svg>*,
+#spawn-mode-tabs button svg * {
fill: white;
}
@@ -200,24 +236,78 @@
margin-left: auto;
}
+#spawn-history-menu {
+ align-items: center;
+ flex-direction: column;
+ max-height: 300px;
+ row-gap: 6px;
+}
+
+#spawn-history-menu button {
+ align-items: center;
+ column-gap: 6px;
+ display:flex;
+ height:32px;
+ text-align: left;
+ padding:0;
+ width:100%;
+}
+
+#spawn-history-menu button span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#spawn-history-menu button svg {
+ border-radius: var(--border-radius-sm);
+ height:24px;
+ padding:4px;
+ width:24px;
+}
+
+#spawn-history-menu button:hover {
+ background-color: transparent;
+ text-decoration: underline;
+}
+
+#spawn-history-menu button:hover svg * {
+ fill:white !important;
+}
+
+#spawn-history-menu button[data-spawned-coalition="blue"] svg {
+ background-color: var(--primary-blue);
+}
+
+#spawn-history-menu button[data-spawned-coalition="red"] svg {
+ background-color: var(--primary-red);
+}
+
+#spawn-history-menu button[data-spawned-coalition="neutral"] svg {
+ background-color: var(--primary-neutral);
+}
+
[data-coalition="blue"]#active-coalition-label,
[data-coalition="blue"].deploy-unit-button,
[data-coalition="blue"]#spawn-airbase-aircraft-button,
-[data-coalition="blue"].create-iads-button {
+[data-coalition="blue"].create-iads-button,
+[data-coalition="blue"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-blue)
}
[data-coalition="red"]#active-coalition-label,
[data-coalition="red"].deploy-unit-button,
[data-coalition="red"]#spawn-airbase-aircraft-button,
-[data-coalition="red"].create-iads-button {
+[data-coalition="red"].create-iads-button,
+[data-coalition="red"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-red)
}
[data-coalition="neutral"]#active-coalition-label,
[data-coalition="neutral"].deploy-unit-button,
[data-coalition="neutral"]#spawn-airbase-aircraft-button,
-[data-coalition="neutral"].create-iads-button {
+[data-coalition="neutral"].create-iads-button,
+[data-coalition="neutral"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-neutral)
}
diff --git a/client/public/stylesheets/style/style.css b/client/public/stylesheets/style/style.css
index 72287056..b1bb100b 100644
--- a/client/public/stylesheets/style/style.css
+++ b/client/public/stylesheets/style/style.css
@@ -171,6 +171,10 @@ button svg.fill-coalition[data-coalition="red"] * {
position: relative;
}
+.ol-select[disabled] {
+ color: var(--ol-dialog-disabled-text-color);
+}
+
.ol-select>.ol-select-value {
align-content: center;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
@@ -215,6 +219,10 @@ button svg.fill-coalition[data-coalition="red"] * {
background-size: 100% 100%;
}
+.ol-select[disabled]:not(.ol-select-image)>.ol-select-value:after {
+ opacity: 15%;
+}
+
.ol-select:not(.ol-select-image)>.ol-select-value.ol-select-warning:after {
background-image: url("/resources/theme/images/icons/chevron-down-warning.svg") !important;
}
@@ -1312,6 +1320,11 @@ dl.ol-data-grid dd {
margin: 4px 0;
}
+
+.ol-dialog label[disabled] {
+ color: var(--ol-dialog-disabled-text-color)
+}
+
.ol-dialog-content table th {
background-color: var(--background-grey);
color:white;
@@ -1383,6 +1396,10 @@ dl.ol-data-grid dd {
text-align: center;
}
+.ol-text-input input[disabled] {
+ color:var(--ol-dialog-disabled-text-color);
+}
+
input[type=number] {
-moz-appearance: textfield;
appearance: textfield;
@@ -1422,6 +1439,11 @@ input[type=number]::-webkit-outer-spin-button {
border: 1px solid white;
}
+.ol-button-apply[disabled] {
+ border-color: var(--ol-dialog-disabled-text-color);
+ color:var(--ol-dialog-disabled-text-color);
+}
+
.ol-button-apply::before {
content: "\2713";
}
diff --git a/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg b/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg
new file mode 100644
index 00000000..a31ed4b6
--- /dev/null
+++ b/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg b/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg
new file mode 100644
index 00000000..50966792
--- /dev/null
+++ b/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/themes/olympus/theme.css b/client/public/themes/olympus/theme.css
index 2da7dde9..328f8117 100644
--- a/client/public/themes/olympus/theme.css
+++ b/client/public/themes/olympus/theme.css
@@ -48,6 +48,8 @@
--ol-switch-off:#686868;
--ol-switch-undefined:#383838;
+ --ol-dialog-disabled-text-color: #ffffff20;
+
/*** General border radii **/
--border-radius-xs: 2px;
--border-radius-sm: 5px;
diff --git a/client/src/constants/constants.ts b/client/src/constants/constants.ts
index a9552b16..7ada419b 100644
--- a/client/src/constants/constants.ts
+++ b/client/src/constants/constants.ts
@@ -26,6 +26,23 @@ export const ROEs: string[] = ["free", "designated", "", "return", "hold"];
export const reactionsToThreat: string[] = ["none", "manoeuvre", "passive", "evade"];
export const emissionsCountermeasures: string[] = ["silent", "attack", "defend", "free"];
+export const ERAS = [{
+ "name": "Early Cold War",
+ "chronologicalOrder": 2
+}, {
+ "name": "Late Cold War",
+ "chronologicalOrder": 4
+}, {
+ "name": "Mid Cold War",
+ "chronologicalOrder": 3
+}, {
+ "name": "Modern",
+ "chronologicalOrder": 5
+}, {
+ "name": "WW2",
+ "chronologicalOrder": 1
+}];
+
export const ROEDescriptions: string[] = [
"Free (Attack anyone)",
"Designated (Attack the designated target only) \nWARNING: Ground and Navy units don't respect this ROE, it will be equivalent to weapons FREE.",
@@ -204,16 +221,19 @@ export const MAP_MARKER_CONTROLS: MapMarkerVisibilityControl[] = [{
"toggles": ["dcs"],
"tooltip": "Toggle DCS-controlled units' visibility"
}, {
+ "category": "Aircraft",
"image": "visibility/aircraft.svg",
"name": "Aircraft",
"toggles": ["aircraft"],
"tooltip": "Toggle aircraft's visibility"
}, {
+ "category": "Helicopter",
"image": "visibility/helicopter.svg",
"name": "Helicopter",
"toggles": ["helicopter"],
"tooltip": "Toggle helicopters' visibility"
}, {
+ "category": "AirDefence",
"image": "visibility/groundunit-sam.svg",
"name": "Air defence",
"toggles": ["groundunit-sam"],
@@ -224,6 +244,7 @@ export const MAP_MARKER_CONTROLS: MapMarkerVisibilityControl[] = [{
"toggles": ["groundunit"],
"tooltip": "Toggle ground units' visibility"
}, {
+ "category": "GroundUnit",
"image": "visibility/navyunit.svg",
"name": "Naval",
"toggles": ["navyunit"],
diff --git a/client/src/contextmenus/mapcontextmenu.ts b/client/src/contextmenus/mapcontextmenu.ts
index d7619c99..bff15bc3 100644
--- a/client/src/contextmenus/mapcontextmenu.ts
+++ b/client/src/contextmenus/mapcontextmenu.ts
@@ -5,8 +5,10 @@ import { Switch } from "../controls/switch";
import { GAME_MASTER } from "../constants/constants";
import { CoalitionArea } from "../map/coalitionarea/coalitionarea";
import { AirDefenceUnitSpawnMenu, AircraftSpawnMenu, GroundUnitSpawnMenu, HelicopterSpawnMenu, NavyUnitSpawnMenu } from "../controls/unitspawnmenu";
-import { Airbase } from "../mission/airbase";
import { SmokeMarker } from "../map/markers/smokemarker";
+import { UnitSpawnTable } from "../interfaces";
+import { getCategoryBlueprintIconSVG, getUnitDatabaseByCategory } from "../other/utils";
+import { SVGInjector } from "@tanem/svg-injector";
/** The MapContextMenu is the main contextmenu shown to the user whenever it rightclicks on the map. It is the primary interaction method for the user.
* It allows to spawn units, create explosions and smoke, and edit CoalitionAreas.
@@ -96,6 +98,8 @@ export class MapContextMenu extends ContextMenu {
this.getContainer()?.addEventListener("hide", () => this.#airDefenceUnitSpawnMenu.clearCirclesPreviews());
this.getContainer()?.addEventListener("hide", () => this.#groundUnitSpawnMenu.clearCirclesPreviews());
this.getContainer()?.addEventListener("hide", () => this.#navyUnitSpawnMenu.clearCirclesPreviews());
+
+ this.#setupHistory();
}
/** Show the contextmenu on top of the map, usually at the location where the user has clicked on it.
@@ -257,4 +261,91 @@ export class MapContextMenu extends ContextMenu {
this.#groundUnitSpawnMenu.setCountries();
this.#navyUnitSpawnMenu.setCountries();
}
+
+ /** Handles all of the logic for historal logging.
+ *
+ */
+ #setupHistory() {
+ /* Set up the tab clicks */
+ const spawnModes = this.getContainer()?.querySelectorAll(".spawn-mode");
+ const activeCoalitionLabel = document.getElementById("active-coalition-label");
+ const tabs = this.getContainer()?.querySelectorAll(".spawn-mode-tab");
+
+ // Default selected tab to the "spawn now" option
+ if (tabs) tabs[tabs.length-1].classList.add("selected");
+
+ tabs?.forEach((btn:Element) => {
+ btn.addEventListener("click", (ev:MouseEventInit) => {
+ // Highlight tab
+ tabs.forEach(tab => tab.classList.remove("selected"));
+ btn.classList.add("selected");
+
+ // Hide/reset
+ spawnModes?.forEach(div => div.classList.add("hide"));
+
+ const prevSiblings = [];
+ let prev = btn.previousElementSibling;
+
+ /* Tabs and content windows are assumed to be in the same order */
+ // Count previous
+ while ( prev ) {
+ prevSiblings.push(prev);
+ prev = prev.previousElementSibling;
+ }
+
+ // Show content
+ if (spawnModes && spawnModes[prevSiblings.length]) {
+ spawnModes[prevSiblings.length].classList.remove("hide");
+ }
+
+ // We don't want to see the "Spawn [coalition] unit" label
+ if (activeCoalitionLabel) activeCoalitionLabel.classList.toggle("hide", !btn.hasAttribute("data-show-label"));
+ });
+ });
+
+ const history = document.getElementById("spawn-history-menu");
+ const maxEntries = 20;
+
+ /** Listen for unit spawned **/
+ document.addEventListener( "unitSpawned", (ev:CustomEventInit) => {
+ const buttons = history.querySelectorAll("button");
+ const detail:any = ev.detail;
+ if (buttons.length === 0) history.innerHTML = ""; // Take out any "no data" messages
+ const button = document.createElement("button");
+ button.title = "Click to spawn";
+ button.setAttribute("data-spawned-coalition", detail.coalition);
+ button.setAttribute("data-unit-type", detail.unitSpawnTable[0].unitType);
+ button.setAttribute("data-unit-qty", detail.unitSpawnTable.length);
+
+ const db = getUnitDatabaseByCategory(detail.category);
+ button.innerHTML = `${db?.getByName(detail.unitSpawnTable[0].unitType)?.label} (${detail.unitSpawnTable.length}) `;
+
+ // Remove a previous instance to save clogging up the list
+ const previous:any = [].slice.call(buttons).find( (button:Element) => (
+ detail.coalition === button.getAttribute("data-spawned-coalition") &&
+ detail.unitSpawnTable[0].unitType === button.getAttribute("data-unit-type") &&
+ detail.unitSpawnTable.length === parseInt(button.getAttribute("data-unit-qty") || "-1")));
+
+ if (previous instanceof HTMLElement) previous.remove();
+
+ /* Click to do the spawn */
+ button.addEventListener("click", (ev:MouseEventInit) => {
+ detail.unitSpawnTable.forEach((table:UnitSpawnTable, i:number) => {
+ table.location = this.getLatLng(); // Set to new menu location
+ table.location.lat += 0.00015 * i;
+ });
+ getApp().getUnitsManager().spawnUnits(detail.category, detail.unitSpawnTable, detail.coalition, detail.immediate, detail.airbase, detail.country);
+ this.hide();
+ });
+
+ /* Insert into DOM */
+ history.prepend(button);
+ SVGInjector(button.querySelectorAll("img"));
+
+ /* Trim down to max number of entries */
+ while (history.querySelectorAll("button").length > maxEntries) {
+ history.childNodes[maxEntries].remove();
+ }
+ });
+ }
}
\ No newline at end of file
diff --git a/client/src/controls/dropdown.ts b/client/src/controls/dropdown.ts
index 1a0c4b6b..38014dc9 100644
--- a/client/src/controls/dropdown.ts
+++ b/client/src/controls/dropdown.ts
@@ -26,7 +26,9 @@ export class Dropdown {
if (options != null) this.setOptions(options);
- (this.#container.querySelector(".ol-select-value") as HTMLElement)?.addEventListener("click", (ev) => { this.#toggle(); });
+ (this.#container.querySelector(".ol-select-value") as HTMLElement)?.addEventListener("click", (ev) => {
+ if (!this.#container.hasAttribute("disabled") && !this.#container.closest("disabled")) this.#toggle();
+ });
document.addEventListener("click", (ev) => {
if (!(this.#value.contains(ev.target as Node) || this.#options.contains(ev.target as Node) || this.#container.contains(ev.target as Node))) {
diff --git a/client/src/map/map.ts b/client/src/map/map.ts
index be47a116..6218214a 100644
--- a/client/src/map/map.ts
+++ b/client/src/map/map.ts
@@ -38,6 +38,7 @@ require("../../public/javascripts/leaflet.nauticscale.js")
require("../../public/javascripts/L.Path.Drag.js")
export type MapMarkerVisibilityControl = {
+ "category"?: string;
"image": string;
"isProtected"?: boolean,
"name": string,
diff --git a/client/src/mission/missionmanager.ts b/client/src/mission/missionmanager.ts
index 139f35ae..fb557e33 100644
--- a/client/src/mission/missionmanager.ts
+++ b/client/src/mission/missionmanager.ts
@@ -2,7 +2,7 @@ import { LatLng } from "leaflet";
import { getApp } from "..";
import { Airbase } from "./airbase";
import { Bullseye } from "./bullseye";
-import { BLUE_COMMANDER, GAME_MASTER, NONE, RED_COMMANDER } from "../constants/constants";
+import { BLUE_COMMANDER, ERAS, GAME_MASTER, NONE, RED_COMMANDER } from "../constants/constants";
import { Dropdown } from "../controls/dropdown";
import { groundUnitDatabase } from "../unit/databases/groundunitdatabase";
import { createCheckboxOption, getCheckboxOptions } from "../other/utils";
@@ -26,13 +26,16 @@ export class MissionManager {
#coalitions: {red: string[], blue: string[]} = {red: [], blue: []};
constructor() {
- document.addEventListener("showCommandModeDialog", () => this.showCommandModeDialog());
document.addEventListener("applycommandModeOptions", () => this.#applycommandModeOptions());
+ document.addEventListener("showCommandModeDialog", () => this.showCommandModeDialog());
+ document.addEventListener("toggleSpawnRestrictions", (ev:CustomEventInit) => {
+ this.#toggleSpawnRestrictions(ev.detail._element.checked)
+ });
/* command-mode settings dialog */
this.#commandModeDialog = document.querySelector("#command-mode-settings-dialog") as HTMLElement;
-
this.#commandModeErasDropdown = new Dropdown("command-mode-era-options", () => {});
+
}
/** Update location of bullseyes
@@ -211,12 +214,18 @@ export class MissionManager {
}
showCommandModeDialog() {
+ const options = this.getCommandModeOptions()
+ const { restrictSpawns, restrictToCoalition, setupTime } = options;
+ this.#toggleSpawnRestrictions(restrictSpawns);
+
/* Create the checkboxes to select the unit eras */
- var eras = aircraftDatabase.getEras().concat(helicopterDatabase.getEras()).concat(groundUnitDatabase.getEras()).concat(navyUnitDatabase.getEras());
- eras = eras.filter((item: string, index: number) => eras.indexOf(item) === index).sort();
- this.#commandModeErasDropdown.setOptionsElements(eras.map((era: string) => {
- return createCheckboxOption(era, `Enable ${era} units spawns`, this.getCommandModeOptions().eras.includes(era));
- }));
+ this.#commandModeErasDropdown.setOptionsElements(
+ ERAS.sort((eraA, eraB) => {
+ return ( eraA.chronologicalOrder > eraB.chronologicalOrder ) ? 1 : -1;
+ }).map((era) => {
+ return createCheckboxOption(era.name, `Enable ${era} units spawns`, this.getCommandModeOptions().eras.includes(era.name));
+ })
+ );
this.#commandModeDialog.classList.remove("hide");
@@ -226,11 +235,11 @@ export class MissionManager {
const redSpawnPointsInput = this.#commandModeDialog.querySelector("#red-spawn-points")?.querySelector("input") as HTMLInputElement;
const setupTimeInput = this.#commandModeDialog.querySelector("#setup-time")?.querySelector("input") as HTMLInputElement;
- restrictSpawnsCheckbox.checked = this.getCommandModeOptions().restrictSpawns;
- restrictToCoalitionCheckbox.checked = this.getCommandModeOptions().restrictToCoalition;
- blueSpawnPointsInput.value = String(this.getCommandModeOptions().spawnPoints.blue);
- redSpawnPointsInput.value = String(this.getCommandModeOptions().spawnPoints.red);
- setupTimeInput.value = String(Math.floor(this.getCommandModeOptions().setupTime / 60.0));
+ restrictSpawnsCheckbox.checked = restrictSpawns;
+ restrictToCoalitionCheckbox.checked = restrictToCoalition;
+ blueSpawnPointsInput.value = String(options.spawnPoints.blue);
+ redSpawnPointsInput.value = String(options.spawnPoints.red);
+ setupTimeInput.value = String(Math.floor(setupTime / 60.0));
}
#applycommandModeOptions() {
@@ -309,4 +318,10 @@ export class MissionManager {
};
xhr.send();
}
+
+ #toggleSpawnRestrictions(restrictionsEnabled:boolean) {
+ this.#commandModeDialog.querySelectorAll("input, label, .ol-select").forEach( el => {
+ if (!el.closest("#restrict-spawns")) el.toggleAttribute("disabled", !restrictionsEnabled);
+ });
+ }
}
\ No newline at end of file
diff --git a/client/src/olympusapp.ts b/client/src/olympusapp.ts
index aa1d9d7e..0f38ebfb 100644
--- a/client/src/olympusapp.ts
+++ b/client/src/olympusapp.ts
@@ -18,6 +18,7 @@ import { Manager } from "./other/manager";
import { SVGInjector } from "@tanem/svg-injector";
import { ServerManager } from "./server/servermanager";
import { sha256 } from 'js-sha256';
+import Ajv from "ajv"
import { BLUE_COMMANDER, FILL_SELECTED_RING, GAME_MASTER, HIDE_UNITS_SHORT_RANGE_RINGS, RED_COMMANDER, SHOW_UNITS_ACQUISITION_RINGS, SHOW_UNITS_ENGAGEMENT_RINGS, SHOW_UNIT_LABELS } from "./constants/constants";
import { aircraftDatabase } from "./unit/databases/aircraftdatabase";
@@ -27,6 +28,8 @@ import { navyUnitDatabase } from "./unit/databases/navyunitdatabase";
import { UnitListPanel } from "./panels/unitlistpanel";
import { ContextManager } from "./context/contextmanager";
import { Context } from "./context/context";
+import { AirDefenceUnitSpawnMenu } from "./controls/unitspawnmenu";
+import { AirbasesJSONSchemaValidator } from "./schemas/schema";
var VERSION = "{{OLYMPUS_VERSION_NUMBER}}";
@@ -193,6 +196,9 @@ export class OlympusApp {
this.#unitsManager = new UnitsManager();
this.#weaponsManager = new WeaponsManager();
+ /* Validate data */
+ this.#validateData();
+
// Toolbars
this.getToolbarsManager().add("primaryToolbar", new PrimaryToolbar("primary-toolbar"))
.add("commandModeToolbar", new CommandModeToolbar("command-mode-toolbar"));
@@ -447,4 +453,28 @@ export class OlympusApp {
img.addEventListener("load", () => { SVGInjector(img); });
})
}
+
+ #validateData() {
+
+ const airbasesValidator = new AirbasesJSONSchemaValidator();
+
+ /*
+ const validator = new Ajv();
+ const schema = {
+ type: "object",
+ properties: {
+ foo: {type: "integer"},
+ bar: {type: "string"},
+ },
+ required: ["foo"],
+ additionalProperties: false,
+ }
+
+ const data = this.#getRunwayData();
+
+ const validate = validator.compile(schema);
+ const valid = validate(data);
+ if (!valid) console.log(validate.errors);
+ //*/
+ }
}
\ No newline at end of file
diff --git a/client/src/other/utils.ts b/client/src/other/utils.ts
index 74866674..0476a610 100644
--- a/client/src/other/utils.ts
+++ b/client/src/other/utils.ts
@@ -5,7 +5,7 @@ import { aircraftDatabase } from "../unit/databases/aircraftdatabase";
import { helicopterDatabase } from "../unit/databases/helicopterdatabase";
import { groundUnitDatabase } from "../unit/databases/groundunitdatabase";
import { Buffer } from "buffer";
-import { ROEs, emissionsCountermeasures, reactionsToThreat, states } from "../constants/constants";
+import { GROUND_UNIT_AIR_DEFENCE_REGEX, ROEs, emissionsCountermeasures, reactionsToThreat, states } from "../constants/constants";
import { Dropdown } from "../controls/dropdown";
import { navyUnitDatabase } from "../unit/databases/navyunitdatabase";
import { DateAndTime, UnitBlueprint } from "../interfaces";
@@ -379,6 +379,20 @@ export function getUnitDatabaseByCategory(category: string) {
return null;
}
+export function getCategoryBlueprintIconSVG(category:string, unitName:string) {
+
+ const path = "/resources/theme/images/buttons/visibility/";
+
+ // We can just send these back okay
+ if (["Aircraft", "Helicopter", "NavyUnit"].includes(category)) return `${path}${category.toLowerCase()}.svg`;
+
+ // Return if not a ground units as it's therefore something we don't recognise
+ if (category !== "GroundUnit") return false;
+
+ /** We need to get the unit detail for ground units so we can work out if it's an air defence unit or not **/
+ return GROUND_UNIT_AIR_DEFENCE_REGEX.test(unitName) ? `${path}groundunit-sam.svg` : `${path}groundunit.svg`;
+}
+
export function base64ToBytes(base64: string) {
return Buffer.from(base64, 'base64').buffer;
}
diff --git a/client/src/schemas/airbases.schema.json b/client/src/schemas/airbases.schema.json
new file mode 100644
index 00000000..c43f57d3
--- /dev/null
+++ b/client/src/schemas/airbases.schema.json
@@ -0,0 +1,67 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "airfields": {
+ "type": "object",
+ "minProperties": 1,
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "properties": {
+ "elevation": {
+ "type": "string",
+ "pattern": "^(0|([1-9][0-9]*))?$"
+ },
+ "ICAO": {
+ "type": "string"
+ },
+ "runways": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "headings": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "properties": {
+ "ILS": {
+ "type": "string",
+ "pattern": "^(1[0-9]{1,2}\\.[0-9][05])?$"
+ },
+ "magHeading": {
+ "type": "string",
+ "pattern": "^([0-2][0-9]{2})|(3(([0-5][0-9])|(60)))?$"
+ }
+ },
+ "required": ["magHeading"]
+ }
+ }
+ },
+ "minItems": 1
+ },
+ "length": {
+ "type": "string",
+ "pattern": "^[1-9][0-9]{3,4}$"
+ }
+ },
+ "required": [ "headings", "length" ]
+ }
+ },
+ "TACAN": {
+ "type": "string",
+ "pattern": "^([1-9][0-9]{1,2}X)?$"
+ }
+ },
+ "required": [ "elevation", "runways" ]
+ }
+ }
+ }
+ },
+ "required": ["airfields"]
+}
\ No newline at end of file
diff --git a/client/src/schemas/importdata.schema.json b/client/src/schemas/importdata.schema.json
new file mode 100644
index 00000000..bfb1943a
--- /dev/null
+++ b/client/src/schemas/importdata.schema.json
@@ -0,0 +1,425 @@
+{
+ "$defs": {
+ "coalitionName": {
+ "enum": [
+ "blue",
+ "neutral",
+ "red"
+ ],
+ "type": "string"
+ },
+ "lat": {
+ "maximum": 90,
+ "minimum": -90,
+ "type": "number"
+ },
+ "lng": {
+ "maximum": 180,
+ "minimum": -180,
+ "type": "number"
+ },
+ "vec2": {
+ "additionalProperties": false,
+ "properties": {
+ "lat": {
+ "$ref": "#/$defs/lat"
+ },
+ "lng": {
+ "$ref": "#/$defs/lng"
+ }
+ },
+ "required": [
+ "lat",
+ "lng"
+ ],
+ "type": "object"
+ },
+ "vec3": {
+ "additionalProperties": false,
+ "properties": {
+ "alt": {
+ "type": "number"
+ },
+ "lat": {
+ "$ref": "#/$defs/lat"
+ },
+ "lng": {
+ "$ref": "#/$defs/lng"
+ }
+ },
+ "required": [
+ "alt",
+ "lat",
+ "lng"
+ ],
+ "type": "object"
+ }
+ },
+ "patternProperties": {
+ ".*": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "activePath": {
+ "items": {
+ "$ref": "#/$defs/vec3"
+ },
+ "type": "array"
+ },
+ "alive": {
+ "type": "boolean"
+ },
+ "ammo": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "category": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "guidance": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "missileCategory": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "name": {
+ "minLength": 3,
+ "type": "string"
+ },
+ "quantity": {
+ "minimum": 0,
+ "type": "number"
+ }
+ },
+ "required": [
+ "quantity",
+ "name",
+ "guidance",
+ "category",
+ "missileCategory"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "category": {
+ "type": "string"
+ },
+ "categoryDisplayName": {
+ "type": "string"
+ },
+ "coalition": {
+ "$ref": "#/$defs/coalitionName"
+ },
+ "contacts": {
+ "type": "array"
+ },
+ "controlled": {
+ "type": "boolean"
+ },
+ "country": {
+ "type": "number"
+ },
+ "desiredAltitude": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "desiredAltitudeType": {
+ "enum": [
+ "AGL",
+ "ASL"
+ ],
+ "type": "string"
+ },
+ "desiredSpeed": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "desiredSpeedType": {
+ "enum": [
+ "CAS",
+ "GS"
+ ],
+ "type": "string"
+ },
+ "emissionsCountermeasures": {
+ "enum": [
+ "attac",
+ "defend",
+ "free",
+ "silent"
+ ],
+ "type": "string"
+ },
+ "followRoads": {
+ "type": "boolean"
+ },
+ "formationOffset": {
+ "additionalProperties": false,
+ "properties": {
+ "x": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "y": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "z": {
+ "minimum": 0,
+ "type": "number"
+ }
+ },
+ "required": [
+ "x",
+ "y",
+ "z"
+ ],
+ "type": "object"
+ },
+ "fuel": {
+ "maximum": 100,
+ "minimum": 0,
+ "type": "number"
+ },
+ "generalSettings": {
+ "additionalProperties": false,
+ "properties": {
+ "prohibitAA": {
+ "type": "boolean"
+ },
+ "prohibitAfterburner": {
+ "type": "boolean"
+ },
+ "prohibitAG": {
+ "type": "boolean"
+ },
+ "prohibitAirWpn": {
+ "type": "boolean"
+ },
+ "prohibitJettison": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "prohibitAA",
+ "prohibitAfterburner",
+ "prohibitAG",
+ "prohibitAirWpn",
+ "prohibitJettison"
+ ],
+ "type": "object"
+ },
+ "groupName": {
+ "type": "string"
+ },
+ "hasTask": {
+ "type": "boolean"
+ },
+ "heading": {
+ "type": "number"
+ },
+ "health": {
+ "maximum": 100,
+ "minimum": 0,
+ "type": "number"
+ },
+ "horizontalVelocity": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "human": {
+ "type": "boolean"
+ },
+ "ID": {
+ "type": "number"
+ },
+ "isActiveAWACS": {
+ "type": "boolean"
+ },
+ "isActiveTanker": {
+ "type": "boolean"
+ },
+ "isLeader": {
+ "type": "boolean"
+ },
+ "leaderID": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "name": {
+ "type": "string"
+ },
+ "onOff": {
+ "type": "boolean"
+ },
+ "operateAs": {
+ "$ref": "#/$defs/coalitionName"
+ },
+ "position": {
+ "$ref": "#/$defs/vec3"
+ },
+ "radio": {
+ "additionalProperties": false,
+ "properties": {
+ "callsign": {
+ "type": "number"
+ },
+ "callsignNumber": {
+ "type": "number"
+ },
+ "frequency": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "frequency",
+ "callsign",
+ "callsignNumber"
+ ],
+ "type": "object"
+ },
+ "reactionToThreat": {
+ "enum": [
+ "evade",
+ "maneouvre",
+ "none",
+ "passive"
+ ],
+ "type": "string"
+ },
+ "ROE": {
+ "enum": [
+ "designated",
+ "free",
+ "hold",
+ "return"
+ ],
+ "type": "string"
+ },
+ "shotsIntensity": {
+ "maximum": 3,
+ "minimum": 1,
+ "type": "number"
+ },
+ "shotsScatter": {
+ "maximum": 3,
+ "minimum": 1,
+ "type": "number"
+ },
+ "speed": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "state": {
+ "type": "string"
+ },
+ "TACAN": {
+ "properties": {
+ "callsign": {
+ "type": "string"
+ },
+ "channel": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "isOn": {
+ "type": "boolean"
+ },
+ "XY": {
+ "enum": [
+ "X",
+ "Y"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "callsign",
+ "channel",
+ "isOn",
+ "XY"
+ ],
+ "type": "object"
+ },
+ "targetID": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "targetPosition": {
+ "$ref": "#/$defs/vec2"
+ },
+ "task": {
+ "type": "string"
+ },
+ "track": {
+ "type": "number"
+ },
+ "unitName": {
+ "type": "string"
+ },
+ "verticalVelocity": {
+ "minimum": 0,
+ "type": "number"
+ }
+ },
+ "type": "object",
+ "required": [
+ "activePath",
+ "alive",
+ "ammo",
+ "category",
+ "categoryDisplayName",
+ "coalition",
+ "contacts",
+ "controlled",
+ "country",
+ "desiredAltitude",
+ "desiredAltitudeType",
+ "desiredSpeed",
+ "desiredSpeedType",
+ "emissionsCountermeasures",
+ "followRoads",
+ "formationOffset",
+ "fuel",
+ "generalSettings",
+ "groupName",
+ "hasTask",
+ "heading",
+ "health",
+ "horizontalVelocity",
+ "human",
+ "ID",
+ "isActiveAWACS",
+ "isActiveTanker",
+ "isLeader",
+ "leaderID",
+ "name",
+ "onOff",
+ "operateAs",
+ "position",
+ "radio",
+ "reactionToThreat",
+ "ROE",
+ "shotsIntensity",
+ "shotsScatter",
+ "speed",
+ "state",
+ "TACAN",
+ "targetID",
+ "targetPosition",
+ "task",
+ "track",
+ "unitName",
+ "verticalVelocity"
+ ]
+ },
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "type": "object"
+}
\ No newline at end of file
diff --git a/client/src/schemas/schema.ts b/client/src/schemas/schema.ts
new file mode 100644
index 00000000..5ad4c527
--- /dev/null
+++ b/client/src/schemas/schema.ts
@@ -0,0 +1,82 @@
+import Ajv from "ajv";
+import { AnySchemaObject } from "ajv/dist/core";
+
+
+// For future extension
+abstract class JSONSchemaValidator {
+
+ #ajv:Ajv;
+ #compiledValidator:any;
+ #schema!:AnySchemaObject;
+
+ constructor( schema:AnySchemaObject ) {
+ this.#schema = schema;
+
+ this.#ajv = new Ajv({
+ "allErrors": true
+ });
+
+ this.#compiledValidator = this.getAjv().compile(this.getSchema());
+
+ }
+
+ getAjv() {
+ return this.#ajv;
+ }
+
+ getCompiledValidator() {
+ return this.#compiledValidator;
+ }
+
+ getErrors() {
+ return this.getCompiledValidator().errors;
+ }
+
+ getSchema() {
+ return this.#schema;
+ }
+
+ validate(data:any) {
+ return (this.getCompiledValidator())(data);
+ }
+
+}
+
+
+export class AirbasesJSONSchemaValidator extends JSONSchemaValidator {
+
+ constructor() {
+
+ const schema = require("../schemas/airbases.schema.json");
+
+ super( schema );
+
+ [
+ require( "../../public/databases/airbases/caucasus.json" ),
+ require( "../../public/databases/airbases/falklands.json" ),
+ require( "../../public/databases/airbases/marianas.json" ),
+ require( "../../public/databases/airbases/nevada.json" ),
+ require( "../../public/databases/airbases/normandy.json" ),
+ require( "../../public/databases/airbases/persiangulf.json" ),
+ require( "../../public/databases/airbases/sinaimap.json" ),
+ require( "../../public/databases/airbases/syria.json" ),
+ require( "../../public/databases/airbases/thechannel.json" )
+ ].forEach( data => {
+ const validate = this.getAjv().compile(this.getSchema());
+ const valid = validate(data);
+
+ if (!valid) console.error(validate.errors);
+ });
+ }
+
+}
+
+
+export class ImportFileJSONSchemaValidator extends JSONSchemaValidator {
+
+ constructor() {
+ const schema = require("../schemas/importdata.schema.json");
+ super( schema );
+ }
+
+}
\ No newline at end of file
diff --git a/client/src/unit/importexport/unitdatafileexport.ts b/client/src/unit/importexport/unitdatafileexport.ts
index 1bdb0d22..2a4f7a82 100644
--- a/client/src/unit/importexport/unitdatafileexport.ts
+++ b/client/src/unit/importexport/unitdatafileexport.ts
@@ -25,6 +25,10 @@ export class UnitDataFileExport extends UnitDataFile {
* Show the form to start the export journey
*/
showForm(units: Unit[]) {
+ this.dialog.getElement().querySelectorAll("[data-on-error]").forEach((el:Element) => {
+ el.classList.toggle("hide", el.getAttribute("data-on-error") === "show");
+ });
+
const data: any = {};
const unitCanBeExported = (unit: Unit) => !["Aircraft", "Helicopter"].includes(unit.getCategory());
diff --git a/client/src/unit/importexport/unitdatafileimport.ts b/client/src/unit/importexport/unitdatafileimport.ts
index 5a0e2221..6cebeef9 100644
--- a/client/src/unit/importexport/unitdatafileimport.ts
+++ b/client/src/unit/importexport/unitdatafileimport.ts
@@ -1,6 +1,7 @@
import { getApp } from "../..";
import { Dialog } from "../../dialog/dialog";
import { UnitData } from "../../interfaces";
+import { ImportFileJSONSchemaValidator } from "../../schemas/schema";
import { UnitDataFile } from "./unitdatafile";
export class UnitDataFileImport extends UnitDataFile {
@@ -48,21 +49,65 @@ export class UnitDataFileImport extends UnitDataFile {
unitsManager.spawnUnits(category, unitsToSpawn, coalition, false);
}
+ }
- /*
- for (let groupName in groups) {
- if (groupName !== "" && groups[groupName].length > 0 && (groups[groupName].every((unit: UnitData) => { return unit.category == "GroundUnit"; }) || groups[groupName].every((unit: any) => { return unit.category == "NavyUnit"; }))) {
- var aliveUnits = groups[groupName].filter((unit: UnitData) => { return unit.alive });
- var units = aliveUnits.map((unit: UnitData) => {
- return { unitType: unit.name, location: unit.position, liveryID: "" }
- });
- getApp().getUnitsManager().spawnUnits(groups[groupName][0].category, units, groups[groupName][0].coalition, true);
+ selectFile() {
+ var input = document.createElement("input");
+ input.type = "file";
+ input.addEventListener("change", (e: any) => {
+ var file = e.target.files[0];
+ if (!file) {
+ return;
}
- }
- //*/
+ var reader = new FileReader();
+ reader.onload = (e: any) => {
+
+ try {
+ this.#fileData = JSON.parse(e.target.result);
+
+ const validator = new ImportFileJSONSchemaValidator();
+ if (!validator.validate(this.#fileData)) {
+ const errors = validator.getErrors().reduce((acc:any, error:any) => {
+ let errorString = error.instancePath.substring(1) + ": " + error.message;
+ if (error.params) {
+ const {allowedValues} = error.params;
+ if (allowedValues)
+ errorString += ": " + allowedValues.join(', ');
+ }
+ acc.push(errorString);
+ return acc;
+ }, [] as string[]);
+ this.#showFileDataErrors(errors);
+ } else {
+ this.#showForm();
+ }
+ } catch(e:any) {
+ this.#showFileDataErrors([e]);
+ }
+ };
+ reader.readAsText(file);
+ })
+ input.click();
+ }
+
+ #showFileDataErrors( reasons:string[]) {
+
+ this.dialog.getElement().querySelectorAll("[data-on-error]").forEach((el:Element) => {
+ el.classList.toggle("hide", el.getAttribute("data-on-error") === "hide");
+ });
+
+ const reasonsList = this.dialog.getElement().querySelector(".import-error-reasons");
+ if (reasonsList instanceof HTMLElement)
+ reasonsList.innerHTML = `${reasons.join(" ")} `;
+
+ this.dialog.show();
}
#showForm() {
+ this.dialog.getElement().querySelectorAll("[data-on-error]").forEach((el:Element) => {
+ el.classList.toggle("hide", el.getAttribute("data-on-error") === "show");
+ });
+
const data: any = {};
for (const [group, units] of Object.entries(this.#fileData)) {
@@ -87,44 +132,11 @@ export class UnitDataFileImport extends UnitDataFile {
}
- /*
- groups.filter((unit:Unit) => unitCanBeImported(unit)).forEach((unit:Unit) => {
- const category = unit.getCategoryLabel();
- const coalition = unit.getCoalition();
-
- if (!data.hasOwnProperty(category)) {
- data[category] = {};
- }
-
- if (!data[category].hasOwnProperty(coalition))
- data[category][coalition] = [];
-
- data[category][coalition].push(unit);
- });
- //*/
this.data = data;
this.buildCategoryCoalitionTable();
this.dialog.show();
}
- selectFile() {
- var input = document.createElement("input");
- input.type = "file";
- input.addEventListener("change", (e: any) => {
- var file = e.target.files[0];
- if (!file) {
- return;
- }
- var reader = new FileReader();
- reader.onload = (e: any) => {
- this.#fileData = JSON.parse(e.target.result);
- this.#showForm();
- };
- reader.readAsText(file);
- })
- input.click();
- }
-
#unitDataCanBeImported(unitData: UnitData) {
return unitData.alive && this.#unitGroupDataCanBeImported([unitData]);
}
diff --git a/client/src/unit/unitsmanager.ts b/client/src/unit/unitsmanager.ts
index da455dc1..28556b46 100644
--- a/client/src/unit/unitsmanager.ts
+++ b/client/src/unit/unitsmanager.ts
@@ -1427,6 +1427,16 @@ export class UnitsManager {
if (spawnPoints <= getApp().getMissionManager().getAvailableSpawnPoints()) {
getApp().getMissionManager().setSpentSpawnPoints(spawnPoints);
spawnFunction();
+ document.dispatchEvent( new CustomEvent( "unitSpawned", {
+ "detail": {
+ "airbase": airbase,
+ "category": category,
+ "coalition": coalition,
+ "country": country,
+ "immediate": immediate,
+ "unitSpawnTable": units
+ }
+ }));
return true;
} else {
(getApp().getPopupsManager().get("infoPopup") as Popup).setText("Not enough spawn points available!");
diff --git a/client/views/contextmenus/map.ejs b/client/views/contextmenus/map.ejs
index a3b87b49..4f375786 100644
--- a/client/views/contextmenus/map.ejs
+++ b/client/views/contextmenus/map.ejs
@@ -1,58 +1,71 @@