diff --git a/docs/release/dev/sp-httpclient-callback.md b/docs/release/dev/sp-httpclient-callback.md new file mode 100644 index 0000000000..ff99ddbab8 --- /dev/null +++ b/docs/release/dev/sp-httpclient-callback.md @@ -0,0 +1 @@ +HTTP Client API now supports callbacks, not only promises. This is useful since in SkyrimPlatform promises aren't resolving in the main menu. diff --git a/docs/release/dev/sp-loadgame.md b/docs/release/dev/sp-loadgame.md new file mode 100644 index 0000000000..661ef35dc8 --- /dev/null +++ b/docs/release/dev/sp-loadgame.md @@ -0,0 +1 @@ +Added `time` and `loadOrder` parameters to `loadGame` function. diff --git a/docs/release/dev/sp-tick.md b/docs/release/dev/sp-tick.md new file mode 100644 index 0000000000..d9262a505e --- /dev/null +++ b/docs/release/dev/sp-tick.md @@ -0,0 +1 @@ +Added initial support for calling script functions in tick context: `Game.getModCount`, `Game.getModName`. diff --git a/savefile/src/SFStructure.cpp b/savefile/src/SFStructure.cpp index e344b9333d..307630add8 100644 --- a/savefile/src/SFStructure.cpp +++ b/savefile/src/SFStructure.cpp @@ -81,7 +81,7 @@ int64_t SaveFile_::SaveFile::FindIndexInFormIdArray(uint32_t refID) } void SaveFile_::SaveFile::OverwritePluginInfo( - std::vector& newPlaginNames) + std::vector& newPluginNames) { uint32_t oldSize = this->pluginInfoSize; @@ -89,9 +89,9 @@ void SaveFile_::SaveFile::OverwritePluginInfo( this->pluginInfo.numPlugins = 0; this->pluginInfo.pluginsName.clear(); - this->pluginInfo.numPlugins = uint8_t(newPlaginNames.size()); + this->pluginInfo.numPlugins = static_cast(newPluginNames.size()); - for (auto& plugin : newPlaginNames) { + for (auto& plugin : newPluginNames) { this->pluginInfo.pluginsName.push_back(plugin); this->pluginInfoSize += uint32_t(2 + plugin.size()); } diff --git a/skymp5-client/src/features/authSystem.ts b/skymp5-client/src/features/authSystem.ts index 5cd774ce10..a863d8b5d6 100644 --- a/skymp5-client/src/features/authSystem.ts +++ b/skymp5-client/src/features/authSystem.ts @@ -1,10 +1,7 @@ import * as sp from "skyrimPlatform"; import * as browser from "./browser"; import { AuthGameData, RemoteAuthGameData } from "./authModel"; -import { Transform } from "../sync/movement"; import { FunctionInfo } from "../lib/functionInfo"; -import { SpApiInteractor } from "../services/spApiInteractor"; -import { LoadGameService } from "../services/services/loadGameService"; const normalizeUrl = (url: string) => { if (url.endsWith('/')) { @@ -53,7 +50,7 @@ export const addAuthListener = (callback: AuthCallback): void => { authListeners.push(callback); } -export const main = (lobbyLocation: Transform): void => { +export const main = (): void => { const settingsGameData = sp.settings["skymp5-client"]["gameData"] as any; const isOfflineMode = Number.isInteger(settingsGameData?.profileId); if (isOfflineMode) { @@ -61,7 +58,7 @@ export const main = (lobbyLocation: Transform): void => { } else { startListenBrowserMessage(); browser.addOnWindowLoadListener(() => { - if (isListenBrowserMessage) loadLobby(lobbyLocation); + if (isListenBrowserMessage) loadLobby(); }); } } @@ -78,7 +75,7 @@ export const setPlayerAuthMode = (frozen: boolean): void => { sp.Game.forceFirstPerson(); } -function createPlaySession(token: string) { +function createPlaySession(token: string, callback: (res: string, err: string) => void) { const client = new sp.HttpClient(authUrl); let masterKey = sp.settings["skymp5-client"]["server-master-key"]; if (!masterKey) { @@ -88,17 +85,21 @@ function createPlaySession(token: string) { masterKey = sp.settings["skymp5-client"]["server-ip"] + ":" + sp.settings["skymp5-client"]["server-port"]; } sp.printConsole({ masterKey }); - return client.post(`/api/users/me/play/${masterKey}`, { + + client.post(`/api/users/me/play/${masterKey}`, { body: '{}', contentType: 'application/json', headers: { 'authorization': token, }, - }).then((res) => { + }, (res: sp.HttpResponse) => { if (res.status != 200) { - throw Error('status code ' + res.status); + callback('', 'status code ' + res.status); + } + else { + // TODO: handle JSON.parse failure? + callback(JSON.parse(res.body).session, ''); } - return JSON.parse(res.body).session; }); } @@ -146,86 +147,64 @@ const checkLoginState = () => { if (!isListenBrowserMessage) { return; } - + new sp.HttpClient(authUrl) - .get("/api/users/login-discord/status?state=" + discordAuthState) - .then(response => { - switch (response.status) { - case 200: - const { - token, - masterApiId, - discordUsername, - discordDiscriminator, - discordAvatar, - } = JSON.parse(response.body) as AuthStatus; - browserState.failCount = 0; - createPlaySession(token).then((playSession) => { - authData = { - session: playSession, + .get("/api/users/login-discord/status?state=" + discordAuthState, undefined, + (response) => { + switch (response.status) { + case 200: + const { + token, masterApiId, discordUsername, discordDiscriminator, discordAvatar, - }; - refreshWidgets(); - }); - break; - case 401: // Unauthorized - browserState.failCount = 0; - browserState.comment = (`Still waiting...`); - setTimeout(() => checkLoginState(), 1.5 + Math.random() * 2); - break; - case 403: // Forbidden - case 404: // Not found - browserState.failCount = 9000; - browserState.comment = (`Fail: ${response.body}`); - break; - default: - ++browserState.failCount; - browserState.comment = `Server returned ${response.status.toString() || "???"} "${response.body || response.error}"`; - setTimeout(() => checkLoginState(), 1.5 + Math.random() * 2); - } - }) - .catch(reason => { - ++browserState.failCount; - if (typeof reason === "string") { - browserState.comment = (`Skyrim platform error (http): ${reason}`) - } else { - browserState.comment = (`Skyrim platform error (http): request rejected`); - } - }) - .finally(() => { - refreshWidgets(); - }); + } = JSON.parse(response.body) as AuthStatus; + browserState.failCount = 0; + createPlaySession(token, (playSession, error) => { + if (error) { + browserState.failCount = 0; + browserState.comment = (error); + setTimeout(() => checkLoginState(), 1.5 + Math.random() * 2); + refreshWidgets(); + return; + } + authData = { + session: playSession, + masterApiId, + discordUsername, + discordDiscriminator, + discordAvatar, + }; + refreshWidgets(); + }); + break; + case 401: // Unauthorized + browserState.failCount = 0; + browserState.comment = (`Still waiting...`); + setTimeout(() => checkLoginState(), 1.5 + Math.random() * 2); + break; + case 403: // Forbidden + case 404: // Not found + browserState.failCount = 9000; + browserState.comment = (`Fail: ${response.body}`); + break; + default: + ++browserState.failCount; + browserState.comment = `Server returned ${response.status.toString() || "???"} "${response.body || response.error}"`; + setTimeout(() => checkLoginState(), 1.5 + Math.random() * 2); + } + }); }; -const loadLobby = (location: Transform): void => { - sp.once("update", () => { - defaultAutoVanityModeDelay = sp.Utility.getINIFloat("fAutoVanityModeDelay:Camera"); - setPlayerAuthMode(true); - authData = browser.getAuthData(); - refreshWidgets(); - sp.browser.setVisible(true); - }); - - sp.once("loadGame", () => { - // In non-offline mode we still want to see our face in RaceMenu - const ironHelment = sp.Armor.from(sp.Game.getFormEx(0x00012e4d)); - const pl = sp.Game.getPlayer(); - if (pl) pl.unequipItem(ironHelment, false, true); - - sp.browser.setFocused(true); - browser.keepCursorMenuOpenedWhenBrowserFocused(); - checkLoginState(); - }); +const loadLobby = (): void => { + authData = browser.getAuthData(); + refreshWidgets(); + sp.browser.setVisible(true); + sp.browser.setFocused(true); - const loadGameService = SpApiInteractor.makeController().lookupListener(LoadGameService); - loadGameService.loadGame( - location.pos, - location.rot, - location.worldOrCell - ); + // Launch checkLoginState loop + checkLoginState(); } declare const window: any; @@ -234,54 +213,54 @@ const browsersideWidgetSetter = () => { const loginWidget = { type: "form", id: 1, - caption: "authorization", + caption: "Авторизация", elements: [ - { - type: "button", - tags: ["BUTTON_STYLE_GITHUB"], - hint: "get a colored nickname and mention in news", - click: () => window.skyrimPlatform.sendMessage(events.openGithub), - }, - { - type: "button", - tags: ["BUTTON_STYLE_PATREON", "ELEMENT_SAME_LINE", "HINT_STYLE_RIGHT"], - hint: "get a colored nickname and other bonuses for patrons", - click: () => window.skyrimPlatform.sendMessage(events.openPatreon), - }, - { - type: "icon", - text: "username", - tags: ["ICON_STYLE_SKYMP"], - }, + // { + // type: "button", + // tags: ["BUTTON_STYLE_GITHUB"], + // hint: "get a colored nickname and mention in news", + // click: () => window.skyrimPlatform.sendMessage(events.openGithub), + // }, + // { + // type: "button", + // tags: ["BUTTON_STYLE_PATREON", "ELEMENT_SAME_LINE", "HINT_STYLE_RIGHT"], + // hint: "get a colored nickname and other bonuses for patrons", + // click: () => window.skyrimPlatform.sendMessage(events.openPatreon), + // }, + // { + // type: "icon", + // text: "username", + // tags: ["ICON_STYLE_SKYMP"], + // }, { type: "text", text: ( authData ? ( authData.discordUsername - ? `${authData.discordUsername}` + ? `Добро пожаловать, ${authData.discordUsername}` : `id: ${authData.masterApiId}` - ) : "Please log in" + ) : "Не авторизирован" ), - tags: ["ELEMENT_SAME_LINE", "ELEMENT_STYLE_MARGIN_EXTENDED"], - }, - { - type: "icon", - text: "discord", - tags: ["ICON_STYLE_DISCORD"], + tags: [/*"ELEMENT_SAME_LINE", */"ELEMENT_STYLE_MARGIN_EXTENDED"], }, + // { + // type: "icon", + // text: "discord", + // tags: ["ICON_STYLE_DISCORD"], + // }, { type: "button", - text: authData ? "change account" : "login or register", - tags: ["ELEMENT_SAME_LINE"], + text: authData ? "Сменить аккаунт" : "Войти через Discord", + tags: [/*"ELEMENT_SAME_LINE"*/], click: () => window.skyrimPlatform.sendMessage(events.openDiscordOauth), - hint: "You can log in or change account at any time", + hint: "Вы можете войти или поменять аккаунт", }, { type: "button", - text: "travel to skyrim", + text: "Играть", tags: ["BUTTON_STYLE_FRAME", "ELEMENT_STYLE_MARGIN_EXTENDED"], click: () => window.skyrimPlatform.sendMessage(events.login), - hint: "Connect to the game server", + hint: "Подключиться к игровому серверу", }, { type: "text", diff --git a/skymp5-client/src/features/browser.ts b/skymp5-client/src/features/browser.ts index b9db61bcd2..6909344aad 100644 --- a/skymp5-client/src/features/browser.ts +++ b/skymp5-client/src/features/browser.ts @@ -4,7 +4,6 @@ import { once, Input, printConsole, - settings, Menu, DxScanCode, writePlugin, diff --git a/skymp5-client/src/index.ts b/skymp5-client/src/index.ts index 5b66abf1fd..64e682164a 100644 --- a/skymp5-client/src/index.ts +++ b/skymp5-client/src/index.ts @@ -31,10 +31,11 @@ import { ActivationService } from "./services/services/activationService"; import { CraftService } from "./services/services/craftService"; import { DropItemService } from "./services/services/dropItemService"; import { HitService } from "./services/services/hitService"; -import { SendMessagesService } from "./services/services/sendMessagesService"; import { RagdollService } from "./services/services/ragdollService"; import { DeathService } from "./services/services/deathService"; import { ContainersService } from "./services/services/containersService"; +import { NetworkingService } from "./services/services/networkingService"; +import { RemoteServer } from "./services/services/remoteServer"; browser.main(); @@ -107,10 +108,11 @@ const main = () => { new CraftService(sp, controller), new DropItemService(sp, controller), new HitService(sp, controller), - new SendMessagesService(sp, controller), new RagdollService(sp, controller), new DeathService(sp, controller), - new ContainersService(sp, controller) + new ContainersService(sp, controller), + new NetworkingService(sp, controller), + new RemoteServer(sp, controller) ]; SpApiInteractor.setup(listeners); listeners.forEach(listener => SpApiInteractor.registerListenerForLookup(listener.constructor.name, listener)); diff --git a/skymp5-client/src/lib/errors.ts b/skymp5-client/src/lib/errors.ts index d8ebc5145e..8f74a15e03 100644 --- a/skymp5-client/src/lib/errors.ts +++ b/skymp5-client/src/lib/errors.ts @@ -7,6 +7,7 @@ export class RespawnNeededError extends Error { export class NeverError extends Error { constructor(message: never) { - super(`Unreachable statement: ${message}`); + super(`NeverError: ${JSON.stringify(message)}`); + Object.setPrototypeOf(this, NeverError.prototype); } } diff --git a/skymp5-client/src/messages.ts b/skymp5-client/src/messages.ts index 9e066257e9..b5c4e0c210 100644 --- a/skymp5-client/src/messages.ts +++ b/skymp5-client/src/messages.ts @@ -1,12 +1,3 @@ -import { Movement, Transform } from "./sync/movement"; -import { Appearance } from "./sync/appearance"; -import { Animation } from "./sync/animation"; -import { Equipment } from "./sync/equipment"; -import { Inventory } from "./sync/inventory"; -import * as spSnippet from "./spSnippet"; -import { ActorValues } from "./sync/actorvalues"; -import { ChangeValuesMessage } from "./services/messages/changeValues"; - export enum MsgType { CustomPacket = 1, UpdateMovement = 2, @@ -30,69 +21,3 @@ export enum MsgType { Teleport = 20, OpenContainer = 21, } - -export interface SetInventory { - type: "setInventory"; - inventory: Inventory; -} - -export interface CreateActorMessage { - type: "createActor"; - idx: number; - refrId?: number; - baseRecordType: "DOOR" | undefined; // see PartOne.cpp - transform: Transform; - isMe: boolean; - appearance?: Appearance; - equipment?: Equipment; - inventory?: Inventory; - baseId?: number; - props?: Record; -} - -export interface DestroyActorMessage { - type: "destroyActor"; - idx: number; -} - -export interface UpdatePropertyMessage { - t: MsgType.UpdateProperty; - idx: number; - refrId: number; - baseRecordType: string; // DOOR, ACTI, etc - data: unknown; - propName: string; -} - -export interface SetRaceMenuOpenMessage { - type: "setRaceMenuOpen"; - open: boolean; -} - -export interface CustomPacket { - type: "customPacket"; - content: Record; -} - -interface SpSnippetMsgBase { - type: "spSnippet"; -} - -export type SpSnippet = SpSnippetMsgBase & spSnippet.Snippet; - -export interface HostStartMessage { - type: "hostStart"; - target: number; -} - -export interface HostStopMessage { - type: "hostStop"; - target: number; -} - -export interface UpdateGamemodeDataMessage { - type: "updateGamemodeData"; - eventSources: Record; - updateOwnerFunctions: Record; - updateNeighborFunctions: Record; -} diff --git a/skymp5-client/src/modelSource/msgHandler.ts b/skymp5-client/src/modelSource/msgHandler.ts deleted file mode 100644 index 0a39ead3b5..0000000000 --- a/skymp5-client/src/modelSource/msgHandler.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as msg from "../messages"; -import { ChangeValuesMessage } from "../services/messages/changeValues"; -import { DeathStateContainerMessage } from "../services/messages/deathStateContainerMessage"; -import { UpdateAnimationMessage } from "../services/messages/updateAnimationMessage"; -import { UpdateAppearanceMessage } from "../services/messages/updateAppearanceMessage"; -import { UpdateEquipmentMessage } from "../services/messages/updateEquipmentMessage"; -import { UpdateMovementMessage } from "../services/messages/updateMovementMessage"; - -export interface MsgHandler { - createActor(msg: msg.CreateActorMessage): void; - destroyActor(msg: msg.DestroyActorMessage): void; - UpdateMovement(msg: UpdateMovementMessage): void; - UpdateAnimation(msg: UpdateAnimationMessage): void; - UpdateAppearance(msg: UpdateAppearanceMessage): void; - UpdateEquipment(msg: UpdateEquipmentMessage): void; - ChangeValues(msg: ChangeValuesMessage): void; - setRaceMenuOpen(msg: msg.SetRaceMenuOpenMessage): void; - customPacket(msg: msg.CustomPacket): void; - DeathStateContainer(msg: DeathStateContainerMessage): void; - - handleConnectionAccepted(): void; - handleDisconnect(): void; -} diff --git a/skymp5-client/src/modelSource/sendTarget.ts b/skymp5-client/src/modelSource/sendTarget.ts deleted file mode 100644 index f7821313a0..0000000000 --- a/skymp5-client/src/modelSource/sendTarget.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface SendTarget { - send(msg: Record, reliable: boolean): void; -} diff --git a/skymp5-client/src/networking.ts b/skymp5-client/src/networking.ts deleted file mode 100644 index 29081ace06..0000000000 --- a/skymp5-client/src/networking.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { mpClientPlugin, PacketType } from "skyrimPlatform"; -import * as sp from "skyrimPlatform"; - -type Handler = (messageOrError: Record | string) => void; -const handlersMap = new Map(); -let lastHostname = ""; -let lastPort = 0; - -const createClientSafe = (hostname: string, port: number): void => { - sp.printConsole("createClientSafe " + hostname + ":" + port); - // Client sometimes call this function with bad parameters. - // It causes assertion failure in Debug mode, but doesn't lead to anything on a regular player's machine. - // It seems that this function will be called with the valid parameters later - if (hostname !== "" && lastPort !== 0) { - mpClientPlugin.createClient(hostname, port); - } -}; - -sp.on("tick", () => { - mpClientPlugin.tick((packetType, jsonContent, error) => { - const handlers = handlersMap.get(packetType) || []; - handlers.forEach((handler) => { - const parse = () => { - try { - return JSON.parse(jsonContent); - } catch (e) { - throw new Error(`JSON ${jsonContent} failed to parse: ${e}`); - } - }; - handler(jsonContent.length ? parse() : error); - }); - }); -}); - -export const connect = (hostname: string, port: number): void => { - lastHostname = hostname; - lastPort = port; - createClientSafe(hostname, port); -}; - -export const close = (): void => { - mpClientPlugin.destroyClient(); -}; - -export const on = (packetType: PacketType, handler: Handler): void => { - let arr = handlersMap.get(packetType); - arr = (arr ? arr : []).concat([handler]); - handlersMap.set(packetType, arr); -}; - -export const send = (msg: Record, reliable: boolean): void => { - // TODO(#175): JS object instead of JSON? - mpClientPlugin.send(JSON.stringify(msg), reliable); -}; - -// Reconnect automatically -export const reconnect = (): void => createClientSafe(lastHostname, lastPort); -on("connectionFailed", reconnect); -on("connectionDenied", reconnect); -on("disconnect", reconnect); diff --git a/skymp5-client/src/services/events/connectionAccepted.ts b/skymp5-client/src/services/events/connectionAccepted.ts new file mode 100644 index 0000000000..08b777d3f0 --- /dev/null +++ b/skymp5-client/src/services/events/connectionAccepted.ts @@ -0,0 +1,2 @@ +export interface ConnectionAccepted { +} diff --git a/skymp5-client/src/services/events/connectionDenied.ts b/skymp5-client/src/services/events/connectionDenied.ts new file mode 100644 index 0000000000..d836232da1 --- /dev/null +++ b/skymp5-client/src/services/events/connectionDenied.ts @@ -0,0 +1,3 @@ +export interface ConnectionDenied { + error: string +} diff --git a/skymp5-client/src/services/events/connectionDisconnect.ts b/skymp5-client/src/services/events/connectionDisconnect.ts new file mode 100644 index 0000000000..5771a682d0 --- /dev/null +++ b/skymp5-client/src/services/events/connectionDisconnect.ts @@ -0,0 +1,2 @@ +export interface ConnectionDisconnect { +} diff --git a/skymp5-client/src/services/events/connectionFailed.ts b/skymp5-client/src/services/events/connectionFailed.ts new file mode 100644 index 0000000000..1ab704c831 --- /dev/null +++ b/skymp5-client/src/services/events/connectionFailed.ts @@ -0,0 +1,2 @@ +export interface ConnectionFailed { +} diff --git a/skymp5-client/src/services/events/connectionMessage.ts b/skymp5-client/src/services/events/connectionMessage.ts new file mode 100644 index 0000000000..288f6b02fe --- /dev/null +++ b/skymp5-client/src/services/events/connectionMessage.ts @@ -0,0 +1,3 @@ +export interface ConnectionMessage { + message: Message; +} diff --git a/skymp5-client/src/services/events/eventEmitterFactory.ts b/skymp5-client/src/services/events/eventEmitterFactory.ts deleted file mode 100644 index ac454acee6..0000000000 --- a/skymp5-client/src/services/events/eventEmitterFactory.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EventEmitter } from "eventemitter3"; -import { GameLoadEvent } from "./gameLoadEvent"; -import { SendMessageEvent } from "./sendMessageEvent"; -import { AnyMessage } from "../messages/anyMessage"; -import { SendMessageWithRefrIdEvent } from "./sendMessageWithRefrIdEvent"; -import { ApplyDeathStateEvent } from "./applyDeathStateEvent"; - -type EventTypes = { - 'gameLoad': [GameLoadEvent], - - 'sendMessage': [SendMessageEvent], - 'sendMessageWithRefrId': [SendMessageWithRefrIdEvent], - - 'applyDeathStateEvent': [ApplyDeathStateEvent] -} - -// https://blog.makerx.com.au/a-type-safe-event-emitter-in-node-js/ -interface TypedEventEmitter> { - emit( - eventName: TEventName, - ...eventArg: TEvents[TEventName] - ): void; - - on( - eventName: TEventName, - handler: (...eventArg: TEvents[TEventName]) => void - ): void; - - off( - eventName: TEventName, - handler: (...eventArg: TEvents[TEventName]) => void - ): void; -} - -export type EventEmitterType = TypedEventEmitter; - -export class EventEmitterFactory { - static makeEventEmitter(): EventEmitterType { - return (new EventEmitter()) as EventEmitterType; - } -} diff --git a/skymp5-client/src/services/events/events.ts b/skymp5-client/src/services/events/events.ts new file mode 100644 index 0000000000..8b1f936c33 --- /dev/null +++ b/skymp5-client/src/services/events/events.ts @@ -0,0 +1,90 @@ +import { EventEmitter } from "eventemitter3"; +import { GameLoadEvent } from "./gameLoadEvent"; +import { SendMessageEvent } from "./sendMessageEvent"; +import { AnyMessage } from "../messages/anyMessage"; +import { SendMessageWithRefrIdEvent } from "./sendMessageWithRefrIdEvent"; +import { ApplyDeathStateEvent } from "./applyDeathStateEvent"; +import { ConnectionFailed } from "./connectionFailed"; +import { ConnectionDenied } from "./connectionDenied"; +import { ConnectionAccepted } from "./connectionAccepted"; +import { ConnectionDisconnect } from "./connectionDisconnect"; +import { ConnectionMessage } from "./connectionMessage"; +import { HostStartMessage } from "../messages/hostStartMessage"; +import { HostStopMessage } from "../messages/hostStopMessage"; +import { SetInventoryMessage } from "../messages/setInventoryMessage"; +import { OpenContainerMessage } from "../messages/openContainerMessage"; +import { ChangeValuesMessage } from "../messages/changeValues"; +import { CreateActorMessage } from "../messages/createActorMessage"; +import { CustomPacketMessage2 } from "../messages/customPacketMessage2"; +import { DestroyActorMessage } from "../messages/destroyActorMessage"; +import { SetRaceMenuOpenMessage } from "../messages/setRaceMenuOpenMessage"; +import { SpSnippetMessage } from "../messages/spSnippetMessage"; +import { TeleportMessage } from "../messages/teleportMessage"; +import { UpdateAnimationMessage } from "../messages/updateAnimationMessage"; +import { UpdateAppearanceMessage } from "../messages/updateAppearanceMessage"; +import { UpdateEquipmentMessage } from "../messages/updateEquipmentMessage"; +import { UpdateGamemodeDataMessage } from "../messages/updateGameModeDataMessage"; +import { UpdateMovementMessage } from "../messages/updateMovementMessage"; +import { UpdatePropertyMessage } from "../messages/updatePropertyMessage"; +import { DeathStateContainerMessage } from "../messages/deathStateContainerMessage"; +import { TeleportMessage2 } from "../messages/teleportMessage2"; + +type EventTypes = { + 'gameLoad': [GameLoadEvent], + + 'sendMessage': [SendMessageEvent], + 'sendMessageWithRefrId': [SendMessageWithRefrIdEvent], + + 'applyDeathStateEvent': [ApplyDeathStateEvent], + + 'connectionFailed': [ConnectionFailed], + 'connectionDenied': [ConnectionDenied], + 'connectionAccepted': [ConnectionAccepted], + 'connectionDisconnect': [ConnectionDisconnect], + + 'updateMovementMessage': [ConnectionMessage], + 'updateAnimationMessage': [ConnectionMessage], + 'updateEquipmentMessage': [ConnectionMessage], + 'changeValuesMessage': [ConnectionMessage], + 'updateAppearanceMessage': [ConnectionMessage], + 'teleportMessage': [ConnectionMessage], + 'openContainerMessage': [ConnectionMessage], + 'hostStartMessage': [ConnectionMessage], + 'hostStopMessage': [ConnectionMessage], + 'setInventoryMessage': [ConnectionMessage], + 'createActorMessage': [ConnectionMessage], + 'customPacketMessage2': [ConnectionMessage], + 'destroyActorMessage': [ConnectionMessage], + 'setRaceMenuOpenMessage': [ConnectionMessage], + 'spSnippetMessage': [ConnectionMessage], + 'updateGamemodeDataMessage': [ConnectionMessage], + 'updatePropertyMessage': [ConnectionMessage], + 'deathStateContainerMessage': [ConnectionMessage], + 'teleportMessage2': [ConnectionMessage] +} + +// https://blog.makerx.com.au/a-type-safe-event-emitter-in-node-js/ +interface TypedEventEmitter> { + emit( + eventName: TEventName, + ...eventArg: TEvents[TEventName] + ): void; + + on( + eventName: TEventName, + handler: (...eventArg: TEvents[TEventName]) => void + ): void; + + off( + eventName: TEventName, + handler: (...eventArg: TEvents[TEventName]) => void + ): void; +} + +export type EventEmitterType = TypedEventEmitter; + +export class EventEmitterFactory { + static makeEventEmitter(): EventEmitterType { + return (new EventEmitter()) as EventEmitterType; + } +} diff --git a/skymp5-client/src/services/messages/anyMessage.ts b/skymp5-client/src/services/messages/anyMessage.ts index 3f771f8563..b8a735343b 100644 --- a/skymp5-client/src/services/messages/anyMessage.ts +++ b/skymp5-client/src/services/messages/anyMessage.ts @@ -3,26 +3,38 @@ import { ActivateMessage } from "./activateMessage"; import { ChangeValuesMessage } from "./changeValues"; import { ConsoleCommandMessage } from "./consoleCommandMessage"; import { CraftItemMessage } from "./craftItemMessage"; +import { CreateActorMessage } from "./createActorMessage"; import { CustomEventMessage } from "./customEventMessage"; import { CustomPacketMessage } from "./customPacketMessage"; +import { CustomPacketMessage2 } from "./customPacketMessage2"; +import { DeathStateContainerMessage } from "./deathStateContainerMessage"; +import { DestroyActorMessage } from "./destroyActorMessage"; import { DropItemMessage } from "./dropItemMessage"; import { FinishSpSnippetMessage } from "./finishSpSnippetMessage"; import { HitMessage } from "./hitMessage"; import { HostMessage } from "./hostMessage"; +import { HostStartMessage } from "./hostStartMessage"; +import { HostStopMessage } from "./hostStopMessage"; import { OnEquipMessage } from "./onEquipMessage"; -import { OpenContainer } from "./openContainer"; +import { OpenContainerMessage } from "./openContainerMessage"; import { PutItemMessage } from "./putItemMessage"; +import { SetInventoryMessage } from "./setInventoryMessage"; +import { SetRaceMenuOpenMessage } from "./setRaceMenuOpenMessage"; +import { SpSnippetMessage } from "./spSnippetMessage"; import { TakeItemMessage } from "./takeItemMessage"; import { TeleportMessage } from "./teleportMessage"; +import { TeleportMessage2 } from "./teleportMessage2"; import { UpdateAnimationMessage } from "./updateAnimationMessage"; import { UpdateAppearanceMessage } from "./updateAppearanceMessage"; import { UpdateEquipmentMessage } from "./updateEquipmentMessage"; +import { UpdateGamemodeDataMessage } from "./updateGameModeDataMessage"; import { UpdateMovementMessage } from "./updateMovementMessage"; +import { UpdatePropertyMessage } from "./updatePropertyMessage"; export type AnyMessage = ActivateMessage - | ConsoleCommandMessage - | PutItemMessage - | TakeItemMessage + | ConsoleCommandMessage + | PutItemMessage + | TakeItemMessage | CraftItemMessage | DropItemMessage | HitMessage @@ -37,4 +49,16 @@ export type AnyMessage = ActivateMessage | CustomPacketMessage | FinishSpSnippetMessage | TeleportMessage - | OpenContainer + | OpenContainerMessage + | HostStartMessage + | HostStopMessage + | SetInventoryMessage + | CreateActorMessage + | CustomPacketMessage2 + | DestroyActorMessage + | SetRaceMenuOpenMessage + | SpSnippetMessage + | UpdateGamemodeDataMessage + | UpdatePropertyMessage + | DeathStateContainerMessage + | TeleportMessage2 diff --git a/skymp5-client/src/services/messages/createActorMessage.ts b/skymp5-client/src/services/messages/createActorMessage.ts new file mode 100644 index 0000000000..9bfe0e133c --- /dev/null +++ b/skymp5-client/src/services/messages/createActorMessage.ts @@ -0,0 +1,18 @@ +import { Appearance } from "../../sync/appearance"; +import { Equipment } from "../../sync/equipment"; +import { Inventory } from "../../sync/inventory"; +import { Transform } from "../../sync/movement"; + +export interface CreateActorMessage { + type: "createActor"; + idx: number; + refrId?: number; + baseRecordType: "DOOR" | undefined; // see PartOne.cpp + transform: Transform; + isMe: boolean; + appearance?: Appearance; + equipment?: Equipment; + inventory?: Inventory; + baseId?: number; + props?: Record; +} diff --git a/skymp5-client/src/services/messages/customPacketMessage2.ts b/skymp5-client/src/services/messages/customPacketMessage2.ts new file mode 100644 index 0000000000..1d6ed02df7 --- /dev/null +++ b/skymp5-client/src/services/messages/customPacketMessage2.ts @@ -0,0 +1,4 @@ +export interface CustomPacketMessage2 { + type: "customPacket"; + content: Record; +} diff --git a/skymp5-client/src/services/messages/deathStateContainerMessage.ts b/skymp5-client/src/services/messages/deathStateContainerMessage.ts index 0030527136..3bb4838b57 100644 --- a/skymp5-client/src/services/messages/deathStateContainerMessage.ts +++ b/skymp5-client/src/services/messages/deathStateContainerMessage.ts @@ -1,6 +1,8 @@ -import { MsgType, UpdatePropertyMessage } from "../../messages"; + +import { MsgType } from "../../messages"; import { ChangeValuesMessage } from "./changeValues"; import { TeleportMessage } from "./teleportMessage"; +import { UpdatePropertyMessage } from "./updatePropertyMessage"; export interface DeathStateContainerMessage { t: MsgType.DeathStateContainer; diff --git a/skymp5-client/src/services/messages/destroyActorMessage.ts b/skymp5-client/src/services/messages/destroyActorMessage.ts new file mode 100644 index 0000000000..118aebdcd4 --- /dev/null +++ b/skymp5-client/src/services/messages/destroyActorMessage.ts @@ -0,0 +1,4 @@ +export interface DestroyActorMessage { + type: "destroyActor"; + idx: number; +} diff --git a/skymp5-client/src/services/messages/hostStartMessage.ts b/skymp5-client/src/services/messages/hostStartMessage.ts new file mode 100644 index 0000000000..214cd7bf28 --- /dev/null +++ b/skymp5-client/src/services/messages/hostStartMessage.ts @@ -0,0 +1,4 @@ +export interface HostStartMessage { + type: "hostStart"; + target: number; +} diff --git a/skymp5-client/src/services/messages/hostStopMessage.ts b/skymp5-client/src/services/messages/hostStopMessage.ts new file mode 100644 index 0000000000..58b7e83e6f --- /dev/null +++ b/skymp5-client/src/services/messages/hostStopMessage.ts @@ -0,0 +1,4 @@ +export interface HostStopMessage { + type: "hostStop"; + target: number; +} diff --git a/skymp5-client/src/services/messages/openContainer.ts b/skymp5-client/src/services/messages/openContainerMessage.ts similarity index 70% rename from skymp5-client/src/services/messages/openContainer.ts rename to skymp5-client/src/services/messages/openContainerMessage.ts index eeee55c516..c7668cacd6 100644 --- a/skymp5-client/src/services/messages/openContainer.ts +++ b/skymp5-client/src/services/messages/openContainerMessage.ts @@ -1,6 +1,6 @@ import { MsgType } from "../../messages"; -export interface OpenContainer { +export interface OpenContainerMessage { t: MsgType.OpenContainer; target: number; } diff --git a/skymp5-client/src/services/messages/setInventoryMessage.ts b/skymp5-client/src/services/messages/setInventoryMessage.ts new file mode 100644 index 0000000000..850e123fdd --- /dev/null +++ b/skymp5-client/src/services/messages/setInventoryMessage.ts @@ -0,0 +1,6 @@ +import { Inventory } from "skyrimPlatform"; + +export interface SetInventoryMessage { + type: "setInventory"; + inventory: Inventory; +} diff --git a/skymp5-client/src/services/messages/setRaceMenuOpenMessage.ts b/skymp5-client/src/services/messages/setRaceMenuOpenMessage.ts new file mode 100644 index 0000000000..55b08929fe --- /dev/null +++ b/skymp5-client/src/services/messages/setRaceMenuOpenMessage.ts @@ -0,0 +1,4 @@ +export interface SetRaceMenuOpenMessage { + type: "setRaceMenuOpen"; + open: boolean; +} diff --git a/skymp5-client/src/services/messages/spSnippetMessage.ts b/skymp5-client/src/services/messages/spSnippetMessage.ts new file mode 100644 index 0000000000..0fb4f04484 --- /dev/null +++ b/skymp5-client/src/services/messages/spSnippetMessage.ts @@ -0,0 +1,5 @@ +import { Snippet } from "../../spSnippet"; + +export type SpSnippetMessage = { + type: "spSnippet"; +} & Snippet; diff --git a/skymp5-client/src/services/messages/teleportMessage2.ts b/skymp5-client/src/services/messages/teleportMessage2.ts new file mode 100644 index 0000000000..a34a3eee6d --- /dev/null +++ b/skymp5-client/src/services/messages/teleportMessage2.ts @@ -0,0 +1,6 @@ +export interface TeleportMessage2 { + type: "teleport"; + pos: number[]; + rot: number[]; + worldOrCell: number; +} diff --git a/skymp5-client/src/services/messages/updateGameModeDataMessage.ts b/skymp5-client/src/services/messages/updateGameModeDataMessage.ts new file mode 100644 index 0000000000..8c7138debe --- /dev/null +++ b/skymp5-client/src/services/messages/updateGameModeDataMessage.ts @@ -0,0 +1,6 @@ +export interface UpdateGamemodeDataMessage { + type: "updateGamemodeData"; + eventSources: Record; + updateOwnerFunctions: Record; + updateNeighborFunctions: Record; +} diff --git a/skymp5-client/src/services/messages/updatePropertyMessage.ts b/skymp5-client/src/services/messages/updatePropertyMessage.ts new file mode 100644 index 0000000000..8604f288e6 --- /dev/null +++ b/skymp5-client/src/services/messages/updatePropertyMessage.ts @@ -0,0 +1,10 @@ +import { MsgType } from "../../messages"; + +export interface UpdatePropertyMessage { + t: MsgType.UpdateProperty; + idx: number; + refrId: number; + baseRecordType: string; // DOOR, ACTI, etc + data: unknown; + propName: string; +} diff --git a/skymp5-client/src/services/services/clientListener.ts b/skymp5-client/src/services/services/clientListener.ts index c5c011b277..8591283c65 100644 --- a/skymp5-client/src/services/services/clientListener.ts +++ b/skymp5-client/src/services/services/clientListener.ts @@ -1,5 +1,5 @@ import * as sp from "skyrimPlatform"; -import { EventEmitterType } from "../events/eventEmitterFactory"; +import { EventEmitterType } from "../events/events"; export interface ClientListenerEvents { on: typeof sp.on, diff --git a/skymp5-client/src/services/services/containersService.ts b/skymp5-client/src/services/services/containersService.ts index 0bf1e8134d..9cdf4c6833 100644 --- a/skymp5-client/src/services/services/containersService.ts +++ b/skymp5-client/src/services/services/containersService.ts @@ -1,7 +1,7 @@ import { Actor, ContainerChangedEvent, printConsole } from "skyrimPlatform"; import { ClientListener, CombinedController, Sp } from "./clientListener"; import { MsgType } from "../../messages"; -import { getPcInventory } from "../../modelSource/remoteServer"; +import { getPcInventory } from "./remoteServer"; import { getInventory, getDiff, hasExtras, removeSimpleItemsAsManyAsPossible, sumInventories } from "../../sync/inventory"; import { LastInvService } from "./lastInvService"; diff --git a/skymp5-client/src/services/services/loadGameService.ts b/skymp5-client/src/services/services/loadGameService.ts index 08b008e059..efbeaf489f 100644 --- a/skymp5-client/src/services/services/loadGameService.ts +++ b/skymp5-client/src/services/services/loadGameService.ts @@ -7,8 +7,8 @@ export class LoadGameService extends ClientListener { this.controller.on("loadGame", () => this.onLoadGame()); } - public loadGame(pos: number[], rot: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc) { - this.sp.loadGame(pos, rot, worldOrCell, changeFormNpc); + public loadGame(pos: number[], rot: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc, loadOrder?: string[], time?: { seconds: number, minutes: number, hours: number }) { + this.sp.loadGame(pos, rot, worldOrCell, changeFormNpc, loadOrder, time); this._isCausedBySkyrimPlatform = true; } diff --git a/skymp5-client/src/services/services/networkingService.ts b/skymp5-client/src/services/services/networkingService.ts new file mode 100644 index 0000000000..6ac3cfb2b6 --- /dev/null +++ b/skymp5-client/src/services/services/networkingService.ts @@ -0,0 +1,197 @@ +import { NetInfo } from "../../debug/netInfoSystem"; +import { NeverError } from "../../lib/errors"; +import { MsgType } from "../../messages"; +import { SendMessageEvent } from "../events/sendMessageEvent"; +import { SendMessageWithRefrIdEvent } from "../events/sendMessageWithRefrIdEvent"; +import { AnyMessage } from "../messages/anyMessage"; +import { ClientListener, CombinedController, Sp } from "./clientListener"; +import { RemoteServer } from "./remoteServer"; + +export class NetworkingService extends ClientListener { + constructor(private sp: Sp, private controller: CombinedController) { + super(); + this.controller.on("tick", () => this.onTick()); + + this.controller.emitter.on("sendMessage", (e) => this.onSendMessage(e)); + this.controller.emitter.on("sendMessageWithRefrId", (e) => this.onSendMessageWithRefrId(e)); + } + + private onSendMessage(e: SendMessageEvent) { + this.sp.mpClientPlugin.send(JSON.stringify(e.message), this.isReliable(e.reliability)); + } + + private onSendMessageWithRefrId(e: SendMessageWithRefrIdEvent) { + const refrId = e.message._refrId; + + const remoteServer = this.controller.lookupListener(RemoteServer); + + const idxInModel = refrId + ? remoteServer.getWorldModel().forms.findIndex((f) => f && f.refrId === refrId) + : remoteServer.getWorldModel().playerCharacterFormIdx; + + // fixes "can't get property idx of null or undefined" + if (!remoteServer.getWorldModel().forms[idxInModel]) return; + + // @ts-ignore + e.message.idx = remoteServer.getWorldModel().forms[idxInModel].idx; + + delete e.message._refrId; + + // TODO: NetInfo should subscribe itself instead of incrementing here + NetInfo.addSentPacketCount(1); + + this.sp.mpClientPlugin.send(JSON.stringify(e.message), this.isReliable(e.reliability)); + } + + connect(hostName: string, port: number) { + this.serverAddress = { hostName, port }; + this.createClientSafe(); + } + + reconnect() { + this.createClientSafe(); + } + + close() { + this.sp.mpClientPlugin.destroyClient(); + } + + send(msg: Record, reliable: boolean) { + // TODO(#175): JS object instead of JSON? + this.sp.mpClientPlugin.send(JSON.stringify(msg), reliable); + } + + private onTick() { + this.sp.mpClientPlugin.tick((packetType, jsonContent, error) => { + switch (packetType) { + case "connectionAccepted": + this.controller.emitter.emit("connectionAccepted", {}); + break; + case "connectionDenied": + this.controller.emitter.emit("connectionDenied", { error }); + this.reconnect(); + break; + case "connectionFailed": + this.controller.emitter.emit("connectionFailed", {}); + this.reconnect(); + break; + case "disconnect": + this.controller.emitter.emit("connectionDisconnect", {}); + this.reconnect(); + break; + case "message": + // TODO: NetInfo should subscribe that event instead of calling this method here + NetInfo.addReceivedPacketCount(1); + + // TODO: in theory can be empty jsonContent and non-empty error + const msgAny: AnyMessage = JSON.parse(jsonContent); + + if ("type" in msgAny) { + if (msgAny.type === "createActor") { + this.controller.emitter.emit("createActorMessage", { message: msgAny }) + } + else if (msgAny.type === "customPacket") { + this.controller.emitter.emit("customPacketMessage2", { message: msgAny }) + } + else if (msgAny.type === "destroyActor") { + this.controller.emitter.emit("destroyActorMessage", { message: msgAny }) + } + else if (msgAny.type === "hostStart") { + this.controller.emitter.emit("hostStartMessage", { message: msgAny }); + } + else if (msgAny.type === "hostStop") { + this.controller.emitter.emit("hostStopMessage", { message: msgAny }); + } + else if (msgAny.type === "setInventory") { + this.controller.emitter.emit("setInventoryMessage", { message: msgAny }); + } + else if (msgAny.type === "setRaceMenuOpen") { + this.controller.emitter.emit("setRaceMenuOpenMessage", { message: msgAny }); + } + else if (msgAny.type === "spSnippet") { + this.controller.emitter.emit("spSnippetMessage", { message: msgAny }); + } + else if (msgAny.type === "updateGamemodeData") { + this.controller.emitter.emit("updateGamemodeDataMessage", { message: msgAny }); + } + else if (msgAny.type === "teleport") { + this.controller.emitter.emit("teleportMessage2", { message: msgAny }); + } + else { + throw new NeverError(msgAny); + } + } + else { + if (msgAny.t === MsgType.OpenContainer) { + this.controller.emitter.emit("openContainerMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.UpdateMovement) { + this.controller.emitter.emit("updateMovementMessage", { message: msgAny }) + } + else if (msgAny.t === MsgType.UpdateAnimation) { + this.controller.emitter.emit("updateAnimationMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.UpdateEquipment) { + this.controller.emitter.emit("updateEquipmentMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.ChangeValues) { + this.controller.emitter.emit("changeValuesMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.UpdateAppearance) { + this.controller.emitter.emit("updateAppearanceMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.Teleport) { + this.controller.emitter.emit("teleportMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.UpdateProperty) { + this.controller.emitter.emit("updatePropertyMessage", { message: msgAny }); + } + else if (msgAny.t === MsgType.DeathStateContainer) { + this.controller.emitter.emit("deathStateContainerMessage", { message: msgAny }); + } + // todo: never error + } + break; + } + }); + } + + private createClientSafe() { + const { hostName, port } = this.serverAddress; + + this.logTrace("createClientSafe " + hostName + ":" + port); + + if (this.serverAddress.hostName !== "" && this.serverAddress.port !== 0) { + this.sp.mpClientPlugin.createClient(hostName, port); + } + else { + this.logError("createClientSafe failed"); + } + } + + private get serverAddress(): { hostName: string, port: number } { + const res: unknown = this.sp.storage["serverAddress"]; + if (typeof res === "object") { + const result = res as { hostName: string, port: number }; + if (typeof result.hostName === "string" && typeof result.port === "number") { + return result; + } + } + return { hostName: "", port: 0 }; + } + + private set serverAddress(newValue: { hostName: string, port: number }) { + this.sp.storage["serverAddress"] = newValue; + } + + private isReliable(reliability: "reliable" | "unreliable") { + switch (reliability) { + case "reliable": + return true; + case "unreliable": + return false; + default: + throw new NeverError(reliability); + } + } +}; diff --git a/skymp5-client/src/modelSource/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts similarity index 65% rename from skymp5-client/src/modelSource/remoteServer.ts rename to skymp5-client/src/services/services/remoteServer.ts index 996453ee2e..809bee75dd 100644 --- a/skymp5-client/src/modelSource/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -5,61 +5,71 @@ import { Cell, Game, ObjectReference, - Spell, TESModPlatform, Ui, Utility, WorldSpace, - browser, on, once, printConsole, storage, } from 'skyrimPlatform'; -import * as netInfo from '../debug/netInfoSystem'; -import * as updateOwner from '../gamemodeApi/updateOwner'; -import * as messages from '../messages'; +import * as updateOwner from '../../gamemodeApi/updateOwner'; +import * as messages from '../../messages'; /* eslint-disable @typescript-eslint/no-empty-function */ -import * as networking from '../networking'; -import * as spSnippet from '../spSnippet'; -import { ObjectReferenceEx } from '../extensions/objectReferenceEx'; -import { AuthGameData } from '../features/authModel'; -import { IdManager } from '../lib/idManager'; -import { nameof } from '../lib/nameof'; -import { setActorValuePercentage } from '../sync/actorvalues'; -import { applyAppearanceToPlayer } from '../sync/appearance'; -import { isBadMenuShown } from '../sync/equipment'; -import { Inventory, applyInventory } from '../sync/inventory'; -import { Movement } from '../sync/movement'; -import { learnSpells, removeAllSpells } from '../sync/spell'; -import { ModelApplyUtils } from '../view/modelApplyUtils'; +import * as networking from './networkingService'; +import * as spSnippet from '../../spSnippet'; +import { ObjectReferenceEx } from '../../extensions/objectReferenceEx'; +import { AuthGameData } from '../../features/authModel'; +import { IdManager } from '../../lib/idManager'; +import { nameof } from '../../lib/nameof'; +import { setActorValuePercentage } from '../../sync/actorvalues'; +import { applyAppearanceToPlayer } from '../../sync/appearance'; +import { isBadMenuShown } from '../../sync/equipment'; +import { Inventory, applyInventory } from '../../sync/inventory'; +import { Movement } from '../../sync/movement'; +import { learnSpells, removeAllSpells } from '../../sync/spell'; +import { ModelApplyUtils } from '../../view/modelApplyUtils'; import { getObjectReference, getViewFromStorage, localIdToRemoteId, remoteIdToLocalId, -} from '../view/worldViewMisc'; -import { FormModel, WorldModel } from './model'; -import { ModelSource } from './modelSource'; -import { MsgHandler } from './msgHandler'; -import { SendTarget } from './sendTarget'; -import { SpApiInteractor } from '../services/spApiInteractor'; -import { LoadGameService } from '../services/services/loadGameService'; -import { UpdateMovementMessage } from '../services/messages/updateMovementMessage'; -import { ChangeValuesMessage } from '../services/messages/changeValues'; -import { UpdateAnimationMessage } from '../services/messages/updateAnimationMessage'; -import { UpdateEquipmentMessage } from '../services/messages/updateEquipmentMessage'; -import { CustomPacketMessage } from '../services/messages/customPacketMessage'; -import { CustomEventMessage } from '../services/messages/customEventMessage'; -import { FinishSpSnippetMessage } from '../services/messages/finishSpSnippetMessage'; -import { RagdollService } from '../services/services/ragdollService'; -import { UpdateAppearanceMessage } from '../services/messages/updateAppearanceMessage'; -import { TeleportMessage } from '../services/messages/teleportMessage'; -import { DeathStateContainerMessage } from '../services/messages/deathStateContainerMessage'; -import { RespawnNeededError } from '../lib/errors'; -import { OpenContainer } from '../services/messages/openContainer'; +} from '../../view/worldViewMisc'; +import { FormModel, WorldModel } from '../../modelSource/model'; +import { ModelSource } from '../../modelSource/modelSource'; +import { SpApiInteractor } from '../spApiInteractor'; +import { LoadGameService } from './loadGameService'; +import { UpdateMovementMessage } from '../messages/updateMovementMessage'; +import { ChangeValuesMessage } from '../messages/changeValues'; +import { UpdateAnimationMessage } from '../messages/updateAnimationMessage'; +import { UpdateEquipmentMessage } from '../messages/updateEquipmentMessage'; +import { CustomPacketMessage } from '../messages/customPacketMessage'; +import { CustomEventMessage } from '../messages/customEventMessage'; +import { FinishSpSnippetMessage } from '../messages/finishSpSnippetMessage'; +import { RagdollService } from './ragdollService'; +import { UpdateAppearanceMessage } from '../messages/updateAppearanceMessage'; +import { TeleportMessage } from '../messages/teleportMessage'; +import { DeathStateContainerMessage } from '../messages/deathStateContainerMessage'; +import { RespawnNeededError } from '../../lib/errors'; +import { OpenContainerMessage } from '../messages/openContainerMessage'; +import { NetworkingService } from './networkingService'; +import { ActivateMessage } from '../messages/activateMessage'; +import { ClientListener, CombinedController, Sp } from './clientListener'; +import { HostStartMessage } from '../messages/hostStartMessage'; +import { HostStopMessage } from '../messages/hostStopMessage'; +import { ConnectionMessage } from '../events/connectionMessage'; +import { SetInventoryMessage } from '../messages/setInventoryMessage'; +import { CreateActorMessage } from '../messages/createActorMessage'; +import { UpdateGamemodeDataMessage } from '../messages/updateGameModeDataMessage'; +import { SpSnippetMessage } from '../messages/spSnippetMessage'; +import { CustomPacketMessage2 } from '../messages/customPacketMessage2'; +import { DestroyActorMessage } from '../messages/destroyActorMessage'; +import { SetRaceMenuOpenMessage } from '../messages/setRaceMenuOpenMessage'; +import { UpdatePropertyMessage } from '../messages/updatePropertyMessage'; +import { TeleportMessage2 } from '../messages/teleportMessage2'; const onceLoad = ( refrId: number, @@ -82,7 +92,7 @@ const onceLoad = ( }; const skipFormViewCreation = ( - msg: messages.UpdatePropertyMessage | messages.CreateActorMessage, + msg: UpdatePropertyMessage | CreateActorMessage, ) => { // Optimization added in #1186, however it doesn't work for doors for some reason return msg.refrId && msg.refrId < 0xff000000 && msg.baseRecordType !== 'DOOR'; @@ -132,7 +142,7 @@ on('tick', () => { if (loggingStartMoment && Date.now() - loggingStartMoment > maxLoggingDelay) { printConsole('Logging in failed. Reconnecting.'); showConnectionError(); - networking.reconnect(); + SpApiInteractor.makeController().lookupListener(NetworkingService).reconnect(); loggingStartMoment = 0; } }); @@ -157,24 +167,27 @@ const loginWithSkympIoCredentials = () => { }, }, }; - // TODO: emit event instead of sending directly to avoid type cast and dependency on network module - networking.send(message as unknown as Record, true); + SpApiInteractor.makeController().emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); return; } if (authData?.remote) { printConsole('Logging in as skymp.io user'); - networking.send( - { - t: messages.MsgType.CustomPacket, - content: { - customPacketType: 'loginWithSkympIo', - gameData: { - session: authData.remote.session, - }, + const message: CustomPacketMessage = { + t: messages.MsgType.CustomPacket, + content: { + customPacketType: 'loginWithSkympIo', + gameData: { + session: authData.remote.session, }, }, - true, - ); + }; + SpApiInteractor.makeController().emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); return; } @@ -209,40 +222,102 @@ const unequipIronHelmet = () => { if (pl) pl.unequipItem(ironHelment, false, true); }; -export class RemoteServer implements MsgHandler, ModelSource, SendTarget { - setInventory(msg: messages.SetInventory): void { +export class RemoteServer extends ClientListener implements ModelSource { + constructor(private sp: Sp, private controller: CombinedController) { + super(); + + this.controller.emitter.on("hostStartMessage", (e) => this.onHostStartMessage(e)); + this.controller.emitter.on("hostStopMessage", (e) => this.onHostStopMessage(e)); + this.controller.emitter.on("setInventoryMessage", (e) => this.onSetInventoryMessage(e)); + this.controller.emitter.on("openContainerMessage", (e) => this.onOpenContainerMessage(e)); + this.controller.emitter.on("updateMovementMessage", (e) => this.onUpdateMovementMessage(e)); + this.controller.emitter.on("updateAnimationMessage", (e) => this.onUpdateAnimationMessage(e)); + this.controller.emitter.on("updateEquipmentMessage", (e) => this.onUpdateEquipmentMessage(e)); + this.controller.emitter.on("changeValuesMessage", (e) => this.onChangeValuesMessage(e)); + this.controller.emitter.on("updateAppearanceMessage", (e) => this.onUpdateAppearanceMessage(e)); + this.controller.emitter.on("teleportMessage", (e) => this.onTeleportMessage(e)); + this.controller.emitter.on("teleportMessage2", (e) => this.onTeleportMessage(e)); + this.controller.emitter.on("setInventoryMessage", (e) => this.onSetInventoryMessage(e)); + this.controller.emitter.on("createActorMessage", (e) => this.onCreateActorMessage(e)); + this.controller.emitter.on("customPacketMessage2", (e) => this.onCustomPacketMessage2(e)); + this.controller.emitter.on("destroyActorMessage", (e) => this.onDestroyActorMessage(e)); + this.controller.emitter.on("setRaceMenuOpenMessage", (e) => this.onSetRaceMenuOpenMessage(e)); + this.controller.emitter.on("spSnippetMessage", (e) => this.onSpSnippetMessage(e)); + this.controller.emitter.on("updateGamemodeDataMessage", (e) => this.onUpdateGamemodeDataMessage(e)); + this.controller.emitter.on("updatePropertyMessage", (e) => this.onUpdatePropertyMessage(e)); + this.controller.emitter.on("deathStateContainerMessage", (e) => this.onDeathStateContainerMessage(e)); + + this.controller.emitter.on("connectionAccepted", () => this.handleConnectionAccepted()); + } + + private onHostStartMessage(event: ConnectionMessage) { + const msg = event.message; + const target = msg.target; + + let hosted = storage['hosted']; + if (typeof hosted !== typeof []) { + // if you try to switch to Set please checkout .concat usage. + // concat compiles but doesn't work as expected + hosted = new Array(); + storage['hosted'] = hosted; + } + + if (!(hosted as Array).includes(target)) { + (hosted as Array).push(target); + } + } + + private onHostStopMessage(event: ConnectionMessage) { + const msg = event.message; + const target = msg.target; + this.logTrace('hostStop ' + target.toString(16)); + + const hosted = storage['hosted'] as Array; + if (typeof hosted === typeof []) { + storage['hosted'] = hosted.filter((x) => x !== target); + } + } + + private onSetInventoryMessage(event: ConnectionMessage): void { + const msg = event.message; once('update', () => { setPcInventory(msg.inventory); pcInvLastApply = 0; }); } - OpenContainer(msg: OpenContainer): void { + private onOpenContainerMessage(event: ConnectionMessage): void { once('update', async () => { await Utility.wait(0.1); // Give a chance to update inventory ( - ObjectReference.from(Game.getFormEx(msg.target)) as ObjectReference + ObjectReference.from(Game.getFormEx(event.message.target)) as ObjectReference ).activate(Game.getPlayer(), true); (async () => { while (!Ui.isMenuOpen('ContainerMenu')) await Utility.wait(0.1); while (Ui.isMenuOpen('ContainerMenu')) await Utility.wait(0.1); - networking.send( - { - t: messages.MsgType.Activate, - data: { caster: 0x14, target: msg.target }, - }, - true, - ); + + const message: ActivateMessage = { + t: messages.MsgType.Activate, + data: { + caster: 0x14, target: event.message.target + } + }; + + SpApiInteractor.makeController().emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); })(); }); } - Teleport(msg: TeleportMessage): void { + private onTeleportMessage(event: ConnectionMessage | ConnectionMessage): void { + const msg = event.message; once('update', () => { - const id = this.getIdManager().getId(msg.idx); + const id = ("idx" in msg && typeof msg.idx === "number") ? this.getIdManager().getId(msg.idx) : this.getMyActorIndex(); const refr = id === this.getMyActorIndex() ? Game.getPlayer() : getObjectReference(id); printConsole( - `Teleporting (idx=${msg.idx}) ${refr?.getFormID().toString(16)}...`, + `Teleporting (id=${id}) ${refr?.getFormID().toString(16)}...`, msg.pos, 'cell/world is', msg.worldOrCell.toString(16), @@ -274,7 +349,8 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { }); } - createActor(msg: messages.CreateActorMessage): void { + private onCreateActorMessage(event: ConnectionMessage): void { + const msg = event.message; if (skipFormViewCreation(msg)) { const refrId = msg.refrId!; onceLoad(refrId, (refr: ObjectReference) => { @@ -294,7 +370,7 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { const refrid = refr.getFormID(); (async () => { - for (let i = 0; i < 5; i ++) { + for (let i = 0; i < 5; i++) { // retry. pillars in bleakfalls are not reliable for some reason let res2 = ObjectReference.from(Game.getFormEx(refrid))?.playAnimation(animation); if (res2) break; @@ -310,6 +386,8 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { return; } + printConsole("Create actor") + loggingStartMoment = 0; const i = this.getIdManager().allocateIdFor(msg.idx); @@ -371,7 +449,7 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { } if (msg.props && msg.props.isRaceMenuOpen && msg.isMe) - this.setRaceMenuOpen({ type: 'setRaceMenuOpen', open: true }); + this.onSetRaceMenuOpenMessage({ message: { type: 'setRaceMenuOpen', open: true } }); const applyPcInv = () => { applyInventory( @@ -386,9 +464,11 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { false, ); if (msg.props && msg.props.inventory) - this.setInventory({ - type: 'setInventory', - inventory: (msg.props as any).inventory as Inventory, + this.onSetInventoryMessage({ + message: { + type: 'setInventory', + inventory: (msg.props as any).inventory as Inventory, + } }); }; @@ -413,6 +493,8 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { if (msg.isMe) { const task = new SpawnTask(); once('update', () => { + // Use MoveRefrToPosition to spawn if possible (not in main menu) + // In case of connection lost this is essential if (!task.running) { task.running = true; printConsole('Using moveRefrToPosition to spawn player'); @@ -498,14 +580,14 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { once('tick', () => { if (!task.running) { task.running = true; - printConsole('Using loadGame to spawn player'); - printConsole( - 'skinColorFromServer:', - msg.appearance - ? msg.appearance.skinColor.toString(16) - : undefined, - ); - const loadGameService = SpApiInteractor.makeController().lookupListener(LoadGameService); + + let loadOrder = new Array(); + for (let i = 0; i < this.sp.Game.getModCount(); ++i) { + loadOrder.push(this.sp.Game.getModName(i)); + } + + this.logTrace(`loading game in world/cell ${msg.transform.worldOrCell.toString(16)}`); + const loadGameService = this.controller.lookupListener(LoadGameService); loadGameService.loadGame( msg.transform.pos, msg.transform.rot, @@ -523,6 +605,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { }, } : undefined, + loadOrder, + // TODO: unhardcode time + { minutes: 0, seconds: 0, hours: 12 } ); once('update', () => { applyPcInv(); @@ -541,7 +626,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { } } - destroyActor(msg: messages.DestroyActorMessage): void { + private onDestroyActorMessage(event: ConnectionMessage): void { + const msg = event.message; + const i = this.getIdManager().getId(msg.idx); this.worldModel.forms[i] = null as unknown as FormModel; getViewFromStorage()?.syncFormArray(this.worldModel); @@ -564,7 +651,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { this.getIdManager().freeIdFor(msg.idx); } - UpdateMovement(msg: UpdateMovementMessage): void { + private onUpdateMovementMessage(event: ConnectionMessage): void { + const msg = event.message; + const i = this.getIdManager().getId(msg.idx); this.worldModel.forms[i].movement = msg.data; if (!this.worldModel.forms[i].numMovementChanges) { @@ -573,12 +662,16 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { (this.worldModel.forms[i].numMovementChanges as number)++; } - UpdateAnimation(msg: UpdateAnimationMessage): void { + private onUpdateAnimationMessage(event: ConnectionMessage): void { + const msg = event.message; + const i = this.getIdManager().getId(msg.idx); this.worldModel.forms[i].animation = msg.data; } - UpdateAppearance(msg: UpdateAppearanceMessage): void { + private onUpdateAppearanceMessage(event: ConnectionMessage): void { + const msg = event.message; + const i = this.getIdManager().getId(msg.idx); this.worldModel.forms[i].appearance = msg.data; if (!this.worldModel.forms[i].numAppearanceChanges) { @@ -587,12 +680,16 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { (this.worldModel.forms[i].numAppearanceChanges as number)++; } - UpdateEquipment(msg: UpdateEquipmentMessage): void { + private onUpdateEquipmentMessage(event: ConnectionMessage): void { + const msg = event.message; + const i = this.getIdManager().getId(msg.idx); this.worldModel.forms[i].equipment = msg.data; } - UpdateProperty(msg: messages.UpdatePropertyMessage): void { + private onUpdatePropertyMessage(event: ConnectionMessage): void { + const msg = event.message; + if (skipFormViewCreation(msg)) { const refrId = msg.refrId; once('update', () => { @@ -616,7 +713,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { (form as Record)[msg.propName] = msg.data; } - DeathStateContainer(msg: DeathStateContainerMessage): void { + private onDeathStateContainerMessage(event: ConnectionMessage): void { + const msg = event.message; + once('update', () => printConsole(`Received death state: ${JSON.stringify(msg.tIsDead)}`), ); @@ -627,12 +726,12 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { return; if (msg.tChangeValues) { - this.ChangeValues(msg.tChangeValues); + this.onChangeValuesMessage({ message: msg.tChangeValues }); } - once('update', () => this.UpdateProperty(msg.tIsDead)); + once('update', () => this.onUpdatePropertyMessage({ message: msg.tIsDead })); if (msg.tTeleport) { - this.Teleport(msg.tTeleport); + this.onTeleportMessage({ message: msg.tTeleport }); } const id = this.getIdManager().getId(msg.tIsDead.idx); @@ -660,16 +759,18 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { }); } - handleConnectionAccepted(): void { + private handleConnectionAccepted(): void { this.worldModel.forms = []; this.worldModel.playerCharacterFormIdx = -1; + this.logTrace("Handle connection accepted"); + loginWithSkympIoCredentials(); } - handleDisconnect(): void { } + private onChangeValuesMessage(event: ConnectionMessage): void { + const msg = event.message; - ChangeValues(msg: ChangeValuesMessage): void { once('update', () => { const id = this.getIdManager().getId(msg.idx); const refr = id === this.getMyActorIndex() ? Game.getPlayer() : getObjectReference(id); @@ -681,7 +782,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { }); } - setRaceMenuOpen(msg: messages.SetRaceMenuOpenMessage): void { + private onSetRaceMenuOpenMessage(event: ConnectionMessage): void { + const msg = event.message; + if (msg.open) { // wait 0.3s cause we can see visual bugs when teleporting // and showing this menu at the same time in onConnect @@ -696,7 +799,10 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { } } - customPacket(msg: messages.CustomPacket): void { + private onCustomPacketMessage2(event: ConnectionMessage): void { + const msg = event.message; + + printConsole("LOGIN REQUIRED"); switch (msg.content.customPacketType) { case 'loginRequired': loginWithSkympIoCredentials(); @@ -704,7 +810,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { } } - spSnippet(msg: messages.SpSnippet): void { + private onSpSnippetMessage(event: ConnectionMessage): void { + const msg = event.message; + once('update', async () => { spSnippet .run(msg) @@ -715,8 +823,11 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { returnValue: res, snippetIdx: msg.snippetIdx, } - // TODO: emit event instead of sending directly to avoid type cast and dependency on network module - this.send(message as unknown as Record, true); + + SpApiInteractor.makeController().emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); }) .catch((e) => printConsole('!!! SpSnippet ' + msg.class + ' ' + msg.function + ' failed', e)); }); @@ -747,7 +858,9 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { storage[`${storageVar}_keys`] = Object.keys(storage[storageVar] as any); } - updateGamemodeData(msg: messages.UpdateGamemodeDataMessage): void { + private onUpdateGamemodeDataMessage(event: ConnectionMessage): void { + const msg = event.message; + // // updateOwnerFunctions/updateNeighborFunctions // @@ -786,8 +899,10 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { args, eventName }; - // TODO: emit event instead of sending directly to avoid type cast and dependency on network module - this.send(message as unknown as Record, true); + SpApiInteractor.makeController().emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); }, getFormIdInServerFormat: (clientsideFormId: number) => { return localIdToRemoteId(clientsideFormId); @@ -817,28 +932,22 @@ export class RemoteServer implements MsgHandler, ModelSource, SendTarget { return this.worldModel.playerCharacterFormIdx; } - send(msg: Record, reliable: boolean): void { - if (this.worldModel.playerCharacterFormIdx === -1) return; - - const refrId = msg._refrId as number | undefined; - - const idxInModel = refrId - ? this.worldModel.forms.findIndex((f) => f && f.refrId === refrId) - : this.worldModel.playerCharacterFormIdx; - // fixes "can't get property idx of null or undefined" - if (!this.worldModel.forms[idxInModel]) return; - msg.idx = this.worldModel.forms[idxInModel].idx; - - delete msg._refrId; - netInfo.NetInfo.addSentPacketCount(1); - networking.send(msg, reliable); - } - private getIdManager() { - if (!this.idManager_) this.idManager_ = new IdManager(); return this.idManager_; } - private worldModel: WorldModel = { forms: [], playerCharacterFormIdx: -1 }; - private idManager_ = new IdManager(); + private get worldModel(): WorldModel { + if (typeof storage["worldModel"] === "function") { + storage["worldModel"] = { forms: [], playerCharacterFormIdx: -1 }; + } + return storage["worldModel"] as WorldModel; + } + + private get idManager_(): IdManager { + if (typeof storage["idManager"] === "function") { + // Note: full IdManager object preserved across hot-reloads, including methods. + storage["idManager"] = new IdManager(); + } + return storage["idManager"] as IdManager; + } } diff --git a/skymp5-client/src/services/services/sendInputsService.ts b/skymp5-client/src/services/services/sendInputsService.ts index a378cc0592..55d7f6cb67 100644 --- a/skymp5-client/src/services/services/sendInputsService.ts +++ b/skymp5-client/src/services/services/sendInputsService.ts @@ -20,6 +20,7 @@ import { ChangeValuesMessage } from "../messages/changeValues"; import { UpdateAnimationMessage } from "../messages/updateAnimationMessage"; import { UpdateEquipmentMessage } from "../messages/updateEquipmentMessage"; import { UpdateAppearanceMessage } from "../messages/updateAppearanceMessage"; +import { RemoteServer } from "./remoteServer"; const playerFormId = 0x14; @@ -75,11 +76,7 @@ export class SendInputsService extends ClientListener { typeof this.sp.storage['hosted'] === typeof [] ? this.sp.storage['hosted'] : []; const targets = [undefined].concat(hosted as any); - const skympClient = this.controller.lookupListener(SkympClient); - const modelSource = skympClient.modelSource; - if (!modelSource) { - return; - } + const modelSource = this.controller.lookupListener(RemoteServer); const world = modelSource.getWorldModel(); diff --git a/skymp5-client/src/services/services/sendMessagesService.ts b/skymp5-client/src/services/services/sendMessagesService.ts deleted file mode 100644 index b1887ba160..0000000000 --- a/skymp5-client/src/services/services/sendMessagesService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NeverError } from "../../lib/errors"; -import { SendMessageEvent } from "../events/sendMessageEvent"; -import { SendMessageWithRefrIdEvent } from "../events/sendMessageWithRefrIdEvent"; -import { AnyMessage } from "../messages/anyMessage"; -import { ClientListener, CombinedController, Sp } from "./clientListener"; -import { SkympClient } from "./skympClient"; - -export class SendMessagesService extends ClientListener{ - constructor(private sp: Sp, private controller: CombinedController) { - super(); - controller.emitter.on("sendMessage", (e) => this.onSendMessage(e)); - controller.emitter.on("sendMessageWithRefrId", (e) => this.onSendMessageWithRefrId(e)); - } - - private onSendMessage(e: SendMessageEvent) { - const skympClient = this.controller.lookupListener(SkympClient); - skympClient.sendTarget?.send(e.message as any, this.isReliable(e.reliability)); - } - - private onSendMessageWithRefrId(e: SendMessageWithRefrIdEvent) { - // Right now sendTarget.send itself handles _refrId - // So the code is the same as for onSendMessage - const skympClient = this.controller.lookupListener(SkympClient); - skympClient.sendTarget?.send(e.message as any, this.isReliable(e.reliability)); - } - - private isReliable(reliability: "reliable" | "unreliable") { - switch (reliability) { - case "reliable": - return true; - case "unreliable": - return false; - default: - throw new NeverError(reliability); - } - } -} diff --git a/skymp5-client/src/services/services/singlePlayerService.ts b/skymp5-client/src/services/services/singlePlayerService.ts index 27e4aa0af0..6c5e560f0c 100644 --- a/skymp5-client/src/services/services/singlePlayerService.ts +++ b/skymp5-client/src/services/services/singlePlayerService.ts @@ -1,6 +1,6 @@ import { ClientListener, CombinedController, Sp } from "./clientListener"; -import * as networking from "../../networking"; import { GameLoadEvent } from "../events/gameLoadEvent"; +import { NetworkingService } from "./networkingService"; export class SinglePlayerService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { @@ -17,7 +17,7 @@ export class SinglePlayerService extends ClientListener { this.sp.Debug.messageBox( 'Save has been loaded in multiplayer, switching to the single-player mode', ); - networking.close(); + this.controller.lookupListener(NetworkingService).close(); this._isSinglePlayer = true; this.sp.Game.setInChargen(false, false, false); } diff --git a/skymp5-client/src/services/services/skympClient.ts b/skymp5-client/src/services/services/skympClient.ts index c3f7bf403b..26d2e0a4ad 100644 --- a/skymp5-client/src/services/services/skympClient.ts +++ b/skymp5-client/src/services/services/skympClient.ts @@ -1,4 +1,3 @@ -import * as sp from 'skyrimPlatform'; import { on, once, @@ -8,12 +7,8 @@ import { } from 'skyrimPlatform'; import * as netInfo from '../../debug/netInfoSystem'; import * as updateOwner from '../../gamemodeApi/updateOwner'; -import * as networking from '../../networking'; -import { HostStartMessage, HostStopMessage, MsgType } from '../../messages'; -import { ModelSource } from '../../modelSource/modelSource'; -import { MsgHandler } from '../../modelSource/msgHandler'; -import { RemoteServer } from '../../modelSource/remoteServer'; -import { SendTarget } from '../../modelSource/sendTarget'; +import * as networking from './networkingService'; +import { RemoteServer } from './remoteServer'; import { setupHooks } from '../../sync/animation'; import * as animDebugSystem from '../../debug/animDebugSystem'; import { WorldView } from '../../view/worldView'; @@ -21,53 +16,12 @@ import { SinglePlayerService } from './singlePlayerService'; import * as authSystem from "../../features/authSystem"; import * as playerCombatSystem from "../../sweetpie/playerCombatSystem"; import { AuthGameData } from '../../features/authModel'; -import { Transform } from '../../sync/movement'; import * as browser from "../../features/browser"; import { ClientListener, CombinedController, Sp } from './clientListener'; - -interface AnyMessage { - type?: string; - t?: number; -} - -const handleMessage = (msgAny: AnyMessage, handler_: MsgHandler) => { - const msgType: string = msgAny.type || (MsgType as any)[msgAny.t as any]; - const handler = handler_ as unknown as Record< - string, - (m: AnyMessage) => void - >; - const f = handler[msgType]; - - if (msgType === 'hostStart') { - const msg = msgAny as HostStartMessage; - const target = msg.target; - - let hosted = storage['hosted']; - if (typeof hosted !== typeof []) { - // if you try to switch to Set checkout .concat usage. - // concat compiles but doesn't work as expected - hosted = new Array(); - storage['hosted'] = hosted; - } - - if (!(hosted as Array).includes(target)) { - (hosted as Array).push(target); - } - } - - if (msgType === 'hostStop') { - const msg = msgAny as HostStopMessage; - const target = msg.target; - printConsole('hostStop', target.toString(16)); - - const hosted = storage['hosted'] as Array; - if (typeof hosted === typeof []) { - storage['hosted'] = hosted.filter((x) => x !== target); - } - } - - if (f && typeof f === 'function') handler[msgType](msgAny); -}; +import { ConnectionFailed } from '../events/connectionFailed'; +import { ConnectionDenied } from '../events/connectionDenied'; +import { ConnectionMessage } from '../events/connectionMessage'; +import { CreateActorMessage } from '../messages/createActorMessage'; printConsole('Hello Multiplayer!'); printConsole('settings:', settings['skymp5-client']); @@ -83,22 +37,15 @@ export const getServerUiPort = () => { return targetPort === 7777 ? 3000 : (targetPort as number) + 1; }; -export const connectWhenICallAndNotWhenIImport = (): void => { - if (storage.targetIp !== targetIp || storage.targetPort !== targetPort) { - storage.targetIp = targetIp; - storage.targetPort = targetPort; - - printConsole(`Connecting to ${storage.targetIp}:${storage.targetPort}`); - networking.connect(targetIp, targetPort); - } else { - printConsole('Reconnect is not required'); - } -}; - export class SkympClient extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { super(); + this.controller.emitter.on("connectionFailed", (e) => this.onConnectionFailed(e)); + this.controller.emitter.on("connectionDenied", (e) => this.onConnectionDenied(e)); + + this.controller.emitter.on("createActorMessage", (e) => this.onActorCreateMessage(e)); + const authGameData = storage[AuthGameData.storageKey] as AuthGameData | undefined; if (!(authGameData?.local || authGameData?.remote)) { authSystem.addAuthListener((data) => { @@ -106,106 +53,76 @@ export class SkympClient extends ClientListener { browser.setAuthData(data.remote); } storage[AuthGameData.storageKey] = data; - this.sp.browser.setFocused(false); + + // Don't let the user use Main Menu buttons + // setTimeout(() => { + // this.sp.browser.setFocused(false); + // }, 3000); + // once("update", () => { + // this.sp.browser.setFocused(false); + // }); + // this.sp.browser.setFocused(false); + this.startClient(); }); - authSystem.main(settings["skymp5-client"]["lobbyLocation"] as Transform); + authSystem.main(); } else { this.startClient(); } } - get sendTarget(): SendTarget | undefined { - return this.rs; + private onActorCreateMessage(e: ConnectionMessage) { + if (e.message.isMe) { + this.sp.browser.setFocused(false); + } } - get msgHandler(): MsgHandler | undefined { - return this.rs; + private onConnectionFailed(e: ConnectionFailed) { + this.logTrace("Connection failed"); } - get modelSource(): ModelSource | undefined { - return this.rs; + private onConnectionDenied(e: ConnectionDenied) { + this.logTrace("Connection denied: " + e.error); } private startClient() { - // TODO: subscribe to events in constructor, not here + // TODO: refactor netInfo into service netInfo.start(); + + // TODO: refactor animDebugSystem into service animDebugSystem.init(settings["skymp5-client"]["animDebug"] as animDebugSystem.AnimDebugSettings); + // TODO: refactor playerCombatSystem into service playerCombatSystem.start(); - once("update", () => authSystem.setPlayerAuthMode(false)); - connectWhenICallAndNotWhenIImport(); + + this.establishConnectionConditional(); this.ctor(); } private ctor() { - // TODO: subscribe to events in constructor, not here + // TODO: refactor WorldView into service this.resetView(); - this.resetRemoteServer(); - setupHooks(); - updateOwner.setup(); - - sp.printConsole('SkympClient ctor'); - networking.on('connectionFailed', () => { - printConsole('Connection failed'); - }); - - networking.on('connectionDenied', (err: Record | string) => { - printConsole('Connection denied: ', err); - }); - - networking.on('connectionAccepted', () => { - const msgHandler = this.msgHandler; - if (msgHandler === undefined) { - return this.logError("this.msgHandler was undefined in networking.on('connectionAccepted')"); - } - msgHandler.handleConnectionAccepted(); - }); + // TODO: refactor into service + setupHooks(); - networking.on('disconnect', () => { - const msgHandler = this.msgHandler; - if (msgHandler === undefined) { - return this.logError("this.msgHandler was undefined in networking.on('disconnect')"); - } - msgHandler.handleDisconnect(); - }); + // TODO: refactor updateOwner into service + updateOwner.setup(); - networking.on('message', (msgAny: Record | string) => { - netInfo.NetInfo.addReceivedPacketCount(1); - handleMessage( - msgAny as Record, - this.msgHandler as MsgHandler, - ); - }); + this.sp.printConsole('SkympClient ctor'); } - private resetRemoteServer() { - const prevRemoteServer: RemoteServer = storage.remoteServer as RemoteServer; - let rs: RemoteServer; - - if (prevRemoteServer && (prevRemoteServer.getWorldModel as unknown)) { - rs = prevRemoteServer; - printConsole('Restore previous RemoteServer'); - - // Keep previous RemoteServer, but update func implementations - const newObj: Record = - new RemoteServer() as unknown as Record; - const rsAny: Record = rs as unknown as Record< - string, - unknown - >; - for (const key in newObj) { - if (typeof newObj[key] === 'function') rsAny[key] = newObj[key]; - } + private establishConnectionConditional() { + if (storage.targetIp !== targetIp || storage.targetPort !== targetPort) { + storage.targetIp = targetIp; + storage.targetPort = targetPort; + + this.logTrace(`Connecting to ${storage.targetIp}:${storage.targetPort}`); + this.controller.lookupListener(networking.NetworkingService).connect(targetIp, targetPort); } else { - rs = new RemoteServer(); - printConsole('Creating RemoteServer'); + this.logTrace('Reconnect is not required'); } - - this.rs = rs; - storage.remoteServer = rs; } private resetView() { @@ -221,14 +138,9 @@ export class SkympClient extends ClientListener { on('update', () => { const singlePlayerService = this.controller.lookupListener(SinglePlayerService); if (!singlePlayerService.isSinglePlayer) { - const modelSource = this.modelSource; - if (modelSource === undefined) { - return this.logError("modelSource was undefined"); - } + const modelSource = this.controller.lookupListener(RemoteServer); view.update(modelSource.getWorldModel()); } }); } - - private rs?: RemoteServer; } diff --git a/skymp5-client/src/services/spApiInteractor.ts b/skymp5-client/src/services/spApiInteractor.ts index 464c8bf595..b42bb5eb54 100644 --- a/skymp5-client/src/services/spApiInteractor.ts +++ b/skymp5-client/src/services/spApiInteractor.ts @@ -1,4 +1,4 @@ -import { EventEmitterFactory } from "./events/eventEmitterFactory"; +import { EventEmitterFactory } from "./events/events"; import { ClientListener, ClientListenerConstructor, CombinedController } from "./services/clientListener"; import * as sp from "skyrimPlatform"; diff --git a/skymp5-front/src/components/FrameButton/FrameButton.scss b/skymp5-front/src/components/FrameButton/FrameButton.scss index 2a89a05b4f..e872c6d6b7 100644 --- a/skymp5-front/src/components/FrameButton/FrameButton.scss +++ b/skymp5-front/src/components/FrameButton/FrameButton.scss @@ -7,10 +7,17 @@ cursor: pointer; text-overflow: ellipsis; font-family: 'Bankir-Retro', serif; + transition: transform 0.15s ease; /* Transition for the transform */ } + +.skymp-button:hover { + transform: scale(1.05); /* Scale the button on hover */ +} + .skymp-button.disabled { cursor: default; } + .button-middle { display: flex; text-transform: uppercase; @@ -24,12 +31,15 @@ color: rgba(157, 158, 158, 0.75); } } + .button-middle--left { padding-left: 40px; } + .button-middle--right { padding-right: 40px; } + .button-middle.disabled { * { color: rgba(157, 158, 158, 0.33); diff --git a/skymp5-front/src/components/SkyrimButton/SkyrimButton.scss b/skymp5-front/src/components/SkyrimButton/SkyrimButton.scss index 4137acdde6..d23f42966f 100644 --- a/skymp5-front/src/components/SkyrimButton/SkyrimButton.scss +++ b/skymp5-front/src/components/SkyrimButton/SkyrimButton.scss @@ -3,26 +3,36 @@ border-radius: 10px; width: 320px; height: 48px; - text-align: center; - align-items: center; - display: flex; - margin-bottom: 0; - cursor: pointer; - &.disabled { + text-align: center; + align-items: center; + display: flex; + margin-bottom: 0; + cursor: pointer; + transition: transform 0.15s ease; + + /* Smooth transition for transform */ + &.disabled { + opacity: 0.33; + + * { opacity: 0.33; - * { - opacity: 0.33; - } } + } + .skymp-input-button_text { - overflow: hidden; - text-overflow: ellipsis; - font-family: 'Bankir-Retro', serif; - font-style: normal; - font-weight: normal; - font-size: 30px; - line-height: 32px; - color: #9D9E9EBF; - margin: auto; - } + overflow: hidden; + text-overflow: ellipsis; + font-family: 'Bankir-Retro', serif; + font-style: normal; + font-weight: normal; + font-size: 30px; + line-height: 32px; + color: #9D9E9EBF; + margin: auto; + } + + &:hover { + transform: scale(1.05); + /* Enlarge button on hover */ + } } diff --git a/skymp5-server/ts/settings.ts b/skymp5-server/ts/settings.ts index 6f1f1e461e..0f0b2751aa 100644 --- a/skymp5-server/ts/settings.ts +++ b/skymp5-server/ts/settings.ts @@ -21,9 +21,9 @@ export class Settings { offlineMode = false; startPoints = [ { - pos: [22659, -8697, -3594], - worldOrCell: '0x1a26f', - angleZ: 268, + pos: [133857, -61130, 14662], + worldOrCell: '0x3c', + angleZ: 72, }, ]; discordAuth: DiscordAuthSettings | null = null; diff --git a/skyrim-platform/src/platform_lib/HttpClientApi.cpp b/skyrim-platform/src/platform_lib/HttpClientApi.cpp index 2df11f7bea..7ead76823d 100644 --- a/skyrim-platform/src/platform_lib/HttpClientApi.cpp +++ b/skyrim-platform/src/platform_lib/HttpClientApi.cpp @@ -53,11 +53,10 @@ JsValue HttpClientApi::Constructor(const JsFunctionArguments& args) JsValue HttpClientApi::Get(const JsFunctionArguments& args) { - JsValue path = args[1], options = args[2], + JsValue path = args[1], options = args[2], callback = args[3], host = args[0].GetProperty("host"); - JsValue resolverFn = JsValue::Function([=](const JsFunctionArguments& args) { - auto resolve = std::make_shared(args[1]); + auto handleGetRequest = [&](const std::shared_ptr& resolver) { auto pathStr = static_cast(path); auto hostStr = static_cast(host); g_httpClient.Get( @@ -69,24 +68,31 @@ JsValue HttpClientApi::Get(const JsFunctionArguments& args) JsValue::String(std::string{ res.body.begin(), res.body.end() })); result.SetProperty("status", res.status); result.SetProperty("error", res.error); - resolve->Call({ JsValue::Undefined(), result }); + resolver->Call({ JsValue::Undefined(), result }); }); + }; + if (callback.GetType() == JsValue::Type::Function) { + auto resolve = std::make_shared(callback); + handleGetRequest(resolve); return JsValue::Undefined(); - }); + } - auto promise = CreatePromise(resolverFn); + JsValue resolverFn = JsValue::Function([=](const JsFunctionArguments& args) { + auto resolve = std::make_shared(args[1]); + handleGetRequest(resolve); + return JsValue::Undefined(); + }); - return promise; + return CreatePromise(resolverFn); } JsValue HttpClientApi::Post(const JsFunctionArguments& args) { - JsValue path = args[1], options = args[2], + JsValue path = args[1], options = args[2], callback = args[3], host = args[0].GetProperty("host"); - auto resolverFn = JsValue::Function([=](const JsFunctionArguments& args) { - auto resolve = std::make_shared(args[1]); + auto handlePostRequest = [&](const std::shared_ptr& resolver) { auto pathStr = static_cast(path); auto hostStr = static_cast(host); auto bodyStr = static_cast(options.GetProperty("body")); @@ -100,9 +106,19 @@ JsValue HttpClientApi::Post(const JsFunctionArguments& args) JsValue::String(std::string{ res.body.begin(), res.body.end() })); result.SetProperty("status", res.status); result.SetProperty("error", res.error); - resolve->Call({ JsValue::Undefined(), result }); + resolver->Call({ JsValue::Undefined(), result }); }); + }; + + if (callback.GetType() == JsValue::Type::Function) { + auto resolve = std::make_shared(callback); + handlePostRequest(resolve); + return JsValue::Undefined(); + } + auto resolverFn = JsValue::Function([=](const JsFunctionArguments& args) { + auto resolve = std::make_shared(args[1]); + handlePostRequest(resolve); return JsValue::Undefined(); }); diff --git a/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt b/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt index 29b227e867..343edb8a07 100644 --- a/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt +++ b/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt @@ -46,7 +46,7 @@ export interface ChangeFormNpc { face?: Face } -export declare function loadGame(pos: number[], angle: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc): void +export declare function loadGame(pos: number[], angle: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc, loadOrder?: string[], time?: { seconds: number, minutes: number, hours: number }): void export declare function worldPointToScreenPoint(...args: number[][]): number[][] @@ -1528,8 +1528,8 @@ export type HttpHeaders = Record export declare class HttpClient { constructor(url: string); - get(path: string, options?: { headers?: HttpHeaders }): Promise; - post(path: string, options: { body: string, contentType: string, headers?: HttpHeaders }): Promise; + get(path: string, options?: { headers?: HttpHeaders }, callback?: (result: HttpResponse) => void): Promise; + post(path: string, options: { body: string, contentType: string, headers?: HttpHeaders }, callback?: (result: HttpResponse) => void): Promise; } export declare function createText(xPos: number, yPos: number, text: string, color: number[], name?: string): number; //default name is Tavern diff --git a/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts b/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts index ebc70d0866..632c40acee 100644 --- a/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts +++ b/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts @@ -51,7 +51,7 @@ export interface ChangeFormNpc { face?: Face } -export declare function loadGame(pos: number[], angle: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc): void +export declare function loadGame(pos: number[], angle: number[], worldOrCell: number, changeFormNpc?: ChangeFormNpc, loadOrder?: string[], time?: { seconds: number, minutes: number, hours: number }): void export declare function worldPointToScreenPoint(...args: number[][]): number[][] @@ -1533,8 +1533,8 @@ export type HttpHeaders = Record export declare class HttpClient { constructor(url: string); - get(path: string, options?: { headers?: HttpHeaders }): Promise; - post(path: string, options: { body: string, contentType: string, headers?: HttpHeaders }): Promise; + get(path: string, options?: { headers?: HttpHeaders }, callback?: (result: HttpResponse) => void): Promise; + post(path: string, options: { body: string, contentType: string, headers?: HttpHeaders }, callback?: (result: HttpResponse) => void): Promise; } export declare function createText(xPos: number, yPos: number, text: string, color: number[], name?: string): number; //default name is Tavern diff --git a/skyrim-platform/src/platform_se/skyrim_platform/CallNativeApi.cpp b/skyrim-platform/src/platform_se/skyrim_platform/CallNativeApi.cpp index 1fdeeb93d0..6dec9d6683 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/CallNativeApi.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/CallNativeApi.cpp @@ -6,15 +6,58 @@ #include "Override.h" #include "VmProvider.h" +#include + JsValue CallNativeApi::CallNative( const JsFunctionArguments& args, const std::function& getNativeCallRequirements) { - auto className = (std::string)args[1]; - auto functionName = (std::string)args[2]; + auto className = static_cast(args[1]); + auto functionName = static_cast(args[2]); auto self = args[3]; constexpr int nativeArgsStart = 4; + // https://github.com/ianpatt/skse64/blob/971babc435e2620521c8556ea8ae7b9a4910ff61/skse64/PapyrusGame.cpp#L94 + if (!stricmp("Game", className.data())) { + if (!stricmp("GetModCount", functionName.data())) { + auto dataHandler = RE::TESDataHandler::GetSingleton(); + if (!dataHandler) { + throw NullPointerException("dataHandler"); + } + return JsValue( + static_cast(dataHandler->compiledFileCollection.files.size())); + + } else if (!stricmp("GetModName", functionName.data())) { + + constexpr int kLightModOffset = 0x100; + + int index = static_cast(args[nativeArgsStart]); + + auto dataHandler = RE::TESDataHandler::GetSingleton(); + if (!dataHandler) { + throw NullPointerException("dataHandler"); + } + + if (index > 0xff) { + uint32_t adjusted = index - kLightModOffset; + if (adjusted >= + dataHandler->compiledFileCollection.smallFiles.size()) { + return JsValue(""); + } + std::string s = + dataHandler->compiledFileCollection.smallFiles[adjusted]->fileName; + return JsValue(s); + } else { + if (index >= dataHandler->compiledFileCollection.files.size()) { + return JsValue(""); + } + std::string s = + dataHandler->compiledFileCollection.files[index]->fileName; + return JsValue(s); + } + } + } + auto requirements = getNativeCallRequirements(); if (!requirements.vm) throw std::runtime_error('\'' + className + '.' + functionName + diff --git a/skyrim-platform/src/platform_se/skyrim_platform/FridaHooks.cpp b/skyrim-platform/src/platform_se/skyrim_platform/FridaHooks.cpp index cb8f10ac0e..04d80b8dc4 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/FridaHooks.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/FridaHooks.cpp @@ -6,6 +6,8 @@ #include "PapyrusTESModPlatform.h" #include "StringHolder.h" +#include + /** * Send Event hook */ @@ -269,6 +271,22 @@ void OnRenderCursorMenuEnter(GumInvocationContext* ic) FridaHooksUtils::SetMenuNumberVariable(RE::CursorMenu::MENU_NAME, "_root.mc_Cursor._alpha", 100); } + + auto strings = { + R"(_root.MenuHolder.Menu_mc.MainListHolder.List_mc._alpha)" + }; + + if (visibleFlag && focusFlag) { + for (auto string : strings) { + FridaHooksUtils::SetMenuNumberVariable(RE::MainMenu::MENU_NAME, string, + 0); + } + } else { + for (auto string : strings) { + FridaHooksUtils::SetMenuNumberVariable(RE::MainMenu::MENU_NAME, string, + 100); + } + } } void InstallRenderCursorMenuHook() diff --git a/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.cpp b/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.cpp index ca6b7e21a0..ec3e36940b 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.cpp @@ -92,16 +92,19 @@ void LoadGame::Run(std::shared_ptr save, const std::array& pos, const std::array& angle, uint32_t cellOrWorld, Time* time, SaveFile_::Weather* _weather, - SaveFile_::ChangeFormNPC_* changeFormNPC) + SaveFile_::ChangeFormNPC_* changeFormNPC, + std::vector* loadOrder) { if (!save) { throw std::runtime_error("Bad SaveFile"); } + ModifyPluginInfo(save); + ModifySaveTime(save, time); ModifySaveWeather(save, _weather); - ModifyPluginInfo(save); ModifyPlayerFormNPC(save, changeFormNPC); + ModifyLoadOrder(save, loadOrder); ModifyEssStructure(save, pos, angle, cellOrWorld); auto name = g_saveFilePrefix + GenerateGuid(); @@ -225,6 +228,14 @@ void LoadGame::ModifyPlayerFormNPC(std::shared_ptr save, } } +void LoadGame::ModifyLoadOrder(std::shared_ptr save, + std::vector* loadOrder) +{ + if (loadOrder) { + save->OverwritePluginInfo(*loadOrder); + } +} + void LoadGame::FillChangeForm( std::shared_ptr save, SaveFile_::ChangeForm* form, std::pair>& newValues) diff --git a/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.h b/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.h index 94acefc249..38f873f52d 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.h +++ b/skyrim-platform/src/platform_se/skyrim_platform/LoadGame.h @@ -41,7 +41,8 @@ class LoadGame const std::array& pos, const std::array& angle, uint32_t cellOrWorld, Time* time = nullptr, SaveFile_::Weather* _weather = nullptr, - SaveFile_::ChangeFormNPC_* changeFormNPC = nullptr); + SaveFile_::ChangeFormNPC_* changeFormNPC = nullptr, + std::vector* loadOrder = nullptr); static std::wstring GetPathToMyDocuments(); @@ -90,6 +91,9 @@ class LoadGame static void ModifyPlayerFormNPC(std::shared_ptr save, SaveFile_::ChangeFormNPC_* changeFormNPC); + static void ModifyLoadOrder(std::shared_ptr save, + std::vector* loadOrder); + static void FillChangeForm( std::shared_ptr save, SaveFile_::ChangeForm* form, std::pair>& newValues); diff --git a/skyrim-platform/src/platform_se/skyrim_platform/LoadGameApi.cpp b/skyrim-platform/src/platform_se/skyrim_platform/LoadGameApi.cpp index 31dc1210f2..b1bf1b3076 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/LoadGameApi.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/LoadGameApi.cpp @@ -107,23 +107,75 @@ std::unique_ptr CreateChangeFormNpc( return changeFormNpc; } +std::unique_ptr CreateTime( + std::shared_ptr, JsValue time_) +{ + if (time_.GetType() != JsValue::Type::Object) { + return nullptr; + } + + auto hours = static_cast(time_.GetProperty("hours")); + auto minutes = static_cast(time_.GetProperty("minutes")); + auto seconds = static_cast(time_.GetProperty("seconds")); + + std::unique_ptr time; + time.reset(new LoadGame::Time); + time->Set(seconds, minutes, hours); + return time; +} + +std::unique_ptr> CreateLoadOrder( + std::shared_ptr, JsValue loadOrder_) +{ + if (loadOrder_.GetType() != JsValue::Type::Array) { + return nullptr; + } + + std::unique_ptr> loadOrder; + loadOrder.reset(new std::vector); + int n = static_cast(loadOrder_.GetProperty("length")); + for (int i = 0; i < n; ++i) { + auto jValue = loadOrder_.GetProperty(i); + auto value = static_cast(jValue); + loadOrder->push_back(value); + } + return loadOrder; +} + } JsValue LoadGameApi::LoadGame(const JsFunctionArguments& args) { std::array pos = JsExtractPoint(args[1]), angle = JsExtractPoint(args[2]); - uint32_t cellOrWorld = (uint32_t)(double)args[3]; + uint32_t cellOrWorld = static_cast(static_cast(args[3])); auto npcData = args[4]; + auto loadOrder = args[5]; + auto time = args[6]; auto save = LoadGame::PrepareSaveFile(); - if (!save) + if (!save) { throw NullPointerException("save"); + } std::unique_ptr changeFormNpc = CreateChangeFormNpc(save, npcData); - LoadGame::Run(save, pos, angle, cellOrWorld, nullptr, nullptr, - changeFormNpc.get()); + std::unique_ptr> saveLoadOrder = + CreateLoadOrder(save, loadOrder); + + std::unique_ptr saveFileTime = CreateTime(save, time); + + const auto& _baseSavefile = save; + const auto& _pos = pos; + const auto& _angle = angle; + const auto& _cellOrWorld = cellOrWorld; + const auto& _time = saveFileTime.get(); + SaveFile_::Weather* _weather = nullptr; + SaveFile_::ChangeFormNPC_* _changeFormNPC = changeFormNpc.get(); + std::vector* _loadOrder = saveLoadOrder.get(); + LoadGame::Run(_baseSavefile, _pos, _angle, _cellOrWorld, _time, _weather, + _changeFormNPC, _loadOrder); + return JsValue::Undefined(); }