From 92598e4fdf2a62a5701a80dfedae2d61d32aa13f Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 21 May 2024 05:36:12 +0500 Subject: [PATCH 01/78] fix(skymp5-client): ignore equipment updates from BOOK, INGR and ALCH equips (#1980) --- .../services/services/sendInputsService.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/skymp5-client/src/services/services/sendInputsService.ts b/skymp5-client/src/services/services/sendInputsService.ts index 4b06bfabed..6d1ba97049 100644 --- a/skymp5-client/src/services/services/sendInputsService.ts +++ b/skymp5-client/src/services/services/sendInputsService.ts @@ -8,7 +8,7 @@ import { getMovement } from "../../sync/movementGet"; import * as worldViewMisc from "../../view/worldViewMisc"; import { Animation, AnimationSource } from "../../sync/animation"; -import { Actor, EquipEvent } from "skyrimPlatform"; +import { Actor, EquipEvent, FormType } from "skyrimPlatform"; import { getAppearance } from "../../sync/appearance"; import { ActorValues, getActorValues } from "../../sync/actorvalues"; import { getEquipment } from "../../sync/equipment"; @@ -47,14 +47,22 @@ export class SendInputsService extends ClientListener { return; } - if (event.actor.getFormID() === playerFormId) { - this.equipmentChanged = true; + if (event.actor.getFormID() !== playerFormId) { + return; + } - this.controller.emitter.emit("sendMessage", { - message: { t: MsgType.OnEquip, baseId: event.baseObj.getFormID() }, - reliability: "unreliable" - }); + const type = event.baseObj.getType(); + if (type !== FormType.Book && type !== FormType.Potion && type !== FormType.Ingredient) { + // Trigger UpdateEquipment only for equips that are not spell tomes, potions, ingredients + this.equipmentChanged = true; } + + // Send OnEquip for any equips including spell tomes, potions, ingredients + // Otherwise, the server won't trigger spell learn, potion drink, ingredient eat and Papyrus scripts + this.controller.emitter.emit("sendMessage", { + message: { t: MsgType.OnEquip, baseId: event.baseObj.getFormID() }, + reliability: "unreliable" + }); } private onUnequip(event: EquipEvent) { @@ -237,6 +245,7 @@ export class SendInputsService extends ClientListener { data: eq, _refrId }; + this.controller.emitter.emit("sendMessageWithRefrId", { message, reliability: "reliable" From 69ea6a469464b6c6edd96246bb8bc230e7f9887b Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 21 May 2024 05:39:37 +0500 Subject: [PATCH 02/78] feat(skyrim-platform): only load js extension (#1981) --- docs/release/dev/sp-js-only.md | 1 + .../src/platform_se/skyrim_platform/SkyrimPlatform.cpp | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) create mode 100644 docs/release/dev/sp-js-only.md diff --git a/docs/release/dev/sp-js-only.md b/docs/release/dev/sp-js-only.md new file mode 100644 index 0000000000..2b62a81175 --- /dev/null +++ b/docs/release/dev/sp-js-only.md @@ -0,0 +1 @@ +SkyrimPlatform now only attempts to load .js files. Other extensions will not be treated as SkyrimPlatform plugins. diff --git a/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp b/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp index 03e4ba677e..56bc68c8ee 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp @@ -173,13 +173,10 @@ class CommonExecutionListener : public TickListener LoadSettingsFile(path); continue; } - if (EndsWith(path.wstring(), L"-logs.txt")) { + if (EndsWith(path.wstring(), L".js")) { + LoadPluginFile(path); continue; } - if (EndsWith(path.wstring(), L".DS_Store")) { - continue; - } - LoadPluginFile(path); } } From 7c8f2efc915386574e666eb2d2912a6d98fdff7d Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 21 May 2024 05:41:38 +0500 Subject: [PATCH 03/78] fix(skymp5-client): fix inability to unequip items (#1982) --- skymp5-client/src/sync/equipment.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/skymp5-client/src/sync/equipment.ts b/skymp5-client/src/sync/equipment.ts index 22474c4113..a784e1b1e0 100644 --- a/skymp5-client/src/sync/equipment.ts +++ b/skymp5-client/src/sync/equipment.ts @@ -104,7 +104,14 @@ export const syncSpellEquipment = ( export const applyEquipment = (ac: Actor, eq: Equipment): boolean => { ac.removeAllItems(null, false, true); - setInventory(ac.getFormID(), removeUnnecessaryExtra(filterWorn(eq.inv))); + + ac.unequipAll(); + + ac.removeAllItems(null, false, true); + + const newInventory = removeUnnecessaryExtra(filterWorn(eq.inv)); + + setInventory(ac.getFormID(), newInventory); syncSpellEquipment(ac, eq.leftSpell, SpellType.Left); syncSpellEquipment(ac, eq.rightSpell, SpellType.Right); From 6da9937e4425eec3201e59ec52c782777424c7e1 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 21 May 2024 05:46:11 +0500 Subject: [PATCH 04/78] feat(skymp5-client): improve auth UX & slightly refactor (#1984) --- .../{authEvent.ts => authAttemptEvent.ts} | 2 +- skymp5-client/src/services/events/events.ts | 4 +- .../src/services/services/authService.ts | 39 ++++++++++++++----- .../services/frontHotReloadService.ts | 6 +-- .../src/services/services/skympClient.ts | 8 ++-- 5 files changed, 40 insertions(+), 19 deletions(-) rename skymp5-client/src/services/events/{authEvent.ts => authAttemptEvent.ts} (71%) diff --git a/skymp5-client/src/services/events/authEvent.ts b/skymp5-client/src/services/events/authAttemptEvent.ts similarity index 71% rename from skymp5-client/src/services/events/authEvent.ts rename to skymp5-client/src/services/events/authAttemptEvent.ts index f29ffc66ce..e9406489cc 100644 --- a/skymp5-client/src/services/events/authEvent.ts +++ b/skymp5-client/src/services/events/authAttemptEvent.ts @@ -1,5 +1,5 @@ import { AuthGameData } from "../../features/authModel"; -export interface AuthEvent { +export interface AuthAttemptEvent { authGameData: AuthGameData; } diff --git a/skymp5-client/src/services/events/events.ts b/skymp5-client/src/services/events/events.ts index 62f7167d3b..58b7ff1b13 100644 --- a/skymp5-client/src/services/events/events.ts +++ b/skymp5-client/src/services/events/events.ts @@ -29,7 +29,7 @@ import { UpdatePropertyMessage } from "../messages/updatePropertyMessage"; import { DeathStateContainerMessage } from "../messages/deathStateContainerMessage"; import { TeleportMessage2 } from "../messages/teleportMessage2"; import { BrowserWindowLoadedEvent } from "./browserWindowLoadedEvent"; -import { AuthEvent } from "./authEvent"; +import { AuthAttemptEvent } from "./authAttemptEvent"; import { NewLocalLagValueCalculatedEvent } from "./newLocalLagValueCalculatedEvent"; import { AuthNeededEvent } from "./authNeededEvent"; import { QueryBlockSetInventoryEvent } from "./queryBlockSetInventoryEvent"; @@ -68,7 +68,7 @@ type EventTypes = { 'teleportMessage2': [ConnectionMessage] 'browserWindowLoaded': [BrowserWindowLoadedEvent], - 'auth': [AuthEvent], + 'authAttempt': [AuthAttemptEvent], 'authNeeded': [AuthNeededEvent], 'anyMessage': [ConnectionMessage], 'newLocalLagValueCalculated': [NewLocalLagValueCalculatedEvent], diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index 09d204df00..c13c6237ea 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -1,12 +1,14 @@ import { RemoteAuthGameData } from "../../features/authModel"; import { FunctionInfo } from "../../lib/functionInfo"; import { ClientListener, CombinedController, Sp } from "./clientListener"; -import { BrowserMessageEvent } from "skyrimPlatform"; +import { BrowserMessageEvent, browser } from "skyrimPlatform"; import { AuthNeededEvent } from "../events/authNeededEvent"; import { BrowserWindowLoadedEvent } from "../events/browserWindowLoadedEvent"; import { TimersService } from "./timersService"; import { MasterApiAuthStatus } from "../messages_http/masterApiAuthStatus"; import { logTrace, logError } from "../../logging"; +import { ConnectionMessage } from "../events/connectionMessage"; +import { CreateActorMessage } from "../messages/createActorMessage"; // for browsersideWidgetSetter declare const window: any; @@ -14,7 +16,7 @@ declare const window: any; // Constants used on both client and browser side (see browsersideWidgetSetter) const events = { openDiscordOauth: 'openDiscordOauth', - login: 'loginRequiredEvent', + authAttempt: 'authAttemptEvent', openGithub: 'openGithub', openPatreon: 'openPatreon', clearAuthData: 'clearAuthData', @@ -31,7 +33,10 @@ export class AuthService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { super(); this.controller.emitter.on("authNeeded", (e) => this.onAuthNeeded(e)); - this.controller.emitter.on("browserWindowLoaded", (e) => this.onBrowserWindowLoaded(e)) + this.controller.emitter.on("browserWindowLoaded", (e) => this.onBrowserWindowLoaded(e)); + this.controller.emitter.on("createActorMessage", (e) => this.onCreateActorMessage(e)); + + // this.controller.emitter.on("connectionDenied"); this.controller.on("browserMessage", (e) => this.onBrowserMessage(e)); } @@ -43,7 +48,7 @@ export class AuthService extends ClientListener { const isOfflineMode = Number.isInteger(settingsGameData?.profileId); if (isOfflineMode) { logTrace(this, `Offline mode detected in settings, emitting auth event with authGameData.local`); - this.controller.emitter.emit("auth", { authGameData: { local: { profileId: settingsGameData.profileId } } }); + this.controller.emitter.emit("authAttempt", { authGameData: { local: { profileId: settingsGameData.profileId } } }); } else { logTrace(this, `No offline mode detectted in settings, regular auth needed`); this.isListenBrowserMessage = true; @@ -64,6 +69,19 @@ export class AuthService extends ClientListener { } } + private onCreateActorMessage(e: ConnectionMessage) { + if (e.message.isMe) { + if (this.authDialogOpen) { + logTrace(this, `Received createActorMessage for self, resetting widgets`); + this.sp.browser.executeJavaScript('window.skyrimPlatform.widgets.set([]);'); + this.authDialogOpen = false; + } + else { + logTrace(this, `Received createActorMessage for self, but auth dialog was not open so not resetting widgets`); + } + } + } + private onBrowserWindowLoadedAndOnlineAuthNeeded() { if (!this.isListenBrowserMessage) { logError(this, `isListenBrowserMessage was false for some reason, aborting auth`); @@ -106,14 +124,14 @@ export class AuthService extends ClientListener { case events.openDiscordOauth: this.sp.win32.loadUrl(`${this.getMasterUrl()}/api/users/login-discord?state=${this.discordAuthState}`); break; - case events.login: + case events.authAttempt: if (authData === null) { - browserState.comment = 'logic error: remoteAuthGameData is null'; + browserState.comment = 'сначала войдите через discord'; this.refreshWidgets(); break; } this.writeAuthDataToDisk(authData); - this.controller.emitter.emit("auth", { authGameData: { remote: authData } }); + this.controller.emitter.emit("authAttempt", { authGameData: { remote: authData } }); break; case events.clearAuthData: this.writeAuthDataToDisk(null); @@ -198,6 +216,7 @@ export class AuthService extends ClientListener { discordDiscriminator, discordAvatar, }; + browserState.comment = 'привязан успешно'; this.refreshWidgets(); }); break; @@ -224,6 +243,7 @@ export class AuthService extends ClientListener { logError(this, `Auth check fail:`, browserState.comment); } this.sp.browser.executeJavaScript(new FunctionInfo(this.browsersideWidgetSetter).getText({ events, browserState, authData: authData })); + this.authDialogOpen = true; }; private readAuthDataFromDisk(): RemoteAuthGameData | null { @@ -327,12 +347,12 @@ export class AuthService extends ClientListener { type: "button", text: "Играть", tags: ["BUTTON_STYLE_FRAME", "ELEMENT_STYLE_MARGIN_EXTENDED"], - click: () => window.skyrimPlatform.sendMessage(events.login), + click: () => window.skyrimPlatform.sendMessage(events.authAttempt), hint: "Подключиться к игровому серверу", }, { type: "text", - text: browserState.failCount > 3 ? browserState.comment : "", + text: browserState.comment, tags: [], }, ] @@ -351,6 +371,7 @@ export class AuthService extends ClientListener { } }; private discordAuthState = "" + Math.random(); + private authDialogOpen = false; private readonly githubUrl = "https://github.com/skyrim-multiplayer/skymp"; private readonly patreonUrl = "https://www.patreon.com/skymp"; diff --git a/skymp5-client/src/services/services/frontHotReloadService.ts b/skymp5-client/src/services/services/frontHotReloadService.ts index 67d86807ce..1df2a0fa3b 100644 --- a/skymp5-client/src/services/services/frontHotReloadService.ts +++ b/skymp5-client/src/services/services/frontHotReloadService.ts @@ -1,6 +1,6 @@ import { logTrace } from "../../logging"; import { AuthGameData, authGameDataStorageKey } from "../../features/authModel"; -import { AuthEvent } from "../events/authEvent"; +import { AuthAttemptEvent } from "../events/authAttemptEvent"; import { ClientListener, Sp, CombinedController } from "./clientListener"; export class FrontHotReloadService extends ClientListener { @@ -25,11 +25,11 @@ export class FrontHotReloadService extends ClientListener { this.connectToFrontHotReload(); } else { logTrace(this, `Unable to recover AuthGameData from storage, waiting for auth`); - this.controller.emitter.on("auth", (e) => this.onAuth(e)); + this.controller.emitter.on("authAttempt", (e) => this.onAuthAttempt(e)); } } - private onAuth(e: AuthEvent) { + private onAuthAttempt(e: AuthAttemptEvent) { this.connectToFrontHotReload(); } diff --git a/skymp5-client/src/services/services/skympClient.ts b/skymp5-client/src/services/services/skympClient.ts index 76d2b190e7..07234aba58 100644 --- a/skymp5-client/src/services/services/skympClient.ts +++ b/skymp5-client/src/services/services/skympClient.ts @@ -16,7 +16,7 @@ import { ConnectionFailed } from '../events/connectionFailed'; import { ConnectionDenied } from '../events/connectionDenied'; import { ConnectionMessage } from '../events/connectionMessage'; import { CreateActorMessage } from '../messages/createActorMessage'; -import { AuthEvent } from '../events/authEvent'; +import { AuthAttemptEvent } from '../events/authAttemptEvent'; import { logTrace } from '../../logging'; printConsole('Hello Multiplayer!'); @@ -57,11 +57,11 @@ export class SkympClient extends ClientListener { this.controller.once("tick", () => { this.controller.emitter.emit("authNeeded", {}); }); - this.controller.emitter.on("auth", (e) => this.onAuth(e)); + this.controller.emitter.on("authAttempt", (e) => this.onAuthAttempt(e)); } } - private onAuth(e: AuthEvent) { + private onAuthAttempt(e: AuthAttemptEvent) { logTrace(this, `Caught auth event`); storage[authGameDataStorageKey] = e.authGameData; @@ -69,7 +69,7 @@ export class SkympClient extends ClientListener { this.startClient(); // TODO: remove this when you will be able to see errors without console - this.sp.browser.setFocused(false); + // this.sp.browser.setFocused(false); } private onActorCreateMessage(e: ConnectionMessage) { From 054bfd1827e17c7a39b622b656cfcd68052d3a25 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 21 May 2024 06:23:56 +0500 Subject: [PATCH 05/78] internal: commit gathered PRs in CI (#1985) --- .github/workflows/pr-windows.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/pr-windows.yml b/.github/workflows/pr-windows.yml index c89fdd2e46..413887d10f 100644 --- a/.github/workflows/pr-windows.yml +++ b/.github/workflows/pr-windows.yml @@ -75,6 +75,15 @@ jobs: } ] + - name: Commit gathered PRs + if: ${{ matrix.DEPLOY_BRANCH != '' && (!matrix.ONLY_PUSH || github.event_name == 'push')}} + run: | + # fake user for bot + git config --global user.email "skyrim_multiplayer_bot@users.noreply.github.com" + git config --global user.name "Skyrim Multiplayer Bot" + git add . + git commit -m "Merge PRs ${{matrix.DEPLOY_BRANCH}}" + - name: Early build skymp5-client if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: | From bed449a69455f0f2836cd434a37c77473b839e19 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Thu, 23 May 2024 00:45:10 +0500 Subject: [PATCH 06/78] feat(skymp5-client): improve auth UX (#1987) --- .../src/services/services/authService.ts | 250 +++++++++++++++++- .../services/services/networkingService.ts | 5 +- .../src/services/services/remoteServer.ts | 92 ------- .../src/services/services/skympClient.ts | 4 +- 4 files changed, 249 insertions(+), 102 deletions(-) diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index c13c6237ea..2a3d647cc4 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -1,7 +1,7 @@ -import { RemoteAuthGameData } from "../../features/authModel"; +import { AuthGameData, RemoteAuthGameData, authGameDataStorageKey } from "../../features/authModel"; import { FunctionInfo } from "../../lib/functionInfo"; import { ClientListener, CombinedController, Sp } from "./clientListener"; -import { BrowserMessageEvent, browser } from "skyrimPlatform"; +import { BrowserMessageEvent, Menu, browser } from "skyrimPlatform"; import { AuthNeededEvent } from "../events/authNeededEvent"; import { BrowserWindowLoadedEvent } from "../events/browserWindowLoadedEvent"; import { TimersService } from "./timersService"; @@ -9,6 +9,11 @@ import { MasterApiAuthStatus } from "../messages_http/masterApiAuthStatus"; import { logTrace, logError } from "../../logging"; import { ConnectionMessage } from "../events/connectionMessage"; import { CreateActorMessage } from "../messages/createActorMessage"; +import { CustomPacketMessage } from "../messages/customPacketMessage"; +import { NetworkingService } from "./networkingService"; +import { CustomPacketMessage2 } from "../messages/customPacketMessage2"; +import { MsgType } from "../../messages"; +import { ConnectionDenied } from "../events/connectionDenied"; // for browsersideWidgetSetter declare const window: any; @@ -20,12 +25,15 @@ const events = { openGithub: 'openGithub', openPatreon: 'openPatreon', clearAuthData: 'clearAuthData', + updateRequired: 'updateRequired', + backToLogin: 'backToLogin', }; // Vaiables used on both client and browser side (see browsersideWidgetSetter) let browserState = { comment: '', failCount: 9000, + loginFailedReason: '', }; let authData: RemoteAuthGameData | null = null; @@ -35,10 +43,11 @@ export class AuthService extends ClientListener { this.controller.emitter.on("authNeeded", (e) => this.onAuthNeeded(e)); this.controller.emitter.on("browserWindowLoaded", (e) => this.onBrowserWindowLoaded(e)); this.controller.emitter.on("createActorMessage", (e) => this.onCreateActorMessage(e)); - - // this.controller.emitter.on("connectionDenied"); - + this.controller.emitter.on("connectionAccepted", () => this.handleConnectionAccepted()); + this.controller.emitter.on("connectionDenied", (e) => this.handleConnectionDenied(e)); + this.controller.emitter.on("customPacketMessage2", (e) => this.onCustomPacketMessage2(e)); this.controller.on("browserMessage", (e) => this.onBrowserMessage(e)); + this.controller.on("tick", () => this.onTick()); } private onAuthNeeded(e: AuthNeededEvent) { @@ -80,6 +89,48 @@ export class AuthService extends ClientListener { logTrace(this, `Received createActorMessage for self, but auth dialog was not open so not resetting widgets`); } } + + this.loggingStartMoment = 0; + this.authAttemptProgressIndicator = false; + } + + private onCustomPacketMessage2(event: ConnectionMessage): void { + const msg = event.message; + + switch (msg.content["customPacketType"]) { + // case 'loginRequired': + // logTrace(this, 'loginRequired received'); + // this.loginWithSkympIoCredentials(); + // break; + case 'loginFailedNotLoggedViaDiscord': + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + logTrace(this, 'loginFailedNotLoggedViaDiscord received'); + browserState.loginFailedReason = 'войдите через discord'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + break; + case 'loginFailedNotInTheDiscordServer': + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + logTrace(this, 'loginFailedNotInTheDiscordServer received'); + browserState.loginFailedReason = 'вступите в discord сервер'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + break; + case 'loginFailedBanned': + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + logTrace(this, 'loginFailedBanned received'); + browserState.loginFailedReason = 'вы забанены'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + break; + case 'loginFailedIpMismatch': + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + logTrace(this, 'loginFailedIpMismatch received'); + browserState.comment = 'что это было?'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + break; + } } private onBrowserWindowLoadedAndOnlineAuthNeeded() { @@ -122,6 +173,8 @@ export class AuthService extends ClientListener { const eventKey = e.arguments[0]; switch (eventKey) { case events.openDiscordOauth: + browserState.comment = 'открываем браузер...'; + this.refreshWidgets(); this.sp.win32.loadUrl(`${this.getMasterUrl()}/api/users/login-discord?state=${this.discordAuthState}`); break; case events.authAttempt: @@ -132,6 +185,10 @@ export class AuthService extends ClientListener { } this.writeAuthDataToDisk(authData); this.controller.emitter.emit("authAttempt", { authGameData: { remote: authData } }); + + this.authAttemptProgressIndicator = true; + + break; case events.clearAuthData: this.writeAuthDataToDisk(null); @@ -142,7 +199,14 @@ export class AuthService extends ClientListener { case events.openPatreon: this.sp.win32.loadUrl(this.patreonUrl); break; + case events.updateRequired: + this.sp.win32.loadUrl("https://skymp.net/"); + break; + case events.backToLogin: + this.sp.browser.executeJavaScript(new FunctionInfo(this.browsersideWidgetSetter).getText({ events, browserState, authData: authData })); + break; default: + logError(this, `Unknown event key`, eventKey); break; } } @@ -222,7 +286,7 @@ export class AuthService extends ClientListener { break; case 401: // Unauthorized browserState.failCount = 0; - browserState.comment = (`Still waiting...`); + browserState.comment = '';//(`Still waiting...`); timersService.setTimeout(() => this.checkLoginState(), 1.5 + Math.random() * 2); break; case 403: // Forbidden @@ -291,6 +355,66 @@ export class AuthService extends ClientListener { return url; }; + private deniedWidgetSetter = () => { + const widget = { + type: "form", + id: 2, + caption: "новинка", + elements: [ + { + type: "text", + text: "ура! вышло обновление", + tags: [] + }, + { + type: "text", + text: "спешите скачать на skymp.net", + tags: [] + }, + { + type: "button", + text: "открыть skymp.net", + tags: ["ELEMENT_STYLE_MARGIN_EXTENDED"], + click: () => window.skyrimPlatform.sendMessage(events.updateRequired), + hint: "Перейти на страницу скачивания обновления", + } + ] + } + window.skyrimPlatform.widgets.set([widget]); + + // Make sure gamemode will not be able to update widgets anymore + window.skyrimPlatform.widgets = null; + } + + private loginFailedWidgetSetter = () => { + const splitParts = browserState.loginFailedReason.split('\n'); + + const textElements = splitParts.map((part) => ({ + type: "text", + text: part, + tags: [], + })); + + const widget = { + type: "form", + id: 2, + caption: "упс", + elements: new Array() + } + + textElements.forEach((element) => widget.elements.push(element)); + + widget.elements.push({ + type: "button", + text: "назад", + tags: ["ELEMENT_STYLE_MARGIN_EXTENDED"], + click: () => window.skyrimPlatform.sendMessage(events.backToLogin), + hint: undefined + }); + + window.skyrimPlatform.widgets.set([widget]); + } + private browsersideWidgetSetter = () => { console.log(new Date()); const loginWidget = { @@ -361,6 +485,115 @@ export class AuthService extends ClientListener { window.skyrimPlatform.widgets.set([loginWidget]); }; + private handleConnectionAccepted() { + this.loginWithSkympIoCredentials(); + } + + private handleConnectionDenied(e: ConnectionDenied) { + this.authAttemptProgressIndicator = false; + + if (e.error.toLowerCase().includes("invalid password")) { + this.controller.once("tick", () => { + this.controller.lookupListener(NetworkingService).close(); + }); + this.sp.browser.executeJavaScript(new FunctionInfo(this.deniedWidgetSetter).getText({ events })); + this.sp.browser.setVisible(true); + this.sp.browser.setFocused(true); + this.controller.once("update", () => { + this.sp.Game.disablePlayerControls(true, true, true, true, true, true, true, true, 0); + }); + } + } + + private loginWithSkympIoCredentials() { + this.loggingStartMoment = Date.now(); + + const authData = this.sp.storage[authGameDataStorageKey] as AuthGameData | undefined; + if (authData?.local) { + logTrace(this, + `Logging in offline mode, profileId =`, authData.local.profileId + ); + const message: CustomPacketMessage = { + t: MsgType.CustomPacket, + content: { + customPacketType: 'loginWithSkympIo', + gameData: { + profileId: authData.local.profileId, + }, + }, + }; + this.controller.emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); + return; + } + + if (authData?.remote) { + logTrace(this, 'Logging in as a master API user'); + const message: CustomPacketMessage = { + t: MsgType.CustomPacket, + content: { + customPacketType: 'loginWithSkympIo', + gameData: { + session: authData.remote.session, + }, + }, + }; + this.controller.emitter.emit("sendMessage", { + message: message, + reliability: "reliable" + }); + return; + } + + logError(this, 'Not found authentication method'); + }; + + private onTick() { + // TODO: Should be no hardcoded/magic-number limit + // TODO: Busy waiting is bad. Should be replaced with some kind of event + const maxLoggingDelay = 15000; + if (this.loggingStartMoment && Date.now() - this.loggingStartMoment > maxLoggingDelay) { + // logError(this, 'Logging in failed. Reconnecting.'); + + // browserState.comment = 'проблемы с авторизацией'; + // this.refreshWidgets(); + + // this.controller.lookupListener(NetworkingService).reconnect(); + this.loggingStartMoment = 0; + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + logTrace(this, 'max logging delay reached received'); + browserState.loginFailedReason = 'технические шоколадки\nпопробуйте еще раз\nпожалуйста\nили напишите нам в discord'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + } + + if (this.authAttemptProgressIndicator) { + this.authAttemptProgressIndicatorCounter++; + + if (this.authAttemptProgressIndicatorCounter === 1000000) { + this.authAttemptProgressIndicatorCounter = 0; + } + + const slowCounter = Math.floor(this.authAttemptProgressIndicatorCounter / 15); + + const dot = slowCounter % 3 === 0 ? '.' : slowCounter % 3 === 1 ? '..' : '...'; + + browserState.comment = "подключение" + dot; + this.refreshWidgets(); + } + } + + // private showConnectionError() { + // // TODO: unhardcode it or render via browser + // this.sp.printConsole("Server connection failed. This may be caused by one of the following:"); + // this.sp.printConsole("1. You are not present on the SkyMP Discord server"); + // this.sp.printConsole("2. You have been banned by server admins"); + // this.sp.printConsole("3. There is some technical issue. Try linking your Discord account again"); + // this.sp.printConsole("If you feel that something is wrong, please contact us on Discord."); + // }; + private isListenBrowserMessage = false; private trigger = { authNeededFired: false, @@ -373,6 +606,11 @@ export class AuthService extends ClientListener { private discordAuthState = "" + Math.random(); private authDialogOpen = false; + private loggingStartMoment = 0; + + private authAttemptProgressIndicator = false; + private authAttemptProgressIndicatorCounter = 0; + private readonly githubUrl = "https://github.com/skyrim-multiplayer/skymp"; private readonly patreonUrl = "https://www.patreon.com/skymp"; private readonly pluginAuthDataName = `auth-data-no-load`; diff --git a/skymp5-client/src/services/services/networkingService.ts b/skymp5-client/src/services/services/networkingService.ts index 83e54c3f59..dafa6e2cbc 100644 --- a/skymp5-client/src/services/services/networkingService.ts +++ b/skymp5-client/src/services/services/networkingService.ts @@ -53,9 +53,8 @@ export class NetworkingService extends ClientListener { this.sp.mpClientPlugin.destroyClient(); } - send(msg: Record, reliable: boolean) { - // TODO(#175): JS object instead of JSON? - this.sp.mpClientPlugin.send(JSON.stringify(msg), reliable); + isConnected() { + return this.sp.mpClientPlugin.isConnected(); } private onTick() { diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index 755dfd7ecf..9b1e61670b 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -17,7 +17,6 @@ import * as messages from '../../messages'; /* eslint-disable @typescript-eslint/no-empty-function */ import { ObjectReferenceEx } from '../../extensions/objectReferenceEx'; -import { AuthGameData, authGameDataStorageKey } from '../../features/authModel'; import { IdManager } from '../../lib/idManager'; import { nameof } from '../../lib/nameof'; import { setActorValuePercentage } from '../../sync/actorvalues'; @@ -33,15 +32,12 @@ 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 { 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'; @@ -49,7 +45,6 @@ import { HostStopMessage } from '../messages/hostStopMessage'; import { ConnectionMessage } from '../events/connectionMessage'; import { SetInventoryMessage } from '../messages/setInventoryMessage'; import { CreateActorMessage, CreateActorMessageAdditionalProps } from '../messages/createActorMessage'; -import { CustomPacketMessage2 } from '../messages/customPacketMessage2'; import { DestroyActorMessage } from '../messages/destroyActorMessage'; import { SetRaceMenuOpenMessage } from '../messages/setRaceMenuOpenMessage'; import { UpdatePropertyMessage } from '../messages/updatePropertyMessage'; @@ -59,7 +54,6 @@ import { TeleportMessage2 } from '../messages/teleportMessage2'; import { getObjectReference, getViewFromStorage, - localIdToRemoteId, remoteIdToLocalId, } from '../../view/worldViewMisc'; import { TimeService } from './timeService'; @@ -97,8 +91,6 @@ export class RemoteServer extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { super(); - this.controller.on("tick", () => this.onTick()); - 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)); @@ -111,7 +103,6 @@ export class RemoteServer extends ClientListener { this.controller.emitter.on("teleportMessage", (e) => this.onTeleportMessage(e)); this.controller.emitter.on("teleportMessage2", (e) => this.onTeleportMessage(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("updatePropertyMessage", (e) => this.onUpdatePropertyMessage(e)); @@ -120,18 +111,6 @@ export class RemoteServer extends ClientListener { this.controller.emitter.on("connectionAccepted", () => this.handleConnectionAccepted()); } - private onTick() { - // TODO: Should be no hardcoded/magic-number limit - // TODO: Busy waiting is bad. Should be replaced with some kind of event - const maxLoggingDelay = 15000; - if (this.loggingStartMoment && Date.now() - this.loggingStartMoment > maxLoggingDelay) { - logError(this, 'Logging in failed. Reconnecting.'); - this.showConnectionError(); - this.controller.lookupListener(NetworkingService).reconnect(); - this.loggingStartMoment = 0; - } - } - private onHostStartMessage(event: ConnectionMessage) { const msg = event.message; const target = msg.target; @@ -330,8 +309,6 @@ export class RemoteServer extends ClientListener { logTrace(this, "Create actor"); - this.loggingStartMoment = 0; - const i = this.getIdManager().allocateIdFor(msg.idx); if (this.worldModel.forms.length <= i) { this.worldModel.forms.length = i + 1; @@ -764,8 +741,6 @@ export class RemoteServer extends ClientListener { this.worldModel.playerCharacterFormIdx = -1; logTrace(this, "Handle connection accepted"); - - this.loginWithSkympIoCredentials(); } private onChangeValuesMessage(event: ConnectionMessage): void { @@ -799,17 +774,6 @@ export class RemoteServer extends ClientListener { } } - private onCustomPacketMessage2(event: ConnectionMessage): void { - const msg = event.message; - - switch (msg.content.customPacketType) { - case 'loginRequired': - logTrace(this, 'loginRequired received'); - this.loginWithSkympIoCredentials(); - break; - } - } - /** Packet handlers end **/ getWorldModel(): WorldModel { @@ -839,51 +803,6 @@ export class RemoteServer extends ClientListener { return storage["idManager"] as IdManager; } - private loginWithSkympIoCredentials() { - this.loggingStartMoment = Date.now(); - - const authData = storage[authGameDataStorageKey] as AuthGameData | undefined; - if (authData?.local) { - logTrace(this, - `Logging in offline mode, profileId =`, authData.local.profileId - ); - const message: CustomPacketMessage = { - t: messages.MsgType.CustomPacket, - content: { - customPacketType: 'loginWithSkympIo', - gameData: { - profileId: authData.local.profileId, - }, - }, - }; - this.controller.emitter.emit("sendMessage", { - message: message, - reliability: "reliable" - }); - return; - } - - if (authData?.remote) { - logTrace(this, 'Logging in as a master API user'); - const message: CustomPacketMessage = { - t: messages.MsgType.CustomPacket, - content: { - customPacketType: 'loginWithSkympIo', - gameData: { - session: authData.remote.session, - }, - }, - }; - this.controller.emitter.emit("sendMessage", { - message: message, - reliability: "reliable" - }); - return; - } - - logError(this, 'Not found authentication method'); - }; - private onceLoad( refrId: number, callback: (refr: ObjectReference) => void, @@ -910,15 +829,4 @@ export class RemoteServer extends ClientListener { // Optimization added in #1186, however it doesn't work for doors for some reason return msg.refrId && msg.refrId < 0xff000000 && msg.baseRecordType !== 'DOOR'; }; - - private showConnectionError() { - // TODO: unhardcode it or render via browser - this.sp.printConsole("Server connection failed. This may be caused by one of the following:"); - this.sp.printConsole("1. You are not present on the SkyMP Discord server"); - this.sp.printConsole("2. You have been banned by server admins"); - this.sp.printConsole("3. There is some technical issue. Try linking your Discord account again"); - this.sp.printConsole("If you feel that something is wrong, please contact us on Discord."); - }; - - private loggingStartMoment = 0; } diff --git a/skymp5-client/src/services/services/skympClient.ts b/skymp5-client/src/services/services/skympClient.ts index 07234aba58..e46c340382 100644 --- a/skymp5-client/src/services/services/skympClient.ts +++ b/skymp5-client/src/services/services/skympClient.ts @@ -102,7 +102,9 @@ export class SkympClient extends ClientListener { } private establishConnectionConditional() { - if (storage.targetIp !== targetIp || storage.targetPort !== targetPort) { + const isConnected = this.controller.lookupListener(networking.NetworkingService).isConnected(); + + if (!isConnected || storage.targetIp !== targetIp || storage.targetPort !== targetPort) { storage.targetIp = targetIp; storage.targetPort = targetPort; From e9f965635169e2e57c27ab21506e46ab1d71717a Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Thu, 23 May 2024 02:24:52 +0500 Subject: [PATCH 07/78] internal: improve misc/gather_prs_locally.cmake (#1988) --- misc/gather_prs_locally.cmake | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/misc/gather_prs_locally.cmake b/misc/gather_prs_locally.cmake index c4e033d2ea..6ff387a622 100644 --- a/misc/gather_prs_locally.cmake +++ b/misc/gather_prs_locally.cmake @@ -35,12 +35,14 @@ if("${GITHUB_TOKEN}" STREQUAL "") message(FATAL_ERROR "GITHUB_TOKEN is not set") endif() +# P.S. GITHUB_TOKEN is used for skyrim-multiplayer/skymp as well to increase the rate limit set(ENV_INPUT_REPOSITORIES " [ { \"owner\": \"skyrim-multiplayer\", \"repo\": \"skymp\", - \"labels\": [\"merge-to:indev\"] + \"labels\": [\"merge-to:indev\"], + \"token\": \"${GITHUB_TOKEN}\" }, { \"owner\": \"skyrim-multiplayer\", @@ -80,7 +82,7 @@ endif() message(STATUS "Current branch: ${CURRENT_BRANCH}") -if(CURRENT_BRANCH MATCHES main) +if("${CURRENT_BRANCH}" STREQUAL main) message(STATUS "Main branch detected. Switching to a temporary branch") string(TIMESTAMP TIMESTAMP "%Y%m%d%H%M%S") @@ -97,6 +99,8 @@ if(CURRENT_BRANCH MATCHES main) else() message(FATAL_ERROR "Failed to switch to a temporary branch: ${GIT_SWITCH_OUTPUT}") endif() +else() + message(FATAL_ERROR "Not a main branch, please switch to main and run the script again") endif() # Commit the changes locally From c3a4b8e5be24006037c6f61316f164de5605a3b2 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 24 May 2024 23:18:31 +0500 Subject: [PATCH 08/78] fix(skymp5-client): fix use of uninitialized services (#1991) --- skymp5-client/src/services/services/skympClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skymp5-client/src/services/services/skympClient.ts b/skymp5-client/src/services/services/skympClient.ts index e46c340382..a580008e53 100644 --- a/skymp5-client/src/services/services/skympClient.ts +++ b/skymp5-client/src/services/services/skympClient.ts @@ -87,7 +87,8 @@ export class SkympClient extends ClientListener { } private startClient() { - this.establishConnectionConditional(); + // once("tick", ...) is needed to ensure networking service initialized + this.controller.once("tick", () => this.establishConnectionConditional()); this.ctor(); } From 9ce2823a91a4c6972a1068d357c6320713ee5c64 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 27 May 2024 00:32:02 +0500 Subject: [PATCH 09/78] fix(skymp5-server): fix tomes ids issue & detect attempts to learn the same twice (#1983) --- .../cpp/server_guest_lib/MpActor.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index 1d247b96d7..fafef774cb 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -770,15 +770,36 @@ void MpActor::EatItem(uint32_t baseId, espm::Type t) bool MpActor::ReadBook(const uint32_t baseId) { + auto& loader = GetParent()->GetEspm(); + auto bookLookupResult = loader.GetBrowser().LookupById(baseId); + + if (!bookLookupResult.rec) { + spdlog::error("ReadBook {:x} - No book form {:x}", GetFormId(), baseId); + return false; + } + const auto bookData = espm::GetData(baseId, GetParent()); + const auto spellOrSkillFormId = + bookLookupResult.ToGlobalId(bookData.spellOrSkillFormId); if (bookData.IsFlagSet(espm::BOOK::Flags::TeachesSpell)) { + if (ChangeForm().learnedSpells.IsSpellLearned(spellOrSkillFormId)) { + spdlog::info( + "ReadBook {:x} - Spell already learned {:x}, not spending the book", + GetFormId(), spellOrSkillFormId); + return false; + } EditChangeForm([&](MpChangeForm& changeForm) { - changeForm.learnedSpells.LearnSpell(bookData.spellOrSkillFormId); + changeForm.learnedSpells.LearnSpell(spellOrSkillFormId); }); return true; + } else if (bookData.IsFlagSet(espm::BOOK::Flags::TeachesSkill)) { + spdlog::info("ReadBook {:x} - Skill book {:x} detected, not implemented", + GetFormId(), baseId); + return false; } + return false; } From e6e103bd75b6fe5afa9b9553f73d321ffe9b652a Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 27 May 2024 02:49:40 +0500 Subject: [PATCH 10/78] docs: update SP release instructions (#1968) --- docs/contributing/en/How to release SP update.md | 4 ++-- ...276\320\262\320\273\320\265\320\275\320\270\320\265 SP.md" | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/contributing/en/How to release SP update.md b/docs/contributing/en/How to release SP update.md index 44640bee90..a191f48d7e 100644 --- a/docs/contributing/en/How to release SP update.md +++ b/docs/contributing/en/How to release SP update.md @@ -4,7 +4,7 @@ 4. Run the SP Types Update action https://github.com/skyrim-multiplayer/skymp/actions/workflows/trigger-sp-types-update.yml. It usually takes less than a minute. 5. Make sure that the commit to the main branch https://github.com/skyrim-platform/skyrim-platform arrived here. 6. Wait for PR Windows, PR Windows AE actions to be built in the main branch. -7. Download SP-AE-nexus.zip and SP-SE-nexus.zip from artifacts. Rename them to `Skyrim Platform 2.X.0 (Anniversary Edition)` and `Skyrim Platform 2.X.0 (Special Edition)`. +7. Download `Skyrim Platform 2.X.0 (Anniversary Edition).zip` and `Skyrim Platform 2.X.0 (Special Edition).zip` from artifacts. 8. Go to file management on Nexus https://www.nexusmods.com/skyrimspecialedition/mods/edit/?id=54909&game_id=1704&step=files, scroll down to "Add a new file" and fill in all the items: File name: `Skyrim Platform 2.X.0 (Anniversary Edition)` @@ -15,7 +15,7 @@ This is the latest version of the mod: `✔️` File category: `Main files` -This is a new version of an existing file (optional): `❌` +This is a new version of an existing file (optional): `Yes it is!` Description: ``` diff --git "a/docs/contributing/ru/\320\232\320\260\320\272 \320\262\321\213\320\277\321\203\321\201\321\202\320\270\321\202\321\214 \320\276\320\261\320\275\320\276\320\262\320\273\320\265\320\275\320\270\320\265 SP.md" "b/docs/contributing/ru/\320\232\320\260\320\272 \320\262\321\213\320\277\321\203\321\201\321\202\320\270\321\202\321\214 \320\276\320\261\320\275\320\276\320\262\320\273\320\265\320\275\320\270\320\265 SP.md" index 9d0ada4726..7c77870b79 100644 --- "a/docs/contributing/ru/\320\232\320\260\320\272 \320\262\321\213\320\277\321\203\321\201\321\202\320\270\321\202\321\214 \320\276\320\261\320\275\320\276\320\262\320\273\320\265\320\275\320\270\320\265 SP.md" +++ "b/docs/contributing/ru/\320\232\320\260\320\272 \320\262\321\213\320\277\321\203\321\201\321\202\320\270\321\202\321\214 \320\276\320\261\320\275\320\276\320\262\320\273\320\265\320\275\320\270\320\265 SP.md" @@ -4,7 +4,7 @@ 4. Запустить экшон SP Types Update https://github.com/skyrim-multiplayer/skymp/actions/workflows/trigger-sp-types-update.yml. Он обычно выполняется не более минуты. 5. Убедиться, что сюда прилетел коммит в main ветку https://github.com/skyrim-platform/skyrim-platform. 6. Ждать, пока соберётся PR Windows, PR Windows AE экшоны в мейн ветке. -7. Скачать SP-AE-nexus.zip и SP-SE-nexus.zip из артефактов. Переименовать их в `Skyrim Platform 2.X.0 (Anniversary Edition)` и `Skyrim Platform 2.X.0 (Special Edition)`. +7. Скачать `Skyrim Platform 2.X.0 (Anniversary Edition).zip` и `Skyrim Platform 2.X.0 (Special Edition).zip` из артефактов. 8. Зайти в управление файлами на Nexus https://www.nexusmods.com/skyrimspecialedition/mods/edit/?id=54909&game_id=1704&step=files, пролистать до "Add a new file" и заполнить все пункты: File name: `Skyrim Platform 2.X.0 (Anniversary Edition)` @@ -15,7 +15,7 @@ This is the latest version of the mod: `✔️` File category: `Main files` -This is a new version of an existing file (optional): `❌` +This is a new version of an existing file (optional): `Yes it is!` Description: ``` From 505cb098cca1603ba018443ca2793ddea851b5e6 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 27 May 2024 04:00:34 +0500 Subject: [PATCH 11/78] feat(skymp5-server): change default respawn time for IsCreatedAsPlayer() false (#1995) --- skymp5-server/cpp/server_guest_lib/MpActor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index fafef774cb..4201ecc8f1 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -1158,7 +1158,7 @@ LocationalData MpActor::GetEditorLocationalData() const const float MpActor::GetRespawnTime() const { if (!IsCreatedAsPlayer()) { - static const auto kNpcSpawnDelay = 100 /*6 * 60.f * 60.f*/; + static const auto kNpcSpawnDelay = 2 * 60.f * 60.f; return kNpcSpawnDelay; } return ChangeForm().spawnDelay; From 119235c1d920452ac708e7111f4c06c5b54cf930 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 28 May 2024 16:40:27 +0500 Subject: [PATCH 12/78] feat(skymp5-server): de-hardcode spawnDelay property for all actors (#1998) --- skymp5-server/cpp/server_guest_lib/MpActor.cpp | 4 ---- skymp5-server/cpp/server_guest_lib/MpChangeForms.h | 1 - 2 files changed, 5 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index 4201ecc8f1..89518be6d6 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -1157,10 +1157,6 @@ LocationalData MpActor::GetEditorLocationalData() const const float MpActor::GetRespawnTime() const { - if (!IsCreatedAsPlayer()) { - static const auto kNpcSpawnDelay = 2 * 60.f * 60.f; - return kNpcSpawnDelay; - } return ChangeForm().spawnDelay; } diff --git a/skymp5-server/cpp/server_guest_lib/MpChangeForms.h b/skymp5-server/cpp/server_guest_lib/MpChangeForms.h index 9c5f810b33..278fd24849 100644 --- a/skymp5-server/cpp/server_guest_lib/MpChangeForms.h +++ b/skymp5-server/cpp/server_guest_lib/MpChangeForms.h @@ -96,7 +96,6 @@ class MpChangeFormREFR { 0.f, 0.f, 72.f }, FormDesc::Tamriel() }; - // Used only for player characters. See GetSpawnDelay float spawnDelay = 25.0f; std::vector templateChain; From fa1cb84544414065d637d9b0ca453ff126310157 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 28 May 2024 17:25:31 +0500 Subject: [PATCH 13/78] docs: document isDead property caveats (#1999) --- docs/docs_properties_system.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs_properties_system.md b/docs/docs_properties_system.md index 7def6f08bb..4f86259d30 100644 --- a/docs/docs_properties_system.md +++ b/docs/docs_properties_system.md @@ -17,6 +17,7 @@ These properties can be modified by a script with `mp.set`. - appearance - isOpen - isDisabled +- isDead (setting isDead to true will not initiate respawn timer. only "natural" actor deaths lead to respawn. consider using DamageActorValue) ### Readonly properties From 5bdf68b161f50a0a25c926e82817c1404bc7e401 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 28 May 2024 18:11:19 +0500 Subject: [PATCH 14/78] docs: revert "document isDead property caveats" (#2000) --- docs/docs_properties_system.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs_properties_system.md b/docs/docs_properties_system.md index 4f86259d30..7def6f08bb 100644 --- a/docs/docs_properties_system.md +++ b/docs/docs_properties_system.md @@ -17,7 +17,6 @@ These properties can be modified by a script with `mp.set`. - appearance - isOpen - isDisabled -- isDead (setting isDead to true will not initiate respawn timer. only "natural" actor deaths lead to respawn. consider using DamageActorValue) ### Readonly properties From 3f43d929bac3ea21feea6ec72a0e0584f265fc1e Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 28 May 2024 19:16:57 +0500 Subject: [PATCH 15/78] fix(skymp5-server): mp.set isDead lacks respawn timer and onDeath event emit (#2001) --- .../cpp/server_guest_lib/MpActor.cpp | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index 89518be6d6..f91df5390b 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -659,6 +659,10 @@ bool MpActor::IsCreatedAsPlayer() const void MpActor::SendAndSetDeathState(bool isDead, bool shouldTeleport) { + spdlog::trace( + "MpActor::SendAndSetDeathState {:x} - isDead: {}, shouldTeleport: {}", + GetFormId(), isDead, shouldTeleport); + float attribute = isDead ? 0.f : 1.f; auto position = GetSpawnPoint(); @@ -709,6 +713,9 @@ DeathStateContainerMessage MpActor::GetDeathStateMsg( void MpActor::MpApiDeath(MpActor* killer) { + spdlog::trace("MpActor::MpApiDeath {:x} - killer is {:x}", GetFormId(), + killer ? killer->GetFormId() : 0); + simdjson::dom::parser parser; bool isRespawnBlocked = false; @@ -724,6 +731,9 @@ void MpActor::MpApiDeath(MpActor* killer) } } + spdlog::trace("MpActor::MpApiDeath {:x} - isRespawnBlocked: {}", GetFormId(), + isRespawnBlocked); + if (!isRespawnBlocked) { RespawnWithDelay(); } @@ -979,6 +989,10 @@ void MpActor::Init(WorldState* worldState, uint32_t formId, bool hasChangeForm) void MpActor::Kill(MpActor* killer, bool shouldTeleport) { + spdlog::trace("MpActor::Kill {:x} - killer is {:x}", GetFormId(), + killer ? killer->GetFormId() : 0); + + // Keep in sync with MpActor::SetIsDead SendAndSetDeathState(true, shouldTeleport); MpApiDeath(killer); AddDeathItem(); @@ -986,6 +1000,9 @@ void MpActor::Kill(MpActor* killer, bool shouldTeleport) void MpActor::RespawnWithDelay(bool shouldTeleport) { + spdlog::trace("MpActor::RespawnWithDelay {:x} - isRespawning: {}", + GetFormId(), pImpl->isRespawning); + if (pImpl->isRespawning) { return; } @@ -1168,15 +1185,28 @@ void MpActor::SetRespawnTime(float time) void MpActor::SetIsDead(bool isDead) { + spdlog::trace("MpActor::SetIsDead {:x} - isDead: {}", GetFormId(), isDead); + constexpr bool kShouldTeleport = false; if (isDead) { if (IsDead() == false) { + + // Keep in sync with MpActor::Kill SendAndSetDeathState(isDead, kShouldTeleport); + MpApiDeath(nullptr); + AddDeathItem(); + + spdlog::trace("MpActor::SetIsDead {:x} - actor is now dead", + GetFormId()); + } else { + spdlog::trace("MpActor::SetIsDead {:x} - actor is already dead", + GetFormId()); } } else { // same as SendAndSetDeathState but resets isRespawning flag Respawn(kShouldTeleport); + spdlog::trace("MpActor::SetIsDead {:x} - actor is now alive", GetFormId()); } } From 837b02d76bc5e7f1c20c8472a99fdd4509b199e1 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 29 May 2024 00:08:09 +0500 Subject: [PATCH 16/78] internal: commit test_isdead.js (#2002) --- misc/tests/test_isdead.js | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 misc/tests/test_isdead.js diff --git a/misc/tests/test_isdead.js b/misc/tests/test_isdead.js new file mode 100644 index 0000000000..c6c9a746b0 --- /dev/null +++ b/misc/tests/test_isdead.js @@ -0,0 +1,50 @@ +const assert = require("node:assert"); + +const main = async () => { + const crabActorId = 0xDC558; + + let onDeathCalledWith = null; + let onRespawnCalledWith = null; + + mp.onDeath = (actorId, killerId) => { + onDeathCalledWith = { actorId, killerId }; + }; + + mp.onRespawn = (actorId) => { + onRespawnCalledWith = { actorId }; + }; + + mp.set(crabActorId, "spawnDelay", 0); + + assert.deepEqual(onDeathCalledWith, null); + assert.deepEqual(onRespawnCalledWith, null); + assert.strictEqual(mp.get(crabActorId, "isDead"), false); + + // variant 1 + // mp.callPapyrusFunction("method", "Actor", "DamageActorValue", + // { type: "form", desc: mp.getDescFromId(crabActorId) }, + // ["Health", 1000] + // ); + + // variant 2 + mp.set(crabActorId, "isDead", true); + + assert.deepEqual(onDeathCalledWith, { actorId: crabActorId, killerId: 0 }); + assert.deepEqual(onRespawnCalledWith, null); + assert.strictEqual(mp.get(crabActorId, "isDead"), true); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + assert.deepEqual(onDeathCalledWith, { actorId: crabActorId, killerId: 0 }); + assert.deepEqual(onRespawnCalledWith, { actorId: crabActorId }); + assert.strictEqual(mp.get(crabActorId, "isDead"), false); +}; + +main().then(() => { + console.log("Test passed!"); + process.exit(0); +}).catch((err) => { + console.log("Test failed!") + console.error(err); + process.exit(1); +}); From 2a2897ba5d617c49c30c8555986c2f73ee18f184 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 29 May 2024 04:55:39 +0500 Subject: [PATCH 17/78] fix(skymp5-server): fix possible partial save of location (#2005) --- misc/tests/test_partial_location_save.js | 75 +++++++++++++++++++ .../dc558_Skyrim.esm.json | 63 ++++++++++++++++ .../cpp/server_guest_lib/ActionListener.cpp | 4 +- .../server_guest_lib/MpObjectReference.cpp | 29 +++++-- .../cpp/server_guest_lib/MpObjectReference.h | 17 ++++- 5 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 misc/tests/test_partial_location_save.js create mode 100644 misc/tests/test_partial_location_save/dc558_Skyrim.esm.json diff --git a/misc/tests/test_partial_location_save.js b/misc/tests/test_partial_location_save.js new file mode 100644 index 0000000000..0f39b9dfca --- /dev/null +++ b/misc/tests/test_partial_location_save.js @@ -0,0 +1,75 @@ +const assert = require("node:assert"); +const fs = require("node:fs"); + +const main = async () => { + const crabActorId = 0xDC558; + + // trigger lazy load of the actor + mp.get(crabActorId, "isDead"); + + // Imagine the actor died and was teleported to a different cell + // Then the server restarts + + assert.strictEqual(mp.get(crabActorId, "isDead"), true); + + const initialPos = [ + 25.0, + -707.0, + 0.0 + ]; + const initialCellOrWorldDesc = "37ee0:Skyrim.esm"; + + assert.deepEqual(mp.get(crabActorId, "pos"), initialPos); + assert.strictEqual(mp.get(crabActorId, "worldOrCellDesc"), initialCellOrWorldDesc); + + + let respawned = false; + + mp.onRespawn = (actorId) => { + if (actorId === crabActorId) { + respawned = true; + } + }; + + // spawnDelay should be 0 so just wait a bit + await new Promise((resolve) => setTimeout(resolve, 1000)); + + assert.strictEqual(respawned, true); + + + assert.strictEqual(mp.get(crabActorId, "isDead"), false); + + const expectedPos = [ + 9409.498046875, + 9289.421875, + -5150.0 + ]; + + assert.deepEqual(mp.get(crabActorId, "pos"), expectedPos); + + // mp.set(crabActorId, "private.foo", "bar"); // force save + + const expectedCellOrWorldDesc = "3c:Skyrim.esm"; + + const cellOrWorldDesc = mp.get(crabActorId, "worldOrCellDesc"); + assert.strictEqual(cellOrWorldDesc, expectedCellOrWorldDesc); + + // database flush + await new Promise((resolve) => setTimeout(resolve, 5000)); + + assert.deepEqual(mp.get(crabActorId, "pos"), expectedPos); + + const changeForm = JSON.parse(fs.readFileSync("world/changeForms/dc558_Skyrim.esm.json", "utf8")); + + assert.strictEqual(changeForm.worldOrCellDesc, expectedCellOrWorldDesc); + assert.deepEqual(changeForm.position, expectedPos); +}; + +main().then(() => { + console.log("Test passed!"); + process.exit(0); +}).catch((err) => { + console.log("Test failed!") + console.error(err); + process.exit(1); +}); diff --git a/misc/tests/test_partial_location_save/dc558_Skyrim.esm.json b/misc/tests/test_partial_location_save/dc558_Skyrim.esm.json new file mode 100644 index 0000000000..e21de79555 --- /dev/null +++ b/misc/tests/test_partial_location_save/dc558_Skyrim.esm.json @@ -0,0 +1,63 @@ +{ + "angle": [ + 0.0, + -0.0, + 111.72675323486328 + ], + "appearanceDump": null, + "baseContainerAdded": true, + "baseDesc": "8cacc:Skyrim.esm", + "consoleCommandsAllowed": false, + "count": 0, + "dynamicFields": {}, + "effects": [], + "equipmentDump": { + "instantSpell": 0, + "inv": { + "entries": [] + }, + "leftSpell": 0, + "numChanges": 1, + "rightSpell": 0, + "voiceSpell": 0 + }, + "formDesc": "dc558:Skyrim.esm", + "healthPercentage": 1.0, + "inv": { + "entries": [] + }, + "isDead": true, + "isDeleted": false, + "isDisabled": false, + "isHarvested": false, + "isOpen": false, + "isRaceMenuOpen": true, + "learnedSpells": [], + "magickaPercentage": 1.0, + "nextRelootDatetime": 0, + "position": [ + 25.0, + -707.0, + 0.0 + ], + "profileId": -1, + "recType": 1, + "spawnDelay": 0.0, + "spawnPoint_cellOrWorldDesc": "3c:Skyrim.esm", + "spawnPoint_pos": [ + 133857.0, + -61130.0, + 14662.0 + ], + "spawnPoint_rot": [ + 0.0, + 0.0, + 72.0 + ], + "staminaPercentage": 1.0, + "templateChain": [ + "8cacc:Skyrim.esm", + "e4011:Skyrim.esm" + ], + "worldOrCellDesc": "37ee0:Skyrim.esm" +} diff --git a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp index c9b5635bec..082b3ba15b 100644 --- a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp +++ b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp @@ -135,8 +135,8 @@ void ActionListener::OnUpdateMovement(const RawMessageData& rawMsgData, actor->ResetBlockCount(); } - actor->SetPos(pos); - actor->SetAngle(rot); + actor->SetPos(pos, SetPosMode::CalledByUpdateMovement); + actor->SetAngle(rot, SetAngleMode::CalledByUpdateMovement); actor->SetAnimationVariableBool("bInJumpState", isInJumpState); actor->SetAnimationVariableBool("_skymp_isWeapDrawn", isWeapDrawn); actor->SetAnimationVariableBool("IsBlocking", isBlocking); diff --git a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp index cf652b7c09..7167ffe730 100644 --- a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp @@ -134,11 +134,25 @@ struct MpObjectReference::Impl }; namespace { -auto MakeMode(bool isLocationSaveNeeded) +ChangeFormGuard::Mode MakeMode(bool isLocationSaveNeeded, + SetPosMode setPosMode) { - return isLocationSaveNeeded ? ChangeFormGuard::Mode::RequestSave - : ChangeFormGuard::Mode::NoRequestSave; + if (isLocationSaveNeeded) { + return ChangeFormGuard::Mode::RequestSave; + } + + switch (setPosMode) { + case SetPosMode::CalledByUpdateMovement: + return ChangeFormGuard::Mode::NoRequestSave; + case SetPosMode::Other: + return ChangeFormGuard::Mode::RequestSave; + default: + spdlog::critical("Invalid SetPosMode"); + std::terminate(); + return ChangeFormGuard::Mode::RequestSave; + } } + MpChangeForm MakeChangeForm(const LocationalData& locationalData) { MpChangeForm changeForm; @@ -439,14 +453,14 @@ void MpObjectReference::Enable() } } -void MpObjectReference::SetPos(const NiPoint3& newPos) +void MpObjectReference::SetPos(const NiPoint3& newPos, SetPosMode setPosMode) { auto oldGridPos = GetGridPos(ChangeForm().position); auto newGridPos = GetGridPos(newPos); EditChangeForm( [&newPos](MpChangeFormREFR& changeForm) { changeForm.position = newPos; }, - MakeMode(IsLocationSavingNeeded())); + MakeMode(IsLocationSavingNeeded(), setPosMode)); if (oldGridPos != newGridPos || !everSubscribedOrListened) ForceSubscriptionsUpdate(); @@ -520,11 +534,12 @@ void MpObjectReference::SetPos(const NiPoint3& newPos) } } -void MpObjectReference::SetAngle(const NiPoint3& newAngle) +void MpObjectReference::SetAngle(const NiPoint3& newAngle, + SetAngleMode setAngleMode) { EditChangeForm( [&](MpChangeFormREFR& changeForm) { changeForm.angle = newAngle; }, - MakeMode(IsLocationSavingNeeded())); + MakeMode(IsLocationSavingNeeded(), setAngleMode)); } void MpObjectReference::SetHarvested(bool harvested) diff --git a/skymp5-server/cpp/server_guest_lib/MpObjectReference.h b/skymp5-server/cpp/server_guest_lib/MpObjectReference.h index 874fb5b89e..2c8dcfed2c 100644 --- a/skymp5-server/cpp/server_guest_lib/MpObjectReference.h +++ b/skymp5-server/cpp/server_guest_lib/MpObjectReference.h @@ -50,6 +50,17 @@ enum class VisitPropertiesMode All }; +enum class SetPosMode +{ + // Will save pos from time to time + CalledByUpdateMovement, + + // Will save pos immediately + Other +}; + +using SetAngleMode = SetPosMode; + class MpObjectReference : public MpForm , public FormIndex @@ -98,8 +109,10 @@ class MpObjectReference virtual void Disable(); virtual void Enable(); - void SetPos(const NiPoint3& newPos); - void SetAngle(const NiPoint3& newAngle); + void SetPos(const NiPoint3& newPos, + SetPosMode setPosMode = SetPosMode::Other); + void SetAngle(const NiPoint3& newAngle, + SetAngleMode setAngleMode = SetAngleMode::Other); void SetHarvested(bool harvested); void SetOpen(bool open); void PutItem(MpActor& actor, const Inventory::Entry& entry); From da77b8f6b5b9c9aa40f9718fcfbeb6f8a28b5c0e Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 29 May 2024 22:55:53 +0500 Subject: [PATCH 18/78] internal: teach launch_server.bat to preserve server's exit code (#2006) --- skymp5-server/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skymp5-server/CMakeLists.txt b/skymp5-server/CMakeLists.txt index 9291d14e67..6b42a06765 100644 --- a/skymp5-server/CMakeLists.txt +++ b/skymp5-server/CMakeLists.txt @@ -59,10 +59,12 @@ if(WIN32) file(WRITE ${PROJECT_BINARY_DIR}/launch_server.bat.tmp "${bat}") # Same but for dev (no pause, placed into ./build dir) - set(bat "setlocal\n") # setlocal/endlocal is for temporary cd + set(bat "@echo off\n") # Turn off command echoing + set(bat "${bat}setlocal\n") # setlocal/endlocal is for temporary cd set(bat "${bat}cd dist/server\n") set(bat "${bat}node dist_back/skymp5-server.js\n") - set(bat "${bat}endlocal\n") + set(bat "${bat}set EXIT_CODE=%ERRORLEVEL%\n") + set(bat "${bat}endlocal & exit /B %EXIT_CODE%\n") file(WRITE ${CMAKE_BINARY_DIR}/launch_server.bat "${bat}") add_custom_command( From 59c66ce764840a69bdb4ef5e0a03abe009f52395 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 29 May 2024 23:01:41 +0500 Subject: [PATCH 19/78] fix(skymp5-server): fix error handling in CreateActor (#2007) --- misc/tests/test_crash.js | 32 +++++++++++++++++++++++++ skymp5-server/cpp/addon/ScampServer.cpp | 18 +++++++------- 2 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 misc/tests/test_crash.js diff --git a/misc/tests/test_crash.js b/misc/tests/test_crash.js new file mode 100644 index 0000000000..9c4cbc9b07 --- /dev/null +++ b/misc/tests/test_crash.js @@ -0,0 +1,32 @@ +const assert = require("node:assert"); +const fs = require("node:fs"); + +const main = async () => { + // instead of [0,0,0] there should be only angleZ + // in older versions of the server, this would crash the server + + let thrown = false; + let unexpectedError = ""; + try { + mp.createActor(0, [1, 1, 1], [0, 0, 0], "3c:Skyrim.esm"); + } catch (e) { + if (e.message.includes("Expected 'angleZ' to be number, but got '[0,0,0]'")) { + thrown = true; + } + else { + unexpectedError = e.message; + } + } + + assert.equal(unexpectedError, ""); + assert.equal(thrown, true); +}; + +main().then(() => { + console.log("Test passed!"); + process.exit(0); +}).catch((err) => { + console.log("Test failed!") + console.error(err); + process.exit(1); +}); diff --git a/skymp5-server/cpp/addon/ScampServer.cpp b/skymp5-server/cpp/addon/ScampServer.cpp index b2977a9beb..c4ad1aec18 100644 --- a/skymp5-server/cpp/addon/ScampServer.cpp +++ b/skymp5-server/cpp/addon/ScampServer.cpp @@ -402,15 +402,17 @@ Napi::Value ScampServer::On(const Napi::CallbackInfo& info) Napi::Value ScampServer::CreateActor(const Napi::CallbackInfo& info) { - auto formId = NapiHelper::ExtractUInt32(info[0], "formId"); - auto pos = NapiHelper::ExtractNiPoint3(info[1], "pos"); - auto angleZ = NapiHelper::ExtractFloat(info[2], "angleZ"); - auto cellOrWorld = NapiHelper::ExtractUInt32(info[3], "cellOrWorld"); - - int32_t userProfileId = -1; - if (info[4].IsNumber()) - userProfileId = info[4].As().Int32Value(); try { + auto formId = NapiHelper::ExtractUInt32(info[0], "formId"); + auto pos = NapiHelper::ExtractNiPoint3(info[1], "pos"); + auto angleZ = NapiHelper::ExtractFloat(info[2], "angleZ"); + auto cellOrWorld = NapiHelper::ExtractUInt32(info[3], "cellOrWorld"); + + int32_t userProfileId = -1; + if (info[4].IsNumber()) { + userProfileId = info[4].As().Int32Value(); + } + uint32_t res = partOne->CreateActor(formId, pos, angleZ, cellOrWorld, userProfileId); return Napi::Number::New(info.Env(), res); From 3cd43303024440a71a2eb3700e7a854662573f32 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Thu, 30 May 2024 01:55:37 +0500 Subject: [PATCH 20/78] feat(skymp5-client): fix collision in anims & separate settings interfaces folder (#2008) --- .../messages_settings/animDebugSettings.ts | 12 ++++ .../src/services/services/animDebugService.ts | 55 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 skymp5-client/src/services/messages_settings/animDebugSettings.ts diff --git a/skymp5-client/src/services/messages_settings/animDebugSettings.ts b/skymp5-client/src/services/messages_settings/animDebugSettings.ts new file mode 100644 index 0000000000..8779f83544 --- /dev/null +++ b/skymp5-client/src/services/messages_settings/animDebugSettings.ts @@ -0,0 +1,12 @@ +export interface AnimTextOutput { + isActive?: boolean; + itemCount?: number; + startPos?: { x: number, y: number }, + yPosDelta?: number, +} + +export interface AnimDebugSettings { + isActive?: boolean; + textOutput?: AnimTextOutput, + animKeys?: { [index: number]: string }; +} diff --git a/skymp5-client/src/services/services/animDebugService.ts b/skymp5-client/src/services/services/animDebugService.ts index 3bd8e7436d..e4a5cfd3c1 100644 --- a/skymp5-client/src/services/services/animDebugService.ts +++ b/skymp5-client/src/services/services/animDebugService.ts @@ -1,6 +1,7 @@ import { logTrace, logError } from "../../logging"; +import { AnimDebugSettings } from "../messages_settings/animDebugSettings"; import { ClientListener, CombinedController, Sp } from "./clientListener"; -import { ButtonEvent } from "skyrimPlatform"; +import { ButtonEvent, CameraStateChangedEvent, DxScanCode } from "skyrimPlatform"; export class AnimDebugService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { @@ -35,8 +36,12 @@ export class AnimDebugService extends ClientListener { } if (this.settings.animKeys) { + logTrace(this, `Found animKeys in settings. Registering buttonEvent listener`); this.controller.on("buttonEvent", (e) => this.onButtonEvent(e)); } + else { + logError(this, `No animKeys defined in settings`); + } } private onSendAnimationEventLeave(ctx: { animEventName: string, animationSucceeded: boolean }) { @@ -46,26 +51,46 @@ export class AnimDebugService extends ClientListener { } private onButtonEvent(e: ButtonEvent) { - if (e.isUp && this.settings && this.settings.animKeys![e.code]) { - this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), this.settings.animKeys![e.code]); + // TODO: de-hardcode controls + if (e.code === DxScanCode.Spacebar + || e.code === DxScanCode.W + || e.code === DxScanCode.A + || e.code === DxScanCode.S + || e.code === DxScanCode.D) { + + if (this.needsExitingAnim) { + + this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), "IdleForceDefaultState"); + logTrace(this, `Sent animation event: IdleForceDefaultState`); + this.needsExitingAnim = false; + this.sp.Game.enablePlayerControls(true, false, true, false, false, false, false, false, 0); + } + } + else { + if (this.needsExitingAnim) { + this.sp.Debug.notification("Пробел, чтобы выйти из анимации"); + } } + + if (!e.isUp) return; + + if (!this.settings) return; + + if (!this.settings.animKeys![e.code]) return; + + this.sp.Game.forceThirdPerson(); + this.sp.Game.disablePlayerControls(true, false, true, false, false, false, false, false, 0); + this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), this.settings.animKeys![e.code]); + + this.needsExitingAnim = true; + + logTrace(this, `Sent animation event: ${this.settings.animKeys![e.code]}`); } private queue?: AnimQueueCollection; private settings?: AnimDebugSettings; -} - -interface AnimTextOutput { - isActive?: boolean; - itemCount?: number; - startPos?: { x: number, y: number }, - yPosDelta?: number, -} -interface AnimDebugSettings { - isActive?: boolean; - textOutput?: AnimTextOutput, - animKeys?: { [index: number]: string }; + private needsExitingAnim = false; } type AnimListItem = { From 278f9dcecc8feadb5adba13ed9f8c783e9b5be47 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 31 May 2024 01:31:06 +0500 Subject: [PATCH 21/78] fix(skyrim-platform): revert "optimize texts collection" (#2010) --- .../platform_se/skyrim_platform/TextApi.cpp | 19 +-- .../skyrim_platform/TextsCollection.cpp | 159 +++++------------- .../skyrim_platform/TextsCollection.h | 20 +-- .../src/platform_se/skyrim_platform/main.cpp | 4 +- 4 files changed, 51 insertions(+), 151 deletions(-) diff --git a/skyrim-platform/src/platform_se/skyrim_platform/TextApi.cpp b/skyrim-platform/src/platform_se/skyrim_platform/TextApi.cpp index 7e290a7ea3..f587d3bcdd 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/TextApi.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/TextApi.cpp @@ -35,7 +35,6 @@ JsValue TextApi::DestroyText(const JsFunctionArguments& args) TextsCollection::GetSingleton().DestroyText(static_cast(args[1])); return JsValue::Undefined(); } - JsValue TextApi::DestroyAllTexts(const JsFunctionArguments&) { TextsCollection::GetSingleton().DestroyAllTexts(); @@ -51,7 +50,6 @@ JsValue TextApi::SetTextPos(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextPos(textNameId, argPosX, argPosY); return JsValue::Undefined(); } - JsValue TextApi::SetTextString(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -63,7 +61,6 @@ JsValue TextApi::SetTextString(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextString(textId, moveArgString); return JsValue::Undefined(); } - JsValue TextApi::SetTextColor(const JsFunctionArguments& args) { std::array argColor; @@ -79,7 +76,6 @@ JsValue TextApi::SetTextColor(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextColor(textNameId, moveArgColor); return JsValue::Undefined(); } - JsValue TextApi::SetTextSize(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -89,7 +85,6 @@ JsValue TextApi::SetTextSize(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextSize(textId, size); return JsValue::Undefined(); } - JsValue TextApi::SetTextRotation(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -99,7 +94,6 @@ JsValue TextApi::SetTextRotation(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextRotation(textId, rot); return JsValue::Undefined(); } - JsValue TextApi::SetTextFont(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -111,7 +105,6 @@ JsValue TextApi::SetTextFont(const JsFunctionArguments& args) return JsValue::Undefined(); } - JsValue TextApi::SetTextDepth(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -121,7 +114,6 @@ JsValue TextApi::SetTextDepth(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextDepth(textId, depth); return JsValue::Undefined(); } - JsValue TextApi::SetTextEffect(const JsFunctionArguments& args) { auto textId = static_cast(args[1]); @@ -131,7 +123,6 @@ JsValue TextApi::SetTextEffect(const JsFunctionArguments& args) TextsCollection::GetSingleton().SetTextEffect(textId, eff); return JsValue::Undefined(); } - JsValue TextApi::SetTextOrigin(const JsFunctionArguments& args) { std::array argOrigin; @@ -149,7 +140,7 @@ JsValue TextApi::SetTextOrigin(const JsFunctionArguments& args) JsValue TextApi::GetTextPos(const JsFunctionArguments& args) { - auto postions = + auto& postions = TextsCollection::GetSingleton().GetTextPos(static_cast(args[1])); auto jsArray = JsValue::Array(2); @@ -158,7 +149,6 @@ JsValue TextApi::GetTextPos(const JsFunctionArguments& args) return jsArray; } - JsValue TextApi::GetTextString(const JsFunctionArguments& args) { const auto& str = @@ -167,7 +157,6 @@ JsValue TextApi::GetTextString(const JsFunctionArguments& args) std::wstring_convert> converter; return JsValue(converter.to_bytes(str)); } - JsValue TextApi::GetTextColor(const JsFunctionArguments& args) { const auto& argArray = @@ -180,7 +169,6 @@ JsValue TextApi::GetTextColor(const JsFunctionArguments& args) return jsArray; } - JsValue TextApi::GetTextSize(const JsFunctionArguments& args) { const auto& size = @@ -188,7 +176,6 @@ JsValue TextApi::GetTextSize(const JsFunctionArguments& args) return JsValue(size); } - JsValue TextApi::GetTextRotation(const JsFunctionArguments& args) { const auto& rot = TextsCollection::GetSingleton().GetTextRotation( @@ -196,7 +183,6 @@ JsValue TextApi::GetTextRotation(const JsFunctionArguments& args) return JsValue(rot); } - JsValue TextApi::GetTextFont(const JsFunctionArguments& args) { const auto& font = @@ -205,7 +191,6 @@ JsValue TextApi::GetTextFont(const JsFunctionArguments& args) std::wstring_convert> converter; return JsValue(converter.to_bytes(font)); } - JsValue TextApi::GetTextDepth(const JsFunctionArguments& args) { const auto& depth = @@ -213,7 +198,6 @@ JsValue TextApi::GetTextDepth(const JsFunctionArguments& args) return JsValue(depth); } - JsValue TextApi::GetTextEffect(const JsFunctionArguments& args) { const auto& effect = @@ -221,7 +205,6 @@ JsValue TextApi::GetTextEffect(const JsFunctionArguments& args) return JsValue(effect); } - JsValue TextApi::GetTextOrigin(const JsFunctionArguments& args) { auto argArray = diff --git a/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.cpp b/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.cpp index 27378d397e..eaad199067 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.cpp @@ -1,8 +1,8 @@ #include "TextsCollection.h" TextsCollection::TextsCollection() + : textCount(0) { - makeId.reset(new MakeID(std::numeric_limits::max())); } TextsCollection::~TextsCollection() @@ -16,185 +16,106 @@ int TextsCollection::CreateText(double xPos, double yPos, std::wstring str, { TextToDraw text{ name, xPos, yPos, str, color }; - uint32_t id; - makeId->CreateID(id); - if (texts.size() <= id) { - texts.resize(id + 1); - } + textCount++; + std::pair arg = { textCount, text }; - texts[id] = text; - return id; + texts.insert(arg); + + return textCount; } void TextsCollection::DestroyText(int textId) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("DestroyText - textId doesn't exist"); - } - texts[textId] = std::nullopt; - makeId->DestroyID(textId); - - while (!texts.empty() && texts.back() == std::nullopt) { - texts.pop_back(); - } + texts.erase(textId); } - void TextsCollection::DestroyAllTexts() { texts.clear(); - makeId.reset(new MakeID(std::numeric_limits::max())); + textCount = 0; } void TextsCollection::SetTextPos(int& textId, double& xPos, double& yPos) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextPos - textId doesn't exist"); - } - texts[textId]->x = xPos; - texts[textId]->y = yPos; + texts.at(textId).x = xPos; + texts.at(textId).y = yPos; } - void TextsCollection::SetTextString(int& textId, std::wstring& str) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextString - textId doesn't exist"); - } - texts[textId]->string = std::move(str); + texts.at(textId).string = std::move(str); } - void TextsCollection::SetTextColor(int& textId, std::array& color) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextColor - textId doesn't exist"); - } - texts[textId]->color = color; + texts.at(textId).color = color; } - void TextsCollection::SetTextFont(int& textId, std::wstring& name) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextFont - textId doesn't exist"); - } - texts[textId]->fontName = name; + texts.at(textId).fontName = name; } - void TextsCollection::SetTextRotation(int& textId, float& rotation) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextRotation - textId doesn't exist"); - } - texts[textId]->rotation = rotation; + texts.at(textId).rotation = rotation; } - void TextsCollection::SetTextSize(int& textId, float& size) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextSize - textId doesn't exist"); - } - texts[textId]->size = size; + texts.at(textId).size = size; } - void TextsCollection::SetTextEffect(int& textId, int& effect) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextEffect - textId doesn't exist"); - } - texts[textId]->effects = static_cast(effect); + texts.at(textId).effects = static_cast(effect); } - void TextsCollection::SetTextDepth(int& textId, int& depth) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextDepth - textId doesn't exist"); - } - texts[textId]->layerDepth = depth; + texts.at(textId).layerDepth = depth; } - void TextsCollection::SetTextOrigin(int& textId, std::array& origin) { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("SetTextOrigin - textId doesn't exist"); - } - texts[textId]->origin = origin; + texts.at(textId).origin = origin; } -std::pair TextsCollection::GetTextPos(int textId) const -{ - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextPos - textId doesn't exist"); - } - return { - texts[textId]->x, - texts[textId]->y, +const std::pair TextsCollection::GetTextPos(int textId) const +{ + std::pair positions = { + texts.at(textId).x, + texts.at(textId).y, }; -} + return positions; +} const std::wstring& TextsCollection::GetTextString(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextString - textId doesn't exist"); - } - return texts[textId]->string; + return texts.at(textId).string; } - const std::array& TextsCollection::GetTextColor(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextColor - textId doesn't exist"); - } - return texts[textId]->color; + return texts.at(textId).color; } - const std::wstring& TextsCollection::GetTextFont(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextFont - textId doesn't exist"); - } - return texts[textId]->fontName; + return texts.at(textId).fontName; } - -float TextsCollection::GetTextRotation(int textId) const +const float& TextsCollection::GetTextRotation(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextRotation - textId doesn't exist"); - } - return texts[textId]->rotation; + return texts.at(textId).rotation; } - -float TextsCollection::GetTextSize(int textId) const +const float& TextsCollection::GetTextSize(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextSize - textId doesn't exist"); - } - return texts[textId]->size; + return texts.at(textId).size; } - -int TextsCollection::GetTextEffect(int textId) const +const int TextsCollection::GetTextEffect(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextEffect - textId doesn't exist"); - } - return texts[textId]->effects; + return texts.at(textId).effects; } - -int TextsCollection::GetTextDepth(int textId) const +const int& TextsCollection::GetTextDepth(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextDepth - textId doesn't exist"); - } - return texts[textId]->layerDepth; + return texts.at(textId).layerDepth; } - -const std::array& TextsCollection::GetTextOrigin(int textId) const +const std::array TextsCollection::GetTextOrigin(int textId) const { - if (makeId->IsID(textId) == false) { - throw std::runtime_error("GetTextOrigin - textId doesn't exist"); - } - return texts[textId]->origin; + return texts.at(textId).origin; } -const std::vector>& -TextsCollection::GetCreatedTexts() const +const std::unordered_map& TextsCollection::GetCreatedTexts() + const { return texts; } diff --git a/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.h b/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.h index b6d0b92323..889e7e92b3 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.h +++ b/skyrim-platform/src/platform_se/skyrim_platform/TextsCollection.h @@ -1,8 +1,6 @@ #pragma once #include -#include -#include class TextsCollection { @@ -35,17 +33,17 @@ class TextsCollection TextsCollection& operator=(const TextsCollection&&) = delete; public: - std::pair GetTextPos(int textId) const; + const std::pair GetTextPos(int textId) const; const std::wstring& GetTextString(int textId) const; const std::array& GetTextColor(int textId) const; const std::wstring& GetTextFont(int textId) const; - float GetTextRotation(int textId) const; - float GetTextSize(int textId) const; - int GetTextEffect(int textId) const; - int GetTextDepth(int textId) const; - const std::array& GetTextOrigin(int textId) const; + const float& GetTextRotation(int textId) const; + const float& GetTextSize(int textId) const; + const int GetTextEffect(int textId) const; + const int& GetTextDepth(int textId) const; + const std::array GetTextOrigin(int textId) const; - const std::vector>& GetCreatedTexts() const; + const std::unordered_map& GetCreatedTexts() const; int GetNumCreatedTexts() const noexcept { return texts.size(); } @@ -53,6 +51,6 @@ class TextsCollection TextsCollection(); private: - std::unique_ptr makeId; - std::vector> texts; + uint32_t textCount; + std::unordered_map texts; }; diff --git a/skyrim-platform/src/platform_se/skyrim_platform/main.cpp b/skyrim-platform/src/platform_se/skyrim_platform/main.cpp index cd8126a738..3f49661803 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/main.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/main.cpp @@ -24,9 +24,7 @@ void GetTextsToDraw(TextToDrawCallback callback) auto text = &TextsCollection::GetSingleton(); for (const auto& a : TextsCollection::GetSingleton().GetCreatedTexts()) { - if (a != std::nullopt) { - callback(*a); - } + callback(a.second); } } From d9723fe078330d140c89f253aeaf8300a8cd714f Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 31 May 2024 01:43:24 +0500 Subject: [PATCH 22/78] fix(skymp5-client): fix a small issue in AnimDebugService (#2011) --- skymp5-client/src/services/services/animDebugService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/skymp5-client/src/services/services/animDebugService.ts b/skymp5-client/src/services/services/animDebugService.ts index e4a5cfd3c1..7906c60384 100644 --- a/skymp5-client/src/services/services/animDebugService.ts +++ b/skymp5-client/src/services/services/animDebugService.ts @@ -1,7 +1,7 @@ import { logTrace, logError } from "../../logging"; import { AnimDebugSettings } from "../messages_settings/animDebugSettings"; import { ClientListener, CombinedController, Sp } from "./clientListener"; -import { ButtonEvent, CameraStateChangedEvent, DxScanCode } from "skyrimPlatform"; +import { ButtonEvent, CameraStateChangedEvent, DxScanCode, Menu } from "skyrimPlatform"; export class AnimDebugService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { @@ -78,6 +78,10 @@ export class AnimDebugService extends ClientListener { if (!this.settings.animKeys![e.code]) return; + if (this.sp.Game.getPlayer()?.isWeaponDrawn()) return; + + if (this.sp.Ui.isMenuOpen(Menu.Favorites)) return; + this.sp.Game.forceThirdPerson(); this.sp.Game.disablePlayerControls(true, false, true, false, false, false, false, false, 0); this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), this.settings.animKeys![e.code]); From f28a9ca52e85c7733db911c5d13078f25651ae33 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 31 May 2024 04:38:59 +0500 Subject: [PATCH 23/78] feat(skymp5-server): enable dropping armor (#2009) --- skymp5-server/cpp/server_guest_lib/MpActor.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index f91df5390b..3b663fc968 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -1269,13 +1269,6 @@ void MpActor::DropItem(const uint32_t baseId, const Inventory::Entry& entry) std::string editorId = lookupRes.rec->GetEditorId(worldState->GetEspmCache()); - // TODO: remove this when we will be sure that none of armors crashes clients - if (lookupRes.rec->GetType().ToString() == "ARMO") { - spdlog::warn("MpActor::DropItem - Attempt to drop ARMO by actor {:x}", - GetFormId()); - return; - } - spdlog::trace("MpActor::DropItem - dropping {}", editorId); RemoveItems({ entry }); From e22e77e716e63e880cf2b312097a62e7d1a22485 Mon Sep 17 00:00:00 2001 From: SupAidmi <85443861+SupAidmi@users.noreply.github.com> Date: Fri, 31 May 2024 19:09:18 +0300 Subject: [PATCH 24/78] feat(skymp5-front): update skills descriptions (#2012) --- skymp5-front/src/features/skillsMenu/content.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skymp5-front/src/features/skillsMenu/content.ts b/skymp5-front/src/features/skillsMenu/content.ts index c5b9f90d22..f254ccf248 100644 --- a/skymp5-front/src/features/skillsMenu/content.ts +++ b/skymp5-front/src/features/skillsMenu/content.ts @@ -13,7 +13,7 @@ const content = [ description: 'шахтер: занимается добычей руды, драгоценных камней и минералов', levelsPrice: [50, 80, 110, 140], - levelsDescription: ['вы звёзд с неба не хватаете, но добыть пару кусков железной руды можете. ртуть или, скажем, корунд - это уже сложно. согласитесь, трудно добывать то, что даже не знаешь как выглядит', 'вы научились обращаться с киркой, да, это не значит владеть ею в совершенстве, но уже неплохо. теперь вы можете извлекать из породы корундовые и ртутные руды', 'можно сказать, что вы преуспели в шахтёрском промысле. теперь вы можете добывать лунный камень и серебро, а также освоили главное правило добычи орихалка - не попадаться оркам', 'вы стали сильнее и выносливее, научились различать минералы, малахит, самоцветные жеоды. редкие металлы, такие как золото, требуют знаний и они у вас есть', 'ваша борода стала пышнее, а ворот вязаной кофты выше, любой камень в захолустном овраге - ваш информатор, ведь вы знаете как вытащить из него что-то ценное, возможно даже эбонит'], + levelsDescription: ['вы звёзд с неба не хватаете, но добыть пару кусков железной руды можете, если очень повезет. ртуть или, скажем, корунд - это явно за пределами ваших возможностей', 'вы научились обращаться с киркой, да, это не значит владеть ею в совершенстве, но уже неплохо. вы хорошо научились добывать железо, самый распространенный металл', 'можно сказать, что вы преуспели в шахтёрском промысле. теперь вы можете добывать корунд и ртуть, а также освоили главное правило добычи орихалка - не попадаться оркам', 'вы научились отличать лунный камень от прочих минералов. редкие металлы, такие как золото или серебро, требуют знаний и они у вас есть', 'ваша борода стала пышнее, а ворот вязаной кофты выше, любой камень в захолустном овраге - ваш информатор, ведь вы знаете как вытащить из него что-то ценное, малахит и возможно даже эбонит'], icon: ' ' }, { @@ -37,7 +37,7 @@ const content = [ description: 'охотник: занимается добычей шкур и мяса диких животных', levelsPrice: [50, 70, 90, 110], - levelsDescription: ['охотиться дело непростое, особенно для новичка. нужно знать слишком много, чтобы суметь отловить пронырливых кроликов или грязекрабов', 'вы научились ставить ловушки на мелкую дичь, такую как лисицы, кролики и злокрысы. также иногда вам удается подстрелить оленя', 'вы давно занимаетесь охотой, лук это ваш лучший друг, а ловушки стали изощреннее и крупнее. теперь вам по плечу более опасные звери. также вы умеете добывать клыки и прочие ингредиенты', 'вы стали сильнее и выносливее, ваша добыча становится все крупнее, но она вам вполне по плечу. такие опасные хищники как медведь и саблезуб - ваша цель', 'вы знаете где искать редкие виды животных, такие как долинный саблезуб или пещерный медведь, а ваши ловушки при определённом везении могут изловть даже мамонта'], + levelsDescription: ['охотиться дело непростое, особенно для новичка. нужно знать слишком много, чтобы суметь отловить пронырливых кроликов или грязекрабов', 'вы освоили базу охотничьего ремесла, однако вам не достает практики, поэтому вы часто портите шкуры и ингредиенты пытаясь разделать тушу в полевых условиях', 'вы достаточно давно занимаетесь охотой, научились извлекать самые ценные ингредиенты, но все ещё недостаточно аккуратны при снятии шкур вне разделочного стола', 'вы опытный охотник, ваша добыча становится все крупнее, а навыки разделывания туш достаточно хороши, чтобы подолгу находиться вдали от городов', 'вы знаете где искать редкие виды животных, и как на них охотиться, вы не ошибаетесь при снятии шкур и добыче ценных ингредиентов'], icon: ' ' }, { @@ -325,7 +325,7 @@ const content = [ name: 'restoration', description: 'целитель: практикует магию школы восстановления', levelsPrice: [50, 80, 110, 410], - levelsDescription: ['вы мало что понимаете в магии, вернее, совсем ничего', 'вы освоили основы магии восстановления, вам доступны такие заклинания как лечение и малый оберег', 'вы осилили следующую ступень магии восстановления и овладели следующими заклинаниями: быстрое лечение, лечение ближних, отталкивание нежити и лечение нежити', 'вы стали экспертом магии восстановления и изучили много новых заклинаний: круг защиты, отпугивание нежити и ядовитая руна', 'вы достигли мастерства в магии разрушения и в совершенстве владеете заклинаниями: проклятие нежити, высшее лечение и защитный круг'], + levelsDescription: ['вы мало что понимаете в магии, вернее, совсем ничего', 'вы освоили основы магии восстановления, вам доступны такие заклинания как лечение и малый оберег', 'вы осилили следующую ступень магии восстановления и овладели следующими заклинаниями: быстрое лечение, лечение ближних, отталкивание нежити и лечение нежити', 'вы стали экспертом магии восстановления и изучили много новых заклинаний: круг защиты, отпугивание нежити и ядовитая руна', 'вы достигли мастерства в магии восстановления и в совершенстве владеете заклинаниями: проклятие нежити, высшее лечение и защитный круг'], icon: '' } ]]; From de58974baf1c857fda29befd4a473692334525b5 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sat, 1 Jun 2024 04:32:46 +0500 Subject: [PATCH 25/78] internal: redirect deploy (#2013) --- .github/workflows/deploy.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 379b061970..0c8cfdc579 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -164,10 +164,10 @@ jobs: - name: Deploy env: DEPLOY_STATUS_WEBHOOK: ${{secrets.DEPLOY_STATUS_WEBHOOK}} - DEPLOY_TARGET_HOST: ${{secrets.DEPLOY_TARGET_HOST}} - DEPLOY_TARGET_USER: skmp - DEPLOY_SSH_PRIVATE_KEY: ${{secrets.DEPLOY_SSH_PRIVATE_KEY}} - DEPLOY_SSH_KNOWN_HOSTS: ${{secrets.DEPLOY_SSH_KNOWN_HOSTS}} + DEPLOY_TARGET_HOST: ${{secrets.DEPLOY_TARGET_HOST_06_2024}} + DEPLOY_TARGET_USER: ${{secrets.DEPLOY_TARGET_USER_06_2024}} + DEPLOY_SSH_PRIVATE_KEY: ${{secrets.DEPLOY_SSH_PRIVATE_KEY_06_2024}} + DEPLOY_SSH_KNOWN_HOSTS: ${{secrets.DEPLOY_SSH_KNOWN_HOSTS_06_2024}} run: | ./misc/deploy/push_branch_to_server.sh From 402adb093074a59564fa43adba07923e9f36129b Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sat, 1 Jun 2024 06:37:57 +0500 Subject: [PATCH 26/78] feat(skymp5-server): implement Cell.IsInterior (#1996) --- libespm/src/CELL.cpp | 1 + .../script_classes/PapyrusCell.cpp | 20 +++++++++++++++++++ .../script_classes/PapyrusCell.h | 2 ++ 3 files changed, 23 insertions(+) diff --git a/libespm/src/CELL.cpp b/libespm/src/CELL.cpp index caad578228..896595685b 100644 --- a/libespm/src/CELL.cpp +++ b/libespm/src/CELL.cpp @@ -10,6 +10,7 @@ CELL::Data CELL::GetData(CompressedFieldsCache& cache) const noexcept this, [&](const char* type, uint32_t size, const char* data) { if (!std::memcmp(type, "DATA", 4)) { + // TODO: support size == 1, docs says it is possible in vanila skyrim result.flags = *reinterpret_cast(data); } }, diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp index 473c4eec81..c437bf86c1 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp @@ -6,10 +6,30 @@ VarValue PapyrusCell::IsAttached(VarValue self, return VarValue(true); // stub } +VarValue PapyrusCell::IsInterior(VarValue self, + const std::vector& arguments) +{ + if (auto lookupRes = GetRecordPtr(self); lookupRes.rec) { + espm::CompressedFieldsCache cache; + + auto cell = espm::Convert(lookupRes.rec); + if (cell) { + bool isInterior = cell->GetData(cache).flags & espm::CELL::Interior; + return VarValue(isInterior); + } else { + spdlog::error( + "PapyrusCell::IsInterior: failed to convert record to CELL"); + } + } else { + spdlog::error("PapyrusCell::IsInterior: record not found"); + } +} + void PapyrusCell::Register(VirtualMachine& vm, std::shared_ptr policy) { compatibilityPolicy = policy; AddMethod(vm, "IsAttached", &PapyrusCell::IsAttached); + AddMethod(vm, "IsInterior", &PapyrusCell::IsInterior); } diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.h b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.h index d7f9ce8e91..b3e33b97e3 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.h +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.h @@ -10,6 +10,8 @@ class PapyrusCell final : public IPapyrusClass VarValue IsAttached(VarValue self, const std::vector& arguments); + VarValue IsInterior(VarValue self, const std::vector& arguments); + void Register(VirtualMachine& vm, std::shared_ptr policy) override; From 61a7e088527e409a2e97a48dd4ca085d2a59c821 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 3 Jun 2024 03:03:27 +0500 Subject: [PATCH 27/78] feat(skymp5-server): increase net timeouts (#2015) --- skymp5-server/cpp/mp_common/MpClientPlugin.cpp | 2 +- skymp5-server/cpp/mp_common/Networking.cpp | 2 +- skymp5-server/cpp/mp_common/Networking.h | 2 +- unit/NetworkingTest.cpp | 4 ++-- unit/Networking_DataTransferTest.cpp | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/skymp5-server/cpp/mp_common/MpClientPlugin.cpp b/skymp5-server/cpp/mp_common/MpClientPlugin.cpp index 377be65b9f..c3918062d3 100644 --- a/skymp5-server/cpp/mp_common/MpClientPlugin.cpp +++ b/skymp5-server/cpp/mp_common/MpClientPlugin.cpp @@ -17,7 +17,7 @@ void MpClientPlugin::CreateClient(State& state, const char* targetHostname, // Keep in sync with installer code static const std::string kPasswordPath = "Data/Platform/Distribution/password"; - static const int kTimeoutMs = 4000; + static const int kTimeoutMs = 60000; try { password = Viet::ReadFileIntoString(kPasswordPath); diff --git a/skymp5-server/cpp/mp_common/Networking.cpp b/skymp5-server/cpp/mp_common/Networking.cpp index 9fc0ced8fd..821c46a718 100644 --- a/skymp5-server/cpp/mp_common/Networking.cpp +++ b/skymp5-server/cpp/mp_common/Networking.cpp @@ -127,7 +127,7 @@ class Client : public Networking::IClient class Server : public Networking::IServer { public: - constexpr static int timeoutTimeMs = 6000; + constexpr static int timeoutTimeMs = 60000; Server(unsigned short port_, unsigned short maxConnections, const char* password_) diff --git a/skymp5-server/cpp/mp_common/Networking.h b/skymp5-server/cpp/mp_common/Networking.h index aed1ef2a97..ea22b5ecd7 100644 --- a/skymp5-server/cpp/mp_common/Networking.h +++ b/skymp5-server/cpp/mp_common/Networking.h @@ -11,7 +11,7 @@ class IdManager; namespace Networking { std::shared_ptr CreateClient( - const char* serverIp, unsigned short serverPort, int timeoutMs = 4000, + const char* serverIp, unsigned short serverPort, int timeoutMs, const char* password = kNetworkingPassword); std::shared_ptr CreateServer( unsigned short port, unsigned short maxConnections, diff --git a/unit/NetworkingTest.cpp b/unit/NetworkingTest.cpp index 328b2204cc..5bf5b1dc35 100644 --- a/unit/NetworkingTest.cpp +++ b/unit/NetworkingTest.cpp @@ -52,7 +52,7 @@ TEST_CASE("Connect/disconnect", "[Networking]") TEST_CASE("Ctors", "[Networking]") { auto server = Networking::CreateServer(7778, MAX_PLAYERS); - auto client = Networking::CreateClient("127.0.0.1", 7778); + auto client = Networking::CreateClient("127.0.0.1", 7778, 4000); try { Networking::CreateServer(7778, MAX_PLAYERS); @@ -62,7 +62,7 @@ TEST_CASE("Ctors", "[Networking]") } try { - Networking::CreateClient("cococo", 1); + Networking::CreateClient("cococo", 1, 4000); REQUIRE(false); } catch (std::exception& e) { REQUIRE(e.what() == std::string("Peer connect failed with code 2")); diff --git a/unit/Networking_DataTransferTest.cpp b/unit/Networking_DataTransferTest.cpp index ac46dd4f09..b81b1120c2 100644 --- a/unit/Networking_DataTransferTest.cpp +++ b/unit/Networking_DataTransferTest.cpp @@ -8,7 +8,7 @@ using namespace std::chrono_literals; TEST_CASE("Data transfer", "[Networking]") { static auto server = Networking::CreateServer(7778, MAX_PLAYERS); - static auto client = Networking::CreateClient("127.0.0.1", 7778); + static auto client = Networking::CreateClient("127.0.0.1", 7778, 4000); std::string res; From 30ac7bf22b2b539bea0c6c69837e0d85247d2c18 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 3 Jun 2024 03:05:06 +0500 Subject: [PATCH 28/78] fix(skymp5-server): fix additional-settings-sources being used not everywhere (#2014) --- skymp5-server/ts/index.ts | 265 +--------------- skymp5-server/ts/settings.ts | 311 ++++++++++++++++--- skymp5-server/ts/systems/discordBanSystem.ts | 6 +- skymp5-server/ts/systems/login.ts | 5 +- skymp5-server/ts/systems/spawn.ts | 3 +- skymp5-server/ts/ui.ts | 3 +- 6 files changed, 301 insertions(+), 292 deletions(-) diff --git a/skymp5-server/ts/index.ts b/skymp5-server/ts/index.ts index 70667655e8..4ec7c8f2c0 100644 --- a/skymp5-server/ts/index.ts +++ b/skymp5-server/ts/index.ts @@ -26,23 +26,10 @@ import * as fs from "fs"; import * as chokidar from "chokidar"; import * as path from "path"; import * as os from "os"; -import * as crypto from "crypto"; import * as manifestGen from "./manifestGen"; import { DiscordBanSystem } from "./systems/discordBanSystem"; import { createScampServer } from "./scampNative"; -import { Octokit } from "@octokit/rest"; -import * as lodash from "lodash"; - -const { - master, - port, - maxPlayers, - name, - ip, - gamemodePath, - offlineMode, -} = Settings.get(); const gamemodeCache = new Map(); @@ -109,15 +96,6 @@ function requireUncached( } } -const log = console.log; -const systems = new Array(); -systems.push( - new MasterClient(log, port, master, maxPlayers, name, ip, 5000, offlineMode), - new Spawn(log), - new Login(log, maxPlayers, master, port, ip, offlineMode), - new DiscordBanSystem() -); - const setupStreams = (scampNative: any) => { class LogsStream { constructor(private logLevel: string) { @@ -145,239 +123,30 @@ const setupStreams = (scampNative: any) => { }; }; -/** - * Resolves a Git ref to a commit hash if it's not already a commit hash. - */ -async function resolveRefToCommitHash(octokit: Octokit, owner: string, repo: string, ref: string): Promise { - // Check if `ref` is already a 40-character hexadecimal string (commit hash). - if (/^[a-f0-9]{40}$/i.test(ref)) { - return ref; // It's already a commit hash. - } - - // Attempt to resolve the ref as both a branch and a tag. - try { - // First, try to resolve it as a branch. - return await getCommitHashFromRef(octokit, owner, repo, `heads/${ref}`); - } catch (error) { - try { - // If the branch resolution fails, try to resolve it as a tag. - return await getCommitHashFromRef(octokit, owner, repo, `tags/${ref}`); - } catch (tagError) { - throw new Error('Could not resolve ref to commit hash.'); - } - } -} - -async function getCommitHashFromRef(octokit: Octokit, owner: string, repo: string, ref: string): Promise { - const { data } = await octokit.git.getRef({ - owner, - repo, - ref, - }); - return data.object.sha; -} - -async function fetchServerSettings(): Promise { - // Load server-settings.json - const settingsPath = 'server-settings.json'; - const rawSettings = fs.readFileSync(settingsPath, 'utf8'); - let serverSettingsFile = JSON.parse(rawSettings); - - let serverSettings: Record = {}; - - const additionalServerSettings = serverSettingsFile.additionalServerSettings || []; - - let dumpFileNameSuffix = ''; - - for (let i = 0; i < additionalServerSettings.length; ++i) { - console.log(`Verifying additional server settings source ${i + 1} / ${additionalServerSettings.length}`); - - const { type, repo, ref, token, pathRegex } = serverSettingsFile.additionalServerSettings[i]; - - if (typeof type !== "string") { - throw new Error(`Expected additionalServerSettings[${i}].type to be string`); - } - - if (type !== "github") { - throw new Error(`Expected additionalServerSettings[${i}].type to be one of ["github"], but got ${type}`); - } - - if (typeof repo !== "string") { - throw new Error(`Expected additionalServerSettings[${i}].repo to be string`); - } - if (typeof ref !== "string") { - throw new Error(`Expected additionalServerSettings[${i}].ref to be string`); - } - if (typeof token !== "string") { - throw new Error(`Expected additionalServerSettings[${i}].token to be string`); - } - if (typeof pathRegex !== "string") { - throw new Error(`Expected additionalServerSettings[${i}].pathRegex to be string`); - } - - const octokit = new Octokit({ auth: token }); - - const [owner, repoName] = repo.split('/'); - - const commitHash = await resolveRefToCommitHash(octokit, owner, repoName, ref); - dumpFileNameSuffix += `-${commitHash}`; - } - - const dumpFileName = `server-settings-dump.json`; - - const readDump: Record | undefined = fs.existsSync(dumpFileName) ? JSON.parse(fs.readFileSync(dumpFileName, 'utf-8')) : undefined; - - let readDumpNoSha512 = structuredClone(readDump); - if (readDumpNoSha512) { - delete readDumpNoSha512['_sha512_']; - } - - const expectedSha512 = readDumpNoSha512 ? crypto.createHash('sha512').update(JSON.stringify(readDumpNoSha512)).digest('hex') : ''; - - if (readDump && readDump["_meta_"] === dumpFileNameSuffix && readDump["_sha512_"] === expectedSha512) { - console.log(`Loading settings dump from ${dumpFileName}`); - serverSettings = JSON.parse(fs.readFileSync(dumpFileName, 'utf-8')); - } - else { - for (let i = 0; i < additionalServerSettings.length; ++i) { - - const { repo, ref, token, pathRegex } = serverSettingsFile.additionalServerSettings[i]; - - console.log(`Fetching settings from "${repo}" at ref "${ref}" with path regex ${pathRegex}`); - - const regex = new RegExp(pathRegex); - - const octokit = new Octokit({ auth: token }); - - const [owner, repoName] = repo.split('/'); - - // List repository contents at specified ref - const rootContent = await octokit.repos.getContent({ - owner, - repo: repoName, - ref, - path: '', - }); - - const { data } = rootContent; - - const rateLimitRemainingInitial = parseInt(rootContent.headers["x-ratelimit-remaining"]) + 1; - let rateLimitRemaining = 0; - - const onFile = async (file: { path: string, name: string }) => { - if (file.name.endsWith('.json')) { - if (regex.test(file.path)) { - // Fetch individual file content if it matches the regex - const fileData = await octokit.repos.getContent({ - owner, - repo: repoName, - ref, - path: file.path, - }); - rateLimitRemaining = parseInt(fileData.headers["x-ratelimit-remaining"]); - - if ('content' in fileData.data && typeof fileData.data.content === 'string') { - // Decode Base64 content and parse JSON - const content = Buffer.from(fileData.data.content, 'base64').toString('utf-8'); - const jsonContent = JSON.parse(content); - // Merge or handle the JSON content as needed - console.log(`Merging "${file.path}"`); - - serverSettings = lodash.merge(serverSettings, jsonContent); - } - else { - throw new Error(`Expected content to be an array (${file.path})`); - } - } - else { - console.log(`Ignoring "${file.path}"`); - } - } - } - - const onDir = async (file: { path: string, name: string }) => { - const fileData = await octokit.repos.getContent({ - owner, - repo: repoName, - ref, - path: file.path, - }); - rateLimitRemaining = parseInt(fileData.headers["x-ratelimit-remaining"]); - - if (Array.isArray(fileData.data)) { - for (const item of fileData.data) { - if (item.type === "file") { - await onFile(item); - } - else if (item.type === "dir") { - await onDir(item); - } - else { - console.warn(`Skipping unsupported item type ${item.type} (${item.path})`); - } - } - } - else { - throw new Error(`Expected data to be an array (${file.path})`); - } - } - - if (Array.isArray(data)) { - for (const item of data) { - if (item.type === "file") { - await onFile(item); - } - else if (item.type === "dir") { - await onDir(item); - } - else { - console.warn(`Skipping unsupported item type ${item.type} (${item.path})`); - } - } - } - else { - throw new Error(`Expected data to be an array (root)`); - } - - console.log(`Rate limit spent: ${rateLimitRemainingInitial - rateLimitRemaining}, remaining: ${rateLimitRemaining}`); - - const xRateLimitReset = rootContent.headers["x-ratelimit-reset"]; - const resetDate = new Date(parseInt(xRateLimitReset, 10) * 1000); - const currentDate = new Date(); - if (resetDate > currentDate) { - console.log("The rate limit will reset in the future"); - const secondsUntilReset = (resetDate.getTime() - currentDate.getTime()) / 1000; - console.log(`Seconds until reset: ${secondsUntilReset}`); - } else { - console.log("The rate limit has already been reset"); - } - } - - if (JSON.stringify(serverSettings) !== JSON.stringify(JSON.parse(rawSettings))) { - console.log(`Dumping ${dumpFileName} for cache and debugging`); - serverSettings["_meta_"] = dumpFileNameSuffix; - serverSettings["_sha512_"] = crypto.createHash('sha512').update(JSON.stringify(serverSettings)).digest('hex'); - fs.writeFileSync(dumpFileName, JSON.stringify(serverSettings, null, 2)); - } - } - - console.log(`Merging "server-settings.json" (original settings file)`); - serverSettings = lodash.merge(serverSettings, serverSettingsFile); - - return serverSettings; -} - const main = async () => { + const settingsObject = await Settings.get(); + const { + port, master, maxPlayers, name, ip, offlineMode, gamemodePath + } = settingsObject; + + const log = console.log; + const systems = new Array(); + systems.push( + new MasterClient(log, port, master, maxPlayers, name, ip, 5000, offlineMode), + new Spawn(log), + new Login(log, maxPlayers, master, port, ip, offlineMode), + new DiscordBanSystem() + ); + setupStreams(scampNative.getScampNative()); - manifestGen.generateManifest(Settings.get()); - ui.main(); + manifestGen.generateManifest(settingsObject); + ui.main(settingsObject); let server: any; try { - const serverSettings = await fetchServerSettings(); - server = createScampServer(port, maxPlayers, serverSettings); + server = createScampServer(port, maxPlayers, settingsObject.allSettings); } catch (e) { console.error(e); diff --git a/skymp5-server/ts/settings.ts b/skymp5-server/ts/settings.ts index 0f0b2751aa..9c203cc915 100644 --- a/skymp5-server/ts/settings.ts +++ b/skymp5-server/ts/settings.ts @@ -1,5 +1,8 @@ -import { ArgumentParser } from 'argparse'; import * as fs from 'fs'; +import * as crypto from "crypto"; +import { Octokit } from '@octokit/rest'; +import { ArgumentParser } from 'argparse'; +import lodash from 'lodash'; export interface DiscordAuthSettings { botToken: string; @@ -28,50 +31,58 @@ export class Settings { ]; discordAuth: DiscordAuthSettings | null = null; - constructor() { + allSettings: Record | null = null; + + constructor() { } + + static async get(): Promise { + if (!Settings.cachedPromise) { + Settings.cachedPromise = (async () => { + const args = Settings.parseArgs(); + const res = new Settings(); + + await res.loadSettings(); // Load settings asynchronously + + // Override settings with command line arguments if available + res.port = +args['port'] || res.port; + res.maxPlayers = +args['maxPlayers'] || res.maxPlayers; + res.master = args['master'] || res.master; + res.name = args['name'] || res.name; + res.ip = args['ip'] || res.ip; + res.offlineMode = args['offlineMode'] || res.offlineMode; + + return res; + })(); + } + + return Settings.cachedPromise; + } + + private async loadSettings() { if (fs.existsSync('./skymp5-gamemode')) { this.gamemodePath = './skymp5-gamemode/gamemode.js'; } else { this.gamemodePath = './gamemode.js'; } - if (fs.existsSync('./server-settings.json')) { - const parsed = JSON.parse(fs.readFileSync('./server-settings.json', 'utf-8')); - [ - 'ip', - 'port', - 'maxPlayers', - 'master', - 'name', - 'gamemodePath', - 'loadOrder', - 'dataDir', - 'startPoints', - 'offlineMode', - 'discordAuth', - ].forEach((prop) => { - if (parsed[prop]) (this as Record)[prop] = parsed[prop]; - }); - } - } - - static cachedSettings: Settings | null = null; + const settings = await fetchServerSettings(); + [ + 'ip', + 'port', + 'maxPlayers', + 'master', + 'name', + 'gamemodePath', + 'loadOrder', + 'dataDir', + 'startPoints', + 'offlineMode', + 'discordAuth', + ].forEach((prop) => { + if (settings[prop]) (this as Record)[prop] = settings[prop]; + }); - static get(): Settings { - if (Settings.cachedSettings) { - return Settings.cachedSettings; - } - const args = Settings.parseArgs(); - const res = new Settings(); - - res.port = +args['port'] || res.port; - res.maxPlayers = +args['maxPlayers'] || res.maxPlayers; - res.master = args['master'] || res.master; - res.name = args['name'] || res.name; - res.ip = args['ip'] || res.ip; - res.offlineMode = args['offlineMode'] || res.offlineMode; - Settings.cachedSettings = res; - return res; + this.allSettings = settings; } private static parseArgs() { @@ -87,4 +98,228 @@ export class Settings { parser.add_argument('--offlineMode'); return parser.parse_args(); } + + private static cachedPromise: Promise | null = null; +} + +/** + * Resolves a Git ref to a commit hash if it's not already a commit hash. + */ +async function resolveRefToCommitHash(octokit: Octokit, owner: string, repo: string, ref: string): Promise { + // Check if `ref` is already a 40-character hexadecimal string (commit hash). + if (/^[a-f0-9]{40}$/i.test(ref)) { + return ref; // It's already a commit hash. + } + + // Attempt to resolve the ref as both a branch and a tag. + try { + // First, try to resolve it as a branch. + return await getCommitHashFromRef(octokit, owner, repo, `heads/${ref}`); + } catch (error) { + try { + // If the branch resolution fails, try to resolve it as a tag. + return await getCommitHashFromRef(octokit, owner, repo, `tags/${ref}`); + } catch (tagError) { + throw new Error('Could not resolve ref to commit hash.'); + } + } +} + +async function getCommitHashFromRef(octokit: Octokit, owner: string, repo: string, ref: string): Promise { + const { data } = await octokit.git.getRef({ + owner, + repo, + ref, + }); + return data.object.sha; +} + +async function fetchServerSettings(): Promise { + // Load server-settings.json + const settingsPath = 'server-settings.json'; + const rawSettings = fs.readFileSync(settingsPath, 'utf8'); + let serverSettingsFile = JSON.parse(rawSettings); + + let serverSettings: Record = {}; + + const additionalServerSettings = serverSettingsFile.additionalServerSettings || []; + + let dumpFileNameSuffix = ''; + + for (let i = 0; i < additionalServerSettings.length; ++i) { + console.log(`Verifying additional server settings source ${i + 1} / ${additionalServerSettings.length}`); + + const { type, repo, ref, token, pathRegex } = serverSettingsFile.additionalServerSettings[i]; + + if (typeof type !== "string") { + throw new Error(`Expected additionalServerSettings[${i}].type to be string`); + } + + if (type !== "github") { + throw new Error(`Expected additionalServerSettings[${i}].type to be one of ["github"], but got ${type}`); + } + + if (typeof repo !== "string") { + throw new Error(`Expected additionalServerSettings[${i}].repo to be string`); + } + if (typeof ref !== "string") { + throw new Error(`Expected additionalServerSettings[${i}].ref to be string`); + } + if (typeof token !== "string") { + throw new Error(`Expected additionalServerSettings[${i}].token to be string`); + } + if (typeof pathRegex !== "string") { + throw new Error(`Expected additionalServerSettings[${i}].pathRegex to be string`); + } + + const octokit = new Octokit({ auth: token }); + + const [owner, repoName] = repo.split('/'); + + const commitHash = await resolveRefToCommitHash(octokit, owner, repoName, ref); + dumpFileNameSuffix += `-${commitHash}`; + } + + const dumpFileName = `server-settings-dump.json`; + + const readDump: Record | undefined = fs.existsSync(dumpFileName) ? JSON.parse(fs.readFileSync(dumpFileName, 'utf-8')) : undefined; + + let readDumpNoSha512 = structuredClone(readDump); + if (readDumpNoSha512) { + delete readDumpNoSha512['_sha512_']; + } + + const expectedSha512 = readDumpNoSha512 ? crypto.createHash('sha512').update(JSON.stringify(readDumpNoSha512)).digest('hex') : ''; + + if (readDump && readDump["_meta_"] === dumpFileNameSuffix && readDump["_sha512_"] === expectedSha512) { + console.log(`Loading settings dump from ${dumpFileName}`); + serverSettings = JSON.parse(fs.readFileSync(dumpFileName, 'utf-8')); + } + else { + for (let i = 0; i < additionalServerSettings.length; ++i) { + + const { repo, ref, token, pathRegex } = serverSettingsFile.additionalServerSettings[i]; + + console.log(`Fetching settings from "${repo}" at ref "${ref}" with path regex ${pathRegex}`); + + const regex = new RegExp(pathRegex); + + const octokit = new Octokit({ auth: token }); + + const [owner, repoName] = repo.split('/'); + + // List repository contents at specified ref + const rootContent = await octokit.repos.getContent({ + owner, + repo: repoName, + ref, + path: '', + }); + + const { data } = rootContent; + + const rateLimitRemainingInitial = parseInt(rootContent.headers["x-ratelimit-remaining"]) + 1; + let rateLimitRemaining = 0; + + const onFile = async (file: { path: string, name: string }) => { + if (file.name.endsWith('.json')) { + if (regex.test(file.path)) { + // Fetch individual file content if it matches the regex + const fileData = await octokit.repos.getContent({ + owner, + repo: repoName, + ref, + path: file.path, + }); + rateLimitRemaining = parseInt(fileData.headers["x-ratelimit-remaining"]); + + if ('content' in fileData.data && typeof fileData.data.content === 'string') { + // Decode Base64 content and parse JSON + const content = Buffer.from(fileData.data.content, 'base64').toString('utf-8'); + const jsonContent = JSON.parse(content); + // Merge or handle the JSON content as needed + console.log(`Merging "${file.path}"`); + + serverSettings = lodash.merge(serverSettings, jsonContent); + } + else { + throw new Error(`Expected content to be an array (${file.path})`); + } + } + else { + console.log(`Ignoring "${file.path}"`); + } + } + } + + const onDir = async (file: { path: string, name: string }) => { + const fileData = await octokit.repos.getContent({ + owner, + repo: repoName, + ref, + path: file.path, + }); + rateLimitRemaining = parseInt(fileData.headers["x-ratelimit-remaining"]); + + if (Array.isArray(fileData.data)) { + for (const item of fileData.data) { + if (item.type === "file") { + await onFile(item); + } + else if (item.type === "dir") { + await onDir(item); + } + else { + console.warn(`Skipping unsupported item type ${item.type} (${item.path})`); + } + } + } + else { + throw new Error(`Expected data to be an array (${file.path})`); + } + } + + if (Array.isArray(data)) { + for (const item of data) { + if (item.type === "file") { + await onFile(item); + } + else if (item.type === "dir") { + await onDir(item); + } + else { + console.warn(`Skipping unsupported item type ${item.type} (${item.path})`); + } + } + } + else { + throw new Error(`Expected data to be an array (root)`); + } + + console.log(`Rate limit spent: ${rateLimitRemainingInitial - rateLimitRemaining}, remaining: ${rateLimitRemaining}`); + + const xRateLimitReset = rootContent.headers["x-ratelimit-reset"]; + const resetDate = new Date(parseInt(xRateLimitReset, 10) * 1000); + const currentDate = new Date(); + if (resetDate > currentDate) { + console.log("The rate limit will reset in the future"); + const secondsUntilReset = (resetDate.getTime() - currentDate.getTime()) / 1000; + console.log(`Seconds until reset: ${secondsUntilReset}`); + } else { + console.log("The rate limit has already been reset"); + } + } + + if (JSON.stringify(serverSettings) !== JSON.stringify(JSON.parse(rawSettings))) { + console.log(`Dumping ${dumpFileName} for cache and debugging`); + serverSettings["_meta_"] = dumpFileNameSuffix; + serverSettings["_sha512_"] = crypto.createHash('sha512').update(JSON.stringify(serverSettings)).digest('hex'); + fs.writeFileSync(dumpFileName, JSON.stringify(serverSettings, null, 2)); + } + } + + console.log(`Merging "server-settings.json" (original settings file)`); + serverSettings = lodash.merge(serverSettings, serverSettingsFile); + + return serverSettings; } diff --git a/skymp5-server/ts/systems/discordBanSystem.ts b/skymp5-server/ts/systems/discordBanSystem.ts index 3d303fa92d..b49d753c40 100644 --- a/skymp5-server/ts/systems/discordBanSystem.ts +++ b/skymp5-server/ts/systems/discordBanSystem.ts @@ -11,9 +11,11 @@ export class DiscordBanSystem implements System { ) { } async initAsync(ctx: SystemContext): Promise { - let discordAuth = Settings.get().discordAuth; + const settingsObject = await Settings.get(); - if (Settings.get().offlineMode) { + let discordAuth = settingsObject.discordAuth; + + if (settingsObject.offlineMode) { return console.log("discord ban system is disabled due to offline mode"); } if (!discordAuth) { diff --git a/skymp5-server/ts/systems/login.ts b/skymp5-server/ts/systems/login.ts index af3a3203e3..1d4de08fc1 100644 --- a/skymp5-server/ts/systems/login.ts +++ b/skymp5-server/ts/systems/login.ts @@ -37,6 +37,8 @@ export class Login implements System { } async initAsync(ctx: SystemContext): Promise { + this.settingsObject = await Settings.get(); + if (this.ip && this.ip != "null") { this.myAddr = this.ip + ":" + this.serverPort; } else { @@ -61,7 +63,7 @@ export class Login implements System { const ip = ctx.svr.getUserIp(userId); console.log(`Connecting a user ${userId} with ip ${ip}`); - let discordAuth = Settings.get().discordAuth; + let discordAuth = this.settingsObject.discordAuth; const gameData = content["gameData"]; if (this.offlineMode === true && gameData && gameData.session) { @@ -162,4 +164,5 @@ export class Login implements System { } private myAddr: string; + private settingsObject: Settings; } diff --git a/skymp5-server/ts/systems/spawn.ts b/skymp5-server/ts/systems/spawn.ts index 06082bd761..c9d377abc7 100644 --- a/skymp5-server/ts/systems/spawn.ts +++ b/skymp5-server/ts/systems/spawn.ts @@ -13,8 +13,9 @@ export class Spawn implements System { constructor(private log: Log) {} async initAsync(ctx: SystemContext): Promise { + const settingsObject = await Settings.get(); ctx.gm.on("spawnAllowed", (userId: number, userProfileId: number, discordRoleIds: string[], discordId: string | undefined) => { - const { startPoints } = Settings.get(); + const { startPoints } = settingsObject; // TODO: Show race menu if character is not created after relogging let actorId = ctx.svr.getActorsByProfileId(userProfileId)[0]; if (actorId) { diff --git a/skymp5-server/ts/ui.ts b/skymp5-server/ts/ui.ts index 1e0327e184..ed718ff749 100644 --- a/skymp5-server/ts/ui.ts +++ b/skymp5-server/ts/ui.ts @@ -18,8 +18,7 @@ const createApp = (getOriginPort: () => number) => { return app; }; -export const main = (): void => { - const settings = Settings.get(); +export const main = (settings: Settings): void => { const devServerPort = 1234; From 62e5dace22e2369bcc62ba7a51706a5e1489bace Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 3 Jun 2024 17:51:46 +0500 Subject: [PATCH 29/78] fix(skymp5-client): fix skymp.net button in auth (#2017) --- skymp5-client/src/services/services/authService.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index 2a3d647cc4..907a3f1de4 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -200,7 +200,7 @@ export class AuthService extends ClientListener { this.sp.win32.loadUrl(this.patreonUrl); break; case events.updateRequired: - this.sp.win32.loadUrl("https://skymp.net/"); + this.sp.win32.loadUrl("https://skymp.net/AlternativeDownload"); break; case events.backToLogin: this.sp.browser.executeJavaScript(new FunctionInfo(this.browsersideWidgetSetter).getText({ events, browserState, authData: authData })); @@ -368,7 +368,12 @@ export class AuthService extends ClientListener { }, { type: "text", - text: "спешите скачать на skymp.net", + text: "спешите скачать на", + tags: [] + }, + { + type: "text", + text: "skymp.net", tags: [] }, { @@ -502,6 +507,7 @@ export class AuthService extends ClientListener { this.controller.once("update", () => { this.sp.Game.disablePlayerControls(true, true, true, true, true, true, true, true, 0); }); + this.isListenBrowserMessage = true; } } From 93839747aa35d20c42622da1dbb88c871fa14e4c Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 4 Jun 2024 17:03:56 +0500 Subject: [PATCH 30/78] internal: treat warning C4715 as error (#2018) --- cmake/apply_default_settings.cmake | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmake/apply_default_settings.cmake b/cmake/apply_default_settings.cmake index 92c52e3539..5f9cb9e861 100644 --- a/cmake/apply_default_settings.cmake +++ b/cmake/apply_default_settings.cmake @@ -10,9 +10,12 @@ function(apply_default_settings) set_target_properties(${target} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" ) - if(MSVC) - target_compile_features(${target} PRIVATE cxx_std_20) + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # "not all execution paths return value" must be error + target_compile_options(${target} PRIVATE -Werror=return-type) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") target_compile_options(${target} PRIVATE + /WX /we4715 # "not all execution paths return value" must be error /wd4551 # disable non critical frida warning /wd5104 # disable non critical winsdk warning /wd5105 # TODO: investigate and fix From 52fa45264ddd25270706180d3cc92cacc4324daf Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 4 Jun 2024 17:44:15 +0500 Subject: [PATCH 31/78] fix(skymp5-server): fix Cell.IsInterior missing return statement (#2019) --- .../cpp/server_guest_lib/script_classes/PapyrusCell.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp index c437bf86c1..c9a85908e3 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusCell.cpp @@ -23,6 +23,7 @@ VarValue PapyrusCell::IsInterior(VarValue self, } else { spdlog::error("PapyrusCell::IsInterior: record not found"); } + return VarValue(false); } void PapyrusCell::Register(VirtualMachine& vm, From b50c453caa0feff292bdefa6c1fc90fcd107d05e Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 4 Jun 2024 21:37:19 +0500 Subject: [PATCH 32/78] fix(skymp5-client): fix auth by deferring hot reload (#2020) --- .../src/services/services/authService.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index 907a3f1de4..7eabd8f81d 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -84,6 +84,15 @@ export class AuthService extends ClientListener { logTrace(this, `Received createActorMessage for self, resetting widgets`); this.sp.browser.executeJavaScript('window.skyrimPlatform.widgets.set([]);'); this.authDialogOpen = false; + + // The idea is to write authData to disk after auth dialog is closed + // This is because write to plugins dir triggers hotreload, which in turn breaks the auth dialog + // So we need to write to disk after dialog is closed + if (this.authDataWriteTask) { + const task = this.authDataWriteTask.authDataToWrite; + this.controller.once("update", () => this.writeAuthDataToDisk(task)); + this.authDataWriteTask = null; + } } else { logTrace(this, `Received createActorMessage for self, but auth dialog was not open so not resetting widgets`); @@ -183,7 +192,7 @@ export class AuthService extends ClientListener { this.refreshWidgets(); break; } - this.writeAuthDataToDisk(authData); + this.authDataWriteTask = { authDataToWrite: authData }; this.controller.emitter.emit("authAttempt", { authGameData: { remote: authData } }); this.authAttemptProgressIndicator = true; @@ -191,7 +200,8 @@ export class AuthService extends ClientListener { break; case events.clearAuthData: - this.writeAuthDataToDisk(null); + // Doesn't seem to be used + this.authDataWriteTask = { authDataToWrite: null }; break; case events.openGithub: this.sp.win32.loadUrl(this.githubUrl); @@ -571,8 +581,11 @@ export class AuthService extends ClientListener { this.authAttemptProgressIndicator = false; this.controller.lookupListener(NetworkingService).close(); logTrace(this, 'max logging delay reached received'); + browserState.comment = ""; browserState.loginFailedReason = 'технические шоколадки\nпопробуйте еще раз\nпожалуйста\nили напишите нам в discord'; this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + + authData = null; } if (this.authAttemptProgressIndicator) { @@ -617,6 +630,8 @@ export class AuthService extends ClientListener { private authAttemptProgressIndicator = false; private authAttemptProgressIndicatorCounter = 0; + private authDataWriteTask: { authDataToWrite: RemoteAuthGameData | null } | null = null; + private readonly githubUrl = "https://github.com/skyrim-multiplayer/skymp"; private readonly patreonUrl = "https://www.patreon.com/skymp"; private readonly pluginAuthDataName = `auth-data-no-load`; From f1896a6b676df8eab66873503b76f6fe9bd5754a Mon Sep 17 00:00:00 2001 From: Zikkey <68398139+ZikkeyLS@users.noreply.github.com> Date: Wed, 5 Jun 2024 00:40:07 +0300 Subject: [PATCH 33/78] fix(skymp5-server): fix file database error-handling (#2021) --- .../database_drivers/FileDatabase.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp index 5d69a5dd60..2a0a309ad1 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp @@ -32,6 +32,15 @@ size_t FileDatabase::Upsert(const std::vector& changeForms) f << MpChangeForm::ToJson(changeForm).dump(2); } + if (!f.is_open()) { + pImpl->logger->error("Unable to open file {}", filePath.string()); + } else if (!f) { + pImpl->logger->error("Unknown error while writing file {}", + filePath.string()); + } else { + ++nUpserted; + } + if (!f.fail()) { f.close(); @@ -43,15 +52,6 @@ size_t FileDatabase::Upsert(const std::vector& changeForms) errorCode.message()); } } - - if (!f.is_open()) { - pImpl->logger->error("Unable to open file {}", filePath.string()); - } else if (!f) { - pImpl->logger->error("Unknown error while writing file {}", - filePath.string()); - } else { - ++nUpserted; - } } return nUpserted; From f407efe8f744ffbdbd86f9a6a98322ffe58c0a69 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 5 Jun 2024 23:35:07 +0500 Subject: [PATCH 34/78] fix(skymp5-client): fix auth retry logic in case of mid-game disconnect (#2022) --- .../src/services/services/authService.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index 7eabd8f81d..4ff3b259dc 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -48,6 +48,7 @@ export class AuthService extends ClientListener { this.controller.emitter.on("customPacketMessage2", (e) => this.onCustomPacketMessage2(e)); this.controller.on("browserMessage", (e) => this.onBrowserMessage(e)); this.controller.on("tick", () => this.onTick()); + this.controller.once("update", () => this.onceUpdate()); } private onAuthNeeded(e: AuthNeededEvent) { @@ -188,7 +189,7 @@ export class AuthService extends ClientListener { break; case events.authAttempt: if (authData === null) { - browserState.comment = 'сначала войдите через discord'; + browserState.comment = 'сначала войдите'; this.refreshWidgets(); break; } @@ -571,21 +572,25 @@ export class AuthService extends ClientListener { // TODO: Busy waiting is bad. Should be replaced with some kind of event const maxLoggingDelay = 15000; if (this.loggingStartMoment && Date.now() - this.loggingStartMoment > maxLoggingDelay) { - // logError(this, 'Logging in failed. Reconnecting.'); + logTrace(this, 'Max logging delay reached received'); - // browserState.comment = 'проблемы с авторизацией'; - // this.refreshWidgets(); - - // this.controller.lookupListener(NetworkingService).reconnect(); - this.loggingStartMoment = 0; - this.authAttemptProgressIndicator = false; - this.controller.lookupListener(NetworkingService).close(); - logTrace(this, 'max logging delay reached received'); - browserState.comment = ""; - browserState.loginFailedReason = 'технические шоколадки\nпопробуйте еще раз\nпожалуйста\nили напишите нам в discord'; - this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); + if (this.playerEverSawActualGameplay) { + logTrace(this, 'Player saw actual gameplay, reconnecting'); + this.loggingStartMoment = 0; + this.controller.lookupListener(NetworkingService).reconnect(); + // TODO: should we prompt user to relogin? + } + else { + logTrace(this, 'Player never saw actual gameplay, showing login dialog'); + this.loggingStartMoment = 0; + this.authAttemptProgressIndicator = false; + this.controller.lookupListener(NetworkingService).close(); + browserState.comment = ""; + browserState.loginFailedReason = 'технические шоколадки\nпопробуйте еще раз\nпожалуйста\nили напишите нам в discord'; + this.sp.browser.executeJavaScript(new FunctionInfo(this.loginFailedWidgetSetter).getText({ events, browserState, authData: authData })); - authData = null; + authData = null; + } } if (this.authAttemptProgressIndicator) { @@ -604,6 +609,10 @@ export class AuthService extends ClientListener { } } + private onceUpdate() { + this.playerEverSawActualGameplay = true; + } + // private showConnectionError() { // // TODO: unhardcode it or render via browser // this.sp.printConsole("Server connection failed. This may be caused by one of the following:"); @@ -632,6 +641,8 @@ export class AuthService extends ClientListener { private authDataWriteTask: { authDataToWrite: RemoteAuthGameData | null } | null = null; + private playerEverSawActualGameplay = false; + private readonly githubUrl = "https://github.com/skyrim-multiplayer/skymp"; private readonly patreonUrl = "https://www.patreon.com/skymp"; private readonly pluginAuthDataName = `auth-data-no-load`; From 9d1dd65a5cb5700de640be056f0ecea719f04f43 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Thu, 6 Jun 2024 22:39:52 +0500 Subject: [PATCH 35/78] feat(skymp5-server): add server-settings-merged.json for debugging (#2023) --- skymp5-server/ts/settings.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skymp5-server/ts/settings.ts b/skymp5-server/ts/settings.ts index 9c203cc915..3bfc1d6f77 100644 --- a/skymp5-server/ts/settings.ts +++ b/skymp5-server/ts/settings.ts @@ -321,5 +321,7 @@ async function fetchServerSettings(): Promise { console.log(`Merging "server-settings.json" (original settings file)`); serverSettings = lodash.merge(serverSettings, serverSettingsFile); + fs.writeFileSync('server-settings-merged.json', JSON.stringify(serverSettings, null, 2)); + return serverSettings; } From 0ee0d160b34a811a6e2e8791bc4ccb3fa9f559e9 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 7 Jun 2024 14:33:19 +0500 Subject: [PATCH 36/78] fix(skymp5-server): fix mongodb errors handling & optimize changeforms copying (#2024) --- .../cpp/server_guest_lib/ChangeFormGuard.cpp | 3 +- .../cpp/server_guest_lib/WorldState.cpp | 144 ++++++++++++++---- .../cpp/server_guest_lib/WorldState.h | 7 + .../database_drivers/FileDatabase.cpp | 68 +++++---- .../database_drivers/FileDatabase.h | 3 +- .../database_drivers/IDatabase.h | 27 +++- .../database_drivers/MigrationDatabase.cpp | 7 +- .../database_drivers/MigrationDatabase.h | 3 +- .../database_drivers/MongoDatabase.cpp | 41 +++-- .../database_drivers/MongoDatabase.h | 3 +- .../save_storages/AsyncSaveStorage.cpp | 12 +- .../save_storages/AsyncSaveStorage.h | 3 +- .../save_storages/ISaveStorage.h | 2 +- unit/SaveStorageTest.cpp | 5 +- 14 files changed, 242 insertions(+), 86 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/ChangeFormGuard.cpp b/skymp5-server/cpp/server_guest_lib/ChangeFormGuard.cpp index 8f69053ddb..08f488fbd4 100644 --- a/skymp5-server/cpp/server_guest_lib/ChangeFormGuard.cpp +++ b/skymp5-server/cpp/server_guest_lib/ChangeFormGuard.cpp @@ -3,6 +3,7 @@ void ChangeFormGuard_::RequestSave(MpObjectReference* self) { - if (auto worldState = self->GetParent()) + [[likely]] if (auto worldState = self->GetParent()) { worldState->RequestSave(*self); + } } diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.cpp b/skymp5-server/cpp/server_guest_lib/WorldState.cpp index 88b6d9d1dd..2c865d4ebd 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.cpp +++ b/skymp5-server/cpp/server_guest_lib/WorldState.cpp @@ -8,6 +8,7 @@ #include "MpObjectReference.h" #include "ScopedTask.h" #include "Timer.h" +#include "database_drivers/IDatabase.h" // UpsertFailedException #include "libespm/GroupUtils.h" #include "papyrus-vm/Reader.h" #include "papyrus-vm/Utils.h" @@ -17,12 +18,15 @@ #include "script_storages/IScriptStorage.h" #include #include +#include #include +#include #include struct WorldState::Impl { - std::unordered_map changes; + std::vector> changesByIdx; + std::shared_ptr saveStorage; std::shared_ptr scriptStorage; bool saveStorageBusy = false; @@ -64,6 +68,8 @@ void WorldState::AttachEspm(espm::Loader* espm_, void WorldState::AttachSaveStorage(std::shared_ptr saveStorage) { + spdlog::info("AttachSaveStorage - db fixes installed"); + pImpl->saveStorage = saveStorage; } @@ -78,15 +84,11 @@ void WorldState::AddForm(std::unique_ptr form, uint32_t formId, const MpChangeForm* optionalChangeFormToApply) { if (!skipChecks && forms.find(formId) != forms.end()) { - throw std::runtime_error( - static_cast(std::stringstream() - << "Form with id " << std::hex - << formId << " already exists") - .str()); + fmt::format("Form with id {:x} already exists", formId)); } - form->Init(this, formId, optionalChangeFormToApply != nullptr); + // Assign formIndex before Init if (auto formIndex = dynamic_cast(form.get())) { if (!formIdxManager) formIdxManager.reset(new MakeID(FormIndex::g_invalidIdx - 1)); @@ -98,6 +100,10 @@ void WorldState::AddForm(std::unique_ptr form, uint32_t formId, formByIdxUnreliable[formIndex->idx] = form.get(); } + // MpObjectReference::Init requests save for newly created forms. That's why + // we want formIndex to be assigned before init. + form->Init(this, formId, optionalChangeFormToApply != nullptr); + auto it = forms.insert({ formId, std::move(form) }).first; if (optionalChangeFormToApply) { @@ -199,7 +205,9 @@ void WorldState::LoadChangeForm(const MpChangeForm& changeForm, std::to_string(changeForm.recType)); } + auto formRawPtr = reinterpret_cast(form.get()); AddForm(std::move(form), formId, false, &changeForm); + auto idx = formRawPtr->GetIdx(); // EnsureBaseContainerAdded forces saving here. // We do not want characters to save when they are load partially @@ -207,10 +215,14 @@ void WorldState::LoadChangeForm(const MpChangeForm& changeForm, // https://github.com/skyrim-multiplayer/issue-tracker/issues/64 // So we expect that RequestSave does nothing in this case: - assert(pImpl->changes.count(formId) == 0); - // For Release configuration we just manually remove formId from changes - pImpl->changes.erase(formId); + if (pImpl->changesByIdx.size() > idx && + pImpl->changesByIdx[idx] != std::nullopt) { + assert(false); + + // For Release configuration we just manually remove formId from changes + pImpl->changesByIdx[idx] = std::nullopt; + } } void WorldState::RequestReloot(MpObjectReference& ref, @@ -222,9 +234,21 @@ void WorldState::RequestReloot(MpObjectReference& ref, void WorldState::RequestSave(MpObjectReference& ref) { - if (!pImpl->formLoadingInProgress) { - pImpl->changes[ref.GetFormId()] = ref.GetChangeForm(); + if (pImpl->formLoadingInProgress) { + return; + } + + auto idx = ref.GetIdx(); + + [[unlikely]] if (idx == FormIndex::g_invalidIdx) { + return spdlog::error("RequestSave {:x} - Invalid index", ref.GetFormId()); } + + if (pImpl->changesByIdx.size() <= idx) { + pImpl->changesByIdx.resize(idx + 1); + } + + pImpl->changesByIdx[idx] = ref.GetChangeForm(); } const std::shared_ptr& WorldState::LookupFormById( @@ -272,6 +296,19 @@ const std::shared_ptr& WorldState::LookupFormById( return it->second; } +const std::shared_ptr& WorldState::LookupFormByIdNoLoad( + uint32_t formId) +{ + static const std::shared_ptr kNullForm; + + auto it = forms.find(formId); + if (it == forms.end()) { + return kNullForm; + } + + return it->second; +} + bool WorldState::AttachEspmRecord(const espm::CombineBrowser& br, const espm::RecordHeader* record, const espm::IdMapping& mapping, @@ -619,22 +656,77 @@ void WorldState::TickSaveStorage(const std::chrono::system_clock::time_point&) return; } - pImpl->saveStorage->Tick(); + try { + pImpl->saveStorage->Tick(); + } catch (UpsertFailedException& e) { + spdlog::error( + "TickSaveStorage - received UpsertFailedException {}, re-saving", + e.what()); + + // No SetTimer here because timers may also break in theory. we faced such + // problems earlier + pImpl->saveStorageBusy = false; + + auto& forms = e.GetAffectedForms(); + size_t numRequested = 0; + + for (size_t i = 0; i < forms.size(); ++i) { + auto& changeForm = forms[i]; + if (changeForm == std::nullopt) { + continue; + } + + // TODO: remove reinterpret_cast + MpObjectReference* form = reinterpret_cast( + LookupFormByIdx(static_cast(i))); + if (!form) { + continue; + } + + auto formId = changeForm->formDesc.ToFormId(espmFiles); - auto& changes = pImpl->changes; - if (!pImpl->saveStorageBusy && !changes.empty()) { - pImpl->saveStorageBusy = true; - std::vector changeForms; - changeForms.reserve(changes.size()); - for (auto [formId, changeForm] : changes) { - changeForms.push_back(changeForm); + if (form->GetFormId() != formId) { + spdlog::error("TickSaveStorage - formIds not matching {:x} <=> {:x}", + form->GetFormId(), formId); + continue; + } + + RequestSave(*form); + numRequested++; } - changes.clear(); - auto pImpl_ = pImpl; - pImpl->saveStorage->Upsert(changeForms, + spdlog::info("TickSaveStorage - requested re-save for {} forms in buffer " + "with size {}", + numRequested, forms.size()); + + } catch (std::exception& e) { + spdlog::error( + "TickSaveStorage - received std::exception {}, can't request re-save", + e.what()); + pImpl->saveStorageBusy = false; + } + + if (pImpl->saveStorageBusy) { + return; + } + + if (pImpl->changesByIdx.empty()) { + return; + } + + pImpl->saveStorageBusy = true; + + auto pImpl_ = pImpl; + + try { + pImpl->saveStorage->Upsert(std::move(pImpl->changesByIdx), [pImpl_] { pImpl_->saveStorageBusy = false; }); + } catch (std::exception& e) { + pImpl->saveStorageBusy = false; + spdlog::error("TickSaveStorage - Upsert failed with {}", e.what()); } + + pImpl->changesByIdx.clear(); } void WorldState::TickTimers(const std::chrono::system_clock::time_point&) @@ -764,9 +856,9 @@ struct LazyState std::shared_ptr pex; std::vector pexBin; - // With Papyrus hotreload enabled, this variable hold references to previous - // versions of pex files. This prevents the invalidation of string/identifier - // types of VarValue + // With Papyrus hotreload enabled, this variable hold references to + // previous versions of pex files. This prevents the invalidation of + // string/identifier types of VarValue std::vector> oldPexHolder; }; diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.h b/skymp5-server/cpp/server_guest_lib/WorldState.h index fc3903ea2e..c8b22fa4b4 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.h +++ b/skymp5-server/cpp/server_guest_lib/WorldState.h @@ -109,17 +109,24 @@ class WorldState wrapper); bool RemoveEffectTimer(uint32_t timerId); + // Loads a requested form, likely resulting in whole chunk + // loading if not yet loaded const std::shared_ptr& LookupFormById( uint32_t formId, std::stringstream* optionalOutTrace = nullptr); + // No loading MpForm* LookupFormByIdx(int idx); + // No loading version of LookupFormById + const std::shared_ptr& LookupFormByIdNoLoad(uint32_t formId); + void SendPapyrusEvent(MpForm* form, const char* eventName, const VarValue* arguments, size_t argumentsCount); const std::set& GetReferencesAtPosition( uint32_t cellOrWorld, int16_t cellX, int16_t cellY); + // See LookupFormById comment template F& GetFormAt(uint32_t formId) { diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp index 2a0a309ad1..a115be2801 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.cpp @@ -18,43 +18,55 @@ FileDatabase::FileDatabase(std::string directory_, std::filesystem::create_directories(p); } -size_t FileDatabase::Upsert(const std::vector& changeForms) +size_t FileDatabase::Upsert( + std::vector>&& changeForms) { - std::filesystem::path p = pImpl->changeFormsDirectory; - size_t nUpserted = 0; + try { + std::filesystem::path p = pImpl->changeFormsDirectory; + size_t nUpserted = 0; - for (auto& changeForm : changeForms) { - std::string fileName = changeForm.formDesc.ToString('_') + ".json"; - auto tempFilePath = p / (fileName + ".tmp"), filePath = p / fileName; + for (auto& changeForm : changeForms) { + if (changeForm == std::nullopt) { + continue; + } - std::ofstream f(tempFilePath); - if (f) { - f << MpChangeForm::ToJson(changeForm).dump(2); - } + std::string fileName = changeForm->formDesc.ToString('_') + ".json"; + auto tempFilePath = p / (fileName + ".tmp"), filePath = p / fileName; - if (!f.is_open()) { - pImpl->logger->error("Unable to open file {}", filePath.string()); - } else if (!f) { - pImpl->logger->error("Unknown error while writing file {}", - filePath.string()); - } else { - ++nUpserted; - } + std::ofstream f(tempFilePath); + if (f) { + f << MpChangeForm::ToJson(*changeForm).dump(2); + } - if (!f.fail()) { - f.close(); + bool wasOpen = f.is_open(); - std::error_code errorCode; - std::filesystem::rename(tempFilePath, filePath, errorCode); - if (errorCode) { - pImpl->logger->error("Unable to rename {} to {}: {}", - tempFilePath.string(), filePath.string(), - errorCode.message()); + if (!f.fail()) { + f.close(); + + std::error_code errorCode; + std::filesystem::rename(tempFilePath, filePath, errorCode); + if (errorCode) { + throw std::runtime_error( + fmt::format("Unable to rename {} to {}: {}", tempFilePath.string(), + filePath.string(), errorCode.message())); + } + } + + if (!wasOpen) { + throw std::runtime_error( + fmt::format("Unable to open file {}", filePath.string())); + } else if (!f) { + throw std::runtime_error(fmt::format( + "Unknown error while writing file {}", filePath.string())); + } else { + ++nUpserted; } } - } - return nUpserted; + return nUpserted; + } catch (std::exception& e) { + throw UpsertFailedException(std::move(changeForms), e.what()); + } } void FileDatabase::Iterate(const IterateCallback& iterateCallback) diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.h b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.h index 1f8744cb08..61beca47d7 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.h +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/FileDatabase.h @@ -8,7 +8,8 @@ class FileDatabase : public IDatabase FileDatabase(std::string directory_, std::shared_ptr logger_); - size_t Upsert(const std::vector& changeForms) override; + size_t Upsert( + std::vector>&& changeForms) override; void Iterate(const IterateCallback& iterateCallback) override; private: diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/IDatabase.h b/skymp5-server/cpp/server_guest_lib/database_drivers/IDatabase.h index 02feba2479..4e6f759dbe 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/IDatabase.h +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/IDatabase.h @@ -1,6 +1,30 @@ #pragma once #include "MpChangeForms.h" #include +#include +#include +#include + +class UpsertFailedException : public std::runtime_error +{ +public: + UpsertFailedException( + std::vector>&& affectedForms_, + std::string what) + : runtime_error(what) + , affectedForms(affectedForms_) + { + } + + const std::vector>& GetAffectedForms() + const noexcept + { + return affectedForms; + } + +private: + const std::vector> affectedForms; +}; class IDatabase { @@ -12,7 +36,8 @@ class IDatabase // Returns numbers of change forms inserted or updated successfully (Suitable // for logging). In practice, it should be equal to `changeForms.size()` when // saving succeed. - virtual size_t Upsert(const std::vector& changeForms) = 0; + virtual size_t Upsert( + std::vector>&& changeForms) = 0; virtual void Iterate(const IterateCallback& iterateCallback) = 0; }; diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.cpp b/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.cpp index cefea07bbd..62517ba4ce 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.cpp +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.cpp @@ -84,7 +84,7 @@ MigrationDatabase::MigrationDatabase(std::shared_ptr newDatabase, size_t totalUpserted = 0; for (size_t i = 0; i < changeForms.size(); i += chunkSize) { - std::vector chunk; + std::vector> chunk; // Calculate the end of the current chunk size_t end = std::min(i + chunkSize, changeForms.size()); @@ -94,7 +94,7 @@ MigrationDatabase::MigrationDatabase(std::shared_ptr newDatabase, std::back_inserter(chunk)); // Upsert the current chunk - size_t numUpserted = newDatabase->Upsert(chunk); + size_t numUpserted = newDatabase->Upsert(std::move(chunk)); totalUpserted += numUpserted; spdlog::info("MigrationDatabase: upserted chunk {}/{} ({} changeForms)", @@ -110,7 +110,8 @@ MigrationDatabase::MigrationDatabase(std::shared_ptr newDatabase, pImpl->exit(); } -size_t MigrationDatabase::Upsert(const std::vector& changeForms) +size_t MigrationDatabase::Upsert( + std::vector>&& changeForms) { spdlog::error("MigrationDatabase::Upsert - should never be reached"); pImpl->terminate(); diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.h b/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.h index b9858bfd07..09c635b885 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.h +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/MigrationDatabase.h @@ -9,7 +9,8 @@ class MigrationDatabase : public IDatabase std::shared_ptr oldDatabase, std::function exit = [] { std::exit(0); }, std::function terminate = [] { std::terminate(); }); - size_t Upsert(const std::vector& changeForms) override; + size_t Upsert( + std::vector>&& changeForms) override; void Iterate(const IterateCallback& iterateCallback) override; private: diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.cpp b/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.cpp index 5bb0ea2023..0d44289a69 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.cpp +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.cpp @@ -37,26 +37,37 @@ MongoDatabase::MongoDatabase(std::string uri_, std::string name_) new mongocxx::collection((*pImpl->db)[pImpl->collectionName])); } -size_t MongoDatabase::Upsert(const std::vector& changeForms) +size_t MongoDatabase::Upsert( + std::vector>&& changeForms) { - auto bulk = pImpl->changeFormsCollection->create_bulk_write(); - for (auto& changeForm : changeForms) { - auto jChangeForm = MpChangeForm::ToJson(changeForm); + try { + auto bulk = pImpl->changeFormsCollection->create_bulk_write(); + for (auto& changeForm : changeForms) { + if (changeForm == std::nullopt) { + continue; + } - auto filter = nlohmann::json::object(); - filter["formDesc"] = changeForm.formDesc.ToString(); + auto jChangeForm = MpChangeForm::ToJson(*changeForm); - auto upd = nlohmann::json::object(); - upd["$set"] = jChangeForm; + auto filter = nlohmann::json::object(); + filter["formDesc"] = changeForm->formDesc.ToString(); - bulk.append(mongocxx::model::update_one( - { std::move(bsoncxx::from_json(filter.dump())), - std::move(bsoncxx::from_json(upd.dump())) }) - .upsert(true)); - } + auto upd = nlohmann::json::object(); + upd["$set"] = jChangeForm; + + bulk.append(mongocxx::model::update_one( + { std::move(bsoncxx::from_json(filter.dump())), + std::move(bsoncxx::from_json(upd.dump())) }) + .upsert(true)); + } - (void)bulk.execute(); - return changeForms.size(); // Should take data from mongo instead? + (void)bulk.execute(); + + // TODO: Should take data from bulk.execute result instead? + return changeForms.size(); + } catch (std::exception& e) { + throw UpsertFailedException(std::move(changeForms), e.what()); + } } void MongoDatabase::Iterate(const IterateCallback& iterateCallback) diff --git a/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.h b/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.h index 9bd2a4a785..d89aa7e9ee 100644 --- a/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.h +++ b/skymp5-server/cpp/server_guest_lib/database_drivers/MongoDatabase.h @@ -6,7 +6,8 @@ class MongoDatabase : public IDatabase { public: MongoDatabase(std::string uri_, std::string name_); - size_t Upsert(const std::vector& changeForms) override; + size_t Upsert( + std::vector>&& changeForms) override; void Iterate(const IterateCallback& iterateCallback) override; private: diff --git a/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.cpp b/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.cpp index a98031ed17..2a85414abe 100644 --- a/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.cpp +++ b/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.cpp @@ -1,12 +1,13 @@ #include "AsyncSaveStorage.h" #include +#include #include struct AsyncSaveStorage::Impl { struct UpsertTask { - std::vector changeForms; + std::vector> changeForms; std::function callback; }; @@ -78,7 +79,9 @@ void AsyncSaveStorage::SaverThreadMain(Impl* pImpl) auto start = std::chrono::high_resolution_clock::now(); size_t numChangeForms = 0; for (auto& t : tasks) { - numChangeForms += pImpl->share.dbImpl->Upsert(t.changeForms); + numChangeForms += + pImpl->share.dbImpl->Upsert(std::move(t.changeForms)); + t.changeForms.clear(); callbacksToFire.push_back(t.callback); } if (numChangeForms > 0 && pImpl->logger) { @@ -108,8 +111,9 @@ void AsyncSaveStorage::IterateSync(const IterateSyncCallback& cb) pImpl->share.dbImpl->Iterate(cb); } -void AsyncSaveStorage::Upsert(const std::vector& changeForms, - const UpsertCallback& cb) +void AsyncSaveStorage::Upsert( + std::vector>&& changeForms, + const UpsertCallback& cb) { std::lock_guard l(pImpl->share3.m); pImpl->share3.upsertTasks.push_back({ changeForms, cb }); diff --git a/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.h b/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.h index 0966be19a4..2a80291bd0 100644 --- a/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.h +++ b/skymp5-server/cpp/server_guest_lib/save_storages/AsyncSaveStorage.h @@ -1,7 +1,6 @@ #pragma once #include "ISaveStorage.h" #include "database_drivers/IDatabase.h" -#include #include class AsyncSaveStorage : public ISaveStorage @@ -13,7 +12,7 @@ class AsyncSaveStorage : public ISaveStorage ~AsyncSaveStorage(); void IterateSync(const IterateSyncCallback& cb) override; - void Upsert(const std::vector& changeForms, + void Upsert(std::vector>&& changeForms, const UpsertCallback& cb) override; uint32_t GetNumFinishedUpserts() const override; void Tick() override; diff --git a/skymp5-server/cpp/server_guest_lib/save_storages/ISaveStorage.h b/skymp5-server/cpp/server_guest_lib/save_storages/ISaveStorage.h index d5a67c5d36..601ca19518 100644 --- a/skymp5-server/cpp/server_guest_lib/save_storages/ISaveStorage.h +++ b/skymp5-server/cpp/server_guest_lib/save_storages/ISaveStorage.h @@ -13,7 +13,7 @@ class ISaveStorage using UpsertCallback = std::function; virtual void IterateSync(const IterateSyncCallback& cb) = 0; - virtual void Upsert(const std::vector& changeForms, + virtual void Upsert(std::vector>&& changeForms, const UpsertCallback& cb) = 0; virtual uint32_t GetNumFinishedUpserts() const = 0; virtual void Tick() = 0; diff --git a/unit/SaveStorageTest.cpp b/unit/SaveStorageTest.cpp index 5fe990b624..d8244f9244 100644 --- a/unit/SaveStorageTest.cpp +++ b/unit/SaveStorageTest.cpp @@ -24,10 +24,11 @@ MpChangeForm CreateChangeForm(const char* descStr) return res; } -void UpsertSync(ISaveStorage& st, std::vector changeForms) +void UpsertSync(ISaveStorage& st, + std::vector> changeForms) { bool finished = false; - st.Upsert(changeForms, [&] { finished = true; }); + st.Upsert(std::move(changeForms), [&] { finished = true; }); int i = 0; while (!finished) { From 148bce0a5800de555d4f2564f1aaf10be942c506 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sun, 9 Jun 2024 21:12:03 +0500 Subject: [PATCH 37/78] feat: move file buffers to viet (#2028) --- libespm/CMakeLists.txt | 2 + libespm/include/libespm/Loader.h | 4 +- libespm/src/Loader.cpp | 11 +++--- .../include}/AllocatedBuffer.h | 6 +-- .../libespm => viet/include}/IBuffer.h | 4 +- .../libespm => viet/include}/MappedBuffer.h | 7 ++-- {libespm => viet}/src/AllocatedBuffer.cpp | 11 +++--- {libespm => viet}/src/MappedBuffer.cpp | 38 ++++++++++--------- 8 files changed, 45 insertions(+), 38 deletions(-) rename {libespm/include/libespm => viet/include}/AllocatedBuffer.h (83%) rename {libespm/include/libespm => viet/include}/IBuffer.h (82%) rename {libespm/include/libespm => viet/include}/MappedBuffer.h (82%) rename {libespm => viet}/src/AllocatedBuffer.cpp (73%) rename {libespm => viet}/src/MappedBuffer.cpp (65%) diff --git a/libespm/CMakeLists.txt b/libespm/CMakeLists.txt index 6ed7b93b6e..5dd4724cd5 100644 --- a/libespm/CMakeLists.txt +++ b/libespm/CMakeLists.txt @@ -13,3 +13,5 @@ add_library(libespm ALIAS espm) find_path(SPARSEPP_INCLUDE_DIR NAMES spp.h PATH_SUFFIXES sparsepp) get_filename_component(SPARSEPP_INCLUDE_DIR ${SPARSEPP_INCLUDE_DIR} DIRECTORY) target_include_directories(espm PUBLIC ${SPARSEPP_INCLUDE_DIR}) + +target_link_libraries(espm PUBLIC viet) diff --git a/libespm/include/libespm/Loader.h b/libespm/include/libespm/Loader.h index 50245cdcd2..0819d4076a 100644 --- a/libespm/include/libespm/Loader.h +++ b/libespm/include/libespm/Loader.h @@ -54,11 +54,11 @@ class Loader std::vector MakeFilePaths(const fs::path& dataDir, const std::vector& fileNames); - std::unique_ptr MakeBuffer(const fs::path& filePath) const; + std::unique_ptr MakeBuffer(const fs::path& filePath) const; struct Entry { - std::unique_ptr buffer; + std::unique_ptr buffer; std::unique_ptr browser; uintmax_t size = 0; diff --git a/libespm/src/Loader.cpp b/libespm/src/Loader.cpp index d09f332d5c..4036760d8f 100644 --- a/libespm/src/Loader.cpp +++ b/libespm/src/Loader.cpp @@ -1,7 +1,7 @@ #include "libespm/Loader.h" -#include "libespm/AllocatedBuffer.h" -#include "libespm/MappedBuffer.h" +#include "AllocatedBuffer.h" +#include "MappedBuffer.h" #include "libespm/Utils.h" namespace espm { @@ -87,13 +87,14 @@ std::vector Loader::MakeFilePaths( return res; } -std::unique_ptr Loader::MakeBuffer(const fs::path& filePath) const +std::unique_ptr Loader::MakeBuffer( + const fs::path& filePath) const { switch (bufferType) { case BufferType::AllocatedBuffer: - return std::make_unique(filePath); + return std::make_unique(filePath); case BufferType::MappedBuffer: - return std::make_unique(filePath); + return std::make_unique(filePath); default: throw std::runtime_error("[espm] unhandled buffer type"); } diff --git a/libespm/include/libespm/AllocatedBuffer.h b/viet/include/AllocatedBuffer.h similarity index 83% rename from libespm/include/libespm/AllocatedBuffer.h rename to viet/include/AllocatedBuffer.h index 796715b21e..cb4634ba7c 100644 --- a/libespm/include/libespm/AllocatedBuffer.h +++ b/viet/include/AllocatedBuffer.h @@ -1,11 +1,11 @@ #pragma once #include +#include #include "IBuffer.h" -#include "Loader.h" -namespace espm { +namespace Viet { class AllocatedBuffer : public IBuffer { @@ -20,4 +20,4 @@ class AllocatedBuffer : public IBuffer std::vector data; }; -} // namespace espm +} diff --git a/libespm/include/libespm/IBuffer.h b/viet/include/IBuffer.h similarity index 82% rename from libespm/include/libespm/IBuffer.h rename to viet/include/IBuffer.h index fbc163ee5e..d6303dd070 100644 --- a/libespm/include/libespm/IBuffer.h +++ b/viet/include/IBuffer.h @@ -2,7 +2,7 @@ #include -namespace espm { +namespace Viet { class IBuffer { @@ -13,4 +13,4 @@ class IBuffer virtual size_t GetLength() const = 0; }; -} // namespace espm +} diff --git a/libespm/include/libespm/MappedBuffer.h b/viet/include/MappedBuffer.h similarity index 82% rename from libespm/include/libespm/MappedBuffer.h rename to viet/include/MappedBuffer.h index 580b449923..274b46e7da 100644 --- a/libespm/include/libespm/MappedBuffer.h +++ b/viet/include/MappedBuffer.h @@ -7,14 +7,13 @@ #endif #include "IBuffer.h" -#include "Loader.h" -namespace espm { +namespace Viet { class MappedBuffer : public IBuffer { public: - MappedBuffer(const fs::path& path); + MappedBuffer(const std::filesystem::path& path); ~MappedBuffer(); @@ -34,4 +33,4 @@ class MappedBuffer : public IBuffer size_t size_; }; -} // namespace espm +} diff --git a/libespm/src/AllocatedBuffer.cpp b/viet/src/AllocatedBuffer.cpp similarity index 73% rename from libespm/src/AllocatedBuffer.cpp rename to viet/src/AllocatedBuffer.cpp index 81f87fedfb..0883eda467 100644 --- a/libespm/src/AllocatedBuffer.cpp +++ b/viet/src/AllocatedBuffer.cpp @@ -1,6 +1,8 @@ -#include "libespm/AllocatedBuffer.h" +#include "AllocatedBuffer.h" -namespace espm { +#include + +namespace Viet { AllocatedBuffer::AllocatedBuffer(const std::filesystem::path& path) : data() @@ -10,7 +12,7 @@ AllocatedBuffer::AllocatedBuffer(const std::filesystem::path& path) std::ifstream f(path.string(), std::ios::binary); if (!f.read(data.data(), size)) { - throw std::runtime_error("[espm] can't read " + path.string()); + throw std::runtime_error("[AllocatedBuffer] can't read " + path.string()); } } @@ -23,5 +25,4 @@ size_t AllocatedBuffer::GetLength() const { return data.size(); } - -} // namespace espm +} diff --git a/libespm/src/MappedBuffer.cpp b/viet/src/MappedBuffer.cpp similarity index 65% rename from libespm/src/MappedBuffer.cpp rename to viet/src/MappedBuffer.cpp index 3e69652eaa..cb2f2a09b4 100644 --- a/libespm/src/MappedBuffer.cpp +++ b/viet/src/MappedBuffer.cpp @@ -1,6 +1,7 @@ -#include "libespm/MappedBuffer.h" +#include "MappedBuffer.h" #include +#include #ifndef WIN32 # include @@ -8,30 +9,31 @@ # include #endif -namespace espm { +namespace Viet { -MappedBuffer::MappedBuffer(const fs::path& path) +MappedBuffer::MappedBuffer(const std::filesystem::path& path) { - size_ = fs::file_size(path); + size_ = std::filesystem::file_size(path); #ifdef WIN32 fileHandle_ = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_RANDOM_ACCESS, 0); if (!fileHandle_) { throw std::system_error(GetLastError(), std::system_category(), - "[espm] CreateFileW failed for " + path.string()); + "[MappedBuffer] CreateFileW failed for " + + path.string()); } mapHandle_ = CreateFileMapping(fileHandle_, NULL, PAGE_READONLY, 0, 0, NULL); if (!mapHandle_) { throw std::system_error(GetLastError(), std::system_category(), - "[espm] CreateFileMapping failed for " + + "[MappedBuffer] CreateFileMapping failed for " + path.string()); } viewPtr_ = MapViewOfFile(mapHandle_, FILE_MAP_READ, 0, 0, 0); if (!mapHandle_) { throw std::system_error(GetLastError(), std::system_category(), - "[espm] CreateFileMapping failed for " + + "[MappedBuffer] CreateFileMapping failed for " + path.string()); } data_ = static_cast(viewPtr_); @@ -39,12 +41,12 @@ MappedBuffer::MappedBuffer(const fs::path& path) fd_ = open(path.c_str(), O_RDONLY); if (fd_ == -1) { throw std::system_error(errno, std::system_category(), - "[espm] open failed for " + path.string()); + "[MappedBuffer] open failed for " + path.string()); } const auto mmapResult = mmap(NULL, size_, PROT_READ, MAP_SHARED, fd_, 0); if (mmapResult == MAP_FAILED) { throw std::system_error(errno, std::system_category(), - "[espm] mmap failed for " + path.string()); + "[MappedBuffer] mmap failed for " + path.string()); } data_ = static_cast(mmapResult); #endif @@ -56,7 +58,7 @@ MappedBuffer::~MappedBuffer() if (viewPtr_) { bool result = UnmapViewOfFile(viewPtr_); if (!result) { - std::cerr << "[espm] can't unmap file, error=" << GetLastError() + std::cerr << "[MappedBuffer] can't unmap file, error=" << GetLastError() << std::endl; std::terminate(); } @@ -64,16 +66,16 @@ MappedBuffer::~MappedBuffer() if (mapHandle_) { bool result = CloseHandle(mapHandle_); if (!result) { - std::cerr << "[espm] can't close map handle, error=" << GetLastError() - << std::endl; + std::cerr << "[MappedBuffer] can't close map handle, error=" + << GetLastError() << std::endl; std::terminate(); } } if (fileHandle_) { bool result = CloseHandle(fileHandle_); if (!result) { - std::cerr << "[espm] can't close file handle, error=" << GetLastError() - << std::endl; + std::cerr << "[MappedBuffer] can't close file handle, error=" + << GetLastError() << std::endl; std::terminate(); } } @@ -81,14 +83,16 @@ MappedBuffer::~MappedBuffer() if (data_) { int result = munmap(data_, size_); if (result == -1) { - std::cerr << "[espm] can't unmap file, errno=" << errno << std::endl; + std::cerr << "[MappedBuffer] can't unmap file, errno=" << errno + << std::endl; std::terminate(); } } if (fd_ != -1) { int result = close(fd_); if (result == -1) { - std::cerr << "[espm] can't close file, errno=" << errno << std::endl; + std::cerr << "[MappedBuffer] can't close file, errno=" << errno + << std::endl; std::terminate(); } } @@ -105,4 +109,4 @@ size_t MappedBuffer::GetLength() const return size_; } -} // namespace espm +} From 3c2ef9364a575c6a7446ba83e22d259e6f0b838e Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sun, 9 Jun 2024 22:24:47 +0500 Subject: [PATCH 38/78] internal: add gather_prs_test.yml (#2029) --- .github/workflows/gather_prs_test.yml | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/gather_prs_test.yml diff --git a/.github/workflows/gather_prs_test.yml b/.github/workflows/gather_prs_test.yml new file mode 100644 index 0000000000..f9aa0b9b29 --- /dev/null +++ b/.github/workflows/gather_prs_test.yml @@ -0,0 +1,35 @@ +name: Gather PRs test + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch, meaning server instance' + required: true + type: choice + options: + - 'sweetpie' + - 'indev' + default: 'indev' +jobs: + gather_prs_test: + runs-on: ubuntu-latest + steps: + - name: Gather PRs + uses: Pospelove/auto-merge-action@main + with: + path: ${{github.workspace}} + repositories: | + [ + { + "owner": "skyrim-multiplayer", + "repo": "skymp", + "labels": ["merge-to:${{env.DEPLOY_BRANCH}}"] + }, + { + "owner": "skyrim-multiplayer", + "repo": "skymp5-patches", + "labels": ["merge-to:${{env.DEPLOY_BRANCH}}"], + "token": "${{secrets.SKYMP5_PATCHES_PAT}}" + } + ] From 4ae34fc81146d6211be550279a2fae4e409d603f Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sun, 9 Jun 2024 23:45:26 +0500 Subject: [PATCH 39/78] fix(skymp5-server): fix inability to attack distant characters with bow/crossbow (#2030) --- skymp5-server/cpp/server_guest_lib/ActionListener.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp index 082b3ba15b..bc04325e55 100644 --- a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp +++ b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp @@ -792,9 +792,9 @@ bool IsDistanceValid(const MpActor& actor, const MpActor& targetActor, auto weapDNAM = espm::GetData(hitData.source, worldState).weapDNAM; if (weapDNAM->animType == espm::WEAP::AnimType::Bow) { - reach = kExteriorCellWidthUnits; + reach = kExteriorCellWidthUnits * 2; } else if (weapDNAM->animType == espm::WEAP::AnimType::Crossbow) { - reach = kExteriorCellWidthUnits; + reach = kExteriorCellWidthUnits * 2; } } } From 65edaf817957c3cd8d7362e15fbefb2442589ad1 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 10 Jun 2024 20:22:04 +0500 Subject: [PATCH 40/78] fix(skyrim-platform): fix playerBowShot event (#2035) --- docs/release/dev/sp-fix-onplayerbowshot.md | 1 + .../src/platform_se/skyrim_platform/EventHandler.cpp | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/release/dev/sp-fix-onplayerbowshot.md diff --git a/docs/release/dev/sp-fix-onplayerbowshot.md b/docs/release/dev/sp-fix-onplayerbowshot.md new file mode 100644 index 0000000000..1ecd2b3b6b --- /dev/null +++ b/docs/release/dev/sp-fix-onplayerbowshot.md @@ -0,0 +1 @@ +Fixed `playerBowShot` event not being fired. diff --git a/skyrim-platform/src/platform_se/skyrim_platform/EventHandler.cpp b/skyrim-platform/src/platform_se/skyrim_platform/EventHandler.cpp index bc25ce53c6..d3b47d5b7e 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/EventHandler.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/EventHandler.cpp @@ -1064,7 +1064,7 @@ EventResult EventHandler::ProcessEvent( auto obj = JsValue::Object(); auto weapon = RE::TESForm::LookupByID(weaponId); - auto ammo = RE::TESForm::LookupByID(ammoId); + auto ammo = RE::TESForm::LookupByID(ammoId); if (!weapon && weaponId != 0) { return; From 09813426a5e33e52ce7a869d8c6b67601b2b4511 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 10 Jun 2024 20:22:30 +0500 Subject: [PATCH 41/78] fix(skymp5-client): fix arrows display broken after relog (#2034) --- skymp5-client/src/sync/equipment.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/skymp5-client/src/sync/equipment.ts b/skymp5-client/src/sync/equipment.ts index a784e1b1e0..0c02488fd6 100644 --- a/skymp5-client/src/sync/equipment.ts +++ b/skymp5-client/src/sync/equipment.ts @@ -63,12 +63,17 @@ const filterWorn = (inv: Inventory): Inventory => { return { entries: inv.entries.filter((x) => x.worn || x.wornLeft) }; }; -const removeUnnecessaryExtra = (inv: Inventory): Inventory => { +const removeUnnecessaryExtra = (inv: Inventory, ignoreAmmo: boolean): Inventory => { return { entries: inv.entries.map((x) => { const r: Entry = JSON.parse(JSON.stringify(x)); r.chargePercent = r.maxCharge; - r.count = Ammo.from(Game.getFormEx(x.baseId)) ? 1000 : 1; + if (ignoreAmmo) { + r.count = Ammo.from(Game.getFormEx(x.baseId)) ? r.count : 1; + } + else { + r.count = Ammo.from(Game.getFormEx(x.baseId)) ? 1000 : 1; + } delete r.name; return r; }), @@ -109,7 +114,7 @@ export const applyEquipment = (ac: Actor, eq: Equipment): boolean => { ac.removeAllItems(null, false, true); - const newInventory = removeUnnecessaryExtra(filterWorn(eq.inv)); + const newInventory = removeUnnecessaryExtra(filterWorn(eq.inv), ac.getFormID() === 0x14); setInventory(ac.getFormID(), newInventory); From 2cd2c171dd1bef8f9ced80842fc28db60e5fbb8c Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 10 Jun 2024 20:52:14 +0500 Subject: [PATCH 42/78] fix(skymp5-client): improve anims system correctness (#2033) --- .../src/services/services/animDebugService.ts | 78 ++++++++++++++----- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/skymp5-client/src/services/services/animDebugService.ts b/skymp5-client/src/services/services/animDebugService.ts index 7906c60384..ec6f826df0 100644 --- a/skymp5-client/src/services/services/animDebugService.ts +++ b/skymp5-client/src/services/services/animDebugService.ts @@ -3,6 +3,8 @@ import { AnimDebugSettings } from "../messages_settings/animDebugSettings"; import { ClientListener, CombinedController, Sp } from "./clientListener"; import { ButtonEvent, CameraStateChangedEvent, DxScanCode, Menu } from "skyrimPlatform"; +const playerId = 0x14; + export class AnimDebugService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { super(); @@ -20,19 +22,19 @@ export class AnimDebugService extends ClientListener { } } + const self = this; + this.sp.hooks.sendAnimationEvent.add({ + enter: (ctx) => { }, + leave: (ctx) => { + self.onSendAnimationEventLeave(ctx); + } + }, playerId, playerId); + if (!this.settings || !this.settings.isActive) return; if (this.settings.textOutput?.isActive) { this.queue = new AnimQueueCollection(this.sp, this.settings); this.sp.storage[AnimQueueCollection.name] = this.queue; - - const self = this; - this.sp.hooks.sendAnimationEvent.add({ - enter: (ctx) => { }, - leave: (ctx) => { - self.onSendAnimationEventLeave(ctx); - } - }, playerId, playerId); } if (this.settings.animKeys) { @@ -45,6 +47,21 @@ export class AnimDebugService extends ClientListener { } private onSendAnimationEventLeave(ctx: { animEventName: string, animationSucceeded: boolean }) { + const animLowerCase = ctx.animEventName.toLowerCase(); + if (animLowerCase.startsWith("idle") && !animLowerCase.startsWith("idleforcedefaultstate")) { + this.controller.once("update", () => { + // This is only for player.playidle + if (!this.sp.Ui.isMenuOpen(Menu.Console)) return; + + if (this.sp.Game.getPlayer()?.getFurnitureReference()) return; + + logTrace(this, `Forcing third person and disabling player controls`); + this.sp.Game.forceThirdPerson(); + this.sp.Game.disablePlayerControls(true, false, true, false, false, false, false, false, 0); + this.needsExitingAnim = true; + }); + } + if (this.queue === undefined) return; this.queue.push(ctx.animEventName, ctx.animationSucceeded ? animationSucceededTextColor : animationNotSucceededTextColor); @@ -59,11 +76,7 @@ export class AnimDebugService extends ClientListener { || e.code === DxScanCode.D) { if (this.needsExitingAnim) { - - this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), "IdleForceDefaultState"); - logTrace(this, `Sent animation event: IdleForceDefaultState`); - this.needsExitingAnim = false; - this.sp.Game.enablePlayerControls(true, false, true, false, false, false, false, false, 0); + this.exitAnim(); } } else { @@ -81,20 +94,50 @@ export class AnimDebugService extends ClientListener { if (this.sp.Game.getPlayer()?.isWeaponDrawn()) return; if (this.sp.Ui.isMenuOpen(Menu.Favorites)) return; + if (this.sp.Ui.isMenuOpen(Menu.Console)) return; - this.sp.Game.forceThirdPerson(); - this.sp.Game.disablePlayerControls(true, false, true, false, false, false, false, false, 0); - this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), this.settings.animKeys![e.code]); + if (this.stopAnimInProgress) return; - this.needsExitingAnim = true; + if (this.sp.Game.getPlayer()?.getFurnitureReference()) return; + if (this.sp.Game.getPlayer()?.isSneaking()) return; + if (this.sp.Game.getPlayer()?.isSwimming()) return; + + if (this.settings.animKeys![e.code].toLowerCase() === "idleforcedefaultstate") { + if (this.needsExitingAnim) { + this.exitAnim(); + } + } + else { + this.sp.Game.forceThirdPerson(); + this.sp.Game.disablePlayerControls(true, false, true, false, false, false, false, false, 0); + this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), this.settings.animKeys![e.code]); + + this.needsExitingAnim = true; + } logTrace(this, `Sent animation event: ${this.settings.animKeys![e.code]}`); } + private exitAnim() { + this.sp.Debug.sendAnimationEvent(this.sp.Game.getPlayer(), "IdleForceDefaultState"); + logTrace(this, `Sent animation event: IdleForceDefaultState`); + this.needsExitingAnim = false; + + this.stopAnimInProgress = true; + this.sp.Utility.wait(0.5).then(() => { + this.sp.Game.enablePlayerControls(true, false, true, false, false, false, false, false, 0); + }); + this.sp.Utility.wait(1).then(() => { + this.stopAnimInProgress = false; + }); + } + private queue?: AnimQueueCollection; private settings?: AnimDebugSettings; private needsExitingAnim = false; + + private stopAnimInProgress = false; } type AnimListItem = { @@ -103,7 +146,6 @@ type AnimListItem = { color: number[] } -const playerId = 0x14; const animationSucceededTextColor = [255, 255, 255, 1]; const animationNotSucceededTextColor = [255, 0, 0, 1]; From cf6a6e3a33b28f3c19007106391578fbaeb06fc9 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 11 Jun 2024 14:19:49 +0500 Subject: [PATCH 43/78] fix(skymp5-client): fix error handling in load order verification (#2031) --- .../services/loadOrderVerificationService.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/skymp5-client/src/services/services/loadOrderVerificationService.ts b/skymp5-client/src/services/services/loadOrderVerificationService.ts index 31555fe87c..8558f4aa38 100644 --- a/skymp5-client/src/services/services/loadOrderVerificationService.ts +++ b/skymp5-client/src/services/services/loadOrderVerificationService.ts @@ -3,6 +3,7 @@ import { getServerIp, getServerUiPort } from "./skympClient"; import { getScreenResolution } from "../../view/formView"; import { ClientListener, CombinedController, Sp } from "./clientListener"; import { Mod, ServerManifest } from "../messages_http/serverManifest"; +import { logTrace } from "../../logging"; const STATE_KEY = 'loadOrderCheckState'; @@ -136,7 +137,7 @@ export class LoadOrderVerificationService extends ClientListener { const result = []; for (let i = 0; i < getCount(); ++i) { const filename = getAt(i); - const { crc32, size } = this.sp.getFileInfo(filename); + const { crc32, size } = this.getFileInfoSafe(filename); result.push({ filename, crc32, size }); } return result; @@ -152,4 +153,24 @@ export class LoadOrderVerificationService extends ClientListener { printConsole(`#${i} ${JSON.stringify(mod)}`); } }; + + private getFileInfoSafe(filename: string) { + try { + return this.sp.getFileInfo(filename); + } + catch (e) { + // InvalidArgumentException.h, Skyrim Platform + const searchString = 'is not a valid argument'; + + const message = (e as Record).message; + + if (typeof message === "string" && message.includes('is not a valid argument')) { + logTrace(this, `Failed to get file info for`, filename); + return { crc32: 0, size: 0 }; + } + else { + throw e; + } + } + } } From 8382597f7755ac5fc14cc9966bac70f5e5ba7db3 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 17 Jun 2024 06:52:06 +0500 Subject: [PATCH 44/78] feat(skymp5-client): block nocturnal anims (#2036) --- skymp5-client/src/index.ts | 4 +++- .../services/blockedAnimationsService.ts | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 skymp5-client/src/services/services/blockedAnimationsService.ts diff --git a/skymp5-client/src/index.ts b/skymp5-client/src/index.ts index 4a5df81a86..4fd08ce456 100644 --- a/skymp5-client/src/index.ts +++ b/skymp5-client/src/index.ts @@ -46,6 +46,7 @@ import { PlayerBowShotService } from "./services/services/playerBowShotService"; import { GamemodeEventSourceService } from "./services/services/gamemodeEventSourceService"; import { GamemodeUpdateService } from "./services/services/gamemodeUpdateService"; import { FrontHotReloadService } from "./services/services/frontHotReloadService"; +import { BlockedAnimationsService } from "./services/services/blockedAnimationsService"; once("update", () => { Utility.setINIBool("bAlwaysActive:General", true); @@ -95,7 +96,8 @@ const main = () => { new PlayerBowShotService(sp, controller), new GamemodeEventSourceService(sp, controller), new GamemodeUpdateService(sp, controller), - new FrontHotReloadService(sp, controller) + new FrontHotReloadService(sp, controller), + new BlockedAnimationsService(sp, controller) ]; SpApiInteractor.setup(listeners); } diff --git a/skymp5-client/src/services/services/blockedAnimationsService.ts b/skymp5-client/src/services/services/blockedAnimationsService.ts new file mode 100644 index 0000000000..b40b7ab699 --- /dev/null +++ b/skymp5-client/src/services/services/blockedAnimationsService.ts @@ -0,0 +1,22 @@ +import { logTrace } from "../../logging"; +import { ClientListener, Sp, CombinedController } from "./clientListener"; + +export class BlockedAnimationsService extends ClientListener { + constructor(private sp: Sp, private controller: CombinedController) { + super(); + + const blockedAnims = ["IdleNocturnal*"]; + + const self = this; + + blockedAnims.forEach(blockedAnim => { + this.sp.hooks.sendAnimationEvent.add({ + enter(ctx) { + logTrace(self, `blocking animation event`, ctx.animEventName); + ctx.animEventName = ""; + }, + leave() { } + }, 0x14, 0x14, blockedAnim); + }); + } +}; From 1bcfba23cde793201f6a653eb710be2ac5f470ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:46:05 +0500 Subject: [PATCH 45/78] internal: bump braces from 3.0.2 to 3.0.3 in /skymp5-server (#2038) --- skymp5-server/yarn.lock | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/skymp5-server/yarn.lock b/skymp5-server/yarn.lock index 8716e928d3..b3047513c3 100644 --- a/skymp5-server/yarn.lock +++ b/skymp5-server/yarn.lock @@ -489,11 +489,11 @@ brace-expansion@^2.0.1: balanced-match "^1.0.0" braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" buffer-from@^1.0.0: version "1.1.2" @@ -764,10 +764,10 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" @@ -1402,7 +1402,16 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -1420,7 +1429,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== From dc3ea0c3bf0145d4fec4a2baf804cc120b148ab7 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 18 Jun 2024 11:12:47 +0500 Subject: [PATCH 46/78] feat(skymp5-front): update string (#2039) --- skymp5-front/src/features/skillsMenu/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skymp5-front/src/features/skillsMenu/index.tsx b/skymp5-front/src/features/skillsMenu/index.tsx index c2ad2fd538..067a8b1662 100644 --- a/skymp5-front/src/features/skillsMenu/index.tsx +++ b/skymp5-front/src/features/skillsMenu/index.tsx @@ -178,7 +178,7 @@ const SkillsMenu = ({ send }: { send: (message: string) => void }) => { const confirmHanlder = () => { setconfirmDiscard(true); - setcurrentLevel('хотите удалить персонажа?'); + setcurrentLevel('хотите сбросить прогресс?'); setcurrentDescription('нажимая “да” вы полностью сбросите все выученные профессии и получите обратно половину потраченного опыта.'); }; From 1c7ab6a3f9450cf40747718c36cdefa042b0cae7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:25:32 +0500 Subject: [PATCH 47/78] internal: bump ws from 8.16.0 to 8.17.1 in /skymp5-server (#2040) --- skymp5-server/package.json | 2 +- skymp5-server/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/skymp5-server/package.json b/skymp5-server/package.json index 2d994ab8b2..1726a2d278 100644 --- a/skymp5-server/package.json +++ b/skymp5-server/package.json @@ -21,7 +21,7 @@ "koa-static": "^5.0.0", "lodash": "^4.17.21", "source-map-support": "^0.5.21", - "ws": "^8.13.0" + "ws": "^8.17.1" }, "devDependencies": { "@types/argparse": "^2.0.10", diff --git a/skymp5-server/yarn.lock b/skymp5-server/yarn.lock index b3047513c3..02c7a1032e 100644 --- a/skymp5-server/yarn.lock +++ b/skymp5-server/yarn.lock @@ -1593,10 +1593,10 @@ ws@8.14.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== -ws@^8.13.0, ws@^8.14.2: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" - integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== +ws@^8.14.2, ws@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== ylru@^1.2.0: version "1.4.0" From dc40c11c6d6ab2c9a2d0148cfc6b71d43bbdfe20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:36:57 +0500 Subject: [PATCH 48/78] internal: bump ws from 8.16.0 to 8.17.1 in /skymp5-front (#2042) --- skymp5-front/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skymp5-front/yarn.lock b/skymp5-front/yarn.lock index 955048b73f..715e7f37b6 100644 --- a/skymp5-front/yarn.lock +++ b/skymp5-front/yarn.lock @@ -11657,9 +11657,9 @@ write-file-atomic@^3.0.0: typedarray-to-buffer "^3.1.5" ws@^8.13.0, ws@^8.2.3: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" - integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== x-default-browser@^0.4.0: version "0.4.0" From 6b4b85e7593558b82c15540d94e5fba37b0dda03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:37:20 +0500 Subject: [PATCH 49/78] internal: bump braces from 3.0.2 to 3.0.3 in /skyrim-platform/tools/plugin-example (#2041) --- .../tools/plugin-example/package-lock.json | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/skyrim-platform/tools/plugin-example/package-lock.json b/skyrim-platform/tools/plugin-example/package-lock.json index 9f9a945d3c..37264019ce 100644 --- a/skyrim-platform/tools/plugin-example/package-lock.json +++ b/skyrim-platform/tools/plugin-example/package-lock.json @@ -60,9 +60,9 @@ } }, "@skyrim-platform/skyrim-platform": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@skyrim-platform/skyrim-platform/-/skyrim-platform-2.3.2.tgz", - "integrity": "sha512-Cvjdyd29Lccy+SVTx7LUFVIPcimLYKe/YBwFHcBXLPa4rwpwBzYIAorGglqRp61ktJY8joktJjWcCroyzKNu1Q==" + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@skyrim-platform/skyrim-platform/-/skyrim-platform-2.9.0.tgz", + "integrity": "sha512-ojrCbrBZNFDQpoJ1RN1Eb9TY9DTEmvfS9M2P86Gw+7dFipUPVpb0jjOmp6lgObGlL/D92AlY/6qHxMSb2bagww==" }, "@types/eslint": { "version": "8.21.2", @@ -435,12 +435,23 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" + }, + "dependencies": { + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + } } }, "browserslist": { @@ -732,15 +743,6 @@ "integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==", "dev": true }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, "find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", From 488bb4a299b4b16d66daa5196cfe17f71ff7afa2 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 19 Jun 2024 11:43:24 +0500 Subject: [PATCH 50/78] feat(viet): handle attempts to map empty files (#2043) --- viet/src/MappedBuffer.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/viet/src/MappedBuffer.cpp b/viet/src/MappedBuffer.cpp index cb2f2a09b4..3057411f4b 100644 --- a/viet/src/MappedBuffer.cpp +++ b/viet/src/MappedBuffer.cpp @@ -14,6 +14,11 @@ namespace Viet { MappedBuffer::MappedBuffer(const std::filesystem::path& path) { size_ = std::filesystem::file_size(path); + if (size_ == 0) { + throw std::runtime_error("[MappedBuffer] attempt to map empty file " + + path.string()); + } + #ifdef WIN32 fileHandle_ = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_RANDOM_ACCESS, 0); From cfef3de506c4baf37eba0e16d4fbddf2d7c84734 Mon Sep 17 00:00:00 2001 From: Zikkey <68398139+ZikkeyLS@users.noreply.github.com> Date: Thu, 20 Jun 2024 10:19:20 +0300 Subject: [PATCH 51/78] feat: implement FACT (#2027) --- libespm/include/libespm/FACT.h | 94 ++++++++++++++ libespm/include/libespm/REFR.h | 1 + libespm/include/libespm/Records.h | 1 + libespm/src/Browser.cpp | 9 ++ libespm/src/FACT.cpp | 54 ++++++++ libespm/src/REFR.cpp | 2 + misc/tests/test_factions.js | 46 +++++++ skymp5-server/cpp/server_guest_lib/Faction.h | 8 ++ .../cpp/server_guest_lib/MpActor.cpp | 110 ++++++++++++++++ skymp5-server/cpp/server_guest_lib/MpActor.h | 8 ++ .../cpp/server_guest_lib/MpChangeForms.cpp | 46 ++++++- .../cpp/server_guest_lib/MpChangeForms.h | 4 + .../cpp/server_guest_lib/WorldState.cpp | 4 +- .../script_classes/PapyrusActor.cpp | 120 ++++++++++++++++++ .../script_classes/PapyrusActor.h | 9 ++ .../script_classes/PapyrusClassesFactory.cpp | 2 + .../script_classes/PapyrusFaction.cpp | 29 +++++ .../script_classes/PapyrusFaction.h | 29 +++++ .../script_classes/PapyrusGame.cpp | 4 +- unit/ActorTest.cpp | 35 +++++ unit/EspmTest.cpp | 28 ++++ unit/SaveStorageTest.cpp | 10 ++ 22 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 libespm/include/libespm/FACT.h create mode 100644 libespm/src/FACT.cpp create mode 100644 misc/tests/test_factions.js create mode 100644 skymp5-server/cpp/server_guest_lib/Faction.h create mode 100644 skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.cpp create mode 100644 skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.h diff --git a/libespm/include/libespm/FACT.h b/libespm/include/libespm/FACT.h new file mode 100644 index 0000000000..f1059e9f8c --- /dev/null +++ b/libespm/include/libespm/FACT.h @@ -0,0 +1,94 @@ +#pragma once +#include "CTDA.h" +#include "RecordHeader.h" +#include + +#pragma pack(push, 1) + +namespace espm { + +class FACT final : public RecordHeader +{ +public: + static constexpr auto kType = "FACT"; + + enum class Flags : uint32_t + { + HiddenFromPC = 0x1, + SpecialCombat = 0x2, + TrackCrime = 0x40, + IgnoreMurder = 0x80, + IgnoreAssault = 0x100, + IgnoreStealing = 0x200, + IgnoreTrespass = 0x400, + DoNotReportCrimesAgainstMembers = 0x800, + CrimeGoldUseDefaults = 0x1000, + IgnorePickpocket = 0x2000, + Vendor = 0x4000, + CanBeOwner = 0x8000, + IgnoreWerewolf = 0x10000 + }; + + enum class CombatState : int32_t + { + Neutral = 0, + Enemy = 1, + Ally = 2, + Friend = 3 + }; + + struct InterfactionRelation + { + uint32_t factionFormId = 0; + int32_t unusedMod = 0; + CombatState combat; + }; + static_assert(sizeof(InterfactionRelation) == 12); + + // This struct can have 3 different layouts(12, 16, 20 bytes). + // Used 12 bytes version. + struct CrimeGold + { + uint8_t arrest = 0; + uint8_t attackOnSight = 0; + uint16_t murder = 0; + uint16_t assault = 0; + uint16_t trespass = 0; + uint16_t pickpocket = 0; + uint16_t unused = 0; + }; + static_assert(sizeof(CrimeGold) == 12); + + struct Rank + { + uint32_t rankId = 0; + std::optional maleTitle; + std::optional femaleTitle; + }; + + struct Data + { + // fullName is lstring = uint - id in String Table (4 byte length) + uint32_t fullNameTableID = 0; + std::vector interfactionRelations; + Flags flags; + uint32_t prisonMarker = 0; + uint32_t followerWaitMarker = 0; + uint32_t evidenceChest = 0; + uint32_t playerBelongingsChest = 0; + uint32_t crimeGroup = 0; + uint32_t jailOutfit = 0; + CrimeGold crimeGold; + std::vector ranks; + // vendor items skipped + std::vector conditions; + }; + + Data GetData(CompressedFieldsCache& compressedFieldsCache) const noexcept; +}; + +static_assert(sizeof(FACT) == sizeof(RecordHeader)); + +} + +#pragma pack(pop) diff --git a/libespm/include/libespm/REFR.h b/libespm/include/libespm/REFR.h index 994c29caa3..f122c24467 100644 --- a/libespm/include/libespm/REFR.h +++ b/libespm/include/libespm/REFR.h @@ -41,6 +41,7 @@ class REFR : public RecordHeader std::vector activationParents; uint32_t linkedRefKeywordId = 0; uint32_t linkedRefId = 0; + uint32_t ownerFaction = 0; }; Data GetData(CompressedFieldsCache& compressedFieldsCache) const noexcept; diff --git a/libespm/include/libespm/Records.h b/libespm/include/libespm/Records.h index 84b0be7e99..7d5d1dcf11 100644 --- a/libespm/include/libespm/Records.h +++ b/libespm/include/libespm/Records.h @@ -10,6 +10,7 @@ #include "CONT.h" #include "DOOR.h" #include "ENCH.h" +#include "FACT.h" #include "FLOR.h" #include "FLST.h" #include "GMST.h" diff --git a/libespm/src/Browser.cpp b/libespm/src/Browser.cpp index 4441881736..52b8e73e7a 100644 --- a/libespm/src/Browser.cpp +++ b/libespm/src/Browser.cpp @@ -3,6 +3,7 @@ #include "libespm/COBJ.h" #include "libespm/CellOrGridPos.h" #include "libespm/CompressedFieldsCache.h" +#include "libespm/FACT.h" #include "libespm/GroupDataInternal.h" #include "libespm/GroupUtils.h" #include "libespm/KYWD.h" @@ -41,6 +42,7 @@ struct Browser::Impl std::vector objectReferences; std::vector constructibleObjects; std::vector keywords; + std::vector factions; std::vector quests; std::vector worlds; @@ -117,6 +119,9 @@ const std::vector& Browser::GetRecordsByType( if (!std::strcmp(type, espm::KYWD::kType)) { return pImpl->keywords; } + if (!std::strcmp(type, espm::FACT::kType)) { + return pImpl->factions; + } if (!std::strcmp(type, espm::QUST::kType)) { return pImpl->quests; } @@ -239,6 +244,10 @@ bool Browser::ReadAny(const GroupStack* parentGrStack) pImpl->keywords.push_back(recHeader); } + if (utils::Is(t)) { + pImpl->factions.push_back(recHeader); + } + if (utils::Is(t)) { auto nvnm = reinterpret_cast(recHeader); diff --git a/libespm/src/FACT.cpp b/libespm/src/FACT.cpp new file mode 100644 index 0000000000..4cd87de60f --- /dev/null +++ b/libespm/src/FACT.cpp @@ -0,0 +1,54 @@ +#include "libespm/FACT.h" +#include "libespm/RecordHeaderAccess.h" +#include + +namespace espm { + +FACT::Data FACT::GetData( + CompressedFieldsCache& compressedFieldsCache) const noexcept +{ + Data result; + RecordHeaderAccess::IterateFields( + this, + [&](const char* type, uint32_t dataSize, const char* data) { + if (!std::memcmp(type, "FULL", 4)) { + result.fullNameTableID = *reinterpret_cast(data); + } else if (!std::memcmp(type, "XNAM", 4)) { + const auto relation = + *reinterpret_cast(data); + result.interfactionRelations.push_back(relation); + } else if (!std::memcmp(type, "DATA", 4)) { + result.flags = *reinterpret_cast(data); + } else if (!std::memcmp(type, "JAIL", 4)) { + result.prisonMarker = *reinterpret_cast(data); + } else if (!std::memcmp(type, "WAIT", 4)) { + result.followerWaitMarker = *reinterpret_cast(data); + } else if (!std::memcmp(type, "STOL", 4)) { + result.evidenceChest = *reinterpret_cast(data); + } else if (!std::memcmp(type, "PLCN", 4)) { + result.playerBelongingsChest = + *reinterpret_cast(data); + } else if (!std::memcmp(type, "CRGR", 4)) { + result.crimeGroup = *reinterpret_cast(data); + } else if (!std::memcmp(type, "JOUT", 4)) { + result.jailOutfit = *reinterpret_cast(data); + } else if (!std::memcmp(type, "CRVA", 4)) { + result.crimeGold = *reinterpret_cast(data); + } else if (!std::memcmp(type, "RNAM", 4)) { + result.ranks.push_back(espm::FACT::Rank()); + result.ranks.back().rankId = *reinterpret_cast(data); + } else if (!std::memcmp(type, "MNAM", 4)) { + result.ranks.back().maleTitle = + *reinterpret_cast(data); + } else if (!std::memcmp(type, "FNAM", 4)) { + result.ranks.back().femaleTitle = + *reinterpret_cast(data); + } else if (!std::memcmp(type, "CTDA", 4)) { + result.conditions.push_back(*reinterpret_cast(data)); + } + }, + compressedFieldsCache); + return result; +} + +} diff --git a/libespm/src/REFR.cpp b/libespm/src/REFR.cpp index ef74689fbe..99e55ad968 100644 --- a/libespm/src/REFR.cpp +++ b/libespm/src/REFR.cpp @@ -36,6 +36,8 @@ REFR::Data REFR::GetData( } else if (dataSize == 4) { result.linkedRefId = *reinterpret_cast(data); } + } else if (!std::memcmp(type, "XOWN", 4)) { + result.ownerFaction = *reinterpret_cast(data); } }, compressedFieldsCache); diff --git a/misc/tests/test_factions.js b/misc/tests/test_factions.js new file mode 100644 index 0000000000..f9979a6f19 --- /dev/null +++ b/misc/tests/test_factions.js @@ -0,0 +1,46 @@ +const assert = require("node:assert"); + +const main = async () => { + if (mp.get(0x15A6E, "type") === "MpActor") { + let selfObject = { type: 'form', desc: mp.getDescFromId(0x15A6E) } + let falmerFaction = mp.callPapyrusFunction("global", "Game", "GetFormEx", null, [0x3E096]) + + let fact = mp.callPapyrusFunction("method", "Actor", "GetFactions", selfObject, [-128, 127]) + assert.strictEqual(fact.length, 2) + assert.strictEqual(fact[0].desc, "13:Skyrim.esm") + assert.strictEqual(fact[0].type, "espm") + assert.strictEqual(fact[1].desc, "3e096:Skyrim.esm") + assert.strictEqual(fact[1].type, "espm") + + let isInFalmerFaction = mp.callPapyrusFunction("method", "Actor", "IsInFaction", selfObject, [falmerFaction]) + assert.strictEqual(isInFalmerFaction, true) + + mp.callPapyrusFunction("method", "Actor", "RemoveFromFaction", selfObject, [falmerFaction]) + + fact = mp.callPapyrusFunction("method", "Actor", "GetFactions", selfObject, [-128, 127]) + assert.strictEqual(fact.length, 1) + assert.strictEqual(fact[0].desc, "13:Skyrim.esm") + assert.strictEqual(fact[0].type, "espm") + + mp.callPapyrusFunction("method", "Actor", "AddToFaction", selfObject, [falmerFaction]) + + fact = mp.callPapyrusFunction("method", "Actor", "GetFactions", selfObject, [-128, 127]) + assert.strictEqual(fact.length, 2) + assert.strictEqual(fact[0].desc, "13:Skyrim.esm") + assert.strictEqual(fact[0].type, "espm") + assert.strictEqual(fact[1].desc, "3e096:Skyrim.esm") + assert.strictEqual(fact[1].type, "espm") + } + else { + assert.fail("0x15A6E is not MpActor") + } +}; + +main().then(() => { + console.log("Test passed!"); + process.exit(0); +}).catch((err) => { + console.log("Test failed!") + console.error(err); + process.exit(1); +}); diff --git a/skymp5-server/cpp/server_guest_lib/Faction.h b/skymp5-server/cpp/server_guest_lib/Faction.h new file mode 100644 index 0000000000..39992d7bba --- /dev/null +++ b/skymp5-server/cpp/server_guest_lib/Faction.h @@ -0,0 +1,8 @@ +#pragma once +#include "FormDesc.h" + +struct Faction +{ + FormDesc formDesc; + int8_t rank = 0; +}; diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.cpp b/skymp5-server/cpp/server_guest_lib/MpActor.cpp index 3b663fc968..dcaf02535f 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpActor.cpp @@ -220,6 +220,94 @@ void MpActor::SetEquipment(const std::string& jsonString) [&](MpChangeForm& changeForm) { changeForm.equipmentDump = jsonString; }); } +void MpActor::AddToFaction(Faction faction, bool lazyLoad) +{ + if (factionsLoaded == false && lazyLoad) + LoadFactions(); + + EditChangeForm([&](MpChangeFormREFR& changeForm) { + if (!changeForm.factions.has_value()) { + changeForm.factions = std::vector(); + changeForm.factions.value().push_back(faction); + } else { + for (const auto& fact : changeForm.factions.value()) { + if (faction.formDesc == fact.formDesc) { + return; + } + } + changeForm.factions.value().push_back(faction); + } + }); +} + +bool MpActor::IsInFaction(FormDesc factionForm, bool lazyLoad) +{ + if (factionsLoaded == false && lazyLoad) + LoadFactions(); + + const auto& factions = GetChangeForm().factions; + + if (!factions.has_value()) { + return false; + } + + for (const auto& faction : factions.value()) { + if (faction.formDesc == factionForm) { + return true; + } + } + return false; +} + +std::vector MpActor::GetFactions(int minFactionRank, + int maxFactionRank, bool lazyLoad) +{ + if (factionsLoaded == false && lazyLoad) + LoadFactions(); + + std::vector result = std::vector(); + + if (minFactionRank < -128 || minFactionRank > 127 || maxFactionRank < -128 || + maxFactionRank > 127 || minFactionRank > maxFactionRank) { + spdlog::warn( + "Actor.GetFactions - minRank > maxRank or out of range (-128/127)"); + return result; + } + + const auto& factions = GetChangeForm().factions; + + if (!factions.has_value()) { + return result; + } + + for (const auto& faction : factions.value()) { + if (faction.rank >= minFactionRank && faction.rank <= maxFactionRank) { + result.push_back(faction); + } + } + + return result; +} + +void MpActor::RemoveFromFaction(FormDesc factionForm, bool lazyLoad) +{ + if (factionsLoaded == false && lazyLoad) + LoadFactions(); + + EditChangeForm([&](MpChangeFormREFR& changeForm) { + if (!changeForm.factions.has_value()) { + return; + } + + auto& factions = changeForm.factions.value(); + factions.erase(std::remove_if(factions.begin(), factions.end(), + [&](const Faction& faction) { + return faction.formDesc == factionForm; + }), + factions.end()); + }); +} + void MpActor::VisitProperties(const PropertiesVisitor& visitor, VisitPropertiesMode mode) { @@ -865,6 +953,28 @@ void MpActor::AddDeathItem() } } +void MpActor::LoadFactions() +{ + std::vector factions = EvaluateTemplate( + GetParent(), GetBaseId(), GetTemplateChain(), + [&](const auto& npcLookupResult, const auto& npcData) { + std::vector factions = std::vector(); + for (auto npcFaction : npcData.factions) { + Faction faction = Faction(); + faction.formDesc = + FormDesc::FromFormId(npcLookupResult.ToGlobalId(npcFaction.formId), + GetParent()->espmFiles); + faction.rank = npcFaction.rank; + factions.push_back(faction); + } + return factions; + }); + for (Faction faction : factions) { + AddToFaction(faction, false); + } + factionsLoaded = true; +} + std::map MpActor::EvaluateDeathItem() { auto worldState = GetParent(); diff --git a/skymp5-server/cpp/server_guest_lib/MpActor.h b/skymp5-server/cpp/server_guest_lib/MpActor.h index 7206b944e7..81833ab5fc 100644 --- a/skymp5-server/cpp/server_guest_lib/MpActor.h +++ b/skymp5-server/cpp/server_guest_lib/MpActor.h @@ -47,6 +47,12 @@ class MpActor : public MpObjectReference void SetAppearance(const Appearance* newAppearance); void SetEquipment(const std::string& jsonString); + void AddToFaction(Faction faction, bool lazyLoad = true); + bool IsInFaction(FormDesc factionForm, bool lazyLoad = true); + std::vector GetFactions(int minFactionID, int maxFactionID, + bool lazyLoad = true); + void RemoveFromFaction(FormDesc factionForm, bool lazyLoad = true); + void VisitProperties(const PropertiesVisitor& visitor, VisitPropertiesMode mode) override; void Disable() override; @@ -144,6 +150,7 @@ class MpActor : public MpObjectReference private: struct Impl; std::shared_ptr pImpl; + bool factionsLoaded = false; void SendAndSetDeathState(bool isDead, bool shouldTeleport); @@ -171,6 +178,7 @@ class MpActor : public MpObjectReference std::map EvaluateDeathItem(); void AddDeathItem(); + void LoadFactions(); protected: void BeforeDestroy() override; diff --git a/skymp5-server/cpp/server_guest_lib/MpChangeForms.cpp b/skymp5-server/cpp/server_guest_lib/MpChangeForms.cpp index b741ad3702..c567fe6bce 100644 --- a/skymp5-server/cpp/server_guest_lib/MpChangeForms.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpChangeForms.cpp @@ -9,6 +9,7 @@ std::vector ToStringArray(const std::vector& formDescs) [](const FormDesc& v) { return v.ToString(); }); return res; } + std::vector ToFormDescsArray(const std::vector& strings) { std::vector res(strings.size()); @@ -96,6 +97,20 @@ nlohmann::json MpChangeForm::ToJson(const MpChangeForm& changeForm) res["displayName"] = *changeForm.displayName; } + if (changeForm.factions.has_value() && + !changeForm.factions.value().empty()) { + auto factionsJson = nlohmann::json::array(); + for (int i = 0; i < static_cast(changeForm.factions.value().size()); + ++i) { + nlohmann::json obj = { + { "formDesc", changeForm.factions.value()[i].formDesc.ToString() }, + { "rank", (uint32_t)changeForm.factions.value()[i].rank } + }; + factionsJson.push_back(obj); + } + res["factions"] = { { "entries", factionsJson } }; + } + return res; } @@ -118,7 +133,7 @@ MpChangeForm MpChangeForm::JsonToChangeForm(simdjson::dom::element& element) spawnDelay("spawnDelay"), effects("effects"), templateChain("templateChain"), lastAnimation("lastAnimation"), setNodeTextureSet("setNodeTextureSet"), setNodeScale("setNodeScale"), - displayName("displayName"); + displayName("displayName"), factions("factions"); MpChangeForm res; ReadEx(element, recType, &res.recType); @@ -281,6 +296,35 @@ MpChangeForm MpChangeForm::JsonToChangeForm(simdjson::dom::element& element) res.displayName = tmp; } + if (element.at_pointer(factions.GetData()).error() == + simdjson::error_code::SUCCESS) { + ReadEx(element, factions, &jTmp); + static const JsonPointer entries("entries"); + + std::vector parsedEntries; + ReadVector(jTmp, entries, &parsedEntries); + + std::vector factions = std::vector(parsedEntries.size()); + + for (size_t i = 0; i != parsedEntries.size(); ++i) { + auto& jEntry = parsedEntries[i]; + + static JsonPointer rank("rank"); + Faction fact = Faction(); + const char* tmp; + ReadEx(jEntry, formDesc, &tmp); + fact.formDesc = FormDesc::FromString(tmp); + + uint32_t rankTemp = 0; + ReadEx(jEntry, rank, &rankTemp); + fact.rank = rankTemp; + + factions[i] = fact; + } + + res.factions = factions; + } + return res; } diff --git a/skymp5-server/cpp/server_guest_lib/MpChangeForms.h b/skymp5-server/cpp/server_guest_lib/MpChangeForms.h index 278fd24849..5a13405314 100644 --- a/skymp5-server/cpp/server_guest_lib/MpChangeForms.h +++ b/skymp5-server/cpp/server_guest_lib/MpChangeForms.h @@ -4,6 +4,7 @@ #include "Appearance.h" #include "DynamicFields.h" #include "Equipment.h" +#include "Faction.h" #include "FormDesc.h" #include "Inventory.h" #include "LocationalData.h" @@ -112,6 +113,9 @@ class MpChangeFormREFR // Used for SetDisplayName (object reference) std::optional displayName; + // Used for Faction (FACT) synchronization + std::optional> factions; + // Used for Quest (QUST) synchronization std::optional> quests; diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.cpp b/skymp5-server/cpp/server_guest_lib/WorldState.cpp index 2c865d4ebd..79a020773f 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.cpp +++ b/skymp5-server/cpp/server_guest_lib/WorldState.cpp @@ -581,6 +581,8 @@ bool WorldState::AttachEspmRecord(const espm::CombineBrowser& br, LocationalDataUtils::GetRot(locationalData), FormDesc::FromFormId(worldOrCell, espmFiles) }; + + MpChangeFormREFR* changeForm = nullptr; if (!isNpc) { form.reset(new MpObjectReference(formLocationalData, formCallbacksFactory(), baseId, @@ -589,7 +591,7 @@ bool WorldState::AttachEspmRecord(const espm::CombineBrowser& br, form.reset( new MpActor(formLocationalData, formCallbacksFactory(), baseId)); } - AddForm(std::move(form), formId, true); + AddForm(std::move(form), formId, true, changeForm); // Do not TriggerFormInitEvent here, doing it later after changeForm apply diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.cpp index 4c951283c2..755cf85a50 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.cpp @@ -305,6 +305,122 @@ VarValue PapyrusActor::WornHasKeyword(VarValue self, return VarValue(false); } +VarValue PapyrusActor::AddToFaction(VarValue self, + const std::vector& arguments) +{ + if (auto actor = GetFormPtr(self)) { + auto worldState = actor->GetParent(); + if (!worldState) { + throw std::runtime_error("Actor.AddToFaction - no WorldState attached"); + } + + if (arguments.size() < 1) { + throw std::runtime_error("Actor.AddToFaction requires one argument"); + } + + const auto& factionRec = GetRecordPtr(arguments[0]); + if (!factionRec.rec) { + spdlog::error("Actor.AddToFaction - invalid faction form"); + return VarValue(); + } + + Faction resultFaction = Faction(); + resultFaction.formDesc = FormDesc::FromFormId( + factionRec.ToGlobalId(factionRec.rec->GetId()), worldState->espmFiles); + resultFaction.rank = 0; + + actor->AddToFaction(resultFaction); + } + return VarValue(); +} + +VarValue PapyrusActor::IsInFaction(VarValue self, + const std::vector& arguments) +{ + if (auto actor = GetFormPtr(self)) { + auto worldState = actor->GetParent(); + if (!worldState) { + throw std::runtime_error("Actor.IsInFaction - no WorldState attached"); + } + + if (arguments.size() < 1) { + throw std::runtime_error("Actor.IsInFaction requires one argument"); + } + + const auto& factionRec = GetRecordPtr(arguments[0]); + if (!factionRec.rec) { + spdlog::error("Actor.IsInFaction - invalid faction form"); + return VarValue(false); + } + + return VarValue(actor->IsInFaction(FormDesc::FromFormId( + factionRec.ToGlobalId(factionRec.rec->GetId()), worldState->espmFiles))); + } + return VarValue(false); +} + +VarValue PapyrusActor::GetFactions(VarValue self, + const std::vector& arguments) +{ + VarValue result = VarValue((uint8_t)VarValue::kType_ObjectArray); + result.pArray = std::make_shared>(); + + if (auto actor = GetFormPtr(self)) { + auto worldState = actor->GetParent(); + if (!worldState) { + throw std::runtime_error("Actor.GetFactions - no WorldState attached"); + } + + if (arguments.size() < 2) { + throw std::runtime_error("Actor.GetFactions requires two arguments"); + } + + auto minFactionRank = static_cast(arguments[0]); + auto maxFactionRank = static_cast(arguments[1]); + + auto factions = actor->GetFactions(minFactionRank, maxFactionRank); + for (auto faction : factions) { + result.pArray->push_back(VarValue(std::make_shared( + worldState->GetEspm().GetBrowser().LookupById( + faction.formDesc.ToFormId(worldState->espmFiles))))); + } + } + return result; +} + +VarValue PapyrusActor::RemoveFromFaction( + VarValue self, const std::vector& arguments) +{ + if (auto actor = GetFormPtr(self)) { + auto worldState = actor->GetParent(); + if (!worldState) { + throw std::runtime_error( + "Actor.RemoveFromFaction - no WorldState attached"); + } + + if (arguments.size() < 1) { + throw std::runtime_error( + "Actor.RemoveFromFaction requires one argument"); + } + + const auto& factionRec = GetRecordPtr(arguments[0]); + if (!factionRec.rec) { + spdlog::error("Actor.RemoveFromFaction - invalid faction form"); + return VarValue(); + } + + const auto& factions = actor->GetChangeForm().factions; + + if (!factions.has_value()) { + return VarValue(); + } + + actor->RemoveFromFaction(FormDesc::FromFormId( + factionRec.ToGlobalId(factionRec.rec->GetId()), worldState->espmFiles)); + } + return VarValue(); +} + VarValue PapyrusActor::AddSpell(VarValue self, const std::vector& arguments) { @@ -450,6 +566,10 @@ void PapyrusActor::Register( AddMethod(vm, "SetDontMove", &PapyrusActor::SetDontMove); AddMethod(vm, "IsDead", &PapyrusActor::IsDead); AddMethod(vm, "WornHasKeyword", &PapyrusActor::WornHasKeyword); + AddMethod(vm, "AddToFaction", &PapyrusActor::AddToFaction); + AddMethod(vm, "IsInFaction", &PapyrusActor::IsInFaction); + AddMethod(vm, "GetFactions", &PapyrusActor::GetFactions); + AddMethod(vm, "RemoveFromFaction", &PapyrusActor::RemoveFromFaction); AddMethod(vm, "AddSpell", &PapyrusActor::AddSpell); AddMethod(vm, "RemoveSpell", &PapyrusActor::RemoveSpell); AddMethod(vm, "GetRace", &PapyrusActor::GetRace); diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.h b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.h index 4f7c0b3881..cfe817edd9 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.h +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusActor.h @@ -44,6 +44,15 @@ class PapyrusActor final : public IPapyrusClass VarValue WornHasKeyword(VarValue self, const std::vector& arguments); + VarValue AddToFaction(VarValue self, const std::vector& arguments); + + VarValue IsInFaction(VarValue self, const std::vector& arguments); + + VarValue GetFactions(VarValue self, const std::vector& arguments); + + VarValue RemoveFromFaction(VarValue self, + const std::vector& arguments); + VarValue AddSpell(VarValue self, const std::vector& arguments); VarValue RemoveSpell(VarValue self, const std::vector& arguments); diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusClassesFactory.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusClassesFactory.cpp index f8f246182d..b844fd822b 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusClassesFactory.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusClassesFactory.cpp @@ -4,6 +4,7 @@ #include "PapyrusCell.h" #include "PapyrusDebug.h" #include "PapyrusEffectShader.h" +#include "PapyrusFaction.h" #include "PapyrusForm.h" #include "PapyrusFormList.h" #include "PapyrusGame.h" @@ -33,6 +34,7 @@ PapyrusClassesFactory::CreateAndRegister( result.emplace_back(std::make_unique()); result.emplace_back(std::make_unique()); result.emplace_back(std::make_unique()); + result.emplace_back(std::make_unique()); result.emplace_back(std::make_unique()); result.emplace_back(std::make_unique()); result.emplace_back(std::make_unique()); diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.cpp new file mode 100644 index 0000000000..cbfc1565d2 --- /dev/null +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.cpp @@ -0,0 +1,29 @@ +#include "PapyrusFaction.h" + +VarValue PapyrusFaction::GetReaction(VarValue self, + const std::vector& arguments) +{ + if (arguments.empty()) { + spdlog::error("Faction.GetReaction - at least one argument expected"); + return VarValue(-1); + } + + espm::LookupResult faction = GetRecordPtr(self); + espm::LookupResult otherFaction = GetRecordPtr(arguments[0]); + + WorldState* worldState = compatibilityPolicy->GetWorldState(); + + espm::FACT::Data ourFactionData = espm::Convert(faction.rec) + ->GetData(worldState->GetEspmCache()); + + int reaction = -1; + for (const auto& interfactionRelation : + ourFactionData.interfactionRelations) { + if (faction.ToGlobalId(interfactionRelation.factionFormId) == + otherFaction.ToGlobalId(otherFaction.rec->GetId())) { + reaction = static_cast(interfactionRelation.combat); + } + } + + return VarValue(reaction); +} diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.h b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.h new file mode 100644 index 0000000000..c5ec651a9a --- /dev/null +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusFaction.h @@ -0,0 +1,29 @@ +#pragma once +#include "IPapyrusClass.h" +#include "WorldState.h" +#include "script_objects/EspmGameObject.h" + +class PapyrusFaction final : public IPapyrusClass +{ +public: + const char* GetName() override { return "faction"; } + + VarValue GetReaction(VarValue self, const std::vector& arguments); + // SetReaction ignored, because no way to edit factions forever? + + void Register(VirtualMachine& vm, + std::shared_ptr policy) override + { + compatibilityPolicy = policy; + + factions = compatibilityPolicy->GetWorldState() + ->GetEspm() + .GetBrowser() + .GetRecordsByType("FACT"); + + AddMethod(vm, "GetReaction", &PapyrusFaction::GetReaction); + } + + std::shared_ptr compatibilityPolicy; + std::vector*> factions; +}; diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusGame.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusGame.cpp index 9dedfe4611..d9678ebf24 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusGame.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusGame.cpp @@ -134,7 +134,9 @@ VarValue PapyrusGame::GetFormInternal(VarValue self, const std::shared_ptr& pForm = compatibilityPolicy->GetWorldState()->LookupFormById(formId); - espm::LookupResult res = GetRecordPtr(VarValue(formId)); + espm::LookupResult res = + compatibilityPolicy->GetWorldState()->GetEspm().GetBrowser().LookupById( + formId); if (!pForm && !res.rec) { return VarValue::None(); diff --git a/unit/ActorTest.cpp b/unit/ActorTest.cpp index d4ca497507..1833814c02 100644 --- a/unit/ActorTest.cpp +++ b/unit/ActorTest.cpp @@ -56,3 +56,38 @@ TEST_CASE("Actor should load be able to load appearance, equipment, " REQUIRE(actor.GetChangeForm().spawnDelay == 8.0f); REQUIRE(actor.GetChangeForm().consoleCommandsAllowed == true); } + +TEST_CASE("Actor factions in changeForm", "[Actor]") +{ + PartOne p; + p.worldState.espmFiles = { "Skyrim.esm" }; + + MpActor actor(LocationalData(), FormCallbacks::DoNothing()); + + Faction faction = Faction(); + faction.formDesc = FormDesc::FromFormId(0x000123, p.worldState.espmFiles); + faction.rank = 0; + + actor.AddToFaction(faction, false); + // Second time should be ignored + actor.AddToFaction(faction, false); + + REQUIRE(actor.GetChangeForm().factions.has_value()); + REQUIRE(actor.GetChangeForm().factions.value().size() == 1); + REQUIRE(actor.GetChangeForm().factions.value()[0].formDesc.shortFormId == + 0x000123); + + REQUIRE( + actor.IsInFaction(FormDesc::FromFormId(0x000223, p.worldState.espmFiles), + false) == false); + REQUIRE(actor.IsInFaction( + FormDesc::FromFormId(0x000123, p.worldState.espmFiles), false)); + + actor.RemoveFromFaction( + FormDesc::FromFormId(0x000003, p.worldState.espmFiles), false); + REQUIRE(actor.GetChangeForm().factions.value().size() == 1); + + actor.RemoveFromFaction( + FormDesc::FromFormId(0x000123, p.worldState.espmFiles), false); + REQUIRE(actor.GetChangeForm().factions.value().size() == 0); +} diff --git a/unit/EspmTest.cpp b/unit/EspmTest.cpp index 3eff6200f5..741d4bf012 100644 --- a/unit/EspmTest.cpp +++ b/unit/EspmTest.cpp @@ -137,6 +137,34 @@ TEST_CASE("Loads Conditions", "[espm]") REQUIRE(data.conditions[1].GetDefaultData().secondParameter == 0); } +TEST_CASE("Loads factions", "[espm]") +{ + auto& br = l.GetBrowser(); + espm::CompressedFieldsCache cache; + + auto form = br.LookupById(0x00EB091); + REQUIRE(form.rec->GetType() == "FACT"); + auto data = reinterpret_cast(form.rec)->GetData(cache); + REQUIRE(form.rec->GetEditorId(cache) == + std::string("KhajiitCaravanFaction")); + + REQUIRE(static_cast(data.flags) == + static_cast(espm::FACT::Flags::CanBeOwner)); + + REQUIRE(data.interfactionRelations.size() == 2); + REQUIRE(data.interfactionRelations[0].factionFormId == 275865); + REQUIRE(data.interfactionRelations[0].unusedMod == 0); + REQUIRE(data.interfactionRelations[0].combat == + espm::FACT::CombatState::Friend); + REQUIRE(data.interfactionRelations[1].factionFormId == 113856); + REQUIRE(data.interfactionRelations[1].unusedMod == 0); + REQUIRE(data.interfactionRelations[1].combat == + espm::FACT::CombatState::Friend); + + REQUIRE(data.crimeGold.arrest == 1); + REQUIRE(data.crimeGold.attackOnSight == 1); +} + TEST_CASE("Loads script-related subrecords for SovngardeWatcherStatue2", "[espm]") { diff --git a/unit/SaveStorageTest.cpp b/unit/SaveStorageTest.cpp index d8244f9244..1dec7d085e 100644 --- a/unit/SaveStorageTest.cpp +++ b/unit/SaveStorageTest.cpp @@ -95,6 +95,11 @@ TEST_CASE("ChangeForm is saved correctly", "[save]") f2.actorValues.healthPercentage = 0; f2.actorValues.magickaPercentage = 0; f2.actorValues.staminaPercentage = 0; + Faction faction = Faction(); + faction.formDesc = FormDesc(13, "Skyrim.esm"); + faction.rank = 10; + f2.factions = std::vector(); + f2.factions.value().push_back(faction); UpsertSync(*st, { f1, f2 }); auto res = ISaveStorageUtils::FindAllSync(*st); @@ -114,6 +119,11 @@ TEST_CASE("ChangeForm is saved correctly", "[save]") REQUIRE(res[{ 2, "" }].actorValues.healthPercentage == 0); REQUIRE(res[{ 2, "" }].actorValues.magickaPercentage == 0); REQUIRE(res[{ 2, "" }].actorValues.staminaPercentage == 0); + REQUIRE(res[{ 2, "" }].factions.has_value()); + REQUIRE(res[{ 2, "" }].factions.value().size() == 1); + REQUIRE(res[{ 2, "" }].factions.value()[0].formDesc.shortFormId == 13); + REQUIRE(res[{ 2, "" }].factions.value()[0].formDesc.file == "Skyrim.esm"); + REQUIRE(res[{ 2, "" }].factions.value()[0].rank == 10); } TEST_CASE("Upsert affects the number of change forms in the database in the " From 009774dded0bc442cd8ffef0e1c987e2ee8365fc Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sun, 23 Jun 2024 17:32:11 +0500 Subject: [PATCH 52/78] internal: add server dist artifact (#2049) --- .github/workflows/pr-windows.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/pr-windows.yml b/.github/workflows/pr-windows.yml index 413887d10f..633113f484 100644 --- a/.github/workflows/pr-windows.yml +++ b/.github/workflows/pr-windows.yml @@ -25,24 +25,28 @@ jobs: DEPLOY_BRANCH: "" ONLY_PUSH: false DIST_ARTIFACT_NAME: dist-1.5 + SERVER_DIST_ARTIFACT_NAME: server-dist-1.5 - DESCRIPTION: 'Skyrim Anniversary Edition (1.6)' SKYRIM_SE_FLAG: OFF SP_NEXUS_ARTIFACT_NAME: Skyrim Platform %SP_VERSION% (Anniversary Edition) DEPLOY_BRANCH: "" ONLY_PUSH: false DIST_ARTIFACT_NAME: dist + SERVER_DIST_ARTIFACT_NAME: server-dist - DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - Indev' SKYRIM_SE_FLAG: OFF SP_NEXUS_ARTIFACT_NAME: nope DEPLOY_BRANCH: "indev" ONLY_PUSH: true DIST_ARTIFACT_NAME: dist-indev + SERVER_DIST_ARTIFACT_NAME: server-dist-indev - DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - SweetPie' SKYRIM_SE_FLAG: OFF SP_NEXUS_ARTIFACT_NAME: nope DEPLOY_BRANCH: "sweetpie" ONLY_PUSH: true DIST_ARTIFACT_NAME: dist-sweetpie + SERVER_DIST_ARTIFACT_NAME: server-dist-sweetpie # VS 2019 is still supported, but GitHub windows-2019 runners have unsupported WinSDK version runs-on: windows-2022 @@ -201,6 +205,12 @@ jobs: name: skymp5-client-js (${{ matrix.DESCRIPTION }}) path: ${{github.workspace}}/build/dist/client/Data/Platform/Plugins/skymp5-client.js + - uses: actions/upload-artifact@v4 + if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} + with: + name: ${{ matrix.SERVER_DIST_ARTIFACT_NAME }} + path: ${{github.workspace}}/build/dist/server + - uses: actions/upload-artifact@v4 if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: From 65171cf073ba3b01e5e3d6ce13ecc1bffe1ce22b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Jun 2024 22:53:30 +0500 Subject: [PATCH 53/78] internal: bump braces from 3.0.2 to 3.0.3 in /skyrim-platform/tools/dev_service (#2050) --- skyrim-platform/tools/dev_service/yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/skyrim-platform/tools/dev_service/yarn.lock b/skyrim-platform/tools/dev_service/yarn.lock index d08c4d29b1..76b6528d86 100644 --- a/skyrim-platform/tools/dev_service/yarn.lock +++ b/skyrim-platform/tools/dev_service/yarn.lock @@ -95,11 +95,11 @@ brace-expansion@^1.1.7: concat-map "0.0.1" braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" cacheable-request@^6.0.0: version "6.1.0" @@ -274,10 +274,10 @@ escape-goat@^2.0.0: resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" From f6bbeb9aeed333927e0794342a7b25e4539429a1 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 24 Jun 2024 17:57:04 +0500 Subject: [PATCH 54/78] fix(skymp5-server): fix linking players (#2037) --- .../cpp/server_guest_lib/ActionListener.cpp | 38 +++++++++---------- .../cpp/server_guest_lib/PartOne.cpp | 27 ++++++++++++- skymp5-server/cpp/server_guest_lib/PartOne.h | 3 ++ 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp index bc04325e55..8b225ae8a2 100644 --- a/skymp5-server/cpp/server_guest_lib/ActionListener.cpp +++ b/skymp5-server/cpp/server_guest_lib/ActionListener.cpp @@ -20,23 +20,6 @@ #include "UpdateEquipmentMessage.h" -namespace { -void SendHostStop(PartOne& partOne, Networking::UserId badHosterUserId, - MpObjectReference& remote) -{ - auto remoteAsActor = dynamic_cast(&remote); - - uint64_t longFormId = remote.GetFormId(); - if (remoteAsActor && longFormId < 0xff000000) { - longFormId += 0x100000000; - } - - Networking::SendFormatted(&partOne.GetSendTarget(), badHosterUserId, - R"({ "type": "hostStop", "target": %llu })", - longFormId); -} -} - MpActor* ActionListener::SendToNeighbours( uint32_t idx, const simdjson::dom::element& jMessage, Networking::UserId userId, Networking::PacketData data, size_t length, @@ -57,6 +40,20 @@ MpActor* ActionListener::SendToNeighbours( } if (idx != myActor->GetIdx()) { + // Possible fix for "players link to each other" bug + // See also PartOne::SetUserActor + Networking::UserId actorsOwningUserId = + partOne.serverState.UserByActor(actor); + if (actorsOwningUserId != Networking::InvalidUserId) { + spdlog::error("SendToNeighbours - No permission to update actor {:x} " + "(already owned by user {})", + actor->GetFormId(), actorsOwningUserId); + partOne.SendHostStop(userId, *actor); + + partOne.worldState.hosters.erase(actor->GetFormId()); + return nullptr; + } + auto it = partOne.worldState.hosters.find(actor->GetFormId()); if (it == partOne.worldState.hosters.end() || it->second != myActor->GetFormId()) { @@ -64,9 +61,10 @@ MpActor* ActionListener::SendToNeighbours( spdlog::warn("SendToNeighbours - idx=0, ::ReadJson or " "similar is probably incorrect"); } - spdlog::error("SendToNeighbours - No permission to update actor {:x}", - actor->GetFormId()); - SendHostStop(partOne, userId, *actor); + spdlog::error( + "SendToNeighbours - No permission to update actor {:x} (not a hoster)", + actor->GetFormId()); + partOne.SendHostStop(userId, *actor); return nullptr; } } diff --git a/skymp5-server/cpp/server_guest_lib/PartOne.cpp b/skymp5-server/cpp/server_guest_lib/PartOne.cpp index f97154c034..9e77b6c342 100644 --- a/skymp5-server/cpp/server_guest_lib/PartOne.cpp +++ b/skymp5-server/cpp/server_guest_lib/PartOne.cpp @@ -142,8 +142,18 @@ void PartOne::SetUserActor(Networking::UserId userId, uint32_t actorFormId) throw std::runtime_error(ss.str()); } + // Clear actor's hoster if any. + // HostStop message will be sent on the next attempt to update actor's + // movement + // Possible fix for "players link to each other" bug + // See also ActionListener::SendToNeighbours + auto hosterActorIt = worldState.hosters.find(actor.GetFormId()); + if (hosterActorIt != worldState.hosters.end()) { + worldState.hosters.erase(hosterActorIt); + } + // Both functions are required here, but it is NOT covered by unit tests - // properly. If you do something wrong here, players would not be able to + // properly. If you do something wrong here, players will not be able to // interact with items in the same cell after reconnecting. actor.UnsubscribeFromAll(); actor.RemoveFromGridAndUnsubscribeAll(); @@ -473,6 +483,21 @@ void PartOne::RequestPacketHistoryPlayback(Networking::UserId userId, } } +void PartOne::SendHostStop(Networking::UserId badHosterUserId, + MpObjectReference& remote) +{ + auto remoteAsActor = dynamic_cast(&remote); + + uint64_t longFormId = remote.GetFormId(); + if (remoteAsActor && longFormId < 0xff000000) { + longFormId += 0x100000000; + } + + Networking::SendFormatted(&GetSendTarget(), badHosterUserId, + R"({ "type": "hostStop", "target": %llu })", + longFormId); +} + FormCallbacks PartOne::CreateFormCallbacks() { static auto g_serializer = diff --git a/skymp5-server/cpp/server_guest_lib/PartOne.h b/skymp5-server/cpp/server_guest_lib/PartOne.h index 52c702029b..09ddfee4a2 100644 --- a/skymp5-server/cpp/server_guest_lib/PartOne.h +++ b/skymp5-server/cpp/server_guest_lib/PartOne.h @@ -94,6 +94,9 @@ class PartOne void RequestPacketHistoryPlayback(Networking::UserId userId, const PacketHistory& history); + void SendHostStop(Networking::UserId badHosterUserId, + MpObjectReference& remote); + private: void Init(); From dd3aad2825b8e3972eddaea8bf5d95189b54abb1 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 26 Jun 2024 12:13:45 +0500 Subject: [PATCH 55/78] refact(skymp5-server): replace error with warn in DiscordBanSystem (#2051) --- skymp5-server/ts/systems/discordBanSystem.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skymp5-server/ts/systems/discordBanSystem.ts b/skymp5-server/ts/systems/discordBanSystem.ts index b49d753c40..2b88681733 100644 --- a/skymp5-server/ts/systems/discordBanSystem.ts +++ b/skymp5-server/ts/systems/discordBanSystem.ts @@ -19,16 +19,16 @@ export class DiscordBanSystem implements System { return console.log("discord ban system is disabled due to offline mode"); } if (!discordAuth) { - return console.error("discordAuth is missing, skipping Discord ban system"); + return console.warn("discordAuth is missing, skipping Discord ban system"); } if (!discordAuth.botToken) { - return console.error("discordAuth.botToken is missing, skipping Discord ban system"); + return console.warn("discordAuth.botToken is missing, skipping Discord ban system"); } if (!discordAuth.guildId) { - return console.error("discordAuth.guildId is missing, skipping Discord ban system"); + return console.warn("discordAuth.guildId is missing, skipping Discord ban system"); } if (!discordAuth.banRoleId) { - return console.error("discordAuth.banRoleId is missing, skipping Discord ban system"); + return console.warn("discordAuth.banRoleId is missing, skipping Discord ban system"); } const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); From 77c37ca7f924099141bce275f7e52970285d83d0 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Thu, 27 Jun 2024 19:07:33 +0500 Subject: [PATCH 56/78] internal: add libarchive as a vcpkg dependency (#2057) --- vcpkg.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vcpkg.json b/vcpkg.json index 5166218942..f263d02f38 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -43,6 +43,11 @@ { "name": "asio", "platform": "windows" + }, + { + "name": "libarchive", + "platform": "windows", + "features": [] } ], "features": { From 63e9d0b2cbddcf4c406c669abe2c863c3931ad7c Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 28 Jun 2024 09:10:33 +0500 Subject: [PATCH 57/78] refact(skymp5-client): make RemoteServer.getIdManager public (#2058) --- skymp5-client/src/services/services/remoteServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index 9b1e61670b..f3c2b8fc22 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -784,7 +784,7 @@ export class RemoteServer extends ClientListener { return this.worldModel.playerCharacterFormIdx; } - private getIdManager() { + getIdManager() { return this.idManager_; } From 726b84df3f4f8b4ca08069cf84a5ab15b57beb9a Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 28 Jun 2024 09:18:08 +0500 Subject: [PATCH 58/78] fix(skymp5-server): fix mp.callPapyrusFunction error handling (#2045) --- skymp5-server/cpp/addon/ScampServer.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/skymp5-server/cpp/addon/ScampServer.cpp b/skymp5-server/cpp/addon/ScampServer.cpp index c4ad1aec18..f51d629000 100644 --- a/skymp5-server/cpp/addon/ScampServer.cpp +++ b/skymp5-server/cpp/addon/ScampServer.cpp @@ -1021,6 +1021,12 @@ Napi::Value ScampServer::GetIdFromDesc(const Napi::CallbackInfo& info) Napi::Value ScampServer::CallPapyrusFunction(const Napi::CallbackInfo& info) { + // This function throws exceptions in case of bad input + // But it also catches exceptions from the Papyrus VM functions + // This is because + // 1) they're rare and unexpected, and we don't want to crash the sever + // 2) in Papyrus (not JS) we catch them all. so it's consistent + // 3) we plan replacing all exceptions with logs in Papyrus VM functions try { auto callType = NapiHelper::ExtractString(info[0], "callType"); auto className = NapiHelper::ExtractString(info[1], "className"); @@ -1045,15 +1051,27 @@ Napi::Value ScampServer::CallPapyrusFunction(const Napi::CallbackInfo& info) auto& vm = partOne->worldState.GetPapyrusVm(); if (callType == "method") { if (self.GetType() == VarValue::Type::kType_Object) { - res = vm.CallMethod(static_cast(self), - functionName.data(), args); + try { + res = vm.CallMethod(static_cast(self), + functionName.data(), args); + } catch (std::exception& e) { + res = VarValue::None(); + spdlog::error("ScampServer::CallPapyrusFunction {} {} - {}", + self.ToString(), functionName, e.what()); + } } else { throw std::runtime_error( "Can't call Papyrus method on non-object self '" + self.ToString() + "'"); } } else if (callType == "global") { - res = vm.CallStatic(className, functionName, args); + try { + res = vm.CallStatic(className, functionName, args); + } catch (std::exception& e) { + res = VarValue::None(); + spdlog::error("ScampServer::CallPapyrusFunction {} {} - {}", className, + functionName, e.what()); + } } else { throw std::runtime_error("Unknown call type '" + callType + "', expected one of ['method', 'global']"); From 7ea4225f9fd74db4d5c1034ae7ee4b81cd0362e9 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 28 Jun 2024 09:18:25 +0500 Subject: [PATCH 59/78] fix(skymp5-server): handle uncaught js exceptions (#2046) --- skymp5-server/ts/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/skymp5-server/ts/index.ts b/skymp5-server/ts/index.ts index 4ec7c8f2c0..e5cf0ae0f2 100644 --- a/skymp5-server/ts/index.ts +++ b/skymp5-server/ts/index.ts @@ -303,6 +303,15 @@ const main = async () => { main(); // This is needed at least to handle axios errors in masterClient +// TODO: implement alerts process.on("unhandledRejection", (...args) => { + console.error("[!!!] unhandledRejection") + console.error(...args); +}); + +// setTimeout on gamemode should not be able to kill the entire server +// TODO: implement alerts +process.on("uncaughtException", (...args) => { + console.error("[!!!] uncaughtException") console.error(...args); }); From 92b394437a7373852cd34516c4d8250baf13dd95 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 28 Jun 2024 09:18:54 +0500 Subject: [PATCH 60/78] fix(skymp5-server): replace exceptions with logs in ObjectReference.RemoveItem (#2044) --- .../script_classes/PapyrusObjectReference.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusObjectReference.cpp b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusObjectReference.cpp index ccf9959064..5a93d786da 100644 --- a/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusObjectReference.cpp +++ b/skymp5-server/cpp/server_guest_lib/script_classes/PapyrusObjectReference.cpp @@ -188,7 +188,9 @@ VarValue PapyrusObjectReference::RemoveItem( auto worldState = selfRefr->GetParent(); if (!worldState) { - throw std::runtime_error("RemoveItem - no WorldState attached"); + spdlog::error("RemoveItem {:x} - no WorldState attached", + selfRefr->GetFormId()); + return VarValue::None(); } if (!selfRefr || !item.rec) @@ -196,7 +198,10 @@ VarValue PapyrusObjectReference::RemoveItem( if (!espm::utils::Is(item.rec->GetType())) { if (!espm::utils::IsItem(item.rec->GetType())) { - throw std::runtime_error("RemoveItem - form is not an item"); + spdlog::error("RemoveItem {:x} - form {:x} is not an item, it is {}", + selfRefr->GetFormId(), item.ToGlobalId(item.rec->GetId()), + item.rec->GetType().ToString()); + return VarValue::None(); } } @@ -205,8 +210,10 @@ VarValue PapyrusObjectReference::RemoveItem( espm::Convert(item.rec)->GetData(worldState->GetEspmCache()); bool isTorch = res.data.flags & espm::LIGH::Flags::CanBeCarried; if (!isTorch) { - throw std::runtime_error( - "RemoveItem - form is LIGH without CanBeCarried flag"); + spdlog::error( + "RemoveItem {:x} - form {:x} is LIGH without CanBeCarried flag", + selfRefr->GetFormId(), item.ToGlobalId(item.rec->GetId())); + return VarValue::None(); } } From 5b7d64bcc39cbdbea0e20c451249431340b7d4d3 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Fri, 28 Jun 2024 11:01:02 +0500 Subject: [PATCH 61/78] refact(skymp5-client): make WorldView a service (#2059) --- skymp5-client/src/index.ts | 4 +- .../src/services/services/remoteServer.ts | 13 +- .../src/services/services/skympClient.ts | 27 --- skymp5-client/src/view/formView.ts | 15 +- skymp5-client/src/view/formViewArray.ts | 4 + skymp5-client/src/view/model.ts | 1 + skymp5-client/src/view/view.ts | 4 - skymp5-client/src/view/worldView.ts | 176 +++++++++++------- skymp5-client/src/view/worldViewMisc.ts | 3 +- 9 files changed, 141 insertions(+), 106 deletions(-) delete mode 100644 skymp5-client/src/view/view.ts diff --git a/skymp5-client/src/index.ts b/skymp5-client/src/index.ts index 4fd08ce456..20e25c6f61 100644 --- a/skymp5-client/src/index.ts +++ b/skymp5-client/src/index.ts @@ -47,6 +47,7 @@ import { GamemodeEventSourceService } from "./services/services/gamemodeEventSou import { GamemodeUpdateService } from "./services/services/gamemodeUpdateService"; import { FrontHotReloadService } from "./services/services/frontHotReloadService"; import { BlockedAnimationsService } from "./services/services/blockedAnimationsService"; +import { WorldView } from "./view/worldView"; once("update", () => { Utility.setINIBool("bAlwaysActive:General", true); @@ -97,7 +98,8 @@ const main = () => { new GamemodeEventSourceService(sp, controller), new GamemodeUpdateService(sp, controller), new FrontHotReloadService(sp, controller), - new BlockedAnimationsService(sp, controller) + new BlockedAnimationsService(sp, controller), + new WorldView(sp, controller) ]; SpApiInteractor.setup(listeners); } diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index f3c2b8fc22..aba742e448 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -367,7 +367,10 @@ export class RemoteServer extends ClientListener { } } - if (msg.isMe) this.worldModel.playerCharacterFormIdx = i; + if (msg.isMe) { + this.worldModel.playerCharacterFormIdx = i; + this.worldModel.playerCharacterRefrId = msg.refrId || 0; + } // TODO: move to a separate module @@ -572,6 +575,7 @@ export class RemoteServer extends ClientListener { if (this.worldModel.playerCharacterFormIdx === i) { this.worldModel.playerCharacterFormIdx = -1; + this.worldModel.playerCharacterRefrId = 0; // TODO: move to a separate module once('update', () => Game.quitToMainMenu()); @@ -739,6 +743,7 @@ export class RemoteServer extends ClientListener { private handleConnectionAccepted(): void { this.worldModel.forms = []; this.worldModel.playerCharacterFormIdx = -1; + this.worldModel.playerCharacterRefrId = 0; logTrace(this, "Handle connection accepted"); } @@ -784,13 +789,17 @@ export class RemoteServer extends ClientListener { return this.worldModel.playerCharacterFormIdx; } + getMyRemoteRefrId(): number { + return this.worldModel.playerCharacterRefrId; + } + getIdManager() { return this.idManager_; } private get worldModel(): WorldModel { if (typeof storage["worldModel"] === "function") { - storage["worldModel"] = { forms: [], playerCharacterFormIdx: -1 }; + storage["worldModel"] = { forms: [], playerCharacterFormIdx: -1, playerCharacterRefrId: 0 }; } return storage["worldModel"] as WorldModel; } diff --git a/skymp5-client/src/services/services/skympClient.ts b/skymp5-client/src/services/services/skympClient.ts index a580008e53..24c41e3c43 100644 --- a/skymp5-client/src/services/services/skympClient.ts +++ b/skymp5-client/src/services/services/skympClient.ts @@ -1,15 +1,10 @@ import { - on, - once, printConsole, settings, storage, } from 'skyrimPlatform'; import * as networking from './networkingService'; -import { RemoteServer } from './remoteServer'; import { setupHooks } from '../../sync/animation'; -import { WorldView } from '../../view/worldView'; -import { SinglePlayerService } from './singlePlayerService'; import { AuthGameData, authGameDataStorageKey } from '../../features/authModel'; import { ClientListener, CombinedController, Sp } from './clientListener'; import { ConnectionFailed } from '../events/connectionFailed'; @@ -93,9 +88,6 @@ export class SkympClient extends ClientListener { } private ctor() { - // TODO: refactor WorldView into service - this.resetView(); - // TODO: refactor into service setupHooks(); @@ -115,23 +107,4 @@ export class SkympClient extends ClientListener { logTrace(this, 'Reconnect is not required'); } } - - private resetView() { - const prevView: WorldView = storage.view as WorldView; - const view = new WorldView(); - once('update', () => { - if (prevView && prevView.destroy) { - prevView.destroy(); - printConsole('Previous View destroyed'); - } - storage.view = view; - }); - on('update', () => { - const singlePlayerService = this.controller.lookupListener(SinglePlayerService); - if (!singlePlayerService.isSinglePlayer) { - const modelSource = this.controller.lookupListener(RemoteServer); - view.update(modelSource.getWorldModel()); - } - }); - } } diff --git a/skymp5-client/src/view/formView.ts b/skymp5-client/src/view/formView.ts index d39bd8126e..9e56b3224a 100644 --- a/skymp5-client/src/view/formView.ts +++ b/skymp5-client/src/view/formView.ts @@ -7,7 +7,6 @@ import { FormModel } from "./model"; import { applyMovement } from "../sync/movementApply"; import { SpawnProcess } from "./spawnProcess"; import { ObjectReferenceEx } from "../extensions/objectReferenceEx"; -import { View } from "./view"; import { PlayerCharacterDataHolder } from "./playerCharacterDataHolder"; import { getMovement } from "../sync/movementGet"; import { lastTryHost, tryHost } from "./hostAttempts"; @@ -33,7 +32,7 @@ export const getScreenResolution = (): ScreenResolution => { return _screenResolution; } -export class FormView implements View { +export class FormView { constructor(private remoteRefrId?: number) { } update(model: FormModel): void { @@ -456,15 +455,15 @@ export class FormView implements View { } } } - - if (refr.is3DLoaded() !== undefined && refr.is3DLoaded() == true){ - if (model.animation){ + + if (refr.is3DLoaded() !== undefined && refr.is3DLoaded() == true) { + if (model.animation) { //printConsole(`${model.animation?.animEventName}`); applyAnimation(refr, model.animation, this.animState); - } - + } + } - + if (model.appearance) { const actor = Actor.from(refr); diff --git a/skymp5-client/src/view/formViewArray.ts b/skymp5-client/src/view/formViewArray.ts index 5514c971b4..830361f43f 100644 --- a/skymp5-client/src/view/formViewArray.ts +++ b/skymp5-client/src/view/formViewArray.ts @@ -106,5 +106,9 @@ export class FormViewArray { return this.formViews[i]; } + getFormViewsArrayLength(): number { + return this.formViews.length; + } + private formViews = new Array(); } diff --git a/skymp5-client/src/view/model.ts b/skymp5-client/src/view/model.ts index a8f1ac2c46..60c3eda99e 100644 --- a/skymp5-client/src/view/model.ts +++ b/skymp5-client/src/view/model.ts @@ -17,4 +17,5 @@ export interface FormModel extends CreateActorMessageAdditionalProps, CreateActo export interface WorldModel { forms: Array; playerCharacterFormIdx: number; + playerCharacterRefrId: number; } diff --git a/skymp5-client/src/view/view.ts b/skymp5-client/src/view/view.ts deleted file mode 100644 index d84fa51bea..0000000000 --- a/skymp5-client/src/view/view.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface View { - update(model: T): void; - destroy(): void; -} diff --git a/skymp5-client/src/view/worldView.ts b/skymp5-client/src/view/worldView.ts index 4241115392..737d690385 100644 --- a/skymp5-client/src/view/worldView.ts +++ b/skymp5-client/src/view/worldView.ts @@ -1,96 +1,146 @@ -import { - Actor, - Form, - Game, - Utility, - on, - once, - printConsole, - settings, -} from 'skyrimPlatform'; +import { Form } from "skyrimPlatform"; import { WorldModel } from './model'; import { FormViewArray } from './formViewArray'; import { PlayerCharacterDataHolder } from './playerCharacterDataHolder'; -import { View } from './view'; - -export class WorldView implements View { - constructor() { - // Work around showRaceMenu issue - // Default nord in Race Menu will have very ugly face - // If other players are spawning when we show this menu - on('update', () => { - const pc = Game.getPlayer() as Actor; - const pcWorldOrCell = ( - (pc.getWorldSpace() || pc.getParentCell()) as Form - ).getFormID(); - if (this.pcWorldOrCell !== pcWorldOrCell) { - if (this.pcWorldOrCell) { - printConsole('Reset all form views'); - this.formViews.resize(0); - this.cloneFormViews.resize(0); - } - this.pcWorldOrCell = pcWorldOrCell; - } - }); - once('update', () => { - // Wait 1s game time (time spent in Race Menu isn't counted) - Utility.wait(1).then(() => { - this.allowUpdate = true; - printConsole('Update is now allowed'); - }); - }); +import { ClientListener, CombinedController, Sp } from '../services/services/clientListener'; +import { logTrace } from "../logging"; +import { SinglePlayerService } from "../services/services/singlePlayerService"; +import { RemoteServer } from "../services/services/remoteServer"; + +export class WorldView extends ClientListener { + constructor(private sp: Sp, private controller: CombinedController) { + super(); + + controller.on("update", () => this.onUpdate()); + controller.once("update", () => this.onceUpdate()); + + this.state = this.makeEmptyState(); + + const oldView = this.sp.storage["view"]; + + // can't use instanceof here because each hot reload creates a new class + this.oldView = typeof oldView === "object" ? oldView as WorldView : undefined; + + this.sp.storage["view"] = this; } getRemoteRefrId(clientsideRefrId: number): number { - return this.formViews.getRemoteRefrId(clientsideRefrId); + return this.state.formViews.getRemoteRefrId(clientsideRefrId); } getLocalRefrId(remoteRefrId: number): number { - return this.formViews.getLocalRefrId(remoteRefrId); + return this.state.formViews.getLocalRefrId(remoteRefrId); + } + + syncFormArray(model: WorldModel) { + const { settings } = this.sp; + const showMe = settings['skymp5-client']['show-me']; + this.state.formViews.syncFormView(model, !!showMe); + } + + destroy() { + this.state.formViews.resize(0); + this.state.cloneFormViews.resize(0); // Recenrly added, not tested if it's needed + this.state = this.makeEmptyState(); + } + + getFormViews() { + return this.state.formViews; + } + + private onUpdate() { + this.resetAllFormViewsIfPlayerChangedWorld(); + + const singlePlayerService = this.controller.lookupListener(SinglePlayerService); + if (!singlePlayerService.isSinglePlayer) { + const modelSource = this.controller.lookupListener(RemoteServer); + this.updateWorld(modelSource.getWorldModel()); + } + } + + private onceUpdate() { + if (this.oldView) { + this.oldView.destroy(); + this.oldView = undefined; + logTrace(this, 'Previous View destroyed'); + } + this.waitOneSecondAndAllowFormViewUpdate(); + } + + private resetAllFormViewsIfPlayerChangedWorld() { + const state = this.state; + const pc = this.sp.Game.getPlayer()!; + const pcWorldOrCell = ( + (pc.getWorldSpace() || pc.getParentCell()) as Form + ).getFormID(); + if (state.pcWorldOrCell !== pcWorldOrCell) { + if (state.pcWorldOrCell) { + logTrace(this, 'Reset all form views'); + state.formViews.resize(0); + state.cloneFormViews.resize(0); + } + state.pcWorldOrCell = pcWorldOrCell; + } + } + + // Work around showRaceMenu issue + // Default nord in Race Menu will have very ugly face + // If other players are spawning when we show this menu + // TODO: separate listener + private waitOneSecondAndAllowFormViewUpdate() { + // Wait 1s game time (time spent in Race Menu isn't counted) + this.sp.Utility.wait(1).then(() => { + this.state.allowUpdate = true; + logTrace(this, 'Update is now allowed'); + }); } - update(model: WorldModel): void { - if (!this.allowUpdate) return; + private updateWorld(model: WorldModel): void { + const { settings } = this.sp; + const state = this.state; + + if (!state.allowUpdate) return; const skipUpdates = settings['skymp5-client']['skipUpdates']; // skip 50% of updates if specified in the settings - this.counter = !this.counter; - if (this.counter && skipUpdates) return; + state.counter = !state.counter; + if (state.counter && skipUpdates) return; - this.formViews.resize(model.forms.length); + state.formViews.resize(model.forms.length); const showMe = settings['skymp5-client']['show-me']; const showClones = settings['skymp5-client']['show-clones']; PlayerCharacterDataHolder.updateData(); - this.formViews.updateAll(model, !!showMe, false); + state.formViews.updateAll(model, !!showMe, false); if (showClones) { - this.cloneFormViews.updateAll(model, false, true); + state.cloneFormViews.updateAll(model, false, true); } else { - this.cloneFormViews.resize(0); + state.cloneFormViews.resize(0); } } - syncFormArray(model: WorldModel) { - const showMe = settings['skymp5-client']['show-me']; - this.formViews.syncFormView(model, !!showMe); - } - - destroy(): void { - this.formViews.resize(0); + private makeEmptyState() { + return { + formViews: new FormViewArray(), + cloneFormViews: new FormViewArray(), + allowUpdate: false, + pcWorldOrCell: 0, + counter: false, + } } - getFormViews() { - return this.formViews; - } + private state: { + formViews: FormViewArray; + cloneFormViews: FormViewArray; + allowUpdate: boolean; + pcWorldOrCell: number; + counter: boolean; + }; - private formViews = new FormViewArray(); - private cloneFormViews = new FormViewArray(); - private allowUpdate = false; - private pcWorldOrCell = 0; - private counter = false; + private oldView?: WorldView; } diff --git a/skymp5-client/src/view/worldViewMisc.ts b/skymp5-client/src/view/worldViewMisc.ts index 274a8e7afa..eba867216b 100644 --- a/skymp5-client/src/view/worldViewMisc.ts +++ b/skymp5-client/src/view/worldViewMisc.ts @@ -2,7 +2,8 @@ import { Game, ObjectReference, storage } from "skyrimPlatform"; import { WorldView } from "./worldView"; export const getViewFromStorage = (): WorldView | undefined => { - const res = storage.view as WorldView; + const res = storage["view"] as WorldView; + // can't use instanceof here because each hot reload creates a new class if (typeof res === "object") return res; return undefined; }; From ef9c7dcce90b4f9b0822264230678d72291b2d84 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 10:18:32 +0500 Subject: [PATCH 62/78] feat(skymp5-server): ban mannequin spawn by default (#2053) --- skymp5-server/cpp/server_guest_lib/WorldState.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.h b/skymp5-server/cpp/server_guest_lib/WorldState.h index c8b22fa4b4..bf436813b6 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.h +++ b/skymp5-server/cpp/server_guest_lib/WorldState.h @@ -249,7 +249,10 @@ class WorldState /* Playable races from ArgonianRace to WoodElfRace */ 0x00013740, 0x00013741, 0x00013742, 0x00013743, 0x00013744, 0x00013745, - 0x00013746, 0x00013747, 0x00013748, 0x00013749 + 0x00013746, 0x00013747, 0x00013748, 0x00013749, + + /* Mannequin */ + 0x0010760a }; private: From fc574e1706e9d2f4ca6a3f28131c2780609f4ea2 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 10:23:25 +0500 Subject: [PATCH 63/78] feat(skyrim-platform): writePlugin/getPluginSourceCode: add overrideFolder arg & support INI setting (#2054) --- 1js/JsEngine.h | 2 +- .../dev/sp-plugins-path-override-folder.md | 1 + docs/release/dev/sp-plugins-path-use.md | 1 + .../codegen/convert-files/Definitions.txt | 4 +- .../codegen/convert-files/skyrimPlatform.ts | 4 +- .../platform_se/skyrim_platform/DevApi.cpp | 79 +++++++++++++++++-- .../platform_se/skyrim_platform/Settings.h | 42 ++++++++++ .../skyrim_platform/SkyrimPlatform.cpp | 27 +------ 8 files changed, 127 insertions(+), 33 deletions(-) create mode 100644 docs/release/dev/sp-plugins-path-override-folder.md create mode 100644 docs/release/dev/sp-plugins-path-use.md diff --git a/1js/JsEngine.h b/1js/JsEngine.h index 12b345c518..1b1bbe98f8 100644 --- a/1js/JsEngine.h +++ b/1js/JsEngine.h @@ -508,7 +508,7 @@ class JsValue { // A bit ugly reinterpret_cast, but it's a hot path. // We do not want to modify the ref counter for each argument. - // This is also unit tested, so we would know if it breaks. + // This is also unit tested, so we will know if it breaks. return i < n ? reinterpret_cast(arr[i]) : *undefined; } diff --git a/docs/release/dev/sp-plugins-path-override-folder.md b/docs/release/dev/sp-plugins-path-override-folder.md new file mode 100644 index 0000000000..3d7a114d47 --- /dev/null +++ b/docs/release/dev/sp-plugins-path-override-folder.md @@ -0,0 +1 @@ +New optional argument `overrideFolder` for `writePlugin` and `getPluginSourceCode` methods: An optional argument `overrideFolder` is now available. This folder can be outside the list of plugin folders defined in `PluginFolders`. While this folder will be writable and readable, SkyrimPlatform will not monitor or load plugins from it. `overrideFolder` is relative to `Data/Platform`. diff --git a/docs/release/dev/sp-plugins-path-use.md b/docs/release/dev/sp-plugins-path-use.md new file mode 100644 index 0000000000..808069a5d2 --- /dev/null +++ b/docs/release/dev/sp-plugins-path-use.md @@ -0,0 +1 @@ +Updated `writePlugin` and `getPluginSourceCode` methods: These methods now support the `PluginFolders` INI setting. 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 2ecb50d579..0d04594e2e 100644 --- a/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt +++ b/skyrim-platform/src/platform_se/codegen/convert-files/Definitions.txt @@ -7,8 +7,8 @@ export declare function writeLogs(pluginName: string, ...arguments: unknown[]): export declare function setPrintConsolePrefixesEnabled(enabled: boolean): void export declare function callNative(className: string, functionName: string, self?: PapyrusObject, ...args: PapyrusValue[]): PapyrusValue export declare function getJsMemoryUsage(): number -export declare function getPluginSourceCode(pluginName: string): string -export declare function writePlugin(pluginName: string, newSources: string): string +export declare function getPluginSourceCode(pluginName: string, overrideFolder?: string): string // overrideFolder is relative to Data/Platform +export declare function writePlugin(pluginName: string, newSources: string, overrideFolder?: string): string // overrideFolder is relative to Data/Platform export declare function getPlatformVersion(): string export declare function disableCtrlPrtScnHotkey(): void export declare function blockPapyrusEvents(block: boolean): void 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 dde7cb3ae7..120a2eb9a6 100644 --- a/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts +++ b/skyrim-platform/src/platform_se/codegen/convert-files/skyrimPlatform.ts @@ -12,8 +12,8 @@ export declare function writeLogs(pluginName: string, ...arguments: unknown[]): export declare function setPrintConsolePrefixesEnabled(enabled: boolean): void export declare function callNative(className: string, functionName: string, self?: PapyrusObject, ...args: PapyrusValue[]): PapyrusValue export declare function getJsMemoryUsage(): number -export declare function getPluginSourceCode(pluginName: string): string -export declare function writePlugin(pluginName: string, newSources: string): string +export declare function getPluginSourceCode(pluginName: string, overrideFolder?: string): string // overrideFolder is relative to Data/Platform +export declare function writePlugin(pluginName: string, newSources: string, overrideFolder?: string): string // overrideFolder is relative to Data/Platform export declare function getPlatformVersion(): string export declare function disableCtrlPrtScnHotkey(): void export declare function blockPapyrusEvents(block: boolean): void diff --git a/skyrim-platform/src/platform_se/skyrim_platform/DevApi.cpp b/skyrim-platform/src/platform_se/skyrim_platform/DevApi.cpp index 018c3ff6c1..c5077a5ede 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/DevApi.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/DevApi.cpp @@ -3,11 +3,28 @@ #include "InvalidArgumentException.h" #include "NullPointerException.h" #include "PapyrusTESModPlatform.h" +#include "Settings.h" #include "Validators.h" std::shared_ptr DevApi::jsEngine = nullptr; DevApi::NativeExportsMap DevApi::nativeExportsMap; +namespace { +bool CreateDirectoryRecursive(const std::string& dirName, std::error_code& err) +{ + err.clear(); + if (!std::filesystem::create_directories(dirName, err)) { + if (std::filesystem::exists(dirName)) { + // The folder already exists: + err.clear(); + return true; + } + return false; + } + return true; +} +} + JsValue DevApi::Require( const JsFunctionArguments& args, const std::vector& pluginLoadDirectories) @@ -64,12 +81,38 @@ JsValue DevApi::AddNativeExports(const JsFunctionArguments& args) } namespace { -std::filesystem::path GetPluginPath(const std::string& pluginName) +std::filesystem::path GetPluginPath(const std::string& pluginName, + std::optional folderOverride) { if (!ValidateFilename(pluginName, /*allowDots*/ false)) { throw InvalidArgumentException("pluginName", pluginName); } - return std::filesystem::path("Data/Platform/Plugins") / (pluginName + ".js"); + + // Folder override is alowed to be not in list of plugin folders + // In this case it will be writable, but SkyrimPlatform will not monitor and + // load plugins from it. + if (folderOverride) { + if (!ValidateFilename(folderOverride->data(), /*allowDots*/ false)) { + throw InvalidArgumentException("folderOverride", *folderOverride); + } + + return std::filesystem::path("Data/Platform") / *folderOverride / + (pluginName + ".js"); + } + + auto pluginFolders = Settings::GetPlatformSettings()->GetPluginFolders(); + + if (!pluginFolders) { + throw NullPointerException("pluginFolders"); + } + + if (pluginFolders->empty()) { + throw std::runtime_error("No plugin folders found"); + } + + auto folder = pluginFolders->front(); + + return folder / (pluginName + ".js"); } } @@ -77,7 +120,16 @@ JsValue DevApi::GetPluginSourceCode(const JsFunctionArguments& args) { // TODO: Support multifile plugins? auto pluginName = args[1].ToString(); - return Viet::ReadFileIntoString(GetPluginPath(pluginName)); + + std::optional overrideFolder; + if (args.GetSize() >= 3) { + auto t = args[2].GetType(); + if (t != JsValue::Type::Undefined && t != JsValue::Type::Null) { + overrideFolder = args[2].ToString(); + } + } + + return Viet::ReadFileIntoString(GetPluginPath(pluginName, overrideFolder)); } JsValue DevApi::WritePlugin(const JsFunctionArguments& args) @@ -86,13 +138,30 @@ JsValue DevApi::WritePlugin(const JsFunctionArguments& args) auto pluginName = args[1].ToString(); auto newSources = args[2].ToString(); - auto path = GetPluginPath(pluginName); + std::optional overrideFolder; + if (args.GetSize() >= 4) { + auto t = args[3].GetType(); + if (t != JsValue::Type::Undefined && t != JsValue::Type::Null) { + overrideFolder = args[3].ToString(); + } + } + + auto path = GetPluginPath(pluginName, overrideFolder); + + std::error_code err; + CreateDirectoryRecursive(path.parent_path().string(), err); + if (err) { + throw std::runtime_error("Failed to create directory " + + path.parent_path().string() + ": " + + err.message()); + } std::ofstream f(path); f << newSources; f.close(); - if (!f) + if (!f) { throw std::runtime_error("Failed to write into " + path.string()); + } return JsValue::Undefined(); } diff --git a/skyrim-platform/src/platform_se/skyrim_platform/Settings.h b/skyrim-platform/src/platform_se/skyrim_platform/Settings.h index 8df70a5fa7..a0d9481923 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/Settings.h +++ b/skyrim-platform/src/platform_se/skyrim_platform/Settings.h @@ -1,4 +1,10 @@ #pragma once +#include +#include +#include +#include +#include +#include namespace Settings { @@ -171,6 +177,42 @@ class File return ini.GetValue(section, key, defaultValue); } + std::unique_ptr> GetPluginFolders() + { + return GetPathsSemicolonSeparated( + "Main", "PluginFolders", + "Data/Platform/Plugins;Data/Platform/PluginsDev"); + } + + std::unique_ptr> + GetPathsSemicolonSeparated(const char* section, const char* key, + const char* defaultValue) + { + std::string utf8pluginFoldersSemicolonSeparated = + GetString("Main", "PluginFolders", + "Data/Platform/Plugins;Data/Platform/PluginsDev"); + + std::istringstream ss(utf8pluginFoldersSemicolonSeparated); + std::string folder; + + if (utf8pluginFoldersSemicolonSeparated.find_first_of("\"") != + std::string::npos) { + throw std::runtime_error( + "Invalid path with quotes in PluginFolders setting. Please remove " + "quotes and restart the game."); + } + + auto result = std::make_unique>(); + + while (std::getline(ss, folder, ';')) { + if (!folder.empty()) { + result->emplace_back(folder); + } + } + + return result; + } + bool SetString(const char* section, const char* key, const char* value, const char* comment = nullptr) { diff --git a/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp b/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp index 56bc68c8ee..2725af7903 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/SkyrimPlatform.cpp @@ -130,31 +130,12 @@ class CommonExecutionListener : public TickListener const std::vector& GetFileDirs() const { if (!pluginFolders) { - auto settings = Settings::GetPlatformSettings(); - std::string utf8pluginFoldersSemicolonSeparated = - settings->GetString("Main", "PluginFolders", - "Data/Platform/Plugins;Data/Platform/PluginsDev"); - - std::istringstream ss(utf8pluginFoldersSemicolonSeparated); - std::string folder; - - if (utf8pluginFoldersSemicolonSeparated.find_first_of("\"") != - std::string::npos) { + try { + pluginFolders = Settings::GetPlatformSettings()->GetPluginFolders(); + } catch (std::exception& e) { pluginFolders = std::make_unique>(); - throw std::runtime_error( - "Invalid path with quotes in PluginFolders setting. Please remove " - "quotes and restart the game."); + throw; } - - auto result = std::make_unique>(); - - while (std::getline(ss, folder, ';')) { - if (!folder.empty()) { - result->emplace_back(folder); - } - } - - pluginFolders = std::move(result); } return *pluginFolders; From 395442da20058ba1ba0be4fd745eda8548484851 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 10:23:42 +0500 Subject: [PATCH 64/78] fix(skymp5-client): vanish AuthService-produced hot reload (#2055) --- skymp5-client/src/services/services/authService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skymp5-client/src/services/services/authService.ts b/skymp5-client/src/services/services/authService.ts index 4ff3b259dc..c9737ab6d5 100644 --- a/skymp5-client/src/services/services/authService.ts +++ b/skymp5-client/src/services/services/authService.ts @@ -325,7 +325,8 @@ export class AuthService extends ClientListener { logTrace(this, `Reading`, this.pluginAuthDataName, `from disk`); try { - const data = this.sp.getPluginSourceCode(this.pluginAuthDataName); + // @ts-expect-error + const data = this.sp.getPluginSourceCode(this.pluginAuthDataName, "PluginsNoLoad"); if (!data) { logTrace(this, `Read empty`, this.pluginAuthDataName, `returning null`); @@ -347,7 +348,9 @@ export class AuthService extends ClientListener { try { this.sp.writePlugin( this.pluginAuthDataName, - content + content, + // @ts-expect-error + "PluginsNoLoad" ); } catch (e) { From ad18d84d77976b010f62ad7f11278bcda44fd0bb Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 13:41:54 +0500 Subject: [PATCH 65/78] refact(skyrim-platform): separate error messages in EventsApi.cpp (#2060) --- .../src/platform_se/skyrim_platform/EventsApi.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/skyrim-platform/src/platform_se/skyrim_platform/EventsApi.cpp b/skyrim-platform/src/platform_se/skyrim_platform/EventsApi.cpp index 3e0c844f51..b241a38e79 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform/EventsApi.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform/EventsApi.cpp @@ -478,11 +478,16 @@ JsValue EventsApi::SendIpcMessage(const JsFunctionArguments& args) auto message = args[2].GetArrayBufferData(); auto messageLength = args[2].GetArrayBufferLength(); - if (!message || messageLength == 0) { + if (!message) { throw std::runtime_error( "sendIpcMessage expects a valid ArrayBuffer instance"); } + if (messageLength == 0) { + throw std::runtime_error( + "sendIpcMessage expects an ArrayBuffer of length > 0"); + } + IPC::Call(targetSystemName, reinterpret_cast(message), messageLength); From 9dc740f069d881d61eb2449fb63b3b2de527132b Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 17:15:17 +0500 Subject: [PATCH 66/78] internal: slightly reformat skyrim-platform\src\platform_se\CMakeLists.txt (#2061) --- .../src/platform_se/CMakeLists.txt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/skyrim-platform/src/platform_se/CMakeLists.txt b/skyrim-platform/src/platform_se/CMakeLists.txt index 02cb0ec7fe..0956994916 100644 --- a/skyrim-platform/src/platform_se/CMakeLists.txt +++ b/skyrim-platform/src/platform_se/CMakeLists.txt @@ -118,16 +118,16 @@ if(NOT "${SKIP_SKYRIM_PLATFORM_BUILDING}") set(DEPENDENCIES_FOR_CUSTOM_TARGETS skyrim_platform SkyrimPlatformCEF skyrim_platform_entry) - add_papyrus_library_ck( - NAME TESModPlatformPsc - DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/psc" - OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/pex" - COMPILER_EXECUTABLE_PATH "${SKYRIM_DIR}/Papyrus compiler/PapyrusCompiler.exe" - ) - if(TARGET TESModPlatformPsc) - add_dependencies(TESModPlatformPsc ${DEPENDENCIES_FOR_CUSTOM_TARGETS}) - list(APPEND DEPENDENCIES_FOR_CUSTOM_TARGETS TESModPlatformPsc) - endif() + add_papyrus_library_ck( + NAME TESModPlatformPsc + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/psc" + OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/pex" + COMPILER_EXECUTABLE_PATH "${SKYRIM_DIR}/Papyrus compiler/PapyrusCompiler.exe" + ) + if(TARGET TESModPlatformPsc) + add_dependencies(TESModPlatformPsc ${DEPENDENCIES_FOR_CUSTOM_TARGETS}) + list(APPEND DEPENDENCIES_FOR_CUSTOM_TARGETS TESModPlatformPsc) + endif() file(GLOB papyrus_sources "${CMAKE_CURRENT_SOURCE_DIR}/psc/*.psc") set(def "") From f820d439dcf9f0203f0951133b37abc09e05a0c8 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 1 Jul 2024 21:55:59 +0500 Subject: [PATCH 67/78] internal: minor cmake modernize in skyrim-platform/src/platform_se/CMakeLists.txt (#2062) --- skyrim-platform/src/platform_se/CMakeLists.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/skyrim-platform/src/platform_se/CMakeLists.txt b/skyrim-platform/src/platform_se/CMakeLists.txt index 0956994916..873633ceae 100644 --- a/skyrim-platform/src/platform_se/CMakeLists.txt +++ b/skyrim-platform/src/platform_se/CMakeLists.txt @@ -74,10 +74,6 @@ if(NOT "${SKIP_SKYRIM_PLATFORM_BUILDING}") ) if(MSVC) - if(SKYRIM_SE) - add_compile_definitions(SKYRIMSE) - endif() - file(GLOB_RECURSE platform_src "skyrim_platform/*") list(APPEND platform_src "${CMAKE_SOURCE_DIR}/.clang-format") list(APPEND platform_src ${CMAKE_CURRENT_BINARY_DIR}/include/Version.h) @@ -111,6 +107,11 @@ if(NOT "${SKIP_SKYRIM_PLATFORM_BUILDING}") target_precompile_headers(skyrim_platform_entry PRIVATE skyrim_platform_entry/PCH.h) apply_default_settings(TARGETS skyrim_platform_entry) + if(SKYRIM_SE) + target_compile_definitions(skyrim_platform PRIVATE SKYRIMSE) + target_compile_definitions(skyrim_platform_entry PRIVATE SKYRIMSE) + endif() + set_target_properties(skyrim_platform SkyrimPlatformCEF skyrim_platform_entry PROPERTIES RUNTIME_OUTPUT_DIRECTORY "bin" PDB_OUTPUT_DIRECTORY "bin" From 8e9137a3f994e6eaf59b5737d4b8c908220fc14d Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 3 Jul 2024 16:56:09 +0500 Subject: [PATCH 68/78] feat(skymp5-server): add getNeighborsByPosition API method (#2047) --- skymp5-server/cpp/addon/ScampServer.cpp | 24 +++++++++++++++++++ skymp5-server/cpp/addon/ScampServer.h | 1 + .../server_guest_lib/MpObjectReference.cpp | 4 ++-- .../cpp/server_guest_lib/WorldState.cpp | 2 +- .../cpp/server_guest_lib/WorldState.h | 2 +- unit/LoadCellsTest.cpp | 2 +- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/skymp5-server/cpp/addon/ScampServer.cpp b/skymp5-server/cpp/addon/ScampServer.cpp index f51d629000..b771b67994 100644 --- a/skymp5-server/cpp/addon/ScampServer.cpp +++ b/skymp5-server/cpp/addon/ScampServer.cpp @@ -92,6 +92,8 @@ Napi::Object ScampServer::Init(Napi::Env env, Napi::Object exports) InstanceMethod("lookupEspmRecordById", &ScampServer::LookupEspmRecordById), InstanceMethod("getEspmLoadOrder", &ScampServer::GetEspmLoadOrder), + InstanceMethod("getNeighborsByPosition", + &ScampServer::GetNeighborsByPosition), InstanceMethod("getDescFromId", &ScampServer::GetDescFromId), InstanceMethod("getIdFromDesc", &ScampServer::GetIdFromDesc), InstanceMethod("callPapyrusFunction", &ScampServer::CallPapyrusFunction), @@ -979,6 +981,28 @@ Napi::Value ScampServer::LookupEspmRecordById(const Napi::CallbackInfo& info) } } +Napi::Value ScampServer::GetNeighborsByPosition(const Napi::CallbackInfo& info) +{ + try { + auto cellOrWorldDesc = FormDesc::FromString( + NapiHelper::ExtractString(info[0], "cellOrWorldDesc")); + auto pos = NapiHelper::ExtractNiPoint3(info[1], "pos"); + + auto cellX = static_cast(pos[0] / 4096); + auto cellY = static_cast(pos[1] / 4096); + auto& refs = partOne->worldState.GetNeighborsByPosition( + cellOrWorldDesc.ToFormId(partOne->worldState.espmFiles), cellX, cellY); + + Napi::Array arr = Napi::Array::New(info.Env(), refs.size()); + for (auto ref : refs) { + arr.Set(arr.Length(), Napi::Number::New(info.Env(), ref->GetFormId())); + } + return arr; + } catch (std::exception& e) { + throw Napi::Error::New(info.Env(), std::string(e.what())); + } +} + Napi::Value ScampServer::GetEspmLoadOrder(const Napi::CallbackInfo& info) { try { diff --git a/skymp5-server/cpp/addon/ScampServer.h b/skymp5-server/cpp/addon/ScampServer.h index 7e8a955770..51b8b66049 100644 --- a/skymp5-server/cpp/addon/ScampServer.h +++ b/skymp5-server/cpp/addon/ScampServer.h @@ -46,6 +46,7 @@ class ScampServer : public Napi::ObjectWrap Napi::Value Set(const Napi::CallbackInfo& info); Napi::Value Place(const Napi::CallbackInfo& info); Napi::Value LookupEspmRecordById(const Napi::CallbackInfo& info); + Napi::Value GetNeighborsByPosition(const Napi::CallbackInfo& info); Napi::Value GetEspmLoadOrder(const Napi::CallbackInfo& info); Napi::Value GetDescFromId(const Napi::CallbackInfo& info); Napi::Value GetIdFromDesc(const Napi::CallbackInfo& info); diff --git a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp index 7167ffe730..6c038388e0 100644 --- a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp @@ -636,7 +636,7 @@ void MpObjectReference::ForceSubscriptionsUpdate() auto& was = *this->listeners; auto pos = GetGridPos(GetPos()); auto& now = - worldState->GetReferencesAtPosition(worldOrCell, pos.first, pos.second); + worldState->GetNeighborsByPosition(worldOrCell, pos.first, pos.second); std::vector toRemove; std::set_difference(was.begin(), was.end(), now.begin(), now.end(), @@ -1221,7 +1221,7 @@ void MpObjectReference::VisitNeighbours(const Visitor& visitor) auto& grid = gridIterator->second; auto pos = GetGridPos(GetPos()); auto& neighbours = - worldState->GetReferencesAtPosition(worldOrCell, pos.first, pos.second); + worldState->GetNeighborsByPosition(worldOrCell, pos.first, pos.second); for (auto neighbour : neighbours) { visitor(neighbour); } diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.cpp b/skymp5-server/cpp/server_guest_lib/WorldState.cpp index 79a020773f..2ff82f520b 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.cpp +++ b/skymp5-server/cpp/server_guest_lib/WorldState.cpp @@ -774,7 +774,7 @@ void WorldState::SendPapyrusEvent(MpForm* form, const char* eventName, return vm.SendEvent(form->ToGameObject(), eventName, args, onEnter); } -const std::set& WorldState::GetReferencesAtPosition( +const std::set& WorldState::GetNeighborsByPosition( uint32_t cellOrWorld, int16_t cellX, int16_t cellY) { if (espm && !pImpl->chunkLoadingInProgress) { diff --git a/skymp5-server/cpp/server_guest_lib/WorldState.h b/skymp5-server/cpp/server_guest_lib/WorldState.h index bf436813b6..3a29c9b3eb 100644 --- a/skymp5-server/cpp/server_guest_lib/WorldState.h +++ b/skymp5-server/cpp/server_guest_lib/WorldState.h @@ -123,7 +123,7 @@ class WorldState void SendPapyrusEvent(MpForm* form, const char* eventName, const VarValue* arguments, size_t argumentsCount); - const std::set& GetReferencesAtPosition( + const std::set& GetNeighborsByPosition( uint32_t cellOrWorld, int16_t cellX, int16_t cellY); // See LookupFormById comment diff --git a/unit/LoadCellsTest.cpp b/unit/LoadCellsTest.cpp index cfe2be8e7f..90fdd32908 100644 --- a/unit/LoadCellsTest.cpp +++ b/unit/LoadCellsTest.cpp @@ -8,7 +8,7 @@ extern espm::Loader l; TEST_CASE("Loading Cells from Solstheim.esm", "[LoadCells]") { auto& p = GetPartOne(); - auto& t = p.worldState.GetReferencesAtPosition(0x04000800, 7, 8); + auto& t = p.worldState.GetNeighborsByPosition(0x04000800, 7, 8); REQUIRE(t.size() != 0); } From 7364d90157703caef7a72e3b93c3f7e54e670721 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Wed, 3 Jul 2024 22:31:06 +0500 Subject: [PATCH 69/78] feat(skymp5-server): increase bound arrows count (SweetPieScript) (#2064) --- skymp5-server/cpp/server_guest_lib/SweetPieScript.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skymp5-server/cpp/server_guest_lib/SweetPieScript.cpp b/skymp5-server/cpp/server_guest_lib/SweetPieScript.cpp index f27412acb3..3bf5a1f090 100644 --- a/skymp5-server/cpp/server_guest_lib/SweetPieScript.cpp +++ b/skymp5-server/cpp/server_guest_lib/SweetPieScript.cpp @@ -192,7 +192,7 @@ void SweetPieScript::Play(MpActor& actor, WorldState& worldState, } uint32_t boundWeaponBaseId = boundItem.GetBaseId(), bookBaseId = it->first; - actor.AddItem(boundWeaponBaseId, isArrow ? 10 : 1); + actor.AddItem(boundWeaponBaseId, isArrow ? 40 : 1); EquipItem(actor, boundWeaponBaseId); uint32_t formId = actor.GetFormId(); float cooldown = boundItem.GetCooldown(); From afdfa92e083324e0d788bb9b26950e97c540d00d Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Sun, 7 Jul 2024 16:21:50 +0500 Subject: [PATCH 70/78] refact(skyrim-platform): indicate that fatal msgbox belongs to SP (#2066) --- skyrim-platform/src/platform_se/skyrim_platform_entry/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skyrim-platform/src/platform_se/skyrim_platform_entry/main.cpp b/skyrim-platform/src/platform_se/skyrim_platform_entry/main.cpp index 13d2406343..983baadfc3 100644 --- a/skyrim-platform/src/platform_se/skyrim_platform_entry/main.cpp +++ b/skyrim-platform/src/platform_se/skyrim_platform_entry/main.cpp @@ -156,7 +156,7 @@ DLLEXPORT bool SKSEPlugin_Load(void* skse) try { return PlatformImplInterface::GetSingleton().Load(skse); } catch (std::exception& e) { - MessageBoxA(0, e.what(), "Fatal", MB_ICONERROR); + MessageBoxA(0, e.what(), "Fatal (SkyrimPlatform)", MB_ICONERROR); return false; } } From 4943d3e05e404541d6fb1048c40721bfa73115db Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 8 Jul 2024 16:11:58 +0500 Subject: [PATCH 71/78] fix: fix workbench activation (#2067) --- .../src/extensions/objectReferenceEx.ts | 7 ++++- .../src/services/services/remoteServer.ts | 28 ++++++++++++++++--- .../server_guest_lib/MpObjectReference.cpp | 4 +++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/skymp5-client/src/extensions/objectReferenceEx.ts b/skymp5-client/src/extensions/objectReferenceEx.ts index 62d8f1c78a..fc738f43b4 100644 --- a/skymp5-client/src/extensions/objectReferenceEx.ts +++ b/skymp5-client/src/extensions/objectReferenceEx.ts @@ -40,7 +40,12 @@ export class ObjectReferenceEx { const caveGSecretDoor01 = 0x6f703; // You can also block for t === FormType.Flora || t === FormType.Tree, but I don't think it's necessary. - if (t === FormType.Container || isItem || t === FormType.NPC || (t === FormType.Door && self.getBaseObject()?.getFormID() !== caveGSecretDoor01)) { + if (t === FormType.Furniture + || t === FormType.Activator + || t === FormType.Container + || isItem + || t === FormType.NPC + || (t === FormType.Door && self.getBaseObject()?.getFormID() !== caveGSecretDoor01)) { self.blockActivation(true); } else { self.blockActivation(false); diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index aba742e448..2b67edc231 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -1,4 +1,4 @@ -import { Actor, Form } from 'skyrimPlatform'; +import { Actor, Form, FormType } from 'skyrimPlatform'; import { Armor, Cell, @@ -159,9 +159,29 @@ export class RemoteServer extends ClientListener { private onOpenContainerMessage(event: ConnectionMessage): void { once('update', async () => { await Utility.wait(0.1); // Give a chance to update inventory - ( - ObjectReference.from(Game.getFormEx(event.message.target)) as ObjectReference - ).activate(Game.getPlayer(), true); + + const remoteId = event.message.target; + const localId = remoteIdToLocalId(remoteId); + const refr = ObjectReference.from(Game.getFormEx(localId)); + + if (refr === null) { + logError(this, 'onOpenContainerMessage - refr not found', 'remoteId', remoteId.toString(16), 'localId', localId.toString(16)); + return; + } + + refr.activate(Game.getPlayer(), true); + + const baseObject = refr.getBaseObject(); + const baseType = baseObject?.getType(); + const isContainer = baseType === FormType.Container; + + if (!isContainer) { + return; + } + + // In SkyMP containers have 2-nd, closing activation under the hood. + // This differs from Skyrim's behavior, where it's just one activation. + (async () => { while (!Ui.isMenuOpen('ContainerMenu')) await Utility.wait(0.1); while (Ui.isMenuOpen('ContainerMenu')) await Utility.wait(0.1); diff --git a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp index 6c038388e0..b061104bd3 100644 --- a/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp +++ b/skymp5-server/cpp/server_guest_lib/MpObjectReference.cpp @@ -1432,6 +1432,10 @@ void MpObjectReference::ProcessActivate(MpObjectReference& activationSource) this->occupant->RemoveEventSink(this->occupantDestroySink); this->occupant = nullptr; } + } else if ((t == espm::ACTI::kType || t == "FURN") && actorActivator) { + // SendOpenContainer being used to activate the object + // TODO: rename SendOpenContainer to SendActivate + activationSource.SendOpenContainer(GetFormId()); } } From c68da84fb42a586172392f471ba59fe071599c95 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 8 Jul 2024 16:44:22 +0500 Subject: [PATCH 72/78] fix(skymp5-client): fix female character bugs (#2063) --- skymp5-client/src/services/services/remoteServer.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index 2b67edc231..34efeab055 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -489,9 +489,6 @@ export class RemoteServer extends ClientListener { // Note: appearance part was copy-pasted if (msg.appearance) { applyAppearanceToPlayer(msg.appearance); - if (msg.appearance.isFemale) - // Fix gender-specific walking anim - (Game.getPlayer()!).resurrect(); } } @@ -567,9 +564,6 @@ export class RemoteServer extends ClientListener { // Note: appearance part was copy-pasted if (msg.appearance) { applyAppearanceToPlayer(msg.appearance); - if (msg.appearance.isFemale) - // Fix gender-specific walking anim - (Game.getPlayer()!).resurrect(); } }); } From 198fc3bc83e24a9f1ab070c6d01f2ad3233c9f71 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 8 Jul 2024 17:04:04 +0500 Subject: [PATCH 73/78] fix(skymp5-server): make recipes without keyword unusable (#2056) --- skymp5-server/cpp/server_guest_lib/FindRecipe.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skymp5-server/cpp/server_guest_lib/FindRecipe.cpp b/skymp5-server/cpp/server_guest_lib/FindRecipe.cpp index 1bef2f8714..9be5e1d3c3 100644 --- a/skymp5-server/cpp/server_guest_lib/FindRecipe.cpp +++ b/skymp5-server/cpp/server_guest_lib/FindRecipe.cpp @@ -18,6 +18,12 @@ bool RecipeMatches(const espm::IdMapping* mapping, const espm::COBJ* recipe, return false; } + // In the original game, setting the benchmark keyword to NONE removes the + // recipe from all crafting stations. + if (recipeData.benchKeywordId == 0) { + return false; + } + auto thisInputObjects = recipeData.inputObjects; for (auto& entry : thisInputObjects) { auto formId = espm::utils::GetMappedId(entry.formId, *mapping); From bd7fd613caed7a34902a1a3954ec302ef2f96895 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 8 Jul 2024 17:21:36 +0500 Subject: [PATCH 74/78] fix(skymp5-client): support FF containers PutItem/TakeItem (#2068) --- skymp5-client/src/services/services/containersService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skymp5-client/src/services/services/containersService.ts b/skymp5-client/src/services/services/containersService.ts index f2a4192b29..06f69dca01 100644 --- a/skymp5-client/src/services/services/containersService.ts +++ b/skymp5-client/src/services/services/containersService.ts @@ -8,6 +8,7 @@ import { LastInvService } from "./lastInvService"; import { PutItemMessage } from "../messages/putItemMessage"; import { TakeItemMessage } from "../messages/takeItemMessage"; import { SweetTaffySweetCantDropService } from "./sweetTaffySweetCantDropService"; +import { localIdToRemoteId } from "../../view/worldViewMisc"; export class ContainersService extends ClientListener { constructor(private sp: Sp, private controller: CombinedController) { @@ -54,8 +55,8 @@ export class ContainersService extends ClientListener { ...entryCopy, t: entry.count > 0 ? MsgType.PutItem : MsgType.TakeItem, target: e.oldContainer.getFormID() === 0x14 - ? e.newContainer.getFormID() - : e.oldContainer.getFormID() + ? localIdToRemoteId(e.newContainer.getFormID()) + : localIdToRemoteId(e.oldContainer.getFormID()) }; msg.count = Math.abs(msg.count); if (this.sp.Game.getFormEx(entry.baseId)?.getName() === msg.name) { From 14addfc50ac6d4891540b6062e2bbccde095d66b Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Mon, 8 Jul 2024 21:28:36 +0500 Subject: [PATCH 75/78] internal: separate pr-windows workflows (#2069) --- .../pr_windows_base/action.yml} | 160 +++++++----------- .../workflows/pr-windows-skyrim-ae-indev.yml | 31 ++++ .../pr-windows-skyrim-ae-sweetpie.yml | 31 ++++ .github/workflows/pr-windows-skyrim-ae.yml | 33 ++++ .github/workflows/pr-windows-skyrim-se.yml | 33 ++++ 5 files changed, 187 insertions(+), 101 deletions(-) rename .github/{workflows/pr-windows.yml => actions/pr_windows_base/action.yml} (54%) create mode 100644 .github/workflows/pr-windows-skyrim-ae-indev.yml create mode 100644 .github/workflows/pr-windows-skyrim-ae-sweetpie.yml create mode 100644 .github/workflows/pr-windows-skyrim-ae.yml create mode 100644 .github/workflows/pr-windows-skyrim-se.yml diff --git a/.github/workflows/pr-windows.yml b/.github/actions/pr_windows_base/action.yml similarity index 54% rename from .github/workflows/pr-windows.yml rename to .github/actions/pr_windows_base/action.yml index 633113f484..ef1e451d1b 100644 --- a/.github/workflows/pr-windows.yml +++ b/.github/actions/pr_windows_base/action.yml @@ -1,66 +1,34 @@ -name: PR Windows - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - schedule: - - cron: '0 0 * * *' - -env: - # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) - BUILD_TYPE: Release - VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' - VCPKG_FEATURE_FLAGS: 'manifests' - -jobs: - build: - strategy: - matrix: - include: - - DESCRIPTION: 'Skyrim Special Edition (1.5)' - SKYRIM_SE_FLAG: ON - SP_NEXUS_ARTIFACT_NAME: Skyrim Platform %SP_VERSION% (Special Edition) - DEPLOY_BRANCH: "" - ONLY_PUSH: false - DIST_ARTIFACT_NAME: dist-1.5 - SERVER_DIST_ARTIFACT_NAME: server-dist-1.5 - - DESCRIPTION: 'Skyrim Anniversary Edition (1.6)' - SKYRIM_SE_FLAG: OFF - SP_NEXUS_ARTIFACT_NAME: Skyrim Platform %SP_VERSION% (Anniversary Edition) - DEPLOY_BRANCH: "" - ONLY_PUSH: false - DIST_ARTIFACT_NAME: dist - SERVER_DIST_ARTIFACT_NAME: server-dist - - DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - Indev' - SKYRIM_SE_FLAG: OFF - SP_NEXUS_ARTIFACT_NAME: nope - DEPLOY_BRANCH: "indev" - ONLY_PUSH: true - DIST_ARTIFACT_NAME: dist-indev - SERVER_DIST_ARTIFACT_NAME: server-dist-indev - - DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - SweetPie' - SKYRIM_SE_FLAG: OFF - SP_NEXUS_ARTIFACT_NAME: nope - DEPLOY_BRANCH: "sweetpie" - ONLY_PUSH: true - DIST_ARTIFACT_NAME: dist-sweetpie - SERVER_DIST_ARTIFACT_NAME: server-dist-sweetpie - - # VS 2019 is still supported, but GitHub windows-2019 runners have unsupported WinSDK version - runs-on: windows-2022 - name: PR Windows - ${{ matrix.DESCRIPTION }} - - steps: - - uses: actions/checkout@v2 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} - with: - fetch-depth: 1 - submodules: 'true' - +name: PR Windows Base +inputs: + DESCRIPTION: + description: "Will be used for artifact names" + required: true + SKYRIM_SE_FLAG: + description: "Set to ON if building for Skyrim Special Edition" + required: false + default: OFF + SP_NEXUS_ARTIFACT_NAME: + description: "May be nope. Artifact name for Skyrim Platform Nexus artifact, %SP_VERSION% will be replaced with the version number" + required: false + default: "nope" + DEPLOY_BRANCH: + description: "May be empty. Branch here refers to indev/sweetpie" + required: false + default: "" + DIST_ARTIFACT_NAME: + description: "Artifact name for the dist folder" + required: true + SERVER_DIST_ARTIFACT_NAME: + description: "Artifact name for the server dist folder" + required: true + SKYMP5_PATCHES_PAT: + description: "PAT for skymp5-patches repository" + required: true +runs: + using: composite + steps: - name: Gather PRs - if: ${{ matrix.DEPLOY_BRANCH != '' && (!matrix.ONLY_PUSH || github.event_name == 'push')}} + if: ${{ inputs.DEPLOY_BRANCH != '' }} uses: Pospelove/auto-merge-action@main with: path: ${{github.workspace}} @@ -69,52 +37,52 @@ jobs: { "owner": "skyrim-multiplayer", "repo": "skymp", - "labels": ["merge-to:${{matrix.DEPLOY_BRANCH}}"] + "labels": ["merge-to:${{inputs.DEPLOY_BRANCH}}"] }, { "owner": "skyrim-multiplayer", "repo": "skymp5-patches", - "labels": ["merge-to:${{matrix.DEPLOY_BRANCH}}"], - "token": "${{secrets.SKYMP5_PATCHES_PAT}}" + "labels": ["merge-to:${{inputs.DEPLOY_BRANCH}}"], + "token": "${{inputs.SKYMP5_PATCHES_PAT}}" } ] - name: Commit gathered PRs - if: ${{ matrix.DEPLOY_BRANCH != '' && (!matrix.ONLY_PUSH || github.event_name == 'push')}} + if: ${{ inputs.DEPLOY_BRANCH != '' }} run: | # fake user for bot git config --global user.email "skyrim_multiplayer_bot@users.noreply.github.com" git config --global user.name "Skyrim Multiplayer Bot" git add . - git commit -m "Merge PRs ${{matrix.DEPLOY_BRANCH}}" + git commit -m "Merge PRs ${{inputs.DEPLOY_BRANCH}}" + shell: powershell - name: Early build skymp5-client - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: | cd ${{github.workspace}}/skymp5-client yarn yarn build + shell: powershell - name: Install tools - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: choco install opencppcoverage + shell: powershell - name: Move vcpkg submodule to a larger drive - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: | Remove-Item -Recurse -Force C:/vcpkg Move-Item -Path ./vcpkg C:/vcpkg + shell: powershell - name: Bootstrap vcpkg - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: C:/vcpkg/bootstrap-vcpkg.bat + shell: powershell - name: Debug - free space - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: Get-PSDrive + shell: powershell - uses: actions/github-script@v6 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); @@ -122,38 +90,32 @@ jobs: # Download Skyrim SE data files - uses: suisei-cn/actions-download-file@v1 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} name: Download Skyrim.esm with: url: "https://gitlab.com/pospelov/se-data/-/raw/main/Skyrim.esm" target: ${{github.workspace}}/skyrim_data_files/ - uses: suisei-cn/actions-download-file@v1 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} name: Download Update.esm with: url: "https://gitlab.com/pospelov/se-data/-/raw/main/Update.esm" target: ${{github.workspace}}/skyrim_data_files/ - uses: suisei-cn/actions-download-file@v1 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} name: Download Dawnguard.esm with: url: "https://gitlab.com/pospelov/se-data/-/raw/main/Dawnguard.esm" target: ${{github.workspace}}/skyrim_data_files/ - uses: suisei-cn/actions-download-file@v1 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} name: Download HearthFires.esm with: url: "https://gitlab.com/pospelov/se-data/-/raw/main/HearthFires.esm" target: ${{github.workspace}}/skyrim_data_files/ - uses: suisei-cn/actions-download-file@v1 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} name: Download Dragonborn.esm with: url: "https://gitlab.com/pospelov/se-data/-/raw/main/Dragonborn.esm" target: ${{github.workspace}}/skyrim_data_files/ - name: Configure CMake - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make. # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type # Outputs profiling data in Google Trace Format, which can be parsed by the about:tracing tab of Google Chrome or using a plugin for a tool like Trace Compass. @@ -164,9 +126,10 @@ jobs: -DUNIT_DATA_DIR="skyrim_data_files" -DPREPARE_NEXUS_ARCHIVES=ON -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} - -DSKYRIM_SE=${{ matrix.SKYRIM_SE_FLAG }} + -DSKYRIM_SE=${{ inputs.SKYRIM_SE_FLAG }} --profiling-output cmake-profiling-output --profiling-format google-trace + shell: powershell - name: Upload vcpkg failure logs if: failure() @@ -176,82 +139,77 @@ jobs: path: C:\vcpkg\buildtrees\spdlog\install-x64-windows-sp-dbg-out.log - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: cmake-profiling-output (${{ matrix.DESCRIPTION }}) + name: cmake-profiling-output (${{ inputs.DESCRIPTION }}) path: cmake-profiling-output - name: Build - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} # Build your program with the given configuration run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} + shell: powershell - name: Test - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} working-directory: ${{github.workspace}}/build # Execute tests defined by the CMake configuration. # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail run: ctest -C ${{env.BUILD_TYPE}} --verbose # --output-on-failure + shell: powershell - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: ${{ matrix.DIST_ARTIFACT_NAME }} + name: ${{ inputs.DIST_ARTIFACT_NAME }} path: ${{github.workspace}}/build/dist - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: skymp5-client-js (${{ matrix.DESCRIPTION }}) + name: skymp5-client-js (${{ inputs.DESCRIPTION }}) path: ${{github.workspace}}/build/dist/client/Data/Platform/Plugins/skymp5-client.js - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: ${{ matrix.SERVER_DIST_ARTIFACT_NAME }} + name: ${{ inputs.SERVER_DIST_ARTIFACT_NAME }} path: ${{github.workspace}}/build/dist/server - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: coverage (${{ matrix.DESCRIPTION }}) + name: coverage (${{ inputs.DESCRIPTION }}) path: ${{github.workspace}}/build/__coverage - name: Extract SP Version Number - if: ${{ (!matrix.ONLY_PUSH || github.event_name == 'push') && matrix.SP_NEXUS_ARTIFACT_NAME != 'nope' }} + if: ${{ inputs.SP_NEXUS_ARTIFACT_NAME != 'nope' }} run: | $version = (Get-Content ./skyrim-platform/package.json | ConvertFrom-Json).version echo "VERSION=$version" | Out-File -Append -FilePath $env:GITHUB_ENV + shell: powershell - name: Replace %SP_VERSION% with actual version in artifact name - if: ${{ (!matrix.ONLY_PUSH || github.event_name == 'push') && matrix.SP_NEXUS_ARTIFACT_NAME != 'nope' }} + if: ${{ inputs.SP_NEXUS_ARTIFACT_NAME != 'nope' }} run: | - $artifactName = "${{ matrix.SP_NEXUS_ARTIFACT_NAME }}" + $artifactName = "${{ inputs.SP_NEXUS_ARTIFACT_NAME }}" $artifactName = $artifactName.Replace("%SP_VERSION%", "${{ env.VERSION }}") echo "SP_NEXUS_ARTIFACT_NAME=$artifactName" | Out-File -Append -FilePath $env:GITHUB_ENV + shell: powershell - uses: actions/upload-artifact@v4 - if: ${{ (!matrix.ONLY_PUSH || github.event_name == 'push') && matrix.SP_NEXUS_ARTIFACT_NAME != 'nope' }} + if: ${{ inputs.SP_NEXUS_ARTIFACT_NAME != 'nope' }} with: name: ${{ env.SP_NEXUS_ARTIFACT_NAME }} # Data folder is skipped for mod managers path: ${{github.workspace}}/build/nexus/sp/data/* - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: papyrus-vm-nexus (${{ matrix.DESCRIPTION }}) + name: papyrus-vm-nexus (${{ inputs.DESCRIPTION }}) # Data folder is skipped for mod managers path: ${{github.workspace}}/build/nexus/papyrus-vm/* - name: Debug - free space - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} run: Get-PSDrive + shell: powershell - uses: actions/upload-artifact@v4 - if: ${{ !matrix.ONLY_PUSH || github.event_name == 'push' }} with: - name: msbuild_files (${{ matrix.DESCRIPTION }}) + name: msbuild_files (${{ inputs.DESCRIPTION }}) path: | ${{github.workspace}}/build/**/*.sln ${{github.workspace}}/build/**/*.vcxproj diff --git a/.github/workflows/pr-windows-skyrim-ae-indev.yml b/.github/workflows/pr-windows-skyrim-ae-indev.yml new file mode 100644 index 0000000000..a75256e099 --- /dev/null +++ b/.github/workflows/pr-windows-skyrim-ae-indev.yml @@ -0,0 +1,31 @@ +name: PR Windows Skyrim AE Indev + +on: + push: + branches: [ main ] + schedule: + - cron: '0 0 * * *' + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + VCPKG_FEATURE_FLAGS: 'manifests' + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + submodules: 'true' + - uses: ./.github/actions/pr_windows_base + with: + DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - Indev' + SKYRIM_SE_FLAG: OFF + SP_NEXUS_ARTIFACT_NAME: nope + DEPLOY_BRANCH: "indev" + DIST_ARTIFACT_NAME: dist-indev + SERVER_DIST_ARTIFACT_NAME: server-dist-indev + SKYMP5_PATCHES_PAT: ${{ secrets.SKYMP5_PATCHES_PAT }} diff --git a/.github/workflows/pr-windows-skyrim-ae-sweetpie.yml b/.github/workflows/pr-windows-skyrim-ae-sweetpie.yml new file mode 100644 index 0000000000..c7b1f5f153 --- /dev/null +++ b/.github/workflows/pr-windows-skyrim-ae-sweetpie.yml @@ -0,0 +1,31 @@ +name: PR Windows Skyrim AE SweetPie + +on: + push: + branches: [ main ] + schedule: + - cron: '0 0 * * *' + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + VCPKG_FEATURE_FLAGS: 'manifests' + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + submodules: 'true' + - uses: ./.github/actions/pr_windows_base + with: + DESCRIPTION: 'Skyrim Anniversary Edition (1.6) - SweetPie' + SKYRIM_SE_FLAG: OFF + SP_NEXUS_ARTIFACT_NAME: nope + DEPLOY_BRANCH: "sweetpie" + DIST_ARTIFACT_NAME: dist-sweetpie + SERVER_DIST_ARTIFACT_NAME: server-dist-sweetpie + SKYMP5_PATCHES_PAT: ${{ secrets.SKYMP5_PATCHES_PAT }} diff --git a/.github/workflows/pr-windows-skyrim-ae.yml b/.github/workflows/pr-windows-skyrim-ae.yml new file mode 100644 index 0000000000..202b947397 --- /dev/null +++ b/.github/workflows/pr-windows-skyrim-ae.yml @@ -0,0 +1,33 @@ +name: PR Windows Skyrim AE + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '0 0 * * *' + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + VCPKG_FEATURE_FLAGS: 'manifests' + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + submodules: 'true' + - uses: ./.github/actions/pr_windows_base + with: + DESCRIPTION: 'Skyrim Anniversary Edition (1.6)' + SKYRIM_SE_FLAG: OFF + SP_NEXUS_ARTIFACT_NAME: Skyrim Platform %SP_VERSION% (Anniversary Edition) + DEPLOY_BRANCH: "" + DIST_ARTIFACT_NAME: dist + SERVER_DIST_ARTIFACT_NAME: server-dist + SKYMP5_PATCHES_PAT: ${{ secrets.SKYMP5_PATCHES_PAT }} diff --git a/.github/workflows/pr-windows-skyrim-se.yml b/.github/workflows/pr-windows-skyrim-se.yml new file mode 100644 index 0000000000..674ad5ba41 --- /dev/null +++ b/.github/workflows/pr-windows-skyrim-se.yml @@ -0,0 +1,33 @@ +name: PR Windows Skyrim SE + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '0 0 * * *' + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + VCPKG_FEATURE_FLAGS: 'manifests' + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + submodules: 'true' + - uses: ./.github/actions/pr_windows_base + with: + DESCRIPTION: 'Skyrim Special Edition (1.5)' + SKYRIM_SE_FLAG: ON + SP_NEXUS_ARTIFACT_NAME: Skyrim Platform %SP_VERSION% (Special Edition) + DEPLOY_BRANCH: "" + DIST_ARTIFACT_NAME: dist-1.5 + SERVER_DIST_ARTIFACT_NAME: server-dist-1.5 + SKYMP5_PATCHES_PAT: ${{ secrets.SKYMP5_PATCHES_PAT }} From 1707933ea3cd2b3fdfb70f94dbf672e5d8c96132 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 9 Jul 2024 16:36:32 +0500 Subject: [PATCH 76/78] fix(skymp5-client): fix node scale/texture for FF objects (#2071) --- skymp5-client/src/logging.ts | 8 ++--- .../src/services/services/remoteServer.ts | 28 ++--------------- skymp5-client/src/view/formView.ts | 10 ++++++ skymp5-client/src/view/modelApplyUtils.ts | 31 ++++++++++++++++++- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/skymp5-client/src/logging.ts b/skymp5-client/src/logging.ts index d0ac3aa471..d7e37eaa21 100644 --- a/skymp5-client/src/logging.ts +++ b/skymp5-client/src/logging.ts @@ -2,11 +2,11 @@ import { printConsole } from "@skyrim-platform/skyrim-platform"; import { ClientListener } from "./services/services/clientListener"; // TODO: redirect this to spdlog -export function logError(service: ClientListener, ...rest: unknown[]) { - printConsole(`Error in ${service.constructor.name}:`, ...rest); +export function logError(service: ClientListener | string, ...rest: unknown[]) { + printConsole(`Error in ${typeof service !== "string" ? service.constructor.name : service}:`, ...rest); } // TODO: redirect this to spdlog -export function logTrace(service: ClientListener, ...rest: unknown[]) { - printConsole(`Trace in ${service.constructor.name}:`, ...rest); +export function logTrace(service: ClientListener | string, ...rest: unknown[]) { + printConsole(`Trace in ${typeof service !== "string" ? service.constructor.name : service}:`, ...rest); } diff --git a/skymp5-client/src/services/services/remoteServer.ts b/skymp5-client/src/services/services/remoteServer.ts index 34efeab055..df096fd86d 100644 --- a/skymp5-client/src/services/services/remoteServer.ts +++ b/skymp5-client/src/services/services/remoteServer.ts @@ -256,33 +256,9 @@ export class RemoteServer extends ClientListener { !!msg.props['isHarvested'], ); - // TODO: move to a separate module - if (msg.props.setNodeScale) { - const setNodeScale = msg.props.setNodeScale; - for (const key in setNodeScale) { - const scale = setNodeScale[key]; - const firstPerson = false; - this.sp.NetImmerse.setNodeScale(refr, key, scale, firstPerson); - logTrace(this, refr.getFormID().toString(16), `Applied node scale`, scale, `to`, key); - } - } + ModelApplyUtils.applyModelNodeScale(refr, msg.props.setNodeScale); - // TODO: move to a separate module - if (msg.props.setNodeTextureSet) { - const setNodeTextureSet = msg.props.setNodeTextureSet; - for (const key in setNodeTextureSet) { - const textureSetId = setNodeTextureSet[key]; - const firstPerson = false; - - const textureSet = this.sp.TextureSet.from(Game.getFormEx(textureSetId)); - if (textureSet !== null) { - this.sp.NetImmerse.setNodeTextureSet(refr, key, textureSet, firstPerson); - logTrace(this, refr.getFormID().toString(16), `Applied texture set`, textureSetId.toString(16), `to`, key); - } else { - logError(this, refr.getFormID().toString(16), `Failed to apply texture set`, textureSetId.toString(16), `to`, key); - } - } - } + ModelApplyUtils.applyModelNodeTextureSet(refr, msg.props.setNodeTextureSet); ModelApplyUtils.applyModelIsDisabled(refr, !!msg.props['disabled']); diff --git a/skymp5-client/src/view/formView.ts b/skymp5-client/src/view/formView.ts index 9e56b3224a..8b0d7720fc 100644 --- a/skymp5-client/src/view/formView.ts +++ b/skymp5-client/src/view/formView.ts @@ -328,6 +328,8 @@ export class FormView { private lastHarvestedApply = 0; private lastOpenApply = 0; + private isSetNodeTextureSetApplied = false; + private isSetNodeScaleApplied = false; private applyAll(refr: ObjectReference, model: FormModel) { let forcedWeapDrawn: boolean | null = null; @@ -345,6 +347,14 @@ export class FormView { this.lastOpenApply = now; ModelApplyUtils.applyModelIsOpen(refr, !!model.isOpen); } + if (!this.isSetNodeScaleApplied) { + this.isSetNodeScaleApplied = true; + ModelApplyUtils.applyModelNodeScale(refr, model.setNodeScale); + } + if (!this.isSetNodeTextureSetApplied) { + this.isSetNodeTextureSetApplied = true; + ModelApplyUtils.applyModelNodeTextureSet(refr, model.setNodeTextureSet); + } if ( model.inventory && diff --git a/skymp5-client/src/view/modelApplyUtils.ts b/skymp5-client/src/view/modelApplyUtils.ts index 27826cd568..1e65d91662 100644 --- a/skymp5-client/src/view/modelApplyUtils.ts +++ b/skymp5-client/src/view/modelApplyUtils.ts @@ -1,5 +1,6 @@ -import { ObjectReference, Actor, Game, FormType } from "skyrimPlatform"; +import { ObjectReference, Actor, Game, FormType, TextureSet, NetImmerse } from "skyrimPlatform"; import { Inventory, applyInventory } from "../sync/inventory"; +import { logError, logTrace } from "../logging"; // For 0xff000000+ used from FormView // For objects from master files used directly from remoteServer.ts @@ -94,4 +95,32 @@ export class ModelApplyUtils { } } } + + static applyModelNodeTextureSet(refr: ObjectReference, setNodeTextureSet?: Record) { + if (setNodeTextureSet) { + for (const key in setNodeTextureSet) { + const textureSetId = setNodeTextureSet[key]; + const firstPerson = false; + + const textureSet = TextureSet.from(Game.getFormEx(textureSetId)); + if (textureSet !== null) { + NetImmerse.setNodeTextureSet(refr, key, textureSet, firstPerson); + logTrace("ModelApplyUtils", refr.getFormID().toString(16), `Applied texture set`, textureSetId.toString(16), `to`, key); + } else { + logError("ModelApplyUtils", refr.getFormID().toString(16), `Failed to apply texture set`, textureSetId.toString(16), `to`, key); + } + } + } + } + + static applyModelNodeScale(refr: ObjectReference, setNodeScale?: Record) { + if (setNodeScale) { + for (const key in setNodeScale) { + const scale = setNodeScale[key]; + const firstPerson = false; + NetImmerse.setNodeScale(refr, key, scale, firstPerson); + logTrace("ModelApplyUtils", refr.getFormID().toString(16), `Applied node scale`, scale, `to`, key); + } + } + } } From 7c03b33a96a3b6209fe34862083ac0167c2f4915 Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 9 Jul 2024 16:57:54 +0500 Subject: [PATCH 77/78] feat(skymp5-client): add hoursOffset setting (#2072) --- skymp5-client/src/services/services/timeService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skymp5-client/src/services/services/timeService.ts b/skymp5-client/src/services/services/timeService.ts index f6b5d0bc8a..772913a95c 100644 --- a/skymp5-client/src/services/services/timeService.ts +++ b/skymp5-client/src/services/services/timeService.ts @@ -7,7 +7,8 @@ export class TimeService extends ClientListener { } public getTime() { - const hoursOffset = -3; + const hoursOffsetSetting = this.sp.settings["skymp5-client"]["hoursOffset"]; + const hoursOffset = typeof hoursOffsetSetting === "number" ? hoursOffsetSetting : -3; const hoursOffsetMs = hoursOffset * 60 * 60 * 1000; const d = new Date(Date.now() + hoursOffsetMs); From 170eb613b1b0d39887774cb3e09eb617d5b4e27a Mon Sep 17 00:00:00 2001 From: Leonid Pospelov Date: Tue, 9 Jul 2024 17:03:04 +0500 Subject: [PATCH 78/78] fix(skymp5-client): always send standing when using furniture (#2073) --- skymp5-client/src/sync/movementGet.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skymp5-client/src/sync/movementGet.ts b/skymp5-client/src/sync/movementGet.ts index d737a53f80..240750447d 100644 --- a/skymp5-client/src/sync/movementGet.ts +++ b/skymp5-client/src/sync/movementGet.ts @@ -97,6 +97,9 @@ const getRunMode = (ac: Actor): RunMode => { const speed = ac.getAnimationVariableFloat("SpeedSampled"); if (!speed) return "Standing"; + const furniture = ac.getFurnitureReference(); + if (furniture !== null) return "Standing"; // TODO: Sitting? + let isRunning = true; if (ac.getFormID() == 0x14) { if (!TESModPlatform.isPlayerRunningEnabled() || speed < 150)