From 33b5b305e264fef5bf71269f16f81faec73d8152 Mon Sep 17 00:00:00 2001 From: James Lyne Date: Sat, 24 Jul 2021 01:15:52 +0100 Subject: [PATCH] Add MapProvider, move dynmap api handling to DynmapMapProvider --- src/App.vue | 45 +- src/api.ts | 777 -------------------------- src/components/Map.vue | 3 - src/index.d.ts | 9 + src/providers/DynmapMapProvider.ts | 846 +++++++++++++++++++++++++++++ src/providers/MapProvider.ts | 25 + src/store/action-types.ts | 6 +- src/store/actions.ts | 72 +-- src/store/mutation-types.ts | 2 - src/store/mutations.ts | 23 +- src/store/state.ts | 10 +- src/util.ts | 15 +- 12 files changed, 932 insertions(+), 901 deletions(-) delete mode 100644 src/api.ts create mode 100644 src/providers/DynmapMapProvider.ts create mode 100644 src/providers/MapProvider.ts diff --git a/src/App.vue b/src/App.vue index 8dc826d..f480058 100644 --- a/src/App.vue +++ b/src/App.vue @@ -43,21 +43,19 @@ export default defineComponent({ setup() { const store = useStore(), - updateInterval = computed(() => store.state.configuration.updateInterval), title = computed(() => store.state.configuration.title), currentUrl = computed(() => store.getters.url), currentServer = computed(() => store.state.currentServer), configurationHash = computed(() => store.state.configurationHash), chatBoxEnabled = computed(() => store.state.components.chatBox), chatVisible = computed(() => store.state.ui.visibleElements.has('chat')), - updatesEnabled = ref(false), - updateTimeout = ref(0), configAttempts = ref(0), loadConfiguration = async () => { try { await store.dispatch(ActionTypes.LOAD_CONFIGURATION, undefined); - startUpdates(); + await store.dispatch(ActionTypes.START_UPDATES, undefined); + requestAnimationFrame(() => { hideSplash(); @@ -80,36 +78,6 @@ export default defineComponent({ } }, - startUpdates = () => { - updatesEnabled.value = true; - update(); - }, - - update = async () => { - //TODO: Error notification for repeated failures? - try { - await store.dispatch(ActionTypes.GET_UPDATE, undefined); - } finally { - if(updatesEnabled.value) { - if(updateTimeout.value) { - clearTimeout(updateTimeout.value); - } - - updateTimeout.value = setTimeout(() => update(), updateInterval.value); - } - } - }, - - stopUpdates = () => { - updatesEnabled.value = false; - - if (updateTimeout.value) { - clearTimeout(updateTimeout.value); - } - - updateTimeout.value = 0; - }, - handleUrl = () => { const parsedUrl = parseUrl(); @@ -174,7 +142,6 @@ export default defineComponent({ watch(currentUrl, (url) => window.history.replaceState({}, '', url)); watch(currentServer, (newServer?: LiveAtlasServerDefinition) => { showSplash(); - stopUpdates(); if(!newServer) { return; @@ -190,17 +157,17 @@ export default defineComponent({ window.history.replaceState({}, '', newServer.id); loadConfiguration(); }, {deep: true}); - watch(configurationHash, (newHash, oldHash) => { + watch(configurationHash, async (newHash, oldHash) => { if(newHash && oldHash) { showSplash(); - stopUpdates(); store.commit(MutationTypes.CLEAR_PARSED_URL, undefined); - loadConfiguration(); + await store.dispatch(ActionTypes.STOP_UPDATES, undefined); + await loadConfiguration(); } }); onMounted(() => loadConfiguration()); - onBeforeUnmount(() => stopUpdates()); + onBeforeUnmount(() => store.dispatch(ActionTypes.STOP_UPDATES, undefined)); handleUrl(); onResize(); diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index cc7acc7..0000000 --- a/src/api.ts +++ /dev/null @@ -1,777 +0,0 @@ -/* - * Copyright 2020 James Lyne - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - DynmapArea, - DynmapChat, - DynmapCircle, - DynmapComponentConfig, - DynmapConfigurationResponse, - DynmapLine, - DynmapMarker, - DynmapMarkerSet, - DynmapMarkerSetUpdates, - DynmapPlayer, - DynmapServerConfig, - DynmapTileUpdate, - DynmapUpdate, - DynmapUpdateResponse, - DynmapUpdates -} from "@/dynmap"; -import {useStore} from "@/store"; -import ChatError from "@/errors/ChatError"; -import {LiveAtlasDimension, LiveAtlasServerMessageConfig, LiveAtlasWorldDefinition} from "@/index"; -import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; - -const titleColours = /§[0-9a-f]/ig, - netherWorldName = /_?nether(_|$)/i, - endWorldName = /(^|_)end(_|$)/i; - -function buildServerConfig(response: any): DynmapServerConfig { - return { - version: response.dynmapversion || '', - grayHiddenPlayers: response.grayplayerswhenhidden || false, - defaultMap: response.defaultmap || undefined, - defaultWorld: response.defaultworld || undefined, - defaultZoom: response.defaultzoom || 0, - followMap: response.followmap || undefined, - followZoom: response.followzoom || 0, - updateInterval: response.updaterate || 3000, - showLayerControl: response.showlayercontrol && response.showlayercontrol !== 'false', //Sent as a string for some reason - title: response.title.replace(titleColours, '') || 'Dynmap', - loginEnabled: response['login-enabled'] || false, - maxPlayers: response.maxcount || 0, - expandUI: response.sidebaropened && response.sidebaropened !== 'false', //Sent as a string for some reason - hash: response.confighash || 0, - }; -} - -function buildMessagesConfig(response: any): LiveAtlasServerMessageConfig { - return { - chatPlayerJoin: response.joinmessage || '', - chatPlayerQuit: response.quitmessage || '', - chatAnonymousJoin: response['msg-hiddennamejoin'] || '', - chatAnonymousQuit: response['msg-hiddennamequit'] || '', - chatErrorNotAllowed: response['msg-chatnotallowed'] || '', - chatErrorRequiresLogin: response['msg-chatrequireslogin'] || '', - chatErrorCooldown: response.spammessage || '', - worldsHeading: response['msg-maptypes'] || '', - playersHeading: response['msg-players'] || '', - } -} - -function buildWorlds(response: any): Array { - const worlds: Map = new Map(); - - //Get all the worlds first so we can handle append_to_world properly - (response.worlds || []).forEach((world: any) => { - let worldType: LiveAtlasDimension = 'overworld'; - - if (netherWorldName.test(world.name) || (world.name == 'DIM-1')) { - worldType = 'nether'; - } else if (endWorldName.test(world.name) || (world.name == 'DIM1')) { - worldType = 'end'; - } - - worlds.set(world.name, { - seaLevel: world.sealevel || 64, - name: world.name, - dimension: worldType, - protected: world.protected || false, - title: world.title || '', - height: world.height || 256, - center: { - x: world.center.x || 0, - y: world.center.y || 0, - z: world.center.z || 0 - }, - maps: new Map(), - }); - }); - - (response.worlds || []).forEach((world: any) => { - (world.maps || []).forEach((map: any) => { - const worldName = map.append_to_world || world.name, - w = worlds.get(worldName); - - if(!w) { - console.warn(`Ignoring map '${map.name}' associated with non-existent world '${worldName}'`); - return; - } - - w.maps.set(map.name, new LiveAtlasMapDefinition({ - world: world, //Ignore append_to_world here otherwise things break - background: map.background || '#000000', - backgroundDay: map.backgroundday || '#000000', - backgroundNight: map.backgroundnight || '#000000', - icon: map.icon || undefined, - imageFormat: map['image-format'] || 'png', - name: map.name || '(Unnamed map)', - nightAndDay: map.nightandday || false, - prefix: map.prefix || '', - protected: map.protected || false, - title: map.title || '', - mapToWorld: map.maptoworld || undefined, - worldToMap: map.worldtomap || undefined, - nativeZoomLevels: map.mapzoomout || 1, - extraZoomLevels: map.mapzoomin || 0 - })); - }); - }); - - return Array.from(worlds.values()); -} - -function buildComponents(response: any): DynmapComponentConfig { - const components: DynmapComponentConfig = { - markers: { - showLabels: false, - }, - chatBox: undefined, - chatBalloons: false, - playerMarkers: undefined, - coordinatesControl: undefined, - linkControl: false, - clockControl: undefined, - logoControls: [], - }; - - (response.components || []).forEach((component: any) => { - const type = component.type || "unknown"; - - switch (type) { - case "markers": - components.markers = { - showLabels: component.showlabel || false, - } - - break; - - case "playermarkers": - components.playerMarkers = { - hideByDefault: component.hidebydefault || false, - layerName: component.label || "Players", - layerPriority: component.layerprio || 0, - showBodies: component.showplayerbody || false, - showSkinFaces: component.showplayerfaces || false, - showHealth: component.showplayerhealth || false, - smallFaces: component.smallplayerfaces || false, - } - - break; - - case "coord": - components.coordinatesControl = { - showY: !(component.hidey || false), - label: component.label || "Location: ", - showRegion: component['show-mcr'] || false, - showChunk: component['show-chunk'] || false, - } - - break; - - case "link": - components.linkControl = true; - - break; - - case "digitalclock": - components.clockControl = { - showDigitalClock: true, - showWeather: false, - showTimeOfDay: false, - } - break; - - case "timeofdayclock": - components.clockControl = { - showTimeOfDay: true, - showDigitalClock: component.showdigitalclock || false, - showWeather: component.showweather || false, - } - break; - - case "logo": - components.logoControls.push({ - text: component.text || '', - url: component.linkurl || undefined, - position: component.position.replace('-', '') || 'topleft', - image: component.logourl || undefined, - }); - break; - - case "chat": - if (response.allowwebchat) { - components.chatSending = { - loginRequired: response['webchat-requires-login'] || false, - maxLength: response['chatlengthlimit'] || 256, - cooldown: response['webchat-interval'] || 5, - } - } - break; - - case "chatbox": - components.chatBox = { - allowUrlName: component.allowurlname || false, - showPlayerFaces: component.showplayerfaces || false, - messageLifetime: component.messagettl || Infinity, - messageHistory: component.scrollback || Infinity, - } - break; - - case "chatballoon": - components.chatBalloons = true; - } - }); - - return components; -} - -function buildMarkerSet(id: string, data: any): any { - return { - id, - label: data.label || "Unnamed set", - hidden: data.hide || false, - priority: data.layerprio || 0, - showLabels: data.showlabels || undefined, - minZoom: typeof data.minzoom !== 'undefined' && data.minzoom > -1 ? data.minzoom : undefined, - maxZoom: typeof data.maxzoom !== 'undefined' && data.maxzoom > -1 ? data.maxzoom : undefined, - } -} - -function buildMarkers(data: any): Map { - const markers = Object.freeze(new Map()) as Map; - - for (const key in data) { - if (!Object.prototype.hasOwnProperty.call(data, key)) { - continue; - } - - markers.set(key, buildMarker(data[key])); - } - - return markers; -} - -function buildMarker(marker: any): DynmapMarker { - return { - label: marker.label || '', - location: { - x: marker.x || 0, - y: marker.y || 0, - z: marker.z || 0, - }, - dimensions: marker.dim ? marker.dim.split('x') : [16, 16], - icon: marker.icon || "default", - isHTML: marker.markup || false, - minZoom: typeof marker.minzoom !== 'undefined' && marker.minzoom > -1 ? marker.minzoom : undefined, - maxZoom: typeof marker.maxzoom !== 'undefined' && marker.maxzoom > -1 ? marker.maxzoom : undefined, - popupContent: marker.desc || undefined, - }; -} - -function buildAreas(data: any): Map { - const areas = Object.freeze(new Map()) as Map; - - for (const key in data) { - if (!Object.prototype.hasOwnProperty.call(data, key)) { - continue; - } - - areas.set(key, buildArea(data[key])); - } - - return areas; -} - -function buildArea(area: any): DynmapArea { - return { - style: { - color: area.color || '#ff0000', - opacity: area.opacity || 1, - weight: area.weight || 1, - fillColor: area.fillcolor || '#ff0000', - fillOpacity: area.fillopacity || 0, - }, - label: area.label || '', - isHTML: area.markup || false, - x: area.x || [0, 0], - y: [area.ybottom || 0, area.ytop || 0], - z: area.z || [0, 0], - minZoom: typeof area.minzoom !== 'undefined' && area.minzoom > -1 ? area.minzoom : undefined, - maxZoom: typeof area.maxzoom !== 'undefined' && area.maxzoom > -1 ? area.maxzoom : undefined, - popupContent: area.desc || undefined, - }; -} - -function buildLines(data: any): Map { - const lines = Object.freeze(new Map()) as Map; - - for (const key in data) { - if (!Object.prototype.hasOwnProperty.call(data, key)) { - continue; - } - - lines.set(key, buildLine(data[key])); - } - - return lines; -} - -function buildLine(line: any): DynmapLine { - return { - x: line.x || [0, 0], - y: line.y || [0, 0], - z: line.z || [0, 0], - style: { - color: line.color || '#ff0000', - opacity: line.opacity || 1, - weight: line.weight || 1, - }, - label: line.label || '', - isHTML: line.markup || false, - minZoom: typeof line.minzoom !== 'undefined' && line.minzoom > -1 ? line.minzoom : undefined, - maxZoom: typeof line.maxzoom !== 'undefined' && line.maxzoom > -1 ? line.maxzoom : undefined, - popupContent: line.desc || undefined, - }; -} - -function buildCircles(data: any): Map { - const circles = Object.freeze(new Map()) as Map; - - for (const key in data) { - if (!Object.prototype.hasOwnProperty.call(data, key)) { - continue; - } - - circles.set(key, buildCircle(data[key])); - } - - return circles; -} - -function buildCircle(circle: any): DynmapCircle { - return { - location: { - x: circle.x || 0, - y: circle.y || 0, - z: circle.z || 0, - }, - radius: [circle.xr || 0, circle.zr || 0], - style: { - fillColor: circle.fillcolor || '#ff0000', - fillOpacity: circle.fillopacity || 0, - color: circle.color || '#ff0000', - opacity: circle.opacity || 1, - weight: circle.weight || 1, - }, - label: circle.label || '', - isHTML: circle.markup || false, - - minZoom: typeof circle.minzoom !== 'undefined' && circle.minzoom > -1 ? circle.minzoom : undefined, - maxZoom: typeof circle.maxzoom !== 'undefined' && circle.maxzoom > -1 ? circle.maxzoom : undefined, - popupContent: circle.desc || undefined, - }; -} - -function buildUpdates(data: Array): DynmapUpdates { - const updates = { - markerSets: new Map(), - tiles: [] as DynmapTileUpdate[], - chat: [] as DynmapChat[], - }, - dropped = { - stale: 0, - noSet: 0, - noId: 0, - unknownType: 0, - unknownCType: 0, - incomplete: 0, - notImplemented: 0, - }, - lastUpdate = useStore().state.updateTimestamp; - - let accepted = 0; - - for (const entry of data) { - switch (entry.type) { - case 'component': { - if (lastUpdate && entry.timestamp < lastUpdate) { - dropped.stale++; - continue; - } - - if (!entry.id) { - dropped.noId++; - continue; - } - - //Set updates don't have a set field, the id is the set - const set = entry.msg.startsWith("set") ? entry.id : entry.set; - - if (!set) { - dropped.noSet++; - continue; - } - - if (entry.ctype !== 'markers') { - dropped.unknownCType++; - continue; - } - - if (!updates.markerSets.has(set)) { - updates.markerSets.set(set, { - areaUpdates: [], - markerUpdates: [], - lineUpdates: [], - circleUpdates: [], - removed: false, - }); - } - - const markerSetUpdates = updates.markerSets.get(set), - update: DynmapUpdate = { - id: entry.id, - removed: entry.msg.endsWith('deleted'), - }; - - if (entry.msg.startsWith("set")) { - markerSetUpdates!.removed = update.removed; - markerSetUpdates!.payload = update.removed ? undefined : buildMarkerSet(set, entry); - } else if (entry.msg.startsWith("marker")) { - update.payload = update.removed ? undefined : buildMarker(entry); - markerSetUpdates!.markerUpdates.push(Object.freeze(update)); - } else if (entry.msg.startsWith("area")) { - update.payload = update.removed ? undefined : buildArea(entry); - markerSetUpdates!.areaUpdates.push(Object.freeze(update)); - - } else if (entry.msg.startsWith("circle")) { - update.payload = update.removed ? undefined : buildCircle(entry); - markerSetUpdates!.circleUpdates.push(Object.freeze(update)); - - } else if (entry.msg.startsWith("line")) { - update.payload = update.removed ? undefined : buildLine(entry); - markerSetUpdates!.lineUpdates.push(Object.freeze(update)); - } - - accepted++; - - break; - } - - case 'chat': - if (!entry.message || !entry.timestamp) { - dropped.incomplete++; - continue; - } - - if (entry.timestamp < lastUpdate) { - dropped.stale++; - continue; - } - - if (entry.source !== 'player' && entry.source !== 'web') { - dropped.notImplemented++; - continue; - } - - updates.chat.push({ - type: 'chat', - source: entry.source || undefined, - playerAccount: entry.account || undefined, - playerName: entry.playerName || undefined, - message: entry.message || "", - timestamp: entry.timestamp, - channel: entry.channel || undefined, - }); - break; - - case 'playerjoin': - if (!entry.account || !entry.timestamp) { - dropped.incomplete++; - continue; - } - - if (entry.timestamp < lastUpdate) { - dropped.stale++; - continue; - } - - updates.chat.push({ - type: 'playerjoin', - playerAccount: entry.account, - playerName: entry.playerName || "", - timestamp: entry.timestamp || undefined, - }); - break; - - case 'playerquit': - if (!entry.account || !entry.timestamp) { - dropped.incomplete++; - continue; - } - - if (entry.timestamp < lastUpdate) { - dropped.stale++; - continue; - } - - updates.chat.push({ - type: 'playerleave', - playerAccount: entry.account, - playerName: entry.playerName || "", - timestamp: entry.timestamp || undefined, - }); - break; - - case 'tile': - if (!entry.name || !entry.timestamp) { - dropped.incomplete++; - continue; - } - - if (lastUpdate && entry.timestamp < lastUpdate) { - dropped.stale++; - continue; - } - - updates.tiles.push({ - name: entry.name, - timestamp: entry.timestamp, - }); - - accepted++; - break; - - default: - dropped.unknownType++; - } - } - - //Sort chat by newest first - updates.chat = updates.chat.sort((one, two) => { - return two.timestamp - one.timestamp; - }); - - console.debug(`Updates: ${accepted} accepted. Rejected: `, dropped); - - return updates; -} - -async function fetchJSON(url: string, signal: AbortSignal) { - let response, json; - - try { - response = await fetch(url, {signal}); - } catch(e) { - if(e instanceof DOMException && e.name === 'AbortError') { - console.warn(`Request aborted (${url}`); - throw e; - } else { - console.error(e); - } - - throw new Error(`Network request failed`); - } - - if (!response.ok) { - throw new Error(`Network request failed (${response.statusText || 'Unknown'})`); - } - - try { - json = await response.json(); - } catch(e) { - if(e instanceof DOMException && e.name === 'AbortError') { - console.warn(`Request aborted (${url}`); - throw e; - } else { - throw new Error('Request returned invalid json'); - } - } - - return json; -} - -let configurationAbort: AbortController | undefined = undefined, - markersAbort: AbortController | undefined = undefined, - updateAbort: AbortController | undefined = undefined; - -export default { - async getConfiguration(): Promise { - if(configurationAbort) { - configurationAbort.abort(); - } - - configurationAbort = new AbortController(); - - const response = await fetchJSON(useStore().getters.serverConfig.dynmap.configuration, configurationAbort.signal); - - if (response.error === 'login-required') { - throw new Error("Login required"); - } else if (response.error) { - throw new Error(response.error); - } - - return { - config: buildServerConfig(response), - messages: buildMessagesConfig(response), - worlds: buildWorlds(response), - components: buildComponents(response), - loggedIn: response.loggedin || false, - } - }, - - async getUpdate(requestId: number, world: string, timestamp: number): Promise { - let url = useStore().getters.serverConfig.dynmap.update; - url = url.replace('{world}', world); - url = url.replace('{timestamp}', timestamp.toString()); - - if(updateAbort) { - updateAbort.abort(); - } - - updateAbort = new AbortController(); - - const response = await fetchJSON(url, updateAbort.signal); - const players: Set = new Set(); - - (response.players || []).forEach((player: any) => { - const world = player.world && player.world !== '-some-other-bogus-world-' ? player.world : undefined; - - players.add({ - account: player.account || "", - health: player.health || 0, - armor: player.armor || 0, - name: player.name || "", - sort: player.sort || 0, - hidden: !world, - location: { - //Add 0.5 to position in the middle of a block - x: !isNaN(player.x) ? player.x + 0.5 : 0, - y: !isNaN(player.y) ? player.y : 0, - z: !isNaN(player.z) ? player.z + 0.5 : 0, - world: world, - } - }); - }); - - //Extra fake players for testing - // for(let i = 0; i < 450; i++) { - // players.add({ - // account: "VIDEO GAMES " + i, - // health: Math.round(Math.random() * 10), - // armor: Math.round(Math.random() * 10), - // name: "VIDEO GAMES " + i, - // sort: Math.round(Math.random() * 10), - // hidden: false, - // location: { - // x: Math.round(Math.random() * 1000) - 500, - // y: 64, - // z: Math.round(Math.random() * 1000) - 500, - // world: "world", - // } - // }); - // } - - return { - worldState: { - timeOfDay: response.servertime || 0, - thundering: response.isThundering || false, - raining: response.hasStorm || false, - }, - playerCount: response.count || 0, - configHash: response.confighash || 0, - timestamp: response.timestamp || 0, - players, - updates: buildUpdates(response.updates || []), - } - }, - - async getMarkerSets(world: string): Promise> { - const url = `${useStore().getters.serverConfig.dynmap.markers}_markers_/marker_${world}.json`; - - if(markersAbort) { - markersAbort.abort(); - } - - markersAbort = new AbortController(); - - const response = await fetchJSON(url, markersAbort.signal); - const sets: Map = new Map(); - - response.sets = response.sets || {}; - - for (const key in response.sets) { - if (!Object.prototype.hasOwnProperty.call(response.sets, key)) { - continue; - } - - const set = response.sets[key], - markers = buildMarkers(set.markers || {}), - circles = buildCircles(set.circles || {}), - areas = buildAreas(set.areas || {}), - lines = buildLines(set.lines || {}); - - sets.set(key, { - ...buildMarkerSet(key, set), - markers, - circles, - areas, - lines, - }); - } - - return sets; - }, - - sendChatMessage(message: string) { - const store = useStore(); - - if (!store.state.components.chatSending) { - return Promise.reject(store.state.messages.chatErrorDisabled); - } - - return fetch(useStore().getters.serverConfig.dynmap.sendmessage, { - method: 'POST', - body: JSON.stringify({ - name: null, - message: message, - }) - }).then((response) => { - if (response.status === 403) { //Rate limited - throw new ChatError(store.state.messages.chatErrorCooldown - .replace('%interval%', store.state.components.chatSending!.cooldown.toString())); - } - - if (!response.ok) { - throw new Error('Network request failed'); - } - - return response.json(); - }).then(response => { - if (response.error !== 'none') { - throw new ChatError(store.state.messages.chatErrorNotAllowed); - } - }).catch(e => { - if (!(e instanceof ChatError)) { - console.error(store.state.messages.chatErrorUnknown); - console.trace(e); - } - - throw e; - }); - } -} diff --git a/src/components/Map.vue b/src/components/Map.vue index 7134336..f6ff591 100644 --- a/src/components/Map.vue +++ b/src/components/Map.vue @@ -45,7 +45,6 @@ import ChatControl from "@/components/map/control/ChatControl.vue"; import LogoControl from "@/components/map/control/LogoControl.vue"; import {MutationTypes} from "@/store/mutation-types"; import {DynmapPlayer} from "@/dynmap"; -import {ActionTypes} from "@/store/action-types"; import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap"; import {LoadingControl} from "@/leaflet/control/LoadingControl"; import MapContextMenu from "@/components/map/MapContextMenu.vue"; @@ -165,8 +164,6 @@ export default defineComponent({ if(newValue) { let location: Coordinate | null = this.scheduledPan; - store.dispatch(ActionTypes.GET_MARKER_SETS, undefined); - // Abort if follow target is present, to avoid panning twice if(store.state.followTarget && store.state.followTarget.location.world === newValue.name) { return; diff --git a/src/index.d.ts b/src/index.d.ts index a55c725..2945fc5 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -136,3 +136,12 @@ interface LiveAtlasParsedUrl { zoom?: number; legacy: boolean; } + +interface LiveAtlasMapProvider { + loadServerConfiguration(): Promise; + loadWorldConfiguration(world: LiveAtlasWorldDefinition): Promise; + startUpdates(): void; + stopUpdates(): void; + sendChatMessage(message: string): void; + destroy(): void; +} diff --git a/src/providers/DynmapMapProvider.ts b/src/providers/DynmapMapProvider.ts new file mode 100644 index 0000000..75829bd --- /dev/null +++ b/src/providers/DynmapMapProvider.ts @@ -0,0 +1,846 @@ +/* + * Copyright 2020 James Lyne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LiveAtlasDimension, + LiveAtlasDynmapServerDefinition, LiveAtlasServerDefinition, + LiveAtlasServerMessageConfig, + LiveAtlasWorldDefinition +} from "@/index"; +import { + DynmapArea, DynmapChat, + DynmapCircle, + DynmapComponentConfig, + DynmapLine, + DynmapMarker, DynmapMarkerSet, DynmapMarkerSetUpdates, DynmapPlayer, + DynmapServerConfig, DynmapTileUpdate, DynmapUpdate, DynmapUpdateResponse, + DynmapUpdates +} from "@/dynmap"; +import {useStore} from "@/store"; +import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; +import ChatError from "@/errors/ChatError"; +import {MutationTypes} from "@/store/mutation-types"; +import MapProvider from "@/providers/MapProvider"; +import {ActionTypes} from "@/store/action-types"; +import {endWorldNameRegex, netherWorldNameRegex, titleColoursRegex} from "@/util"; + +export default class DynmapMapProvider extends MapProvider { + private configurationAbort?: AbortController = undefined; + private markersAbort?: AbortController = undefined; + private updateAbort?: AbortController = undefined; + + private updatesEnabled = false; + private updateTimeout: number = 0; + private updateTimestamp: Date = new Date(); + private updateInterval: number = 3000; + + constructor(config: LiveAtlasDynmapServerDefinition) { + super(config as LiveAtlasServerDefinition); + } + + private static buildServerConfig(response: any): DynmapServerConfig { + return { + version: response.dynmapversion || '', + grayHiddenPlayers: response.grayplayerswhenhidden || false, + defaultMap: response.defaultmap || undefined, + defaultWorld: response.defaultworld || undefined, + defaultZoom: response.defaultzoom || 0, + followMap: response.followmap || undefined, + followZoom: response.followzoom || 0, + updateInterval: response.updaterate || 3000, + showLayerControl: response.showlayercontrol && response.showlayercontrol !== 'false', //Sent as a string for some reason + title: response.title.replace(titleColoursRegex, '') || 'Dynmap', + loginEnabled: response['login-enabled'] || false, + maxPlayers: response.maxcount || 0, + expandUI: response.sidebaropened && response.sidebaropened !== 'false', //Sent as a string for some reason + hash: response.confighash || 0, + }; + } + + private static buildMessagesConfig(response: any): LiveAtlasServerMessageConfig { + return { + chatPlayerJoin: response.joinmessage || '', + chatPlayerQuit: response.quitmessage || '', + chatAnonymousJoin: response['msg-hiddennamejoin'] || '', + chatAnonymousQuit: response['msg-hiddennamequit'] || '', + chatErrorNotAllowed: response['msg-chatnotallowed'] || '', + chatErrorRequiresLogin: response['msg-chatrequireslogin'] || '', + chatErrorCooldown: response.spammessage || '', + worldsHeading: response['msg-maptypes'] || '', + playersHeading: response['msg-players'] || '', + } + } + + private buildWorlds(response: any): Array { + const worlds: Map = new Map(); + + //Get all the worlds first so we can handle append_to_world properly + (response.worlds || []).forEach((world: any) => { + let worldType: LiveAtlasDimension = 'overworld'; + + if (netherWorldNameRegex.test(world.name) || (world.name == 'DIM-1')) { + worldType = 'nether'; + } else if (endWorldNameRegex.test(world.name) || (world.name == 'DIM1')) { + worldType = 'end'; + } + + worlds.set(world.name, { + seaLevel: world.sealevel || 64, + name: world.name, + dimension: worldType, + protected: world.protected || false, + title: world.title || '', + height: world.height || 256, + center: { + x: world.center.x || 0, + y: world.center.y || 0, + z: world.center.z || 0 + }, + maps: new Map(), + }); + }); + + (response.worlds || []).forEach((world: any) => { + (world.maps || []).forEach((map: any) => { + const worldName = map.append_to_world || world.name, + w = worlds.get(worldName); + + if(!w) { + console.warn(`Ignoring map '${map.name}' associated with non-existent world '${worldName}'`); + return; + } + + w.maps.set(map.name, new LiveAtlasMapDefinition({ + world: world, //Ignore append_to_world here otherwise things break + background: map.background || '#000000', + backgroundDay: map.backgroundday || '#000000', + backgroundNight: map.backgroundnight || '#000000', + icon: map.icon || undefined, + imageFormat: map['image-format'] || 'png', + name: map.name || '(Unnamed map)', + nightAndDay: map.nightandday || false, + prefix: map.prefix || '', + protected: map.protected || false, + title: map.title || '', + mapToWorld: map.maptoworld || undefined, + worldToMap: map.worldtomap || undefined, + nativeZoomLevels: map.mapzoomout || 1, + extraZoomLevels: map.mapzoomin || 0 + })); + }); + }); + + return Array.from(worlds.values()); + } + + private buildComponents(response: any): DynmapComponentConfig { + const components: DynmapComponentConfig = { + markers: { + showLabels: false, + }, + chatBox: undefined, + chatBalloons: false, + playerMarkers: undefined, + coordinatesControl: undefined, + linkControl: false, + clockControl: undefined, + logoControls: [], + }; + + (response.components || []).forEach((component: any) => { + const type = component.type || "unknown"; + + switch (type) { + case "markers": + components.markers = { + showLabels: component.showlabel || false, + } + + break; + + case "playermarkers": + components.playerMarkers = { + hideByDefault: component.hidebydefault || false, + layerName: component.label || "Players", + layerPriority: component.layerprio || 0, + showBodies: component.showplayerbody || false, + showSkinFaces: component.showplayerfaces || false, + showHealth: component.showplayerhealth || false, + smallFaces: component.smallplayerfaces || false, + } + + break; + + case "coord": + components.coordinatesControl = { + showY: !(component.hidey || false), + label: component.label || "Location: ", + showRegion: component['show-mcr'] || false, + showChunk: component['show-chunk'] || false, + } + + break; + + case "link": + components.linkControl = true; + + break; + + case "digitalclock": + components.clockControl = { + showDigitalClock: true, + showWeather: false, + showTimeOfDay: false, + } + break; + + case "timeofdayclock": + components.clockControl = { + showTimeOfDay: true, + showDigitalClock: component.showdigitalclock || false, + showWeather: component.showweather || false, + } + break; + + case "logo": + components.logoControls.push({ + text: component.text || '', + url: component.linkurl || undefined, + position: component.position.replace('-', '') || 'topleft', + image: component.logourl || undefined, + }); + break; + + case "chat": + if (response.allowwebchat) { + components.chatSending = { + loginRequired: response['webchat-requires-login'] || false, + maxLength: response['chatlengthlimit'] || 256, + cooldown: response['webchat-interval'] || 5, + } + } + break; + + case "chatbox": + components.chatBox = { + allowUrlName: component.allowurlname || false, + showPlayerFaces: component.showplayerfaces || false, + messageLifetime: component.messagettl || Infinity, + messageHistory: component.scrollback || Infinity, + } + break; + + case "chatballoon": + components.chatBalloons = true; + } + }); + + return components; + } + + private static buildMarkerSet(id: string, data: any): any { + return { + id, + label: data.label || "Unnamed set", + hidden: data.hide || false, + priority: data.layerprio || 0, + showLabels: data.showlabels || undefined, + minZoom: typeof data.minzoom !== 'undefined' && data.minzoom > -1 ? data.minzoom : undefined, + maxZoom: typeof data.maxzoom !== 'undefined' && data.maxzoom > -1 ? data.maxzoom : undefined, + } + } + + private static buildMarkers(data: any): Map { + const markers = Object.freeze(new Map()) as Map; + + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + continue; + } + + markers.set(key, DynmapMapProvider.buildMarker(data[key])); + } + + return markers; + } + + private static buildMarker(marker: any): DynmapMarker { + return { + label: marker.label || '', + location: { + x: marker.x || 0, + y: marker.y || 0, + z: marker.z || 0, + }, + dimensions: marker.dim ? marker.dim.split('x') : [16, 16], + icon: marker.icon || "default", + isHTML: marker.markup || false, + minZoom: typeof marker.minzoom !== 'undefined' && marker.minzoom > -1 ? marker.minzoom : undefined, + maxZoom: typeof marker.maxzoom !== 'undefined' && marker.maxzoom > -1 ? marker.maxzoom : undefined, + popupContent: marker.desc || undefined, + }; + } + + private static buildAreas(data: any): Map { + const areas = Object.freeze(new Map()) as Map; + + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + continue; + } + + areas.set(key, DynmapMapProvider.buildArea(data[key])); + } + + return areas; + } + + private static buildArea(area: any): DynmapArea { + return { + style: { + color: area.color || '#ff0000', + opacity: area.opacity || 1, + weight: area.weight || 1, + fillColor: area.fillcolor || '#ff0000', + fillOpacity: area.fillopacity || 0, + }, + label: area.label || '', + isHTML: area.markup || false, + x: area.x || [0, 0], + y: [area.ybottom || 0, area.ytop || 0], + z: area.z || [0, 0], + minZoom: typeof area.minzoom !== 'undefined' && area.minzoom > -1 ? area.minzoom : undefined, + maxZoom: typeof area.maxzoom !== 'undefined' && area.maxzoom > -1 ? area.maxzoom : undefined, + popupContent: area.desc || undefined, + }; + } + + private static buildLines(data: any): Map { + const lines = Object.freeze(new Map()) as Map; + + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + continue; + } + + lines.set(key, DynmapMapProvider.buildLine(data[key])); + } + + return lines; + } + + private static buildLine(line: any): DynmapLine { + return { + x: line.x || [0, 0], + y: line.y || [0, 0], + z: line.z || [0, 0], + style: { + color: line.color || '#ff0000', + opacity: line.opacity || 1, + weight: line.weight || 1, + }, + label: line.label || '', + isHTML: line.markup || false, + minZoom: typeof line.minzoom !== 'undefined' && line.minzoom > -1 ? line.minzoom : undefined, + maxZoom: typeof line.maxzoom !== 'undefined' && line.maxzoom > -1 ? line.maxzoom : undefined, + popupContent: line.desc || undefined, + }; + } + + private static buildCircles(data: any): Map { + const circles = Object.freeze(new Map()) as Map; + + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + continue; + } + + circles.set(key, DynmapMapProvider.buildCircle(data[key])); + } + + return circles; + } + + private static buildCircle(circle: any): DynmapCircle { + return { + location: { + x: circle.x || 0, + y: circle.y || 0, + z: circle.z || 0, + }, + radius: [circle.xr || 0, circle.zr || 0], + style: { + fillColor: circle.fillcolor || '#ff0000', + fillOpacity: circle.fillopacity || 0, + color: circle.color || '#ff0000', + opacity: circle.opacity || 1, + weight: circle.weight || 1, + }, + label: circle.label || '', + isHTML: circle.markup || false, + + minZoom: typeof circle.minzoom !== 'undefined' && circle.minzoom > -1 ? circle.minzoom : undefined, + maxZoom: typeof circle.maxzoom !== 'undefined' && circle.maxzoom > -1 ? circle.maxzoom : undefined, + popupContent: circle.desc || undefined, + }; + } + + private buildUpdates(data: Array): DynmapUpdates { + const updates = { + markerSets: new Map(), + tiles: [] as DynmapTileUpdate[], + chat: [] as DynmapChat[], + }, + dropped = { + stale: 0, + noSet: 0, + noId: 0, + unknownType: 0, + unknownCType: 0, + incomplete: 0, + notImplemented: 0, + }, + lastUpdate = this.updateTimestamp; + + let accepted = 0; + + for (const entry of data) { + switch (entry.type) { + case 'component': { + if (lastUpdate && entry.timestamp < lastUpdate) { + dropped.stale++; + continue; + } + + if (!entry.id) { + dropped.noId++; + continue; + } + + //Set updates don't have a set field, the id is the set + const set = entry.msg.startsWith("set") ? entry.id : entry.set; + + if (!set) { + dropped.noSet++; + continue; + } + + if (entry.ctype !== 'markers') { + dropped.unknownCType++; + continue; + } + + if (!updates.markerSets.has(set)) { + updates.markerSets.set(set, { + areaUpdates: [], + markerUpdates: [], + lineUpdates: [], + circleUpdates: [], + removed: false, + }); + } + + const markerSetUpdates = updates.markerSets.get(set), + update: DynmapUpdate = { + id: entry.id, + removed: entry.msg.endsWith('deleted'), + }; + + if (entry.msg.startsWith("set")) { + markerSetUpdates!.removed = update.removed; + markerSetUpdates!.payload = update.removed ? undefined : DynmapMapProvider.buildMarkerSet(set, entry); + } else if (entry.msg.startsWith("marker")) { + update.payload = update.removed ? undefined : DynmapMapProvider.buildMarker(entry); + markerSetUpdates!.markerUpdates.push(Object.freeze(update)); + } else if (entry.msg.startsWith("area")) { + update.payload = update.removed ? undefined : DynmapMapProvider.buildArea(entry); + markerSetUpdates!.areaUpdates.push(Object.freeze(update)); + + } else if (entry.msg.startsWith("circle")) { + update.payload = update.removed ? undefined : DynmapMapProvider.buildCircle(entry); + markerSetUpdates!.circleUpdates.push(Object.freeze(update)); + + } else if (entry.msg.startsWith("line")) { + update.payload = update.removed ? undefined : DynmapMapProvider.buildLine(entry); + markerSetUpdates!.lineUpdates.push(Object.freeze(update)); + } + + accepted++; + + break; + } + + case 'chat': + if (!entry.message || !entry.timestamp) { + dropped.incomplete++; + continue; + } + + if (entry.timestamp < lastUpdate) { + dropped.stale++; + continue; + } + + if (entry.source !== 'player' && entry.source !== 'web') { + dropped.notImplemented++; + continue; + } + + updates.chat.push({ + type: 'chat', + source: entry.source || undefined, + playerAccount: entry.account || undefined, + playerName: entry.playerName || undefined, + message: entry.message || "", + timestamp: entry.timestamp, + channel: entry.channel || undefined, + }); + break; + + case 'playerjoin': + if (!entry.account || !entry.timestamp) { + dropped.incomplete++; + continue; + } + + if (entry.timestamp < lastUpdate) { + dropped.stale++; + continue; + } + + updates.chat.push({ + type: 'playerjoin', + playerAccount: entry.account, + playerName: entry.playerName || "", + timestamp: entry.timestamp || undefined, + }); + break; + + case 'playerquit': + if (!entry.account || !entry.timestamp) { + dropped.incomplete++; + continue; + } + + if (entry.timestamp < lastUpdate) { + dropped.stale++; + continue; + } + + updates.chat.push({ + type: 'playerleave', + playerAccount: entry.account, + playerName: entry.playerName || "", + timestamp: entry.timestamp || undefined, + }); + break; + + case 'tile': + if (!entry.name || !entry.timestamp) { + dropped.incomplete++; + continue; + } + + if (lastUpdate && entry.timestamp < lastUpdate) { + dropped.stale++; + continue; + } + + updates.tiles.push({ + name: entry.name, + timestamp: entry.timestamp, + }); + + accepted++; + break; + + default: + dropped.unknownType++; + } + } + + //Sort chat by newest first + updates.chat = updates.chat.sort((one, two) => { + return two.timestamp - one.timestamp; + }); + + console.debug(`Updates: ${accepted} accepted. Rejected: `, dropped); + + return updates; + } + + private static async fetchJSON(url: string, signal: AbortSignal) { + let response, json; + + try { + response = await fetch(url, {signal}); + } catch(e) { + if(e instanceof DOMException && e.name === 'AbortError') { + console.warn(`Request aborted (${url}`); + throw e; + } else { + console.error(e); + } + + throw new Error(`Network request failed`); + } + + if (!response.ok) { + throw new Error(`Network request failed (${response.statusText || 'Unknown'})`); + } + + try { + json = await response.json(); + } catch(e) { + if(e instanceof DOMException && e.name === 'AbortError') { + console.warn(`Request aborted (${url}`); + throw e; + } else { + throw new Error('Request returned invalid json'); + } + } + + return json; + } + + private async getMarkerSets(world: string): Promise> { + const url = `${useStore().getters.serverConfig.dynmap.markers}_markers_/marker_${world}.json`; + + if(this.markersAbort) { + this.markersAbort.abort(); + } + + this.markersAbort = new AbortController(); + + const response = await DynmapMapProvider.fetchJSON(url, this.markersAbort.signal); + const sets: Map = new Map(); + + response.sets = response.sets || {}; + + for (const key in response.sets) { + if (!Object.prototype.hasOwnProperty.call(response.sets, key)) { + continue; + } + + const set = response.sets[key], + markers = DynmapMapProvider.buildMarkers(set.markers || {}), + circles = DynmapMapProvider.buildCircles(set.circles || {}), + areas = DynmapMapProvider.buildAreas(set.areas || {}), + lines = DynmapMapProvider.buildLines(set.lines || {}); + + sets.set(key, { + ...DynmapMapProvider.buildMarkerSet(key, set), + markers, + circles, + areas, + lines, + }); + } + + return sets; + } + + + async loadServerConfiguration(): Promise { + if(this.configurationAbort) { + this.configurationAbort.abort(); + } + + this.configurationAbort = new AbortController(); + + const response = await DynmapMapProvider.fetchJSON(useStore().getters.serverConfig.dynmap.configuration, this.configurationAbort.signal); + + if (response.error === 'login-required') { + throw new Error("Login required"); + } else if (response.error) { + throw new Error(response.error); + } + + const store = useStore(), + config = DynmapMapProvider.buildServerConfig(response); + + this.updateInterval = config.updateInterval; + + store.commit(MutationTypes.SET_SERVER_CONFIGURATION, config); + store.commit(MutationTypes.SET_SERVER_MESSAGES, DynmapMapProvider.buildMessagesConfig(response)); + store.commit(MutationTypes.SET_WORLDS, this.buildWorlds(response)); + store.commit(MutationTypes.SET_COMPONENTS, this.buildComponents(response)); + store.commit(MutationTypes.SET_LOGGED_IN, response.loggedin || false); + } + + async loadWorldConfiguration(): Promise { + const markerSets = await this.getMarkerSets(this.store.state.currentWorld!.name); + + useStore().commit(MutationTypes.SET_MARKER_SETS, markerSets); + } + + async getUpdate(): Promise { + let url = useStore().getters.serverConfig.dynmap.update; + url = url.replace('{world}', this.store.state.currentWorld!.name); + url = url.replace('{timestamp}', this.updateTimestamp.getTime().toString()); + + if(this.updateAbort) { + this.updateAbort.abort(); + } + + this.updateAbort = new AbortController(); + + const response = await DynmapMapProvider.fetchJSON(url, this.updateAbort.signal); + const players: Set = new Set(); + + (response.players || []).forEach((player: any) => { + const world = player.world && player.world !== '-some-other-bogus-world-' ? player.world : undefined; + + players.add({ + account: player.account || "", + health: player.health || 0, + armor: player.armor || 0, + name: player.name || "", + sort: player.sort || 0, + hidden: !world, + location: { + //Add 0.5 to position in the middle of a block + x: !isNaN(player.x) ? player.x + 0.5 : 0, + y: !isNaN(player.y) ? player.y : 0, + z: !isNaN(player.z) ? player.z + 0.5 : 0, + world: world, + } + }); + }); + + //Extra fake players for testing + // for(let i = 0; i < 450; i++) { + // players.add({ + // account: "VIDEO GAMES " + i, + // health: Math.round(Math.random() * 10), + // armor: Math.round(Math.random() * 10), + // name: "VIDEO GAMES " + i, + // sort: Math.round(Math.random() * 10), + // hidden: false, + // location: { + // x: Math.round(Math.random() * 1000) - 500, + // y: 64, + // z: Math.round(Math.random() * 1000) - 500, + // world: "world", + // } + // }); + // } + + return { + worldState: { + timeOfDay: response.servertime || 0, + thundering: response.isThundering || false, + raining: response.hasStorm || false, + }, + playerCount: response.count || 0, + configHash: response.confighash || 0, + timestamp: response.timestamp || 0, + players, + updates: this.buildUpdates(response.updates || []), + } + } + + sendChatMessage(message: string) { + const store = useStore(); + + if (!store.state.components.chatSending) { + return Promise.reject(store.state.messages.chatErrorDisabled); + } + + return fetch(useStore().getters.serverConfig.dynmap.sendmessage, { + method: 'POST', + body: JSON.stringify({ + name: null, + message: message, + }) + }).then((response) => { + if (response.status === 403) { //Rate limited + throw new ChatError(store.state.messages.chatErrorCooldown + .replace('%interval%', store.state.components.chatSending!.cooldown.toString())); + } + + if (!response.ok) { + throw new Error('Network request failed'); + } + + return response.json(); + }).then(response => { + if (response.error !== 'none') { + throw new ChatError(store.state.messages.chatErrorNotAllowed); + } + }).catch(e => { + if (!(e instanceof ChatError)) { + console.error(store.state.messages.chatErrorUnknown); + console.trace(e); + } + + throw e; + }); + } + + startUpdates() { + this.updatesEnabled = true; + this.update(); + } + + private async update() { + try { + const update = await this.getUpdate(); + + this.updateTimestamp = new Date(update.timestamp); + + this.store.commit(MutationTypes.SET_WORLD_STATE, update.worldState); + this.store.commit(MutationTypes.ADD_MARKER_SET_UPDATES, update.updates.markerSets); + this.store.commit(MutationTypes.ADD_TILE_UPDATES, update.updates.tiles); + this.store.commit(MutationTypes.ADD_CHAT, update.updates.chat); + this.store.commit(MutationTypes.SET_SERVER_CONFIGURATION_HASH, update.configHash); + + await this.store.dispatch(ActionTypes.SET_PLAYERS, update.players); + } finally { + if(this.updatesEnabled) { + if(this.updateTimeout) { + clearTimeout(this.updateTimeout); + } + + this.updateTimeout = setTimeout(() => this.update(), this.updateInterval); + } + } + } + + stopUpdates() { + this.updatesEnabled = false; + + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + } + + this.updateTimeout = 0; + } + + destroy() { + if(this.configurationAbort) { + this.configurationAbort.abort(); + } + + if(this.updateAbort) { + this.updateAbort.abort(); + } + + if(this.markersAbort) { + this.markersAbort.abort(); + } + } +} diff --git a/src/providers/MapProvider.ts b/src/providers/MapProvider.ts new file mode 100644 index 0000000..8644e52 --- /dev/null +++ b/src/providers/MapProvider.ts @@ -0,0 +1,25 @@ +import {LiveAtlasMapProvider, LiveAtlasServerDefinition, LiveAtlasWorldDefinition} from "@/index"; +import {useStore} from "@/store"; +import {watch} from "vue"; +import {computed} from "@vue/runtime-core"; + +export default abstract class MapProvider implements LiveAtlasMapProvider { + protected readonly store = useStore(); + + protected constructor(config: LiveAtlasServerDefinition) { + const currentWorld = computed(() => this.store.state.currentWorld); + + watch(currentWorld, (newValue) => { + if(newValue) { + this.loadWorldConfiguration(newValue); + } + }); + } + + abstract destroy(): void; + abstract loadServerConfiguration(): Promise; + abstract loadWorldConfiguration(world: LiveAtlasWorldDefinition): Promise; + abstract sendChatMessage(message: string): void; + abstract startUpdates(): void; + abstract stopUpdates(): void; +} diff --git a/src/store/action-types.ts b/src/store/action-types.ts index b9cba0c..11051ab 100644 --- a/src/store/action-types.ts +++ b/src/store/action-types.ts @@ -16,8 +16,8 @@ export enum ActionTypes { LOAD_CONFIGURATION = "loadConfiguration", - GET_UPDATE = "getUpdate", - GET_MARKER_SETS = "getMarkerSets", + START_UPDATES = "startUpdates", + STOP_UPDATES = "stopUpdates", SET_PLAYERS = "setPlayers", POP_MARKER_UPDATES = "popMarkerUpdates", POP_AREA_UPDATES = "popAreaUpdates", @@ -25,4 +25,4 @@ export enum ActionTypes { POP_LINE_UPDATES = "popLineUpdates", POP_TILE_UPDATES = "popTileUpdates", SEND_CHAT_MESSAGE = "sendChatMessage", -} \ No newline at end of file +} diff --git a/src/store/actions.ts b/src/store/actions.ts index bc0d59b..9421a99 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -20,14 +20,11 @@ import {State} from "@/store/state"; import {ActionTypes} from "@/store/action-types"; import {Mutations} from "@/store/mutations"; import { - DynmapAreaUpdate, DynmapCircleUpdate, - DynmapConfigurationResponse, DynmapLineUpdate, + DynmapAreaUpdate, DynmapCircleUpdate, DynmapLineUpdate, DynmapMarkerSet, DynmapMarkerUpdate, DynmapPlayer, DynmapTileUpdate, - DynmapUpdateResponse } from "@/dynmap"; -import {getAPI} from "@/util"; import {LiveAtlasWorldDefinition} from "@/index"; type AugmentedActionContext = { @@ -40,13 +37,13 @@ type AugmentedActionContext = { export interface Actions { [ActionTypes.LOAD_CONFIGURATION]( {commit}: AugmentedActionContext, - ):Promise - [ActionTypes.GET_UPDATE]( + ):Promise + [ActionTypes.START_UPDATES]( {commit}: AugmentedActionContext, - ):Promise - [ActionTypes.GET_MARKER_SETS]( + ):Promise + [ActionTypes.STOP_UPDATES]( {commit}: AugmentedActionContext, - ):Promise> + ):Promise [ActionTypes.SET_PLAYERS]( {commit}: AugmentedActionContext, payload: Set @@ -78,21 +75,20 @@ export interface Actions { } export const actions: ActionTree & Actions = { - async [ActionTypes.LOAD_CONFIGURATION]({commit, state}): Promise { + async [ActionTypes.LOAD_CONFIGURATION]({commit, state}): Promise { //Clear any existing has to avoid triggering a second config load, after this load changes the hash commit(MutationTypes.CLEAR_SERVER_CONFIGURATION_HASH, undefined); - const config = await getAPI().getConfiguration(); + if(!state.currentServer) { + console.warn('No current server'); + return; + } - commit(MutationTypes.SET_SERVER_CONFIGURATION, config.config); - commit(MutationTypes.SET_SERVER_MESSAGES, config.messages); - commit(MutationTypes.SET_WORLDS, config.worlds); - commit(MutationTypes.SET_COMPONENTS, config.components); - commit(MutationTypes.SET_LOGGED_IN, config.loggedIn); + await state.currentMapProvider!.loadServerConfiguration(); //Skip default map/ui visibility logic if we already have a map selected (i.e config reload after hash change) if(state.currentMap) { - return config; + return; } //Make UI visible if configured, there's enough space to do so, and this is the first config load @@ -104,8 +100,8 @@ export const actions: ActionTree & Actions = { let worldName, mapName; // Use config default world if it exists - if(config.config.defaultWorld && state.worlds.has(config.config.defaultWorld)) { - worldName = config.config.defaultWorld; + if(state.configuration.defaultWorld && state.worlds.has(state.configuration.defaultWorld)) { + worldName = state.configuration.defaultWorld; } // Prefer world from parsed url if present and it exists @@ -122,8 +118,8 @@ export const actions: ActionTree & Actions = { const world = state.worlds.get(worldName) as LiveAtlasWorldDefinition; // Use config default map if it exists - if(config.config.defaultMap && world.maps.has(config.config.defaultMap)) { - mapName = config.config.defaultMap; + if(state.configuration.defaultMap && world.maps.has(state.configuration.defaultMap)) { + mapName = state.configuration.defaultMap; } // Prefer map from parsed url if present and it exists @@ -142,27 +138,22 @@ export const actions: ActionTree & Actions = { worldName, mapName }); } - - return config; }, - async [ActionTypes.GET_UPDATE]({commit, dispatch, state}) { + async [ActionTypes.START_UPDATES]({state}) { if(!state.currentWorld) { return Promise.reject("No current world"); } - const update = await getAPI().getUpdate(state.updateRequestId, state.currentWorld.name, state.updateTimestamp.valueOf()); + state.currentMapProvider!.startUpdates(); + }, - commit(MutationTypes.SET_WORLD_STATE, update.worldState); - commit(MutationTypes.SET_UPDATE_TIMESTAMP, new Date(update.timestamp)); - commit(MutationTypes.INCREMENT_REQUEST_ID, undefined); - commit(MutationTypes.ADD_MARKER_SET_UPDATES, update.updates.markerSets); - commit(MutationTypes.ADD_TILE_UPDATES, update.updates.tiles); - commit(MutationTypes.ADD_CHAT, update.updates.chat); - commit(MutationTypes.SET_SERVER_CONFIGURATION_HASH, update.configHash); + async [ActionTypes.STOP_UPDATES]({state}) { + if(!state.currentWorld) { + return Promise.reject("No current world"); + } - await dispatch(ActionTypes.SET_PLAYERS, update.players); - return update; + state.currentMapProvider!.stopUpdates(); }, [ActionTypes.SET_PLAYERS]({commit, state}, players: Set) { @@ -191,17 +182,6 @@ export const actions: ActionTree & Actions = { }); }, - async [ActionTypes.GET_MARKER_SETS]({commit, state}) { - if(!state.currentWorld) { - throw new Error("No current world"); - } - - const markerSets = await getAPI().getMarkerSets(state.currentWorld.name) - commit(MutationTypes.SET_MARKER_SETS, markerSets); - - return markerSets; - }, - async [ActionTypes.POP_MARKER_UPDATES]({commit, state}, {markerSet, amount}: {markerSet: string, amount: number}): Promise { if(!state.markerSets.has(markerSet)) { console.warn(`POP_MARKER_UPDATES: Marker set ${markerSet} doesn't exist`); @@ -263,6 +243,6 @@ export const actions: ActionTree & Actions = { }, async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise { - await getAPI().sendChatMessage(message); + await state.currentMapProvider!.sendChatMessage(message); }, } diff --git a/src/store/mutation-types.ts b/src/store/mutation-types.ts index 0fcee97..29f57e9 100644 --- a/src/store/mutation-types.ts +++ b/src/store/mutation-types.ts @@ -28,7 +28,6 @@ export enum MutationTypes { CLEAR_MARKER_SETS = 'clearMarkerSets', ADD_WORLD = 'addWorld', SET_WORLD_STATE = 'setWorldState', - SET_UPDATE_TIMESTAMP = 'setUpdateTimestamp', ADD_MARKER_SET_UPDATES = 'addMarkerSetUpdates', ADD_TILE_UPDATES = 'addTileUpdates', ADD_CHAT = 'addChat', @@ -37,7 +36,6 @@ export enum MutationTypes { POP_CIRCLE_UPDATES = 'popCircleUpdates', POP_LINE_UPDATES = 'popLineUpdates', POP_TILE_UPDATES = 'popTileUpdates', - INCREMENT_REQUEST_ID = 'incrementRequestId', SET_PLAYERS_ASYNC = 'setPlayersAsync', CLEAR_PLAYERS = 'clearPlayers', SYNC_PLAYERS = 'syncPlayers', diff --git a/src/store/mutations.ts b/src/store/mutations.ts index 35ff007..997918a 100644 --- a/src/store/mutations.ts +++ b/src/store/mutations.ts @@ -38,8 +38,9 @@ import { LiveAtlasParsedUrl, LiveAtlasGlobalConfig, LiveAtlasGlobalMessageConfig, - LiveAtlasServerMessageConfig + LiveAtlasServerMessageConfig, LiveAtlasDynmapServerDefinition } from "@/index"; +import DynmapMapProvider from "@/providers/DynmapMapProvider"; export type CurrentMapPayload = { worldName: string; @@ -59,7 +60,6 @@ export type Mutations = { [MutationTypes.CLEAR_MARKER_SETS](state: S): void [MutationTypes.ADD_WORLD](state: S, world: LiveAtlasWorldDefinition): void [MutationTypes.SET_WORLD_STATE](state: S, worldState: LiveAtlasWorldState): void - [MutationTypes.SET_UPDATE_TIMESTAMP](state: S, time: Date): void [MutationTypes.ADD_MARKER_SET_UPDATES](state: S, updates: Map): void [MutationTypes.ADD_TILE_UPDATES](state: S, updates: Array): void [MutationTypes.ADD_CHAT](state: State, chat: Array): void @@ -70,7 +70,6 @@ export type Mutations = { [MutationTypes.POP_LINE_UPDATES](state: S, payload: {markerSet: string, amount: number}): void [MutationTypes.POP_TILE_UPDATES](state: S, amount: number): void - [MutationTypes.INCREMENT_REQUEST_ID](state: S): void [MutationTypes.SET_PLAYERS_ASYNC](state: S, players: Set): Set [MutationTypes.SYNC_PLAYERS](state: S, keep: Set): void [MutationTypes.CLEAR_PLAYERS](state: S): void @@ -257,11 +256,6 @@ export const mutations: MutationTree & Mutations = { state.currentWorldState = Object.assign(state.currentWorldState, worldState); }, - //Sets the timestamp of the last update fetch - [MutationTypes.SET_UPDATE_TIMESTAMP](state: State, timestamp: Date) { - state.updateTimestamp = timestamp; - }, - //Adds markerset related updates from an update fetch to the pending updates list [MutationTypes.ADD_MARKER_SET_UPDATES](state: State, updates: Map) { for(const entry of updates) { @@ -411,11 +405,6 @@ export const mutations: MutationTree & Mutations = { state.pendingTileUpdates.splice(0, amount); }, - //Increments the request id for the next update fetch - [MutationTypes.INCREMENT_REQUEST_ID](state: State) { - state.updateRequestId++; - }, - // Set up to 10 players at once [MutationTypes.SET_PLAYERS_ASYNC](state: State, players: Set): Set { let count = 0; @@ -493,6 +482,14 @@ export const mutations: MutationTree & Mutations = { } state.currentServer = state.servers.get(serverName); + + if(state.currentMapProvider) { + state.currentMapProvider.stopUpdates(); + state.currentMapProvider.destroy(); + } + + state.currentMapProvider = Object.seal( + new DynmapMapProvider(state.servers.get(serverName) as LiveAtlasDynmapServerDefinition)); }, //Sets the currently active map/world diff --git a/src/store/state.ts b/src/store/state.ts index 024302e..a87f1ae 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -29,7 +29,7 @@ import { LiveAtlasUIElement, LiveAtlasWorldDefinition, LiveAtlasParsedUrl, - LiveAtlasMessageConfig + LiveAtlasMessageConfig, LiveAtlasMapProvider } from "@/index"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; @@ -60,6 +60,7 @@ export type State = { followTarget?: DynmapPlayer; panTarget?: DynmapPlayer; + currentMapProvider?: Readonly; currentServer?: LiveAtlasServerDefinition; currentWorldState: LiveAtlasWorldState; currentWorld?: LiveAtlasWorldDefinition; @@ -67,9 +68,6 @@ export type State = { currentLocation: Coordinate; currentZoom: number; - updateRequestId: number; - updateTimestamp: Date; - ui: { playersAboveMarkers: boolean; playersSearch: boolean; @@ -203,6 +201,7 @@ export const state: State = { followTarget: undefined, panTarget: undefined, + currentMapProvider: undefined, currentServer: undefined, currentWorld: undefined, currentMap: undefined, @@ -218,9 +217,6 @@ export const state: State = { timeOfDay: 0, }, - updateRequestId: 0, - updateTimestamp: new Date(), - ui: { playersAboveMarkers: true, playersSearch: true, diff --git a/src/util.ts b/src/util.ts index 934a064..92af5d5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import API from '@/api'; import {DynmapPlayer} from "@/dynmap"; import {useStore} from "@/store"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; @@ -32,6 +31,10 @@ const headCache = new Map(), headQueue: HeadQueueEntry[] = []; +export const titleColoursRegex = /§[0-9a-f]/ig; +export const netherWorldNameRegex = /_?nether(_|$)/i; +export const endWorldNameRegex = /(^|_)end(_|$)/i; + export const getMinecraftTime = (serverTime: number) => { const day = serverTime >= 0 && serverTime < 13700; @@ -211,16 +214,6 @@ export const parseMapSearchParams = (query: URLSearchParams) => { } } -export const getAPI = () => { - const store = useStore(); - - if(!store.state.currentServer) { - throw new RangeError("No current server"); - } - - return API; -} - export const getUrlForLocation = (map: LiveAtlasMapDefinition, location: { x: number, y: number,