mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 13:55:04 +02:00
999 lines
28 KiB
TypeScript
999 lines
28 KiB
TypeScript
import { getWriterBuffer } from 'ag-sockets';
|
|
import { remove, compact } from 'lodash';
|
|
import {
|
|
TileType, WorldState, Season, Holiday, WorldStateFlags, LeaveReason, NotificationFlags, UpdateFlags, Action,
|
|
} from '../common/interfaces';
|
|
import { removeItem, distance, clamp, randomPoint, includes, fromNow } from '../common/utils';
|
|
import { HOUR_LENGTH, DAY_LENGTH } from '../common/timeUtils';
|
|
import {
|
|
AFK_TIMEOUT, MAP_DISCARD_TIMEOUT, JOINS_PER_UPDATE, REMOVE_INTERVAL, REMOVE_TIMEOUT,
|
|
MAP_SWITCHES_PER_UPDATE, MINUTE, SERVER_FPS, HOUR
|
|
} from '../common/constants';
|
|
import {
|
|
IClient, ServerEntity, Controller, GetSettings, ServerNotification, SocketStats, ServerMap, MapUsage
|
|
} from './serverInterfaces';
|
|
import { isTileLocked, getMapInfo, setTile, hasAnyClients, createMinimap } from './serverMap';
|
|
import { getModInfo } from './accountUtils';
|
|
import { IAccount } from './db';
|
|
import { PartyService } from './services/party';
|
|
import { NotificationService } from './services/notification';
|
|
import { AroundEntry, ServerLiveSettings, ServerConfig } from '../common/adminInterfaces';
|
|
import { fixPosition, updateEntity, pushRemoveEntityToClient, pushUpdateEntityToClient } from './entityUtils';
|
|
import { isBanned, isShadowed } from '../common/adminUtils';
|
|
import {
|
|
createAndUpdateCharacterState, setEntityExpression, reloadFriends, updateEntityPlayerState, resetClientUpdates, isMutedOrShadowed
|
|
} from './playerUtils';
|
|
import {
|
|
sparseRegionUpdate, addToRegion, removeFromRegion, unsubscribeFromOutOfRangeRegions,
|
|
subscribeToRegionsInRange, unsubscribeFromAllRegions, commitRegionUpdates, updateRegions, setupTiming,
|
|
clearTiming, resetEncodeUpdate
|
|
} from './regionUtils';
|
|
import { roundPosition, roundPositionXMidPixel, roundPositionYMidPixel } from '../common/positionUtils';
|
|
import { logger } from './logger';
|
|
import { updateCamera, centerCameraOn } from '../common/camera';
|
|
import { timingStart, timingEnd } from './timing';
|
|
import { getRegionGlobal, getTile } from '../common/worldMap';
|
|
import { getEntityTypeName } from '../common/entities';
|
|
import { toFriendOnline, toFriendOffline, FriendsService } from './services/friends';
|
|
// import { Pool, createPool } from './pool';
|
|
import { isStaticCollision, getCollisionStats, fixCollision, updatePosition } from '../common/collision';
|
|
import { HidingService } from './services/hiding';
|
|
import { generateRegionCollider } from '../common/region';
|
|
import { updateTileIndices } from '../client/tileUtils';
|
|
import { removeEntityFromRegion } from './serverRegion';
|
|
import { createIslandMap } from './maps/islandMap';
|
|
import { createHouseMap } from './maps/houseMap';
|
|
import { updateMainMapSeason } from './maps/mainMap';
|
|
|
|
interface MapSwitch {
|
|
map: ServerMap;
|
|
x: number;
|
|
y: number;
|
|
client: IClient;
|
|
}
|
|
|
|
interface ReservedID {
|
|
id: number;
|
|
time: number;
|
|
}
|
|
|
|
const MAP_SOFT_LIMIT = 10000;
|
|
|
|
export class World {
|
|
season = Season.Summer;
|
|
holiday = Holiday.None;
|
|
maps: ServerMap[] = [];
|
|
controllers: Controller[] = [];
|
|
options = {
|
|
restoreTerrain: !DEVELOPMENT,
|
|
};
|
|
clients: IClient[] = [];
|
|
clientsByAccount = new Map<string, IClient>();
|
|
joinQueue: IClient[] = [];
|
|
mapSwitchQueue: MapSwitch[] = [];
|
|
now = 0;
|
|
start = 0;
|
|
// mapPools = new Map<string, Pool<ServerMap>>();
|
|
private maxId = 0 >>> 0;
|
|
private offlineClients: IClient[] = [];
|
|
private baseTime = 0;
|
|
private entityById = new Map<number, ServerEntity>();
|
|
private reservedIds = new Map<number, string>();
|
|
private reservedIdsByKey = new Map<string, ReservedID>();
|
|
constructor(
|
|
public readonly server: ServerConfig,
|
|
private readonly partyService: PartyService,
|
|
private readonly friendsService: FriendsService,
|
|
public readonly hidingService: HidingService,
|
|
private readonly notifications: NotificationService,
|
|
private readonly getSettings: GetSettings,
|
|
private readonly liveSettings: ServerLiveSettings,
|
|
private readonly socketStats: SocketStats,
|
|
) {
|
|
// this.mapPools.set('island', createPool(10, () => createIslandMap(this, true), resetIslandMap));
|
|
// this.mapPools.set('house', createPool(10, () => createHouseMap(this, true), resetHouseMap));
|
|
|
|
partyService.partyChanged.subscribe(client => {
|
|
if (client.isConnected && client.map.usage === MapUsage.Party) {
|
|
if (
|
|
client.party && client.party.leader === client && client.map.instance === client.accountId &&
|
|
!this.maps.some(m => m.id === client.map.id && m.instance === client.party!.id)
|
|
) {
|
|
client.map.instance = client.party.id;
|
|
} else {
|
|
refreshMap(this, client);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
get featureFlags() {
|
|
return this.server.flags;
|
|
}
|
|
// entities
|
|
get time() {
|
|
return this.baseTime + Date.now();
|
|
}
|
|
setTime(hour: number) {
|
|
let newBaseTime = hour * HOUR_LENGTH - (Date.now() % DAY_LENGTH);
|
|
|
|
while (newBaseTime < 0) {
|
|
newBaseTime += DAY_LENGTH;
|
|
}
|
|
|
|
this.baseTime = newBaseTime;
|
|
this.updateWorldState();
|
|
}
|
|
setTile(map: ServerMap, x: number, y: number, type: TileType) {
|
|
if (!BETA && map.tilesLocked)
|
|
return;
|
|
|
|
if (x >= 0 && y >= 0 && x < map.width && y < map.height && !isTileLocked(map, x, y) && type !== getTile(map, x, y)) {
|
|
setTile(map, x, y, type);
|
|
}
|
|
}
|
|
toggleWall(map: ServerMap, x: number, y: number, type: TileType) {
|
|
for (const controller of map.controllers) {
|
|
if (controller.toggleWall) {
|
|
controller.toggleWall(x, y, type);
|
|
}
|
|
}
|
|
}
|
|
removeWalls(map: ServerMap) {
|
|
for (const controller of map.controllers) {
|
|
if (controller.removeWalls) {
|
|
controller.removeWalls();
|
|
}
|
|
}
|
|
}
|
|
getState(): WorldState {
|
|
return {
|
|
time: this.time,
|
|
season: this.season,
|
|
holiday: this.holiday,
|
|
flags: this.getSettings().filterSwears ? WorldStateFlags.Safe : WorldStateFlags.None,
|
|
featureFlags: this.featureFlags,
|
|
};
|
|
}
|
|
setSeason(season: Season, holiday: Holiday) {
|
|
this.season = season;
|
|
this.holiday = holiday;
|
|
this.updateWorldState();
|
|
updateMainMapSeason(this, this.getMainMap(), season, holiday);
|
|
}
|
|
private updateWorldState() {
|
|
const state = this.getState();
|
|
|
|
for (const client of this.clients) {
|
|
client.worldState(state, false);
|
|
}
|
|
}
|
|
getEntityById(id: number) {
|
|
return this.entityById.get(id);
|
|
}
|
|
getNewEntityId() {
|
|
do {
|
|
this.maxId = (this.maxId + 1) >>> 0;
|
|
} while (this.maxId === 0 || this.entityById.has(this.maxId) || this.reservedIds.has(this.maxId));
|
|
|
|
return this.maxId;
|
|
}
|
|
addEntity(entity: ServerEntity, map: ServerMap) {
|
|
if (DEVELOPMENT) {
|
|
if (entity.update) {
|
|
console.error('Entity update() method is only for client-side use');
|
|
}
|
|
|
|
if (entity.id && this.entityById.has(entity.id)) {
|
|
console.error(`Entity already added to the world ${getEntityTypeName(entity.type)} [${entity.id}]`);
|
|
}
|
|
}
|
|
|
|
entity.id = entity.id || this.getNewEntityId();
|
|
entity.timestamp = this.now / 1000;
|
|
this.entityById.set(entity.id, entity);
|
|
roundPosition(entity);
|
|
const region = getRegionGlobal(map, entity.x, entity.y);
|
|
addToRegion(entity, region, map);
|
|
return entity;
|
|
}
|
|
removeEntity(entity: ServerEntity, map: ServerMap) {
|
|
let removed = false;
|
|
|
|
if (entity.region) {
|
|
removed = removeFromRegion(entity, entity.region, map);
|
|
}
|
|
|
|
this.entityById.delete(entity.id);
|
|
return removed;
|
|
}
|
|
removeEntityFromSomeMap(entity: ServerEntity) {
|
|
const map = this.maps.find(m => m.regions.some(r => includes(r.entities, entity)));
|
|
|
|
if (map) {
|
|
this.removeEntity(entity, map);
|
|
} else {
|
|
DEVELOPMENT && logger.error(`Missing map for entity`);
|
|
}
|
|
}
|
|
// map
|
|
getMainMap() {
|
|
return this.maps[0];
|
|
}
|
|
switchToMap(client: IClient, map: ServerMap, x: number, y: number) {
|
|
if (client.map === map) {
|
|
DEVELOPMENT && logger.error(`Switching to the same map`);
|
|
return;
|
|
}
|
|
|
|
if (this.mapSwitchQueue.some(x => x.client === client)) {
|
|
DEVELOPMENT && logger.error(`Already in map switch queue`);
|
|
return;
|
|
}
|
|
|
|
this.mapSwitchQueue.push({ client, map, x, y });
|
|
|
|
client.isSwitchingMap = true;
|
|
client.pony.vx = 0;
|
|
client.pony.vy = 0;
|
|
updateEntity(client.pony, false);
|
|
client.mapSwitching();
|
|
}
|
|
actualSwitchToMap(client: IClient, map: ServerMap, x: number, y: number) {
|
|
unsubscribeFromAllRegions(client, false);
|
|
|
|
if (client.pony.region) {
|
|
removeFromRegion(client.pony, client.pony.region, client.map);
|
|
}
|
|
|
|
x = clamp(x, 0, map.width);
|
|
y = clamp(y, 0, map.height);
|
|
|
|
resetClientUpdates(client);
|
|
|
|
client.mapState(getMapInfo(map), map.state);
|
|
client.map = map;
|
|
client.pony.x = x;
|
|
client.pony.y = y;
|
|
client.safeX = x;
|
|
client.safeY = y;
|
|
client.lastTime = 0;
|
|
client.lastMapSwitch = Date.now();
|
|
client.loading = true;
|
|
client.lastCameraX = 0;
|
|
client.lastCameraY = 0;
|
|
client.lastCameraW = 0;
|
|
client.lastCameraH = 0;
|
|
client.isSwitchingMap = false;
|
|
|
|
addToRegion(client.pony, getRegionGlobal(map, x, y), map);
|
|
fixPosition(client.pony, map, x, y, true);
|
|
|
|
client.reporter.systemLog(`Switched map to [${client.map.id || 'main'}]`);
|
|
}
|
|
// main
|
|
initialize(now: number) {
|
|
this.start = now;
|
|
this.now = now;
|
|
const nowSeconds = now / 1000;
|
|
|
|
for (const controller of this.controllers) {
|
|
controller.initialize(nowSeconds);
|
|
}
|
|
|
|
for (const map of this.maps) {
|
|
for (const controller of map.controllers) {
|
|
controller.initialize(nowSeconds);
|
|
}
|
|
}
|
|
}
|
|
update(delta: number, now: number) {
|
|
const started = Date.now();
|
|
|
|
timingStart('world.update()');
|
|
|
|
resetEncodeUpdate();
|
|
|
|
this.now = now;
|
|
|
|
const nowSeconds = now / 1000;
|
|
const deltaSeconds = delta / 1000;
|
|
|
|
timingStart('update tiles and colliders');
|
|
for (const map of this.maps) {
|
|
if (!map.dontUpdateTilesAndColliders) {
|
|
for (const region of map.regions) {
|
|
if (region.tilesDirty) {
|
|
updateTileIndices(region, map);
|
|
}
|
|
if (region.colliderDirty) {
|
|
generateRegionCollider(region, map);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('update positions');
|
|
for (const map of this.maps) {
|
|
for (const region of map.regions) {
|
|
// TODO: update only moving entities, separate list of movingEntities
|
|
for (const entity of region.movables) {
|
|
// TODO: make sure timestamp is initialized if entity is moving
|
|
const delta = nowSeconds - entity.timestamp;
|
|
|
|
if (delta > 0) {
|
|
if (entity.vx !== 0 || entity.vy !== 0) {
|
|
updatePosition(entity, delta, map);
|
|
}
|
|
|
|
entity.timestamp = nowSeconds;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('updateCamera + updateSubscriptions');
|
|
for (const client of this.clients) {
|
|
if (updateClientCamera(client)) {
|
|
unsubscribeFromOutOfRangeRegions(client);
|
|
subscribeToRegionsInRange(client);
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('update controllers');
|
|
for (const controller of this.controllers) {
|
|
controller.update(deltaSeconds, nowSeconds);
|
|
}
|
|
|
|
for (const map of this.maps) {
|
|
for (const controller of map.controllers) {
|
|
controller.update(deltaSeconds, nowSeconds);
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('actualSwitchToMap');
|
|
for (let i = 0; i < MAP_SWITCHES_PER_UPDATE && this.mapSwitchQueue.length; i++) {
|
|
const { client, map, x, y } = this.mapSwitchQueue.shift()!;
|
|
this.actualSwitchToMap(client, map, x, y);
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('updateRegions');
|
|
updateRegions(this.maps); // NOTE: creates transfers
|
|
timingEnd();
|
|
|
|
timingStart('timeoutEntityExpression + inTheAirDelay');
|
|
for (const client of this.clients) {
|
|
// timeout expressions
|
|
if (client.pony.exprTimeout && client.pony.exprTimeout < now) {
|
|
setEntityExpression(client.pony, undefined); // NOTE: creates updates
|
|
}
|
|
|
|
// count down in-the-air delay
|
|
if (client.pony.inTheAirDelay !== undefined && client.pony.inTheAirDelay > 0) {
|
|
client.pony.inTheAirDelay -= deltaSeconds;
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
// const { totalUpdates, reusedUpdates } = this.updatesStats();
|
|
|
|
timingStart(`commitRegionUpdates`); // [${totalUpdates} / ${reusedUpdates}]`);
|
|
for (const map of this.maps) {
|
|
commitRegionUpdates(map.regions);
|
|
}
|
|
timingEnd();
|
|
|
|
let clientsWithAdds = 0;
|
|
let clientsWithUpdates = 0;
|
|
let clientsWithSays = 0;
|
|
let totalSays = 0;
|
|
|
|
timingStart(`send updates`);
|
|
for (const client of this.clients) {
|
|
const { updateQueue, regionUpdates, saysQueue, unsubscribes, subscribes } = client;
|
|
const updateBuffer = updateQueue.offset ? getWriterBuffer(updateQueue) : null;
|
|
const total = updateQueue.offset + regionUpdates.length + saysQueue.length + unsubscribes.length + subscribes.length;
|
|
|
|
if (total !== 0) {
|
|
if (updateQueue.offset > 0)
|
|
clientsWithAdds++;
|
|
if (regionUpdates.length > 0)
|
|
clientsWithUpdates++;
|
|
if (saysQueue.length > 0)
|
|
clientsWithSays++;
|
|
totalSays += saysQueue.length;
|
|
|
|
setupTiming(client);
|
|
timingStart('client.update()');
|
|
client.update(unsubscribes, subscribes, updateBuffer, regionUpdates, saysQueue);
|
|
timingEnd();
|
|
clearTiming(client);
|
|
|
|
resetClientUpdates(client);
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('joinQueuedClients');
|
|
if (Date.now() < (started + (1000 / SERVER_FPS))) {
|
|
for (let i = 0; i < JOINS_PER_UPDATE && this.joinQueue.length > 0; i++) {
|
|
this.joinClientToWorld(this.joinQueue.shift()!); // NOTE: creates adds
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
const { isCollidingCount, isCollidingObjectCount } = getCollisionStats();
|
|
|
|
timingStart(`adds [${clientsWithAdds}]\n` +
|
|
`updates [${clientsWithUpdates}]\n` +
|
|
`says [${totalSays} / ${clientsWithSays}]\n` +
|
|
`sockets [${this.socketStatsText()}]\n` +
|
|
`collisions [${isCollidingObjectCount} / ${isCollidingCount}]`);
|
|
this.cleanupOfflineClients();
|
|
timingEnd();
|
|
|
|
timingEnd();
|
|
}
|
|
sparseUpdate(now: number) {
|
|
timingStart('world.sparseUpdate()');
|
|
|
|
timingStart('sparse update controllers');
|
|
for (const controller of this.controllers) {
|
|
if (controller.sparseUpdate !== undefined) {
|
|
controller.sparseUpdate();
|
|
}
|
|
}
|
|
|
|
for (const map of this.maps) {
|
|
for (const controller of map.controllers) {
|
|
if (controller.sparseUpdate !== undefined) {
|
|
controller.sparseUpdate();
|
|
}
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('sparseRegionUpdate');
|
|
for (const map of this.maps) {
|
|
for (const region of map.regions) {
|
|
sparseRegionUpdate(map, region, this.options);
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('kick afk clients');
|
|
for (const client of this.clients) {
|
|
if ((now - client.lastPacket) > AFK_TIMEOUT) {
|
|
this.kick(client, 'afk');
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('send queue status (join)');
|
|
for (let i = 0; i < this.joinQueue.length; i++) {
|
|
this.joinQueue[i].queue(i + 1);
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('send queue status (map)');
|
|
for (let i = 0; i < this.mapSwitchQueue.length; i++) {
|
|
this.mapSwitchQueue[i].client.queue(i + 1);
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('cleanup unused maps');
|
|
const discardTimeout = this.maps.length > MAP_SOFT_LIMIT ? 5 * MINUTE : MAP_DISCARD_TIMEOUT;
|
|
const mapDiscardThreshold = now - discardTimeout;
|
|
|
|
for (const map of this.maps) {
|
|
if (map.instance && (hasAnyClients(map) || this.mapSwitchQueue.some(q => q.map === map))) {
|
|
map.lastUsed = now;
|
|
}
|
|
}
|
|
|
|
for (let i = this.maps.length - 1; i > 0; i--) {
|
|
const map = this.maps[i];
|
|
|
|
if (map.instance && map.lastUsed < mapDiscardThreshold) {
|
|
this.maps.splice(i, 1);
|
|
// const pool = this.mapPools.get(map.id);
|
|
|
|
// if (pool && pool.dispose(map)) {
|
|
// for (const region of map.regions) {
|
|
// resetRegionUpdates(region);
|
|
// }
|
|
// } else {
|
|
for (const region of map.regions) {
|
|
for (const entity of region.entities) {
|
|
this.entityById.delete(entity.id);
|
|
}
|
|
}
|
|
// }
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('cleanup reserved ids');
|
|
const threshold = Date.now() - 5 * MINUTE;
|
|
|
|
for (const key of Array.from(this.reservedIdsByKey.keys())) { // TODO: avoid doing this to save gc
|
|
const { id, time } = this.reservedIdsByKey.get(key)!;
|
|
|
|
if (time < threshold) {
|
|
this.reservedIdsByKey.delete(key);
|
|
this.reservedIds.delete(id);
|
|
}
|
|
}
|
|
timingEnd();
|
|
|
|
timingStart('cleanup parties');
|
|
this.partyService.cleanupParties();
|
|
timingEnd();
|
|
|
|
timingEnd();
|
|
}
|
|
private socketStatsText() {
|
|
const { sent, received, sentPackets, receivedPackets } = this.socketStats.stats();
|
|
return `sent: ${(sent / 1024).toFixed(2)} kb (${sentPackets}), ` +
|
|
`recv: ${(received / 1024).toFixed(2)} kb (${receivedPackets})`;
|
|
}
|
|
updatesStats() {
|
|
timingStart('updatesStats()');
|
|
|
|
let totalUpdates = 0, reusedUpdates = 0;
|
|
|
|
for (const map of this.maps) {
|
|
for (const region of map.regions) {
|
|
totalUpdates += region.entityUpdates.length;
|
|
reusedUpdates += region.reusedUpdates;
|
|
}
|
|
}
|
|
|
|
timingEnd();
|
|
|
|
return { totalUpdates, reusedUpdates };
|
|
}
|
|
// clients
|
|
private lastCleanup = 0;
|
|
private cleanupOfflineClients() {
|
|
const now = Date.now();
|
|
|
|
if ((now - this.lastCleanup) > REMOVE_INTERVAL) {
|
|
this.lastCleanup = now;
|
|
const removeFrom = now - REMOVE_TIMEOUT;
|
|
|
|
remove(this.offlineClients, c => c.offline && !c.party && c.offlineAt && c.offlineAt.getTime() < removeFrom);
|
|
}
|
|
}
|
|
joinClientToQueue(client: IClient) {
|
|
if (this.liveSettings.shutdown) {
|
|
client.leaveReason = 'shutdown';
|
|
client.disconnect(false, true);
|
|
return;
|
|
}
|
|
|
|
const { tokenId } = client;
|
|
|
|
function findClientsToKick(clients: IClient[]) {
|
|
return clients.filter(c => c.tokenId === tokenId);
|
|
}
|
|
|
|
const clientsToKick = [
|
|
...findClientsToKick(this.clients),
|
|
...findClientsToKick(this.joinQueue),
|
|
];
|
|
|
|
for (const client of clientsToKick) {
|
|
const reason = client.tokenId === tokenId ? 'kicked [joining again]' : 'kicked [alone on ip]';
|
|
this.kick(client, reason, LeaveReason.None, true);
|
|
}
|
|
|
|
// TODO: wait for all clients to be kicked before adding to the queue
|
|
// another queue before joinQueue
|
|
|
|
this.joinQueue.push(client);
|
|
}
|
|
joinClientToWorld(client: IClient) {
|
|
timingStart('joinClientToWorld()');
|
|
|
|
const key = `${client.accountId}:${client.characterId}`;
|
|
const reserved = this.reservedIdsByKey.get(key);
|
|
|
|
if (reserved) {
|
|
client.pony.id = reserved.id;
|
|
this.reservedIdsByKey.delete(client.accountId);
|
|
this.reservedIds.delete(reserved.id);
|
|
} else {
|
|
client.pony.id = this.getNewEntityId();
|
|
}
|
|
|
|
client.myEntity(client.pony.id, client.characterName, client.character.info!, client.characterId, client.pony.crc || 0);
|
|
|
|
this.clients.push(client);
|
|
this.clientsByAccount.set(client.accountId, client);
|
|
this.partyService.clientConnected(client);
|
|
this.hidingService.connected(client);
|
|
|
|
let map = findOrCreateMapForClient(this, client.characterState.map || '', client);
|
|
|
|
if (!map) {
|
|
map = this.getMainMap();
|
|
const { x, y } = randomPoint(map.spawnArea);
|
|
client.pony.x = x;
|
|
client.pony.y = y;
|
|
}
|
|
|
|
if (isStaticCollision(client.pony, map)) {
|
|
if (!fixCollision(client.pony, map)) {
|
|
const { x, y } = randomPoint(map.spawnArea);
|
|
client.pony.x = x;
|
|
client.pony.y = y;
|
|
}
|
|
}
|
|
|
|
client.pony.x = roundPositionXMidPixel(client.pony.x);
|
|
client.pony.y = roundPositionYMidPixel(client.pony.y);
|
|
client.map = map;
|
|
|
|
centerCameraOn(client.camera, client.pony);
|
|
|
|
client.worldState(this.getState(), true);
|
|
client.mapState(getMapInfo(client.map), client.map.state);
|
|
|
|
if (BETA) {
|
|
timingStart('minimap');
|
|
client.mapTest(client.map.width, client.map.height, createMinimap(this, client.map));
|
|
timingEnd();
|
|
}
|
|
|
|
updateCamera(client.camera, client.pony, map);
|
|
|
|
this.addEntity(client.pony, client.map);
|
|
|
|
const visibleOnlineFriends: IClient[] = [];
|
|
|
|
for (const c of this.clients) {
|
|
if (c.selected && c.selected.client && c.selected.client.accountId === client.accountId) {
|
|
c.updateSelection(c.selected.id, client.pony.id);
|
|
}
|
|
|
|
if (client.friends.has(c.accountId)) {
|
|
if (!c.accountSettings.hidden && !c.shadowed) {
|
|
visibleOnlineFriends.push(c);
|
|
}
|
|
|
|
if (!client.accountSettings.hidden) {
|
|
c.updateFriends([toFriendOnline(client)], false);
|
|
}
|
|
|
|
if (!c.friends.has(client.accountId)) {
|
|
reloadFriends(c).catch(e => logger.error(e));
|
|
}
|
|
} else if (c.friends.has(client.accountId)) {
|
|
reloadFriends(c).catch(e => logger.error(e));
|
|
}
|
|
}
|
|
|
|
if (visibleOnlineFriends.length) {
|
|
client.updateFriends(visibleOnlineFriends.map(toFriendOnline), false);
|
|
}
|
|
|
|
if (this.liveSettings.updating) {
|
|
this.notifications.addNotification(client, updateNotification());
|
|
}
|
|
|
|
// TEMP: duplicate pony bug
|
|
if (client.map.instance) {
|
|
for (const region of client.map.regions) {
|
|
for (const entity of region.entities) {
|
|
if (entity.client !== undefined && entity !== client.pony && entity.client.accountId === client.accountId) {
|
|
const sameClients = this.clients.filter(c => c.accountId === client.accountId).length;
|
|
|
|
client.reporter.systemLog(`Client pony already on map ` +
|
|
`(old: ${entity.id}, new: ${client.pony.id}, sameClients: ${sameClients})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
timingEnd();
|
|
}
|
|
getClientByEntityId(entityId: number) {
|
|
if (entityId === 0) {
|
|
return undefined;
|
|
} else {
|
|
const byPonyId = (c: IClient) => c.pony.id === entityId;
|
|
return this.clients.find(byPonyId) || this.offlineClients.find(byPonyId);
|
|
}
|
|
}
|
|
private removeEntityFromAnyMap(entity: ServerEntity) {
|
|
for (const map of this.maps) {
|
|
for (const region of map.regions) {
|
|
const index = region.entities.indexOf(entity);
|
|
|
|
if (index !== - 1) {
|
|
removeEntityFromRegion(region, entity, map);
|
|
return map;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
leaveClient(client: IClient) {
|
|
const friends = findAllOnlineFriends(this, client);
|
|
|
|
if (!client.accountSettings.hidden) {
|
|
for (const friend of friends) {
|
|
friend.updateFriends([toFriendOffline(client)], false);
|
|
}
|
|
}
|
|
|
|
this.reservedIds.set(client.pony.id, client.accountId);
|
|
this.reservedIdsByKey.set(`${client.accountId}:${client.characterId}`, { id: client.pony.id, time: Date.now() });
|
|
|
|
if (!this.removeEntity(client.pony, client.map)) {
|
|
const map = this.removeEntityFromAnyMap(client.pony);
|
|
client.reporter.systemLog(`Removing from any map (` +
|
|
`expected: ${client.map && client.map.id} [${client.map && client.map.instance}], ` +
|
|
`actual: ${map && map.id} [${map && map.instance}])`);
|
|
}
|
|
|
|
unsubscribeFromAllRegions(client, true);
|
|
removeItem(this.joinQueue, client);
|
|
removeItem(this.clients, client);
|
|
this.clientsByAccount.delete(client.accountId);
|
|
this.offlineClients.push(client);
|
|
|
|
const index = this.mapSwitchQueue.findIndex(x => x.client === client);
|
|
|
|
if (index !== -1) {
|
|
this.mapSwitchQueue.splice(index, 1);
|
|
}
|
|
}
|
|
notifyHidden(by: string, who: string) {
|
|
const byClient = findClientByAccountId(this, by);
|
|
const whoClient = findClientByAccountId(this, who);
|
|
|
|
if (byClient && whoClient) {
|
|
updateEntityPlayerState(byClient, whoClient.pony);
|
|
updateEntityPlayerState(whoClient, byClient.pony);
|
|
}
|
|
}
|
|
resetToSpawn(client: IClient) {
|
|
Object.assign(client.pony, randomPoint(client.map.spawnArea));
|
|
}
|
|
kick(client: IClient | undefined, leaveReason = 'kicked', reason = LeaveReason.None, force = false) {
|
|
if (client) {
|
|
removeItem(this.joinQueue, client);
|
|
this.notifications.rejectAll(client);
|
|
this.leaveClient(client);
|
|
client.leaveReason = leaveReason;
|
|
client.left(reason);
|
|
|
|
if (force) {
|
|
client.disconnect(true);
|
|
} else {
|
|
setTimeout(() => {
|
|
if (client.isConnected) {
|
|
client.disconnect(true);
|
|
}
|
|
}, 200);
|
|
}
|
|
}
|
|
|
|
return !!client;
|
|
}
|
|
kickAll() {
|
|
this.clients.slice().forEach(c => this.kick(c, 'kickAll'));
|
|
this.joinQueue.slice().forEach(c => c.disconnect());
|
|
this.joinQueue = [];
|
|
}
|
|
kickByAccount(accountId: string) {
|
|
return this.kick(findClientByAccountId(this, accountId), 'kickByAccount');
|
|
}
|
|
kickByCharacter(characterId: string) {
|
|
return this.kick(findClientByCharacterId(this, characterId), 'kickByCharacter');
|
|
}
|
|
accountUpdated(account: IAccount) {
|
|
const accountId = account._id.toString();
|
|
const client = findClientByAccountId(this, accountId);
|
|
|
|
if (client) {
|
|
this.updateClientAccount(client, account);
|
|
|
|
for (const c of this.clients) {
|
|
if (c.isMod && c.selected === client.pony) {
|
|
pushUpdateEntityToClient(c, { entity: client.pony, flags: UpdateFlags.Options, options: { modInfo: getModInfo(client) } });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private updateClientAccount(client: IClient, newAccount: IAccount) {
|
|
const oldAccount = client.account;
|
|
client.account = newAccount;
|
|
|
|
if (oldAccount.ban !== newAccount.ban && isBanned(newAccount)) {
|
|
sendAcl(client);
|
|
this.kick(client, 'kick (ban)');
|
|
return;
|
|
}
|
|
|
|
if (oldAccount.shadow !== newAccount.shadow) {
|
|
if (isShadowed(newAccount)) {
|
|
this.shadow(client);
|
|
} else if (isShadowed(oldAccount)) {
|
|
sendAcl(client);
|
|
this.kick(client, 'kick (unshadow)');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const shouldSendAcl = oldAccount.mute !== newAccount.mute
|
|
|| oldAccount.ban !== newAccount.ban
|
|
|| oldAccount.shadow !== newAccount.shadow;
|
|
|
|
if (shouldSendAcl) {
|
|
sendAcl(client);
|
|
}
|
|
}
|
|
private shadow(client: IClient) {
|
|
client.shadowed = true;
|
|
this.partyService.clientDisconnected(client);
|
|
this.friendsService.clientDisconnected(client);
|
|
this.notifications.dismissAll(client);
|
|
|
|
if (client.pony.region) {
|
|
for (const c of client.pony.region.clients) {
|
|
if (c !== client) {
|
|
pushRemoveEntityToClient(c, client.pony);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// update notification
|
|
notifyUpdate() {
|
|
for (const client of this.clients) {
|
|
this.notifications.addNotification(client, updateNotification());
|
|
}
|
|
}
|
|
saveClientStates() {
|
|
for (const client of this.clients) {
|
|
createAndUpdateCharacterState(client, this.server);
|
|
}
|
|
}
|
|
}
|
|
|
|
// account creation lock
|
|
function sendAcl(client: IClient) {
|
|
const acl = isMutedOrShadowed(client) ? fromNow(12 * HOUR) : new Date(0);
|
|
client.actionParam(client.pony.id, Action.ACL, acl.toISOString());
|
|
}
|
|
|
|
function updateNotification(): ServerNotification {
|
|
return {
|
|
id: 0,
|
|
name: '',
|
|
message: 'Server will restart shortly for updates and maintenance',
|
|
flags: NotificationFlags.Ok,
|
|
};
|
|
}
|
|
|
|
export function refreshMap(world: World, client: IClient) {
|
|
const map = findOrCreateMapForClient(world, client.map.id, client);
|
|
|
|
if (map) {
|
|
world.switchToMap(client, map, client.pony.x, client.pony.y);
|
|
} else {
|
|
logger.warn(`Missing map: ${client.map.id}`);
|
|
}
|
|
}
|
|
|
|
export function goToMap(world: World, client: IClient, id: string, spawn?: string) {
|
|
const map = findOrCreateMapForClient(world, id, client);
|
|
|
|
if (map) {
|
|
const area = spawn && map.spawns.get(spawn) || map.spawnArea;
|
|
const { x, y } = randomPoint(area);
|
|
world.switchToMap(client, map, x, y);
|
|
} else {
|
|
logger.warn(`Missing map: ${id}`);
|
|
}
|
|
}
|
|
|
|
function findOrCreateMapInstance(world: World, id: string, instance: string) {
|
|
let map = world.maps.find(m => m.id === id && m.instance === instance);
|
|
|
|
if (!map) {
|
|
// const pool = world.mapPools.get(id);
|
|
|
|
// if (!pool) {
|
|
// throw new Error(`Invalid map id: ${id}`);
|
|
// }
|
|
|
|
switch (id) {
|
|
case 'house':
|
|
map = createHouseMap(world, true);
|
|
break;
|
|
case 'island':
|
|
map = createIslandMap(world, true);
|
|
break;
|
|
default:
|
|
throw new Error(`Invalid map id: ${id}`);
|
|
}
|
|
|
|
// map = pool.create();
|
|
map.instance = instance;
|
|
map.lastUsed = Date.now();
|
|
map.controllers.forEach(c => c.initialize(world.now / 1000));
|
|
world.maps.push(map);
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function findOrCreateMapForClient(world: World, id: string, client: IClient) {
|
|
const map = world.maps.find(m => !m.instance && m.id === id);
|
|
|
|
if (map) {
|
|
return map;
|
|
} else {
|
|
if (client.party) {
|
|
return findOrCreateMapInstance(world, id, client.party.id);
|
|
} else {
|
|
return findOrCreateMapInstance(world, id, client.accountId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateClientCamera(client: IClient) {
|
|
const camera = client.camera;
|
|
|
|
updateCamera(camera, client.pony, client.map);
|
|
|
|
if (
|
|
client.lastCameraX !== camera.x || client.lastCameraY !== camera.y ||
|
|
client.lastCameraW !== camera.w || client.lastCameraH !== camera.h
|
|
) {
|
|
client.lastCameraX = camera.x;
|
|
client.lastCameraY = camera.y;
|
|
client.lastCameraW = camera.w;
|
|
client.lastCameraH = camera.h;
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function findAllOnlineFriends(world: World, client: IClient) {
|
|
return compact(Array.from(client.friends.keys())
|
|
.map(account => findClientByAccountId(world, account)));
|
|
}
|
|
|
|
export function findClientByAccountId(world: World, accountId: string) {
|
|
return world.clientsByAccount.get(accountId);
|
|
}
|
|
|
|
export function findClientByCharacterId(world: World, characterId: string) {
|
|
return world.clients.find(c => c.characterId === characterId);
|
|
}
|
|
|
|
export function findClientsAroundAccountId(world: World, accountId: string): AroundEntry[] {
|
|
const client = findClientByAccountId(world, accountId);
|
|
|
|
return client ? world.clients
|
|
.filter(c => c !== client && c.map === client.map)
|
|
.map(c => ({
|
|
account: c.accountId,
|
|
distance: distance(client.pony, c.pony),
|
|
party: !!(c.party && c.party === client.party),
|
|
}))
|
|
.filter(x => x.distance < 5 || x.party)
|
|
.sort((a, b) => a.distance - b.distance)
|
|
.slice(0, 12) : [];
|
|
}
|