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 @@
    -
    -
    - - - - - - +
    + +
    -
    - - - - +
    +

    Spawn history

    +
    +

    You do not have any units to show.

    -
    -
    -
    - -
    -
    - -
    -
    - -
    - -
    - - - - - -
    -
    - - - - - - +
    +
    +
    + + + + + + + +
    +
    + + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    + + + + + +
    +
    + + + + + + +
    \ No newline at end of file diff --git a/client/views/other/dialogs/commandmodesettings.ejs b/client/views/other/dialogs/commandmodesettings.ejs index 3b278812..5d783473 100644 --- a/client/views/other/dialogs/commandmodesettings.ejs +++ b/client/views/other/dialogs/commandmodesettings.ejs @@ -8,11 +8,13 @@
    +
    +
    -

    <%= textContent %>

    - <% if (showFilenameInput) { %> -
    - - - -
    - <% } %> +
    +

    <%= textContent %>

    + + <% if (showFilenameInput) { %> +
    + + + +
    + <% } %> + + + + + + +
    +
    - - - - - -
    -
    +
    - + + +
    \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..05f1ae58 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3' + +services: + client: + build: client + ports: + - 3000:3000 + volumes: + - ./olympus.json:/usr/src/olympus.json