Add MapProvider, move dynmap api handling to DynmapMapProvider

This commit is contained in:
James Lyne 2021-07-24 01:15:52 +01:00
parent c99215e259
commit 33b5b305e2
12 changed files with 932 additions and 901 deletions

View File

@ -43,21 +43,19 @@ export default defineComponent({
setup() { setup() {
const store = useStore(), const store = useStore(),
updateInterval = computed(() => store.state.configuration.updateInterval),
title = computed(() => store.state.configuration.title), title = computed(() => store.state.configuration.title),
currentUrl = computed(() => store.getters.url), currentUrl = computed(() => store.getters.url),
currentServer = computed(() => store.state.currentServer), currentServer = computed(() => store.state.currentServer),
configurationHash = computed(() => store.state.configurationHash), configurationHash = computed(() => store.state.configurationHash),
chatBoxEnabled = computed(() => store.state.components.chatBox), chatBoxEnabled = computed(() => store.state.components.chatBox),
chatVisible = computed(() => store.state.ui.visibleElements.has('chat')), chatVisible = computed(() => store.state.ui.visibleElements.has('chat')),
updatesEnabled = ref(false),
updateTimeout = ref(0),
configAttempts = ref(0), configAttempts = ref(0),
loadConfiguration = async () => { loadConfiguration = async () => {
try { try {
await store.dispatch(ActionTypes.LOAD_CONFIGURATION, undefined); await store.dispatch(ActionTypes.LOAD_CONFIGURATION, undefined);
startUpdates(); await store.dispatch(ActionTypes.START_UPDATES, undefined);
requestAnimationFrame(() => { requestAnimationFrame(() => {
hideSplash(); 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 = () => { handleUrl = () => {
const parsedUrl = parseUrl(); const parsedUrl = parseUrl();
@ -174,7 +142,6 @@ export default defineComponent({
watch(currentUrl, (url) => window.history.replaceState({}, '', url)); watch(currentUrl, (url) => window.history.replaceState({}, '', url));
watch(currentServer, (newServer?: LiveAtlasServerDefinition) => { watch(currentServer, (newServer?: LiveAtlasServerDefinition) => {
showSplash(); showSplash();
stopUpdates();
if(!newServer) { if(!newServer) {
return; return;
@ -190,17 +157,17 @@ export default defineComponent({
window.history.replaceState({}, '', newServer.id); window.history.replaceState({}, '', newServer.id);
loadConfiguration(); loadConfiguration();
}, {deep: true}); }, {deep: true});
watch(configurationHash, (newHash, oldHash) => { watch(configurationHash, async (newHash, oldHash) => {
if(newHash && oldHash) { if(newHash && oldHash) {
showSplash(); showSplash();
stopUpdates();
store.commit(MutationTypes.CLEAR_PARSED_URL, undefined); store.commit(MutationTypes.CLEAR_PARSED_URL, undefined);
loadConfiguration(); await store.dispatch(ActionTypes.STOP_UPDATES, undefined);
await loadConfiguration();
} }
}); });
onMounted(() => loadConfiguration()); onMounted(() => loadConfiguration());
onBeforeUnmount(() => stopUpdates()); onBeforeUnmount(() => store.dispatch(ActionTypes.STOP_UPDATES, undefined));
handleUrl(); handleUrl();
onResize(); onResize();

View File

@ -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<LiveAtlasWorldDefinition> {
const worlds: Map<string, LiveAtlasWorldDefinition> = new Map<string, LiveAtlasWorldDefinition>();
//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<string, DynmapMarker> {
const markers = Object.freeze(new Map()) as Map<string, DynmapMarker>;
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<string, DynmapArea> {
const areas = Object.freeze(new Map()) as Map<string, DynmapArea>;
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<string, DynmapLine> {
const lines = Object.freeze(new Map()) as Map<string, DynmapLine>;
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<string, DynmapCircle> {
const circles = Object.freeze(new Map()) as Map<string, DynmapCircle>;
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<any>): DynmapUpdates {
const updates = {
markerSets: new Map<string, DynmapMarkerSetUpdates>(),
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<DynmapConfigurationResponse> {
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<DynmapUpdateResponse> {
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<DynmapPlayer> = 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<Map<string, DynmapMarkerSet>> {
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<string, DynmapMarkerSet> = 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;
});
}
}

View File

@ -45,7 +45,6 @@ import ChatControl from "@/components/map/control/ChatControl.vue";
import LogoControl from "@/components/map/control/LogoControl.vue"; import LogoControl from "@/components/map/control/LogoControl.vue";
import {MutationTypes} from "@/store/mutation-types"; import {MutationTypes} from "@/store/mutation-types";
import {DynmapPlayer} from "@/dynmap"; import {DynmapPlayer} from "@/dynmap";
import {ActionTypes} from "@/store/action-types";
import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap"; import LiveAtlasLeafletMap from "@/leaflet/LiveAtlasLeafletMap";
import {LoadingControl} from "@/leaflet/control/LoadingControl"; import {LoadingControl} from "@/leaflet/control/LoadingControl";
import MapContextMenu from "@/components/map/MapContextMenu.vue"; import MapContextMenu from "@/components/map/MapContextMenu.vue";
@ -165,8 +164,6 @@ export default defineComponent({
if(newValue) { if(newValue) {
let location: Coordinate | null = this.scheduledPan; let location: Coordinate | null = this.scheduledPan;
store.dispatch(ActionTypes.GET_MARKER_SETS, undefined);
// Abort if follow target is present, to avoid panning twice // Abort if follow target is present, to avoid panning twice
if(store.state.followTarget && store.state.followTarget.location.world === newValue.name) { if(store.state.followTarget && store.state.followTarget.location.world === newValue.name) {
return; return;

9
src/index.d.ts vendored
View File

@ -136,3 +136,12 @@ interface LiveAtlasParsedUrl {
zoom?: number; zoom?: number;
legacy: boolean; legacy: boolean;
} }
interface LiveAtlasMapProvider {
loadServerConfiguration(): Promise<void>;
loadWorldConfiguration(world: LiveAtlasWorldDefinition): Promise<void>;
startUpdates(): void;
stopUpdates(): void;
sendChatMessage(message: string): void;
destroy(): void;
}

View File

@ -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<LiveAtlasWorldDefinition> {
const worlds: Map<string, LiveAtlasWorldDefinition> = new Map<string, LiveAtlasWorldDefinition>();
//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<string, DynmapMarker> {
const markers = Object.freeze(new Map()) as Map<string, DynmapMarker>;
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<string, DynmapArea> {
const areas = Object.freeze(new Map()) as Map<string, DynmapArea>;
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<string, DynmapLine> {
const lines = Object.freeze(new Map()) as Map<string, DynmapLine>;
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<string, DynmapCircle> {
const circles = Object.freeze(new Map()) as Map<string, DynmapCircle>;
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<any>): DynmapUpdates {
const updates = {
markerSets: new Map<string, DynmapMarkerSetUpdates>(),
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<Map<string, DynmapMarkerSet>> {
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<string, DynmapMarkerSet> = 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<void> {
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<void> {
const markerSets = await this.getMarkerSets(this.store.state.currentWorld!.name);
useStore().commit(MutationTypes.SET_MARKER_SETS, markerSets);
}
async getUpdate(): Promise<DynmapUpdateResponse> {
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<DynmapPlayer> = 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();
}
}
}

View File

@ -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<void>;
abstract loadWorldConfiguration(world: LiveAtlasWorldDefinition): Promise<void>;
abstract sendChatMessage(message: string): void;
abstract startUpdates(): void;
abstract stopUpdates(): void;
}

View File

@ -16,8 +16,8 @@
export enum ActionTypes { export enum ActionTypes {
LOAD_CONFIGURATION = "loadConfiguration", LOAD_CONFIGURATION = "loadConfiguration",
GET_UPDATE = "getUpdate", START_UPDATES = "startUpdates",
GET_MARKER_SETS = "getMarkerSets", STOP_UPDATES = "stopUpdates",
SET_PLAYERS = "setPlayers", SET_PLAYERS = "setPlayers",
POP_MARKER_UPDATES = "popMarkerUpdates", POP_MARKER_UPDATES = "popMarkerUpdates",
POP_AREA_UPDATES = "popAreaUpdates", POP_AREA_UPDATES = "popAreaUpdates",

View File

@ -20,14 +20,11 @@ import {State} from "@/store/state";
import {ActionTypes} from "@/store/action-types"; import {ActionTypes} from "@/store/action-types";
import {Mutations} from "@/store/mutations"; import {Mutations} from "@/store/mutations";
import { import {
DynmapAreaUpdate, DynmapCircleUpdate, DynmapAreaUpdate, DynmapCircleUpdate, DynmapLineUpdate,
DynmapConfigurationResponse, DynmapLineUpdate,
DynmapMarkerSet, DynmapMarkerSet,
DynmapMarkerUpdate, DynmapMarkerUpdate,
DynmapPlayer, DynmapTileUpdate, DynmapPlayer, DynmapTileUpdate,
DynmapUpdateResponse
} from "@/dynmap"; } from "@/dynmap";
import {getAPI} from "@/util";
import {LiveAtlasWorldDefinition} from "@/index"; import {LiveAtlasWorldDefinition} from "@/index";
type AugmentedActionContext = { type AugmentedActionContext = {
@ -40,13 +37,13 @@ type AugmentedActionContext = {
export interface Actions { export interface Actions {
[ActionTypes.LOAD_CONFIGURATION]( [ActionTypes.LOAD_CONFIGURATION](
{commit}: AugmentedActionContext, {commit}: AugmentedActionContext,
):Promise<DynmapConfigurationResponse> ):Promise<void>
[ActionTypes.GET_UPDATE]( [ActionTypes.START_UPDATES](
{commit}: AugmentedActionContext, {commit}: AugmentedActionContext,
):Promise<DynmapUpdateResponse> ):Promise<void>
[ActionTypes.GET_MARKER_SETS]( [ActionTypes.STOP_UPDATES](
{commit}: AugmentedActionContext, {commit}: AugmentedActionContext,
):Promise<Map<string, DynmapMarkerSet>> ):Promise<void>
[ActionTypes.SET_PLAYERS]( [ActionTypes.SET_PLAYERS](
{commit}: AugmentedActionContext, {commit}: AugmentedActionContext,
payload: Set<DynmapPlayer> payload: Set<DynmapPlayer>
@ -78,21 +75,20 @@ export interface Actions {
} }
export const actions: ActionTree<State, State> & Actions = { export const actions: ActionTree<State, State> & Actions = {
async [ActionTypes.LOAD_CONFIGURATION]({commit, state}): Promise<DynmapConfigurationResponse> { async [ActionTypes.LOAD_CONFIGURATION]({commit, state}): Promise<void> {
//Clear any existing has to avoid triggering a second config load, after this load changes the hash //Clear any existing has to avoid triggering a second config load, after this load changes the hash
commit(MutationTypes.CLEAR_SERVER_CONFIGURATION_HASH, undefined); 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); await state.currentMapProvider!.loadServerConfiguration();
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);
//Skip default map/ui visibility logic if we already have a map selected (i.e config reload after hash change) //Skip default map/ui visibility logic if we already have a map selected (i.e config reload after hash change)
if(state.currentMap) { 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 //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<State, State> & Actions = {
let worldName, mapName; let worldName, mapName;
// Use config default world if it exists // Use config default world if it exists
if(config.config.defaultWorld && state.worlds.has(config.config.defaultWorld)) { if(state.configuration.defaultWorld && state.worlds.has(state.configuration.defaultWorld)) {
worldName = config.config.defaultWorld; worldName = state.configuration.defaultWorld;
} }
// Prefer world from parsed url if present and it exists // Prefer world from parsed url if present and it exists
@ -122,8 +118,8 @@ export const actions: ActionTree<State, State> & Actions = {
const world = state.worlds.get(worldName) as LiveAtlasWorldDefinition; const world = state.worlds.get(worldName) as LiveAtlasWorldDefinition;
// Use config default map if it exists // Use config default map if it exists
if(config.config.defaultMap && world.maps.has(config.config.defaultMap)) { if(state.configuration.defaultMap && world.maps.has(state.configuration.defaultMap)) {
mapName = config.config.defaultMap; mapName = state.configuration.defaultMap;
} }
// Prefer map from parsed url if present and it exists // Prefer map from parsed url if present and it exists
@ -142,27 +138,22 @@ export const actions: ActionTree<State, State> & Actions = {
worldName, mapName worldName, mapName
}); });
} }
return config;
}, },
async [ActionTypes.GET_UPDATE]({commit, dispatch, state}) { async [ActionTypes.START_UPDATES]({state}) {
if(!state.currentWorld) { if(!state.currentWorld) {
return Promise.reject("No current world"); 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); async [ActionTypes.STOP_UPDATES]({state}) {
commit(MutationTypes.SET_UPDATE_TIMESTAMP, new Date(update.timestamp)); if(!state.currentWorld) {
commit(MutationTypes.INCREMENT_REQUEST_ID, undefined); return Promise.reject("No current world");
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);
await dispatch(ActionTypes.SET_PLAYERS, update.players); state.currentMapProvider!.stopUpdates();
return update;
}, },
[ActionTypes.SET_PLAYERS]({commit, state}, players: Set<DynmapPlayer>) { [ActionTypes.SET_PLAYERS]({commit, state}, players: Set<DynmapPlayer>) {
@ -191,17 +182,6 @@ export const actions: ActionTree<State, State> & 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<DynmapMarkerUpdate[]> { async [ActionTypes.POP_MARKER_UPDATES]({commit, state}, {markerSet, amount}: {markerSet: string, amount: number}): Promise<DynmapMarkerUpdate[]> {
if(!state.markerSets.has(markerSet)) { if(!state.markerSets.has(markerSet)) {
console.warn(`POP_MARKER_UPDATES: Marker set ${markerSet} doesn't exist`); console.warn(`POP_MARKER_UPDATES: Marker set ${markerSet} doesn't exist`);
@ -263,6 +243,6 @@ export const actions: ActionTree<State, State> & Actions = {
}, },
async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise<void> { async [ActionTypes.SEND_CHAT_MESSAGE]({commit, state}, message: string): Promise<void> {
await getAPI().sendChatMessage(message); await state.currentMapProvider!.sendChatMessage(message);
}, },
} }

View File

@ -28,7 +28,6 @@ export enum MutationTypes {
CLEAR_MARKER_SETS = 'clearMarkerSets', CLEAR_MARKER_SETS = 'clearMarkerSets',
ADD_WORLD = 'addWorld', ADD_WORLD = 'addWorld',
SET_WORLD_STATE = 'setWorldState', SET_WORLD_STATE = 'setWorldState',
SET_UPDATE_TIMESTAMP = 'setUpdateTimestamp',
ADD_MARKER_SET_UPDATES = 'addMarkerSetUpdates', ADD_MARKER_SET_UPDATES = 'addMarkerSetUpdates',
ADD_TILE_UPDATES = 'addTileUpdates', ADD_TILE_UPDATES = 'addTileUpdates',
ADD_CHAT = 'addChat', ADD_CHAT = 'addChat',
@ -37,7 +36,6 @@ export enum MutationTypes {
POP_CIRCLE_UPDATES = 'popCircleUpdates', POP_CIRCLE_UPDATES = 'popCircleUpdates',
POP_LINE_UPDATES = 'popLineUpdates', POP_LINE_UPDATES = 'popLineUpdates',
POP_TILE_UPDATES = 'popTileUpdates', POP_TILE_UPDATES = 'popTileUpdates',
INCREMENT_REQUEST_ID = 'incrementRequestId',
SET_PLAYERS_ASYNC = 'setPlayersAsync', SET_PLAYERS_ASYNC = 'setPlayersAsync',
CLEAR_PLAYERS = 'clearPlayers', CLEAR_PLAYERS = 'clearPlayers',
SYNC_PLAYERS = 'syncPlayers', SYNC_PLAYERS = 'syncPlayers',

View File

@ -38,8 +38,9 @@ import {
LiveAtlasParsedUrl, LiveAtlasParsedUrl,
LiveAtlasGlobalConfig, LiveAtlasGlobalConfig,
LiveAtlasGlobalMessageConfig, LiveAtlasGlobalMessageConfig,
LiveAtlasServerMessageConfig LiveAtlasServerMessageConfig, LiveAtlasDynmapServerDefinition
} from "@/index"; } from "@/index";
import DynmapMapProvider from "@/providers/DynmapMapProvider";
export type CurrentMapPayload = { export type CurrentMapPayload = {
worldName: string; worldName: string;
@ -59,7 +60,6 @@ export type Mutations<S = State> = {
[MutationTypes.CLEAR_MARKER_SETS](state: S): void [MutationTypes.CLEAR_MARKER_SETS](state: S): void
[MutationTypes.ADD_WORLD](state: S, world: LiveAtlasWorldDefinition): void [MutationTypes.ADD_WORLD](state: S, world: LiveAtlasWorldDefinition): void
[MutationTypes.SET_WORLD_STATE](state: S, worldState: LiveAtlasWorldState): 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<string, DynmapMarkerSetUpdates>): void [MutationTypes.ADD_MARKER_SET_UPDATES](state: S, updates: Map<string, DynmapMarkerSetUpdates>): void
[MutationTypes.ADD_TILE_UPDATES](state: S, updates: Array<DynmapTileUpdate>): void [MutationTypes.ADD_TILE_UPDATES](state: S, updates: Array<DynmapTileUpdate>): void
[MutationTypes.ADD_CHAT](state: State, chat: Array<DynmapChat>): void [MutationTypes.ADD_CHAT](state: State, chat: Array<DynmapChat>): void
@ -70,7 +70,6 @@ export type Mutations<S = State> = {
[MutationTypes.POP_LINE_UPDATES](state: S, payload: {markerSet: string, amount: number}): void [MutationTypes.POP_LINE_UPDATES](state: S, payload: {markerSet: string, amount: number}): void
[MutationTypes.POP_TILE_UPDATES](state: S, 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<DynmapPlayer>): Set<DynmapPlayer> [MutationTypes.SET_PLAYERS_ASYNC](state: S, players: Set<DynmapPlayer>): Set<DynmapPlayer>
[MutationTypes.SYNC_PLAYERS](state: S, keep: Set<string>): void [MutationTypes.SYNC_PLAYERS](state: S, keep: Set<string>): void
[MutationTypes.CLEAR_PLAYERS](state: S): void [MutationTypes.CLEAR_PLAYERS](state: S): void
@ -257,11 +256,6 @@ export const mutations: MutationTree<State> & Mutations = {
state.currentWorldState = Object.assign(state.currentWorldState, worldState); 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 //Adds markerset related updates from an update fetch to the pending updates list
[MutationTypes.ADD_MARKER_SET_UPDATES](state: State, updates: Map<string, DynmapMarkerSetUpdates>) { [MutationTypes.ADD_MARKER_SET_UPDATES](state: State, updates: Map<string, DynmapMarkerSetUpdates>) {
for(const entry of updates) { for(const entry of updates) {
@ -411,11 +405,6 @@ export const mutations: MutationTree<State> & Mutations = {
state.pendingTileUpdates.splice(0, amount); 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 // Set up to 10 players at once
[MutationTypes.SET_PLAYERS_ASYNC](state: State, players: Set<DynmapPlayer>): Set<DynmapPlayer> { [MutationTypes.SET_PLAYERS_ASYNC](state: State, players: Set<DynmapPlayer>): Set<DynmapPlayer> {
let count = 0; let count = 0;
@ -493,6 +482,14 @@ export const mutations: MutationTree<State> & Mutations = {
} }
state.currentServer = state.servers.get(serverName); 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 //Sets the currently active map/world

View File

@ -29,7 +29,7 @@ import {
LiveAtlasUIElement, LiveAtlasUIElement,
LiveAtlasWorldDefinition, LiveAtlasWorldDefinition,
LiveAtlasParsedUrl, LiveAtlasParsedUrl,
LiveAtlasMessageConfig LiveAtlasMessageConfig, LiveAtlasMapProvider
} from "@/index"; } from "@/index";
import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition";
@ -60,6 +60,7 @@ export type State = {
followTarget?: DynmapPlayer; followTarget?: DynmapPlayer;
panTarget?: DynmapPlayer; panTarget?: DynmapPlayer;
currentMapProvider?: Readonly<LiveAtlasMapProvider>;
currentServer?: LiveAtlasServerDefinition; currentServer?: LiveAtlasServerDefinition;
currentWorldState: LiveAtlasWorldState; currentWorldState: LiveAtlasWorldState;
currentWorld?: LiveAtlasWorldDefinition; currentWorld?: LiveAtlasWorldDefinition;
@ -67,9 +68,6 @@ export type State = {
currentLocation: Coordinate; currentLocation: Coordinate;
currentZoom: number; currentZoom: number;
updateRequestId: number;
updateTimestamp: Date;
ui: { ui: {
playersAboveMarkers: boolean; playersAboveMarkers: boolean;
playersSearch: boolean; playersSearch: boolean;
@ -203,6 +201,7 @@ export const state: State = {
followTarget: undefined, followTarget: undefined,
panTarget: undefined, panTarget: undefined,
currentMapProvider: undefined,
currentServer: undefined, currentServer: undefined,
currentWorld: undefined, currentWorld: undefined,
currentMap: undefined, currentMap: undefined,
@ -218,9 +217,6 @@ export const state: State = {
timeOfDay: 0, timeOfDay: 0,
}, },
updateRequestId: 0,
updateTimestamp: new Date(),
ui: { ui: {
playersAboveMarkers: true, playersAboveMarkers: true,
playersSearch: true, playersSearch: true,

View File

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import API from '@/api';
import {DynmapPlayer} from "@/dynmap"; import {DynmapPlayer} from "@/dynmap";
import {useStore} from "@/store"; import {useStore} from "@/store";
import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition";
@ -32,6 +31,10 @@ const headCache = new Map<string, HTMLImageElement>(),
headQueue: HeadQueueEntry[] = []; headQueue: HeadQueueEntry[] = [];
export const titleColoursRegex = /§[0-9a-f]/ig;
export const netherWorldNameRegex = /_?nether(_|$)/i;
export const endWorldNameRegex = /(^|_)end(_|$)/i;
export const getMinecraftTime = (serverTime: number) => { export const getMinecraftTime = (serverTime: number) => {
const day = serverTime >= 0 && serverTime < 13700; 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: { export const getUrlForLocation = (map: LiveAtlasMapDefinition, location: {
x: number, x: number,
y: number, y: number,