Add dock hub with missions trade and shipyard
This commit is contained in:
parent
2488de8d96
commit
364a20e9b2
@ -132,7 +132,7 @@
|
||||
</div>
|
||||
<button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button>
|
||||
<button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button>
|
||||
<button class="mobile-control ship-action buy" data-action="open-shipyard" aria-label="Open shipyard">SHIPYARD</button>
|
||||
<button class="mobile-control ship-action buy" data-action="open-dock" aria-label="Dock at port">DOCK</button>
|
||||
<button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import './game-v4.js';
|
||||
import './mobile-hud.js';
|
||||
import './dock-ui.js';
|
||||
import './shipyard-ui.js';
|
||||
|
||||
178
src/game-v4.js
178
src/game-v4.js
@ -43,6 +43,16 @@ import {
|
||||
upgradePathForSlot,
|
||||
} from './progression/ship-loadout.js';
|
||||
import { MODULE_CATALOG } from './progression/modules.js';
|
||||
import {
|
||||
acceptMission,
|
||||
buyTradeGood,
|
||||
completeMission,
|
||||
marketAtPort,
|
||||
missionOffersAtPort,
|
||||
missionStatus,
|
||||
sellTradeGood,
|
||||
tradeCargoUnits,
|
||||
} from './progression/dock-economy.js';
|
||||
import {
|
||||
createMerchantTrafficPlans,
|
||||
merchantPointOnRoute,
|
||||
@ -106,7 +116,9 @@ class GameScene extends Phaser.Scene {
|
||||
this.progression = createStarterProgression();
|
||||
this.playerStats = deriveShipStats(this.progression.loadout);
|
||||
this.pendingMobileActions = new Set();
|
||||
this.pendingActionDetails = new Map();
|
||||
this.pendingModulePurchaseId = null;
|
||||
this.dockOpen = false;
|
||||
this.shipyardOpen = false;
|
||||
this.playerSpeed = 0;
|
||||
this.playerHull = this.playerStats.maxHull;
|
||||
@ -230,6 +242,7 @@ class GameScene extends Phaser.Scene {
|
||||
this.handleMobileAction = (event) => {
|
||||
const action = String(event.detail?.action || '');
|
||||
if (!action) return;
|
||||
this.pendingActionDetails.set(action, { ...(event.detail || {}) });
|
||||
if (action === 'purchase-module') {
|
||||
this.pendingModulePurchaseId = String(event.detail?.moduleId || '');
|
||||
}
|
||||
@ -550,39 +563,76 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
const { port, range } = nearestPort(this.world, this.player.x, this.player.y);
|
||||
const docked = Boolean(port && range <= 230);
|
||||
const openRequested = this.pendingMobileActions.delete('open-shipyard')
|
||||
|| Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade);
|
||||
const closeRequested = this.pendingMobileActions.delete('close-shipyard')
|
||||
|| Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
|
||||
const purchaseRequested = this.pendingMobileActions.delete('purchase-module');
|
||||
const mobileFit = this.pendingMobileActions.delete('fit-salvage');
|
||||
const scrapSalvage = this.pendingMobileActions.delete('scrap-salvage');
|
||||
const takeAction = (action) => {
|
||||
const requested = this.pendingMobileActions.delete(action);
|
||||
const detail = this.pendingActionDetails.get(action) || null;
|
||||
if (requested) this.pendingActionDetails.delete(action);
|
||||
return requested ? (detail || {}) : null;
|
||||
};
|
||||
|
||||
if (closeRequested && this.shipyardOpen) {
|
||||
const openDockAction = takeAction('open-dock');
|
||||
const openShipyardAction = takeAction('open-shipyard');
|
||||
const closeShipyardAction = takeAction('close-shipyard');
|
||||
const closeDockAction = takeAction('close-dock');
|
||||
const purchaseAction = takeAction('purchase-module');
|
||||
const fitAction = takeAction('fit-salvage');
|
||||
const scrapAction = takeAction('scrap-salvage');
|
||||
const buyTradeAction = takeAction('buy-trade-good');
|
||||
const sellTradeAction = takeAction('sell-trade-good');
|
||||
const acceptMissionAction = takeAction('accept-mission');
|
||||
const completeMissionAction = takeAction('complete-mission');
|
||||
|
||||
const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade);
|
||||
const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
|
||||
|
||||
if ((closeShipyardAction || (keyboardEscape && this.shipyardOpen)) && this.shipyardOpen) {
|
||||
this.shipyardOpen = false;
|
||||
this.pendingModulePurchaseId = null;
|
||||
window.dispatchEvent(new CustomEvent('airship:shipyard-close'));
|
||||
window.dispatchEvent(new CustomEvent('airship:dock-return', {
|
||||
detail: this.buildHudState(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (openRequested) {
|
||||
if ((closeDockAction || (keyboardEscape && this.dockOpen && !this.shipyardOpen)) && this.dockOpen) {
|
||||
this.shipyardOpen = false;
|
||||
this.dockOpen = false;
|
||||
this.pendingModulePurchaseId = null;
|
||||
window.dispatchEvent(new CustomEvent('airship:shipyard-close'));
|
||||
window.dispatchEvent(new CustomEvent('airship:dock-close'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (openDockAction || keyboardDock) {
|
||||
if (!docked) {
|
||||
this.status.setText('GET WITHIN 230m OF A PORT TO ENTER SHIPYARD');
|
||||
this.status.setText('GET WITHIN 230m OF A PORT TO DOCK');
|
||||
this.time.delayedCall(1400, () => {
|
||||
if (this.gameState === 'playing') this.status.setText('');
|
||||
});
|
||||
} else {
|
||||
this.shipyardOpen = true;
|
||||
this.dockOpen = true;
|
||||
this.shipyardOpen = false;
|
||||
this.playerSpeed = 0;
|
||||
this.player.setVelocity(0, 0);
|
||||
window.dispatchEvent(new CustomEvent('airship:shipyard-open', {
|
||||
window.dispatchEvent(new CustomEvent('airship:dock-open', {
|
||||
detail: this.buildHudState(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchaseRequested) {
|
||||
const moduleId = this.pendingModulePurchaseId;
|
||||
if (openShipyardAction && this.dockOpen && docked && !this.shipyardOpen) {
|
||||
this.shipyardOpen = true;
|
||||
window.dispatchEvent(new CustomEvent('airship:dock-hide'));
|
||||
window.dispatchEvent(new CustomEvent('airship:shipyard-open', {
|
||||
detail: this.buildHudState(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchaseAction) {
|
||||
const moduleId = String(purchaseAction.moduleId || this.pendingModulePurchaseId || '');
|
||||
this.pendingModulePurchaseId = null;
|
||||
if (!this.shipyardOpen || !docked || !moduleId) return;
|
||||
|
||||
@ -607,7 +657,7 @@ class GameScene extends Phaser.Scene {
|
||||
}));
|
||||
}
|
||||
|
||||
if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) {
|
||||
if (fitAction || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) {
|
||||
if (!this.shipyardOpen || !docked) {
|
||||
this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD');
|
||||
} else {
|
||||
@ -630,7 +680,7 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
if (scrapSalvage) {
|
||||
if (scrapAction) {
|
||||
if (!this.shipyardOpen || !docked) {
|
||||
this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD');
|
||||
} else {
|
||||
@ -648,6 +698,68 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dockOpen && docked && !this.shipyardOpen) {
|
||||
const market = marketAtPort(this.world, port);
|
||||
|
||||
if (buyTradeAction) {
|
||||
const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId);
|
||||
this.progression = buyTradeGood(this.progression, item, this.playerStats, 1);
|
||||
this.status.setText(
|
||||
this.progression.traded
|
||||
? `BOUGHT ${item?.name || 'CARGO'} // ¢${this.progression.credits}`
|
||||
: this.progression.reason === 'cargo-full'
|
||||
? 'CARGO HOLD FULL'
|
||||
: 'NOT ENOUGH COIN',
|
||||
);
|
||||
}
|
||||
|
||||
if (sellTradeAction) {
|
||||
const item = market.find(({ goodId }) => goodId === sellTradeAction.goodId);
|
||||
this.progression = sellTradeGood(this.progression, item, 1);
|
||||
this.status.setText(
|
||||
this.progression.traded
|
||||
? `SOLD ${item?.name || 'CARGO'} // ¢${this.progression.credits}`
|
||||
: 'NONE OF THAT CARGO IN HOLD',
|
||||
);
|
||||
}
|
||||
|
||||
if (acceptMissionAction) {
|
||||
const offers = missionOffersAtPort(this.world, port, this.defeated);
|
||||
const mission = offers.find(({ id }) => id === acceptMissionAction.missionId);
|
||||
this.progression = acceptMission(
|
||||
this.progression,
|
||||
mission,
|
||||
this.playerStats,
|
||||
this.defeated,
|
||||
);
|
||||
this.status.setText(
|
||||
this.progression.missionAccepted
|
||||
? `MISSION ACCEPTED\n${mission?.title || ''}`
|
||||
: this.progression.reason === 'cargo-full'
|
||||
? 'NOT ENOUGH CARGO SPACE'
|
||||
: 'FINISH CURRENT MISSION FIRST',
|
||||
);
|
||||
}
|
||||
|
||||
if (completeMissionAction) {
|
||||
const activeBefore = this.progression.activeMission;
|
||||
this.progression = completeMission(this.progression, {
|
||||
portId: port.id,
|
||||
defeated: this.defeated,
|
||||
});
|
||||
this.status.setText(
|
||||
this.progression.missionCompleted
|
||||
? `MISSION COMPLETE +¢${activeBefore?.reward || 0}`
|
||||
: 'MISSION NOT COMPLETE YET',
|
||||
);
|
||||
}
|
||||
|
||||
if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction) {
|
||||
window.dispatchEvent(new CustomEvent('airship:dock-state', {
|
||||
detail: this.buildHudState(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEnvironmentPhysics() {
|
||||
@ -1230,7 +1342,7 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.updateProgressionInteraction();
|
||||
if (this.shipyardOpen) {
|
||||
if (this.dockOpen) {
|
||||
this.player.setVelocity(0, 0);
|
||||
const hudState = this.buildHudState();
|
||||
if (!this.mobileHudActive) this.drawHud(hudState);
|
||||
@ -1707,6 +1819,15 @@ class GameScene extends Phaser.Scene {
|
||||
upgradePathForSlot(this.progression.loadout, slot, marketStock),
|
||||
]),
|
||||
);
|
||||
const dockMarket = docked ? marketAtPort(this.world, portState.port) : [];
|
||||
const cargoUsed = tradeCargoUnits(this.progression);
|
||||
const missionOffers = docked
|
||||
? missionOffersAtPort(this.world, portState.port, this.defeated)
|
||||
: [];
|
||||
const activeMission = missionStatus(this.progression, {
|
||||
portId: docked ? portState.port?.id : null,
|
||||
defeated: this.defeated,
|
||||
});
|
||||
|
||||
return {
|
||||
gameState: this.gameState,
|
||||
@ -1741,6 +1862,28 @@ class GameScene extends Phaser.Scene {
|
||||
losses: this.tradeLosses,
|
||||
playerLosses: this.playerTradeLosses,
|
||||
},
|
||||
dock: {
|
||||
open: this.dockOpen,
|
||||
shipyardOpen: this.shipyardOpen,
|
||||
portId: docked ? portState.port?.id : null,
|
||||
portName: docked ? portState.port?.name : null,
|
||||
portType: docked ? portState.port?.type : null,
|
||||
portExports: docked ? portState.port?.exports || [] : [],
|
||||
cargoUsed,
|
||||
cargoCapacity: this.playerStats?.cargoCapacity || 0,
|
||||
market: dockMarket.map((item) => {
|
||||
const held = this.progression.tradeCargo?.[item.goodId] || 0;
|
||||
return {
|
||||
...item,
|
||||
held,
|
||||
canBuy: (this.progression.credits || 0) >= item.buyPrice
|
||||
&& cargoUsed < (this.playerStats?.cargoCapacity || 0),
|
||||
canSell: held > 0,
|
||||
};
|
||||
}),
|
||||
missionOffers,
|
||||
activeMission,
|
||||
},
|
||||
progression: {
|
||||
credits: this.progression?.credits || 0,
|
||||
scrap: this.progression?.scrap || 0,
|
||||
@ -1748,6 +1891,7 @@ class GameScene extends Phaser.Scene {
|
||||
nearestPort: portState.port?.name || null,
|
||||
nearestPortRange: portState.range,
|
||||
docked,
|
||||
dockOpen: this.dockOpen,
|
||||
shipyardOpen: this.shipyardOpen,
|
||||
shipStats: { ...this.playerStats },
|
||||
loadout: { ...this.progression.loadout },
|
||||
|
||||
@ -805,9 +805,14 @@ if (!isDesktopPointer.matches) {
|
||||
buyButton.hidden = !progression.docked;
|
||||
buyButton.disabled = !progression.docked;
|
||||
const salvageCount = progression.inventoryCount || 0;
|
||||
buyButton.textContent = salvageCount > 0
|
||||
? `SHIPYARD\nSALVAGE ${salvageCount}`
|
||||
: 'SHIPYARD';
|
||||
const missionActive = Boolean(state.dock?.activeMission);
|
||||
const badges = [
|
||||
missionActive ? 'MISSION' : '',
|
||||
salvageCount > 0 ? `SALVAGE ${salvageCount}` : '',
|
||||
].filter(Boolean);
|
||||
buyButton.textContent = badges.length
|
||||
? `DOCK\n${badges.join(' • ')}`
|
||||
: 'DOCK';
|
||||
}
|
||||
|
||||
const statusText = String(state.status || '').trim();
|
||||
|
||||
@ -18,6 +18,8 @@ export const createStarterProgression = () => ({
|
||||
scrap: 0,
|
||||
loadout: { ...STARTER_LOADOUT },
|
||||
inventory: [],
|
||||
tradeCargo: {},
|
||||
activeMission: null,
|
||||
});
|
||||
|
||||
export const deriveShipStats = (loadout = STARTER_LOADOUT) => {
|
||||
|
||||
87
tests/dock-economy.test.mjs
Normal file
87
tests/dock-economy.test.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { generateWorld } from '../src/world/world-generator.js';
|
||||
import { createStarterProgression, deriveShipStats } from '../src/progression/ship-loadout.js';
|
||||
import {
|
||||
acceptMission,
|
||||
buyTradeGood,
|
||||
completeMission,
|
||||
marketAtPort,
|
||||
missionOffersAtPort,
|
||||
missionStatus,
|
||||
sellTradeGood,
|
||||
tradeCargoUnits,
|
||||
} from '../src/progression/dock-economy.js';
|
||||
|
||||
test('port markets are deterministic and expose buy/sell prices', () => {
|
||||
const world = generateWorld({ seed: 'DOCK-MARKET' });
|
||||
const port = world.ports[0];
|
||||
const a = marketAtPort(world, port);
|
||||
const b = marketAtPort(world, port);
|
||||
assert.deepEqual(a, b);
|
||||
assert.ok(a.length >= 4);
|
||||
assert.ok(a.every((item) => item.buyPrice > item.sellPrice));
|
||||
});
|
||||
|
||||
test('trade cargo respects capacity and selling returns coin', () => {
|
||||
const world = generateWorld({ seed: 'DOCK-TRADE' });
|
||||
const market = marketAtPort(world, world.ports[0]);
|
||||
const item = market[0];
|
||||
const starter = { ...createStarterProgression(), credits: 2000 };
|
||||
const stats = deriveShipStats(starter.loadout);
|
||||
const bought = buyTradeGood(starter, item, stats);
|
||||
assert.equal(bought.traded, true);
|
||||
assert.equal(tradeCargoUnits(bought), 1);
|
||||
assert.ok(bought.credits < starter.credits);
|
||||
|
||||
const sold = sellTradeGood(bought, item);
|
||||
assert.equal(sold.traded, true);
|
||||
assert.equal(tradeCargoUnits(sold), 0);
|
||||
assert.ok(sold.credits > bought.credits);
|
||||
});
|
||||
|
||||
test('delivery missions follow generated trade routes and complete at destination', () => {
|
||||
const world = generateWorld({ seed: 'DOCK-MISSIONS' });
|
||||
const origin = world.ports[0];
|
||||
const offers = missionOffersAtPort(world, origin, 0);
|
||||
const delivery = offers.find(({ type }) => type === 'delivery');
|
||||
assert.ok(delivery);
|
||||
assert.ok(world.tradeRoutes.some((route) =>
|
||||
(route.fromPortId === origin.id && route.toPortId === delivery.targetPortId)
|
||||
|| (route.toPortId === origin.id && route.fromPortId === delivery.targetPortId)
|
||||
));
|
||||
|
||||
const starter = createStarterProgression();
|
||||
const stats = deriveShipStats(starter.loadout);
|
||||
const accepted = acceptMission(starter, delivery, stats, 0);
|
||||
assert.equal(accepted.missionAccepted, true);
|
||||
assert.equal(missionStatus(accepted, { portId: origin.id, defeated: 0 }).ready, false);
|
||||
|
||||
const completed = completeMission(accepted, {
|
||||
portId: delivery.targetPortId,
|
||||
defeated: 0,
|
||||
});
|
||||
assert.equal(completed.missionCompleted, true);
|
||||
assert.equal(completed.activeMission, null);
|
||||
assert.equal(completed.credits, starter.credits + delivery.reward);
|
||||
});
|
||||
|
||||
test('bounty missions advance from actual pirate kill count', () => {
|
||||
const world = generateWorld({ seed: 'DOCK-BOUNTY' });
|
||||
const offers = missionOffersAtPort(world, world.ports[0], 5);
|
||||
const bounty = offers.find(({ type }) => type === 'bounty');
|
||||
const starter = createStarterProgression();
|
||||
const stats = deriveShipStats(starter.loadout);
|
||||
const accepted = acceptMission(starter, bounty, stats, 5);
|
||||
const pending = missionStatus(accepted, {
|
||||
portId: world.ports[0].id,
|
||||
defeated: 5 + bounty.targetKills - 1,
|
||||
});
|
||||
assert.equal(pending.ready, false);
|
||||
const ready = missionStatus(accepted, {
|
||||
portId: world.ports[0].id,
|
||||
defeated: 5 + bounty.targetKills,
|
||||
});
|
||||
assert.equal(ready.ready, true);
|
||||
});
|
||||
@ -263,21 +263,25 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
|
||||
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
||||
});
|
||||
|
||||
test('mobile controls route salvage management through the docked shipyard', async () => {
|
||||
const [game, hud, index] = await Promise.all([
|
||||
test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => {
|
||||
const [game, hud, index, entry, dockUi] = await Promise.all([
|
||||
read('src/game-v4.js'),
|
||||
read('src/mobile-hud.js'),
|
||||
read('index.html'),
|
||||
read('src/game-entry.js'),
|
||||
read('src/dock-ui.js'),
|
||||
]);
|
||||
|
||||
assert.match(index, /data-action="open-shipyard"/);
|
||||
assert.doesNotMatch(index, /data-action="fit-salvage"/);
|
||||
assert.match(index, /CustomEvent\('airship:action'/);
|
||||
assert.match(game, /this\.pendingMobileActions\.delete\('open-shipyard'\)/);
|
||||
assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/);
|
||||
assert.match(game, /this\.pendingMobileActions\.delete\('scrap-salvage'\)/);
|
||||
assert.match(hud, /buyButton\.textContent = salvageCount > 0/);
|
||||
assert.doesNotMatch(hud, /const fitButton = root\.querySelector/);
|
||||
assert.match(index, /data-action="open-dock"/);
|
||||
assert.match(entry, /import '.\/dock-ui\.js'/);
|
||||
assert.match(game, /this\.pendingMobileActions\.delete\('open-dock'\)/);
|
||||
assert.match(game, /this\.dockOpen = true/);
|
||||
assert.match(game, /if \(this\.dockOpen\)/);
|
||||
assert.match(dockUi, /MISSIONS/);
|
||||
assert.match(dockUi, /TRADE/);
|
||||
assert.match(dockUi, /SHIPYARD/);
|
||||
assert.match(dockUi, /dispatchAction\('open-shipyard'\)/);
|
||||
assert.match(hud, /buyButton\.textContent = badges\.length/);
|
||||
});
|
||||
|
||||
test('shipyard overlay uses supplied component art and explicit module purchases', async () => {
|
||||
@ -305,12 +309,36 @@ test('shipyard overlay uses supplied component art and explicit module purchases
|
||||
assert.match(game, /marketForPort\(port\)/);
|
||||
assert.match(game, /airship:shipyard-state/);
|
||||
assert.match(game, /this\.shipyardOpen = true/);
|
||||
assert.match(game, /airship:dock-hide/);
|
||||
assert.match(game, /airship:dock-return/);
|
||||
assert.match(modules, /'reinforced-rudder'/);
|
||||
assert.match(modules, /price: 800/);
|
||||
assert.match(modules, /price: 600/);
|
||||
assert.match(modules, /price: 400/);
|
||||
});
|
||||
|
||||
test('dock trade and missions are driven by generated port economy state', async () => {
|
||||
const [game, economy, dockUi] = await Promise.all([
|
||||
read('src/game-v4.js'),
|
||||
read('src/progression/dock-economy.js'),
|
||||
read('src/dock-ui.js'),
|
||||
]);
|
||||
|
||||
assert.match(game, /marketAtPort\(this\.world, port\)/);
|
||||
assert.match(game, /missionOffersAtPort\(this\.world, port, this\.defeated\)/);
|
||||
assert.match(game, /buyTradeGood\(/);
|
||||
assert.match(game, /sellTradeGood\(/);
|
||||
assert.match(game, /acceptMission\(/);
|
||||
assert.match(game, /completeMission\(/);
|
||||
assert.match(game, /dock: \{/);
|
||||
assert.match(economy, /connectedRoutes\(world, port\.id\)/);
|
||||
assert.match(economy, /port\.exports/);
|
||||
assert.match(dockUi, /buy-trade-good/);
|
||||
assert.match(dockUi, /sell-trade-good/);
|
||||
assert.match(dockUi, /accept-mission/);
|
||||
assert.match(dockUi, /complete-mission/);
|
||||
});
|
||||
|
||||
test('mobile merchant traffic uses a reduced render budget', async () => {
|
||||
const source = await read('src/game-v4.js');
|
||||
assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user