feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
// Pure orchestration for the ship-class calculator. The calculator
|
||||
// renders three areas — ship design, derived results, planet build — and
|
||||
// supports single-target "goal-seek": the player pins one derived result
|
||||
// and the model back-solves the single input it claims. All numeric math
|
||||
// lives in `pkg/calc` (reached through `Core`); this module only decides
|
||||
// which `Core` call to make, in what order, and how to fold the result
|
||||
// back into the field set. Keeping it a pure function of
|
||||
// `(CalculatorInput, Core)` makes the goal-seek logic unit-testable
|
||||
// without booting WASM or mounting a component.
|
||||
|
||||
import type { Core } from "../../platform/core/index";
|
||||
import {
|
||||
validateShipClassValues,
|
||||
type ShipClassValueInvalidReason,
|
||||
} from "../util/ship-class-validation";
|
||||
|
||||
/** LockableOutputId names every derived result the player may pin. */
|
||||
export type LockableOutputId =
|
||||
| "emptyMass"
|
||||
| "loadedMass"
|
||||
| "speedEmpty"
|
||||
| "speedLoaded"
|
||||
| "attack"
|
||||
| "defense";
|
||||
|
||||
/** ClaimedInput names every input a locked result can back-solve. */
|
||||
export type ClaimedInput = "drive" | "weapons" | "shields" | "cargo" | "load";
|
||||
|
||||
/**
|
||||
* CLAIM_MAP fixes which single input each lockable result back-solves.
|
||||
* The pairing is the natural lever for each result: attack rides on the
|
||||
* weapons block, defence on shields, both speeds on the drive block,
|
||||
* empty mass on the cargo block (the free filler), and loaded mass on the
|
||||
* cargo load.
|
||||
*/
|
||||
export const CLAIM_MAP: Record<LockableOutputId, ClaimedInput> = {
|
||||
emptyMass: "cargo",
|
||||
loadedMass: "load",
|
||||
speedEmpty: "drive",
|
||||
speedLoaded: "drive",
|
||||
attack: "weapons",
|
||||
defense: "shields",
|
||||
};
|
||||
|
||||
export type LoadMode = "empty" | "full" | "custom";
|
||||
|
||||
export interface DesignBlocks {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
export interface CalculatorInput {
|
||||
blocks: DesignBlocks;
|
||||
// Effective tech levels (the caller resolves default vs. override).
|
||||
driveTech: number;
|
||||
weaponsTech: number;
|
||||
shieldsTech: number;
|
||||
cargoTech: number;
|
||||
loadMode: LoadMode;
|
||||
customLoad: number;
|
||||
// The single pinned result, or null when nothing is locked.
|
||||
lock: { output: LockableOutputId; value: number } | null;
|
||||
}
|
||||
|
||||
export interface CalculatorOutputs {
|
||||
emptyMass: number;
|
||||
loadedMass: number;
|
||||
speedEmpty: number;
|
||||
speedLoaded: number;
|
||||
attack: number;
|
||||
defense: number;
|
||||
bombing: number;
|
||||
}
|
||||
|
||||
export interface CalculatorResult {
|
||||
/** Blocks after goal-seek may have overwritten the claimed one. */
|
||||
blocks: DesignBlocks;
|
||||
/** Which input the active lock drove, or null. */
|
||||
computedInput: ClaimedInput | null;
|
||||
/** False when the lock's target cannot be reached. */
|
||||
lockFeasible: boolean;
|
||||
/** Whether the resolved blocks pass the engine value rules. */
|
||||
valuesValid: boolean;
|
||||
valueReason: ShipClassValueInvalidReason | null;
|
||||
/** Resolved cargo load in cargo units. */
|
||||
load: number;
|
||||
cargoCapacity: number;
|
||||
/** Derived results, or null when invalid / no Core. */
|
||||
outputs: CalculatorOutputs | null;
|
||||
}
|
||||
|
||||
function resolveLoad(
|
||||
mode: LoadMode,
|
||||
customLoad: number,
|
||||
cargo: number,
|
||||
cargoTech: number,
|
||||
core: Core,
|
||||
): number {
|
||||
if (mode === "empty") return 0;
|
||||
if (mode === "custom") return customLoad > 0 ? customLoad : 0;
|
||||
return core.cargoCapacity({ cargo, cargoTech });
|
||||
}
|
||||
|
||||
// solveClaimedBlock back-solves the block claimed by a locked result
|
||||
// (everything except a `load` claim, which is resolved with the cargo
|
||||
// load). Returns null when the target is unreachable or the design's
|
||||
// weapons/armament pairing is invalid.
|
||||
function solveClaimedBlock(
|
||||
lock: { output: LockableOutputId; value: number },
|
||||
raw: DesignBlocks,
|
||||
input: CalculatorInput,
|
||||
prelimLoad: number,
|
||||
core: Core,
|
||||
): number | null {
|
||||
switch (lock.output) {
|
||||
case "attack":
|
||||
return core.weaponsForAttack({
|
||||
targetAttack: lock.value,
|
||||
weaponsTech: input.weaponsTech,
|
||||
});
|
||||
case "defense": {
|
||||
const restExclShields = core.emptyMass({ ...raw, shields: 0 });
|
||||
if (restExclShields === null) return null;
|
||||
const carrying = core.carryingMass({
|
||||
load: prelimLoad,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
return core.shieldsForDefence({
|
||||
targetDefence: lock.value,
|
||||
shieldsTech: input.shieldsTech,
|
||||
restMass: restExclShields + carrying,
|
||||
});
|
||||
}
|
||||
case "speedEmpty": {
|
||||
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
|
||||
if (restExclDrive === null) return null;
|
||||
return core.driveForSpeed({
|
||||
targetSpeed: lock.value,
|
||||
driveTech: input.driveTech,
|
||||
restMass: restExclDrive,
|
||||
});
|
||||
}
|
||||
case "speedLoaded": {
|
||||
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
|
||||
if (restExclDrive === null) return null;
|
||||
const carrying = core.carryingMass({
|
||||
load: prelimLoad,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
return core.driveForSpeed({
|
||||
targetSpeed: lock.value,
|
||||
driveTech: input.driveTech,
|
||||
restMass: restExclDrive + carrying,
|
||||
});
|
||||
}
|
||||
case "emptyMass": {
|
||||
const restExclCargo = core.emptyMass({ ...raw, cargo: 0 });
|
||||
if (restExclCargo === null) return null;
|
||||
return core.cargoForEmptyMass({
|
||||
targetEmptyMass: lock.value,
|
||||
restMass: restExclCargo,
|
||||
});
|
||||
}
|
||||
case "loadedMass":
|
||||
// Claims the cargo load, resolved alongside the load below.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* computeCalculator resolves the full calculator state for one input
|
||||
* snapshot: it applies the active goal-seek lock (if any), resolves the
|
||||
* cargo load, validates the blocks, and computes every derived result.
|
||||
* `outputs` is null when no `Core` is available or the blocks are
|
||||
* invalid, mirroring the Phase 18 designer rule of hiding the preview
|
||||
* until the design is sound.
|
||||
*/
|
||||
export function computeCalculator(
|
||||
input: CalculatorInput,
|
||||
core: Core | null,
|
||||
): CalculatorResult {
|
||||
const raw = input.blocks;
|
||||
if (core === null) {
|
||||
return {
|
||||
blocks: raw,
|
||||
computedInput: null,
|
||||
lockFeasible: true,
|
||||
valuesValid: false,
|
||||
valueReason: null,
|
||||
load: 0,
|
||||
cargoCapacity: 0,
|
||||
outputs: null,
|
||||
};
|
||||
}
|
||||
|
||||
const blocks: DesignBlocks = { ...raw };
|
||||
let computedInput: ClaimedInput | null = null;
|
||||
let lockFeasible = true;
|
||||
|
||||
// Preliminary load from the raw cargo, used by solvers that need the
|
||||
// carrying mass (speedLoaded, defence). It matches the final load for
|
||||
// every claim except `emptyMass` (which solves cargo without load) and
|
||||
// `loadedMass` (which solves the load itself).
|
||||
const prelimLoad = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
raw.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
|
||||
if (input.lock !== null) {
|
||||
const claimed = CLAIM_MAP[input.lock.output];
|
||||
if (claimed !== "load") {
|
||||
const solved = solveClaimedBlock(
|
||||
input.lock,
|
||||
raw,
|
||||
input,
|
||||
prelimLoad,
|
||||
core,
|
||||
);
|
||||
if (solved === null) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let load: number;
|
||||
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
||||
const emptyMass = core.emptyMass(blocks);
|
||||
const solvedLoad =
|
||||
emptyMass === null
|
||||
? null
|
||||
: core.loadForFullMass({
|
||||
targetFullMass: input.lock.value,
|
||||
emptyMass,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
if (solvedLoad === null) {
|
||||
lockFeasible = false;
|
||||
load = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
blocks.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
} else {
|
||||
load = solvedLoad;
|
||||
computedInput = "load";
|
||||
}
|
||||
} else {
|
||||
load = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
blocks.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
}
|
||||
|
||||
const valuesValidation = validateShipClassValues(blocks);
|
||||
const valuesValid = valuesValidation.ok;
|
||||
const valueReason = valuesValidation.ok ? null : valuesValidation.reason;
|
||||
const cargoCapacity = core.cargoCapacity({
|
||||
cargo: blocks.cargo,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
|
||||
let outputs: CalculatorOutputs | null = null;
|
||||
if (valuesValid) {
|
||||
const emptyMass = core.emptyMass(blocks);
|
||||
if (emptyMass !== null) {
|
||||
const carrying = core.carryingMass({ load, cargoTech: input.cargoTech });
|
||||
const loadedMass = core.fullMass({ emptyMass, carryingMass: carrying });
|
||||
const driveEffective = core.driveEffective({
|
||||
drive: blocks.drive,
|
||||
driveTech: input.driveTech,
|
||||
});
|
||||
outputs = {
|
||||
emptyMass,
|
||||
loadedMass,
|
||||
speedEmpty: core.speed({ driveEffective, fullMass: emptyMass }),
|
||||
speedLoaded: core.speed({ driveEffective, fullMass: loadedMass }),
|
||||
attack: core.effectiveAttack({
|
||||
weapons: blocks.weapons,
|
||||
weaponsTech: input.weaponsTech,
|
||||
}),
|
||||
defense: core.effectiveDefence({
|
||||
shields: blocks.shields,
|
||||
shieldsTech: input.shieldsTech,
|
||||
fullMass: loadedMass,
|
||||
}),
|
||||
bombing: core.bombingPower({
|
||||
weapons: blocks.weapons,
|
||||
weaponsTech: input.weaponsTech,
|
||||
armament: blocks.armament,
|
||||
number: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
computedInput,
|
||||
lockFeasible,
|
||||
valuesValid,
|
||||
valueReason,
|
||||
load,
|
||||
cargoCapacity,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanetBuildInput {
|
||||
/** The designed ship's empty mass. */
|
||||
shipMass: number;
|
||||
/** Free industrial potential (the "L" parameter, FreeIndustry). */
|
||||
freeIndustry: number;
|
||||
/** Material stockpile (resolved: planet value or the player override). */
|
||||
material: number;
|
||||
/** Planet resources rating. */
|
||||
resources: number;
|
||||
}
|
||||
|
||||
export interface PlanetBuildResult {
|
||||
/** Whole ships plus fractional progress completable this turn. */
|
||||
shipsPerTurn: number;
|
||||
wholeShips: number;
|
||||
progress: number;
|
||||
/** Turns to finish one ship, or null when none can be produced. */
|
||||
turnsPerShip: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* computePlanetBuild folds one turn of ship production into the headline
|
||||
* "ships per turn" and "turns per ship" the planet area shows. It assumes
|
||||
* the planet keeps building this ship at the current (or overridden) MAT;
|
||||
* the realistic multi-turn forecast with population growth and CAP/COL
|
||||
* supply lands in Phase 34. Returns null without a `Core`.
|
||||
*/
|
||||
export function computePlanetBuild(
|
||||
input: PlanetBuildInput,
|
||||
core: Core | null,
|
||||
): PlanetBuildResult | null {
|
||||
if (core === null) return null;
|
||||
if (input.shipMass <= 0 || input.freeIndustry <= 0) {
|
||||
return { shipsPerTurn: 0, wholeShips: 0, progress: 0, turnsPerShip: null };
|
||||
}
|
||||
const r = core.produceShipsInTurn({
|
||||
productionAvailable: input.freeIndustry,
|
||||
material: input.material,
|
||||
resources: input.resources,
|
||||
shipMass: input.shipMass,
|
||||
});
|
||||
const shipsPerTurn = r.ships + r.progress;
|
||||
return {
|
||||
shipsPerTurn,
|
||||
wholeShips: r.ships,
|
||||
progress: r.progress,
|
||||
turnsPerShip: shipsPerTurn > 0 ? 1 / shipsPerTurn : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Shared signal that asks the sidebar calculator to open and load a ship
|
||||
// class. The ship-classes table (row activation, "new" button) and the
|
||||
// mobile bottom-tabs entry publish a request here; the in-game layout
|
||||
// watches it to flip the sidebar to the calculator tab, and the
|
||||
// calculator watches it to load the requested class. A module singleton
|
||||
// keeps these siblings decoupled, mirroring `reach.svelte`.
|
||||
//
|
||||
// `token` increments on every request so a repeat request for the same
|
||||
// class still re-triggers the watchers; each watcher records the last
|
||||
// token it handled to act exactly once per request.
|
||||
|
||||
class CalculatorLoadRequest {
|
||||
/** The class name to load, or null to start a fresh design. */
|
||||
name: string | null = $state(null);
|
||||
token = $state(0);
|
||||
|
||||
request(name: string | null): void {
|
||||
this.name = name;
|
||||
this.token += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export const calculatorLoadRequest = new CalculatorLoadRequest();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Shared bridge between the ship-class calculator (sidebar) and the map
|
||||
// view: the calculator publishes the selected planet's origin and the
|
||||
// current design's loaded speed here, and the map reads it to draw reach
|
||||
// circles. A module singleton keeps the two siblings decoupled — neither
|
||||
// imports the other — and survives sidebar tab switches. The store is
|
||||
// cleared whenever the calculator has no valid design or no selected
|
||||
// planet, which makes the map drop the rings.
|
||||
|
||||
class ReachStore {
|
||||
origin: { x: number; y: number } | null = $state(null);
|
||||
speedPerTurn = $state(0);
|
||||
|
||||
set(origin: { x: number; y: number }, speedPerTurn: number): void {
|
||||
this.origin = origin;
|
||||
this.speedPerTurn = speedPerTurn;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.origin = null;
|
||||
this.speedPerTurn = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const reachStore = new ReachStore();
|
||||
@@ -0,0 +1,184 @@
|
||||
<!--
|
||||
Reusable "Ship Class design area": the five design blocks (drive,
|
||||
armament, weapons, shields, cargo) plus the four tech levels they are
|
||||
built with. Each tech defaults to the player's current level and shows a
|
||||
lock icon once overridden; clicking the lock resets it. A block claimed
|
||||
by an active goal-seek lock renders read-only with its own lock marker.
|
||||
The component is presentational — the parent owns the state and the
|
||||
calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { ClaimedInput } from "./calc-model";
|
||||
|
||||
export interface DesignBlocksState {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
export interface TechState {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
export type TechKey = keyof TechState;
|
||||
|
||||
type Props = {
|
||||
blocks: DesignBlocksState;
|
||||
// Blocks after goal-seek: the claimed block carries its solved
|
||||
// value, which is what the read-only computed cell displays.
|
||||
resolved: DesignBlocksState;
|
||||
techs: TechState;
|
||||
techOverridden: Record<TechKey, boolean>;
|
||||
computedInput?: ClaimedInput | null;
|
||||
blocksReadonly?: boolean;
|
||||
onTechInput: (key: TechKey) => void;
|
||||
onResetTech: (key: TechKey) => void;
|
||||
};
|
||||
let {
|
||||
blocks = $bindable(),
|
||||
resolved,
|
||||
techs = $bindable(),
|
||||
techOverridden,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
onTechInput,
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
}[] = [
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="design" data-testid="calculator-design-area">
|
||||
<div class="cols">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
|
||||
</div>
|
||||
{#each BLOCK_ROWS as row (row.key)}
|
||||
{@const isComputed = computedInput === row.key}
|
||||
<div class="row">
|
||||
<span class="label">{row.label()}</span>
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
type="number"
|
||||
step={row.step}
|
||||
readonly
|
||||
value={resolved[row.key]}
|
||||
data-computed="true"
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
type="number"
|
||||
step={row.step}
|
||||
min="0"
|
||||
bind:value={blocks[row.key]}
|
||||
readonly={blocksReadonly}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
/>
|
||||
{/if}
|
||||
{#if row.tech !== null}
|
||||
{@const techKey = row.tech}
|
||||
<span class="tech-cell">
|
||||
<input
|
||||
class="tech"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
bind:value={techs[techKey]}
|
||||
oninput={() => onTechInput(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
/>
|
||||
{#if techOverridden[techKey]}
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.tech.reset")}
|
||||
aria-label={i18n.t("game.calculator.tech.reset")}
|
||||
data-testid={`calculator-tech-reset-${techKey}`}
|
||||
onclick={() => onResetTech(techKey)}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.design {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.cols,
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.col-head {
|
||||
color: #8890b0;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.label {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
input {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[data-computed="true"],
|
||||
input[readonly] {
|
||||
color: #9fb0ff;
|
||||
background: #11162a;
|
||||
}
|
||||
.tech-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user