mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-13 02:01:51 +00:00
Add helper state from yorg3
This commit is contained in:
parent
87139872f7
commit
91ff34c4c5
@ -45,6 +45,8 @@ module.exports = ({ watch = false, standalone = false }) => {
|
||||
G_BUILD_COMMIT_HASH: JSON.stringify(utils.getRevision()),
|
||||
G_BUILD_VERSION: JSON.stringify(utils.getVersion()),
|
||||
G_ALL_UI_IMAGES: JSON.stringify(utils.getAllResourceImages()),
|
||||
// TODO: Get API endpoint from tobspr
|
||||
G_API_ENDPOINT: JSON.stringify(lzString.compressToEncodedURIComponent("http://localhost:8000")),
|
||||
}),
|
||||
|
||||
new CircularDependencyPlugin({
|
||||
|
||||
@ -38,6 +38,8 @@ module.exports = ({
|
||||
G_BUILD_COMMIT_HASH: JSON.stringify(utils.getRevision()),
|
||||
G_BUILD_VERSION: JSON.stringify(utils.getVersion()),
|
||||
G_ALL_UI_IMAGES: JSON.stringify(utils.getAllResourceImages()),
|
||||
// TODO: Get API endpoint from tobspr
|
||||
G_API_ENDPOINT: JSON.stringify(lzString.compressToEncodedURIComponent("https://api.shapez.io/v1")),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
@import "states/about";
|
||||
@import "states/mobile_warning";
|
||||
@import "states/changelog";
|
||||
@import "states/mods";
|
||||
|
||||
@import "ingame_hud/buildings_toolbar";
|
||||
@import "ingame_hud/building_placer";
|
||||
|
||||
109
src/css/states/mods.scss
Normal file
109
src/css/states/mods.scss
Normal file
@ -0,0 +1,109 @@
|
||||
#state_ModsState {
|
||||
|
||||
.mainContent {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.devHint {
|
||||
font-size: calc(13px*var(--ui-scale));
|
||||
line-height: calc(17px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
letter-spacing: .04em;
|
||||
color: #aaa;
|
||||
|
||||
.moddingDocsLink {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
color: #26c6da;
|
||||
}
|
||||
}
|
||||
|
||||
.mod {
|
||||
padding: calc(14px*var(--ui-scale));
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
grid-gap: calc(5px*var(--ui-scale));
|
||||
grid-column-gap: calc(15px*var(--ui-scale));
|
||||
|
||||
.title {
|
||||
font-size: calc(19px*var(--ui-scale));
|
||||
line-height: calc(21px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 1 / 2;
|
||||
letter-spacing: .04em;
|
||||
|
||||
.version {
|
||||
font-size: calc(13px*var(--ui-scale));
|
||||
line-height: calc(17px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
letter-spacing: .04em;
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.website {
|
||||
font-size: calc(13px*var(--ui-scale));
|
||||
line-height: calc(17px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
color: #26c6da;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-column: 1 / 5;
|
||||
grid-row: 3 / 4;
|
||||
border-top: 1px solid;
|
||||
border-top-width: calc(1px*var(--ui-scale));
|
||||
padding-top: calc(5px*var(--ui-scale));
|
||||
}
|
||||
|
||||
.installedText, button.installMod, button.uninstallMod {
|
||||
grid-column: 4 / 5;
|
||||
grid-row: 1 / 3;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.author, .gameChangingHint {
|
||||
font-size: calc(13px*var(--ui-scale));
|
||||
line-height: calc(17px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.gameChangingHint {
|
||||
grid-column: 1 / 5;
|
||||
grid-row: 4 / 5;
|
||||
color: #ef5072;
|
||||
border-top: 1px solid;
|
||||
border-top-width: calc(1px*var(--ui-scale));
|
||||
padding-top: calc(5px*var(--ui-scale));
|
||||
}
|
||||
|
||||
.installed {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.installCount, .installedText {
|
||||
font-size: calc(13px*var(--ui-scale));
|
||||
line-height: calc(17px*var(--ui-scale));
|
||||
font-weight: 400;
|
||||
letter-spacing: .04em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.installedText {
|
||||
text-transform: uppercase;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,7 @@ import { PreloadState } from "./states/preload";
|
||||
import { SettingsState } from "./states/settings";
|
||||
import { ModsState } from "./states/mods";
|
||||
import { ModManager } from "./core/mod_manager";
|
||||
import { BackendAPI } from "./core/backend";
|
||||
|
||||
const logger = createLogger("application");
|
||||
|
||||
@ -72,6 +73,7 @@ export class Application {
|
||||
this.savegameMgr = new SavegameManager(this);
|
||||
this.inputMgr = new InputDistributor(this);
|
||||
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
|
||||
this.api = new BackendAPI(this);
|
||||
this.modManager = new ModManager(this);
|
||||
|
||||
// Platform dependent stuff
|
||||
|
||||
193
src/js/core/backend.js
Normal file
193
src/js/core/backend.js
Normal file
@ -0,0 +1,193 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { JSON_stringify } from "./builtins";
|
||||
import { globalError, globalLog, globalWarn } from "./logging";
|
||||
import { decodeHashedString } from "./sensitive_utils.encrypt";
|
||||
import { globalConfig } from "./config";
|
||||
import { BACKEND_ERRORS } from "./backend_errors";
|
||||
import { RequestChannel } from "./request_channel";
|
||||
|
||||
export class BackendAPI {
|
||||
constructor(app) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
this.url = decodeHashedString(G_API_ENDPOINT);
|
||||
|
||||
// For testing
|
||||
if (G_IS_DEV && window.location.hostname !== "localhost") {
|
||||
this.url = "http://172.0.0.1:8000";
|
||||
}
|
||||
|
||||
this.runningRequest = null;
|
||||
this.requestChannel = new RequestChannel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of mods
|
||||
*
|
||||
* Route: /mods/gallery
|
||||
* @returns {Promise<Array<any>>}
|
||||
*/
|
||||
fetchModGallery() {
|
||||
return this.performRequest("GET", "/mods/gallery").then(res => {
|
||||
if (!res.mods) {
|
||||
throw BACKEND_ERRORS.badResponse;
|
||||
}
|
||||
return res.mods;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of mods
|
||||
*
|
||||
* Route: /mods/track-download/:id
|
||||
* @returns {Promise<Array<any>>}
|
||||
*/
|
||||
trackModDownload(id) {
|
||||
return this.performRequest("POST", "/mods/track-download/" + id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an endpoint like '/user/profile' to a full url
|
||||
* @param {string} endpoint
|
||||
*/
|
||||
getEndpointUrl(endpoint) {
|
||||
assertAlways(endpoint.startsWith("/"), "Endpoint must start with '/'");
|
||||
return this.url + endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal fetch helper
|
||||
* @param {string} method
|
||||
* @param {string} endpoint
|
||||
* @param {Object.<string, any>} parameters
|
||||
*/
|
||||
internalFetch(method, endpoint, parameters) {
|
||||
if (G_IS_DEV && globalConfig.debug.alwaysOffline) {
|
||||
return Promise.reject("offline");
|
||||
}
|
||||
|
||||
return (
|
||||
fetch(this.getEndpointUrl(endpoint), {
|
||||
method: method,
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
referrer: "no-referrer",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-App-Version": G_BUILD_VERSION,
|
||||
},
|
||||
credentials: "omit",
|
||||
body: method === "POST" ? JSON_stringify(parameters) : undefined,
|
||||
})
|
||||
// Catch network errors / bad status codes
|
||||
.catch(err => {
|
||||
globalLog(this, "Network error:", err);
|
||||
throw BACKEND_ERRORS.networkError;
|
||||
})
|
||||
|
||||
// Check if the response was good
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(
|
||||
data => {
|
||||
if (data.error) {
|
||||
globalWarn(this, "API request error:", data);
|
||||
throw data.error;
|
||||
}
|
||||
globalWarn(this, "API response has no error payload:", data);
|
||||
throw BACKEND_ERRORS.unknownError;
|
||||
},
|
||||
err => {
|
||||
globalError(this, "API bad json:", err);
|
||||
throw BACKEND_ERRORS.badResponse;
|
||||
}
|
||||
);
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
})
|
||||
|
||||
// JSON parsing
|
||||
.then(response => {
|
||||
try {
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
globalError(this, "API response json parsing error:", err);
|
||||
throw BACKEND_ERRORS.badResponse;
|
||||
}
|
||||
})
|
||||
|
||||
// Check if error flag is set
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
globalWarn(this, "API sent error:", data.error);
|
||||
const str = new String(data.error);
|
||||
// @ts-ignore
|
||||
str.originalError = data;
|
||||
throw str;
|
||||
}
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a request
|
||||
* @param {string} method
|
||||
* @param {string} endpoint
|
||||
* @param {Object.<string, any>=} parameters
|
||||
*/
|
||||
performRequest(method, endpoint, parameters = null) {
|
||||
if (this.runningRequest) {
|
||||
globalWarn(
|
||||
this,
|
||||
"Request to",
|
||||
endpoint,
|
||||
"queried while request to",
|
||||
this.runningRequest,
|
||||
"did not finish yet!"
|
||||
);
|
||||
}
|
||||
assertAlways(method === "POST" || method === "GET", "Invalid mode");
|
||||
|
||||
globalLog(this, "🔗 " + method + " " + endpoint);
|
||||
this.runningRequest = endpoint;
|
||||
|
||||
return this.requestChannel.watch(
|
||||
new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
let timeout = setTimeout(() => {
|
||||
globalWarn("api", "Request to", endpoint, "timed out");
|
||||
timedOut = true;
|
||||
this.runningRequest = null;
|
||||
reject(BACKEND_ERRORS.networkError);
|
||||
}, 30000);
|
||||
const request = this.internalFetch(method, endpoint, parameters);
|
||||
|
||||
request.then(
|
||||
res => {
|
||||
if (timedOut) {
|
||||
globalWarn("api", "Request finished but already timed out");
|
||||
} else {
|
||||
this.runningRequest = null;
|
||||
clearTimeout(timeout);
|
||||
resolve(res);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
if (timedOut) {
|
||||
globalWarn("api", "Request finished with error but already timed out");
|
||||
} else {
|
||||
this.runningRequest = null;
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/js/core/backend_errors.js
Normal file
37
src/js/core/backend_errors.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { T } from "../translations";
|
||||
|
||||
/**
|
||||
* Translates error codes like "invalid-auth-key" into their translations (E.g. "Invalid Auth Key").
|
||||
* If the code is not known or an arbitrary string, returns the code
|
||||
* @param {string} err The error code
|
||||
* @returns {string} Translated code
|
||||
*/
|
||||
export function tryTranslateBackendError(err) {
|
||||
// TODO: Add errors to base-en.yaml
|
||||
// return "<div class='backendError'>" + (T.backend.errors[err] || err) + "</div>";
|
||||
return "<div class='backendError'> ☹" + err + "</div>";
|
||||
}
|
||||
|
||||
export const BACKEND_ERRORS = {
|
||||
// Frontend errors
|
||||
networkError: "network-error",
|
||||
offline: "offline",
|
||||
badResponse: "bad-response",
|
||||
rateLimited: "rate-limited",
|
||||
|
||||
// Backend errors
|
||||
unsupportedVersion: "unsupported-version",
|
||||
unknownError: "unknown-error",
|
||||
unauthenticated: "unauthenticated",
|
||||
invalidRequestSchema: "invalid-request-schema",
|
||||
internalServerError: "internal-server-error",
|
||||
databaseError: "database-error",
|
||||
notFound: "not-found",
|
||||
serverOverloaded: "server-overloaded",
|
||||
|
||||
// Binary checksum stuff
|
||||
failedToDecompress: "failed-to-decompress",
|
||||
checksumMissing: "checksum-missing",
|
||||
checksumMismatch: "checksum-mismatch",
|
||||
failedToParse: "failed-to-parse",
|
||||
};
|
||||
@ -109,6 +109,7 @@ export const globalConfig = {
|
||||
// instantBelts: true,
|
||||
// instantProcessors: true,
|
||||
// instantMiners: true,
|
||||
// alwaysOffline: true,
|
||||
|
||||
// renderForTrailer: true,
|
||||
/* dev:end */
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ReadWriteProxy } from "./read_write_proxy";
|
||||
import { ExplainedResult } from "./explained_result";
|
||||
import { globalError, globalLog, globalWarn } from "./logging";
|
||||
import { globalError, globalWarn } from "./logging";
|
||||
import { ModApi } from "./mod_api";
|
||||
import { queryParamOptions } from "./query_parameters";
|
||||
|
||||
@ -272,10 +272,13 @@ export class ModManager extends ReadWriteProxy {
|
||||
|
||||
this.currentData.mods.push(mod);
|
||||
|
||||
// TODO: Check if this use of trackUiClick is okay.
|
||||
// Track download in the background
|
||||
return this.writeAsync()
|
||||
.then(() => this.app.analytics.trackUiClick("mod/" + id))
|
||||
.then(() =>
|
||||
this.app.api
|
||||
.trackModDownload(id)
|
||||
.catch(err => globalWarn(this, "Failed to track mod download:", err))
|
||||
)
|
||||
.then(() => (this.needsRestart = true))
|
||||
.then(() => null);
|
||||
}
|
||||
|
||||
1
src/js/globals.d.ts
vendored
1
src/js/globals.d.ts
vendored
@ -7,6 +7,7 @@ declare function assertAlways(condition: boolean | object | string, ...errorMess
|
||||
|
||||
declare const abstract: void;
|
||||
|
||||
declare const G_API_ENDPOINT: string;
|
||||
declare const G_APP_ENVIRONMENT: string;
|
||||
declare const G_HAVE_ASSERT: boolean;
|
||||
declare const G_BUILD_TIME: number;
|
||||
|
||||
@ -37,10 +37,10 @@ export class ModsState extends TextualGameState {
|
||||
</span>
|
||||
|
||||
|
||||
<strong class="category_label">${T.mods.installedMods}</strong>
|
||||
<strong class="categoryLabel">${T.mods.installedMods}</strong>
|
||||
<div class="installedMods"></div>
|
||||
|
||||
<strong class="category_label">${T.mods.modsBrowser}</strong>
|
||||
<strong class="categoryLabel">${T.mods.modsBrowser}</strong>
|
||||
<div class="modGallery"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { GameState } from "../core/game_state";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { createLogger, globalError } from "../core/logging";
|
||||
import { findNiceValue, waitNextFrame } from "../core/utils";
|
||||
import { cachebust } from "../core/cachebust";
|
||||
import { PlatformWrapperImplBrowser } from "../platform/browser/wrapper";
|
||||
@ -173,6 +173,21 @@ export class PreloadState extends GameState {
|
||||
});
|
||||
})
|
||||
|
||||
.then(() => this.setStatus("Initializing mods"))
|
||||
.then(() => {
|
||||
return this.app.modManager.initialize().catch(err => {
|
||||
globalError(this, "Failed to initialize mods:", err);
|
||||
return new Promise(resolve => {
|
||||
const { ok } = this.dialogs.showWarning(
|
||||
T.global.error,
|
||||
T.dialogs.modLoadFailDialog.content + "<br><br><b>" + err + "</b>",
|
||||
["ok:good"]
|
||||
);
|
||||
ok.add(resolve);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
.then(() => this.setStatus("Downloading resources"))
|
||||
.then(() => {
|
||||
return this.app.backgroundResourceLoader.getPromiseForBareGame();
|
||||
|
||||
121
src/js/states/sync_savegame_mods.js
Normal file
121
src/js/states/sync_savegame_mods.js
Normal file
@ -0,0 +1,121 @@
|
||||
import { TextualGameState } from "../core/textual_game_state";
|
||||
import { T } from "../translations";
|
||||
import { Savegame } from "../savegame/savegame";
|
||||
|
||||
export class SyncSavegameModsState extends TextualGameState {
|
||||
constructor() {
|
||||
super("SyncSavegameModsState");
|
||||
}
|
||||
|
||||
getMainContentHTML() {
|
||||
return `
|
||||
|
||||
|
||||
<div class="comparisonDiv">
|
||||
<span class="desc">${T.syncSavegameMods.desc}</span>
|
||||
|
||||
<div class="modsComparison">
|
||||
|
||||
<div class="savegameMods modsGroup">
|
||||
<strong>${T.syncSavegameMods.savegameMods}</strong>
|
||||
<div class="modsList">
|
||||
<span>Mymod@1.0.0</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="installedMods modsGroup">
|
||||
<strong>${T.syncSavegameMods.yourMods}</strong>
|
||||
<div class="modsList">
|
||||
<span>Mymod@1.0.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttonDiv">
|
||||
<button class="styledButton cancel">${T.dialogs.buttons.cancel}</button>
|
||||
<button class="styledButton syncMods">${T.syncSavegameMods.syncMods}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getShowDiamonds() {
|
||||
return false;
|
||||
}
|
||||
|
||||
onEnter(payload) {
|
||||
/** @type {Savegame} */
|
||||
const savegame = payload.savegame;
|
||||
const nextStateId = payload.nextStateId;
|
||||
const nextStatePayload = payload.nextStatePayload;
|
||||
|
||||
this.targetMods = savegame.getInstalledMods();
|
||||
|
||||
if (this.app.modManager.needsRestart) {
|
||||
this.containerElement.classList.add("loading");
|
||||
const { cancel, restart } = this.dialogs.showWarning(
|
||||
T.syncSavegameMods.dialog.needs_restart.title,
|
||||
T.syncSavegameMods.dialog.needs_restart.desc,
|
||||
this.app.platformWrapper.getSupportsRestart()
|
||||
? ["cancel:bad", "restart:misc"]
|
||||
: ["cancel:bad"]
|
||||
);
|
||||
cancel.add(() => this.moveToState("MainMenuState"));
|
||||
if (restart) {
|
||||
restart.add(() => this.app.platformWrapper.performRestart());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMods = (mods, targetId) => {
|
||||
const targetElement = this.containerElement.querySelector(targetId);
|
||||
|
||||
const filteredMods = mods.filter(mod => mod.is_game_changing);
|
||||
|
||||
if (filteredMods.length > 0) {
|
||||
targetElement.innerHTML = filteredMods
|
||||
.map(m => "<strong>" + m.name + "@" + m.version + "</strong>")
|
||||
.join("");
|
||||
} else {
|
||||
targetElement.innerHTML = `<span class='noMods'>${T.syncSavegameMods.no_game_changing_mods}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
renderMods(this.app.modManager.getMods(), ".installedMods .modsList");
|
||||
renderMods(savegame.getInstalledMods(), ".savegameMods .modsList");
|
||||
|
||||
this.trackClicks(this.htmlElement.querySelector("button.cancel"), this.onBackButton);
|
||||
this.trackClicks(this.htmlElement.querySelector("button.syncMods"), this.doSyncMods);
|
||||
}
|
||||
|
||||
doSyncMods() {
|
||||
const closeLoading = this.dialogs.showLoadingDialog();
|
||||
|
||||
this.app.modManager.syncModsFromSavegame(this.targetMods).then(
|
||||
() => {
|
||||
closeLoading();
|
||||
const { cancel, restart } = this.dialogs.showWarning(
|
||||
T.syncSavegameMods.dialog_synced.title,
|
||||
T.syncSavegameMods.dialog_synced.desc,
|
||||
this.app.platformWrapper.getSupportsRestart()
|
||||
? ["cancel:bad", "restart:misc"]
|
||||
: ["cancel:bad"]
|
||||
);
|
||||
cancel.add(this.onBackButton, this);
|
||||
if (restart) {
|
||||
restart.add(() => this.app.platformWrapper.performRestart());
|
||||
}
|
||||
},
|
||||
err => {
|
||||
closeLoading();
|
||||
const { ok } = this.dialogs.showWarning(T.global.error, err);
|
||||
ok.add(this.onBackButton, this);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onBackButton() {
|
||||
this.moveToState("SingleplayerOverviewState");
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,12 @@ mods:
|
||||
title: Mod Removed
|
||||
desc: The mod has successfully beeen removed and will get unloaded after a restart.
|
||||
|
||||
syncSavegameMods:
|
||||
desc: You need
|
||||
|
||||
savegameMods: Savegame Mods
|
||||
yourMods: Loaded Mods
|
||||
syncMods: Sync Mods
|
||||
|
||||
demoBanners:
|
||||
# This is the "advertisement" shown in the main menu and other various places
|
||||
@ -243,6 +249,10 @@ dialogs:
|
||||
title: New Marker
|
||||
desc: Give it a meaningful name
|
||||
|
||||
modLoadFailDialog:
|
||||
content: >-
|
||||
Failed to initialize mods:
|
||||
|
||||
ingame:
|
||||
# This is shown in the top left corner and displays useful keybindings in
|
||||
# every situation
|
||||
|
||||
Loading…
Reference in New Issue
Block a user