Prototype two-player four-faction setup
This commit is contained in:
1 parent
70ef142577
commit
24255905ed
28 files changed
+1091
-73
No files matched your search
+87
-2
@@ -1,3 +1,5 @@
|
||||
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
|
||||
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -1406,6 +1408,8 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
|
||||
twoPlayerControllers?: TwoPlayerControllers;
|
||||
/** Explicit final turn after an agreed extension; absent means turn ten. */
|
||||
victoryFinalTurn?: number;
|
||||
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */
|
||||
@@ -5675,7 +5679,32 @@ export function createGame(
|
||||
aid: {},
|
||||
};
|
||||
}
|
||||
export function joinGame(g: Game, p: Player) {
|
||||
function twoPlayerRule<T>(operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (error instanceof TwoPlayerControllersError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject stale or ambiguous ownership before either projecting or mutating a game. */
|
||||
function twoPlayerControllersIntegrity(g: Game): void {
|
||||
if (g.twoPlayerControllers === undefined) return;
|
||||
const seats = g.players.map(player => player.id);
|
||||
twoPlayerRule(() => {
|
||||
if (g.status === 'lobby') validateTwoPlayerControllersLobby(g.twoPlayerControllers, seats);
|
||||
else validateTwoPlayerControllersComplete(g.twoPlayerControllers, seats);
|
||||
});
|
||||
const assigned = g.twoPlayerControllers.controllers.flatMap(controller => controller.seats);
|
||||
requireRule(assigned.length === seats.length && seats.every(seat => assigned.includes(seat)),
|
||||
'Every two-person faction must belong to exactly one controller.');
|
||||
requireRule(g.players.every(player => !player.bot),
|
||||
'Two-person setup requires human faction seats.');
|
||||
}
|
||||
|
||||
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
|
||||
twoPlayerControllersIntegrity(g);
|
||||
requireRule(g.status === 'lobby', 'This game has already started.');
|
||||
requireRule(g.players.length < 6, 'This table has six players.');
|
||||
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
|
||||
@@ -5688,11 +5717,31 @@ export function joinGame(g: Game, p: Player) {
|
||||
g.expansions.includes(faction(p.faction).expansion),
|
||||
'The host has not enabled that expansion.',
|
||||
);
|
||||
let mapping = g.twoPlayerControllers;
|
||||
if (mapping) {
|
||||
requireRule(!p.bot, 'Two-person setup requires human faction seats.');
|
||||
mapping = twoPlayerRule(() => addTwoPlayerControlledSeat(mapping!,
|
||||
[...g.players.map(player => player.id), p.id], controllerId ?? p.id, p.id));
|
||||
} else requireRule(controllerId === undefined, 'Enable two-person setup before adding a second faction.');
|
||||
g.players.push(p);
|
||||
if (mapping) g.twoPlayerControllers = mapping;
|
||||
g.playerPositions = normalizedPlayerPositions(g);
|
||||
g.players.forEach((x) => (x.ready = !!x.bot));
|
||||
log(g, `${p.name} joined as ${faction(p.faction).name}.`);
|
||||
}
|
||||
export function joinGame(g: Game, p: Player) {
|
||||
joinFactionSeat(g, p);
|
||||
}
|
||||
|
||||
/** The caller must authenticate actorId's distinct seat credential separately. */
|
||||
export function joinControlledFaction(g: Game, p: Player, actorId: string) {
|
||||
twoPlayerControllersIntegrity(g);
|
||||
requireRule(g.status === 'lobby' && g.twoPlayerControllers,
|
||||
'Enable two-person setup before adding a second faction.');
|
||||
const controller = g.twoPlayerControllers.controllers.find(entry => entry.seats.includes(actorId));
|
||||
requireRule(controller, 'This faction has no two-person controller.');
|
||||
joinFactionSeat(g, p, controller.id);
|
||||
}
|
||||
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
|
||||
if (knowledge === undefined) delete g.movementDiscardKnowledge;
|
||||
else g.movementDiscardKnowledge = knowledge;
|
||||
@@ -10285,13 +10334,17 @@ function requireFreshBaseRuntime(g: Game) {
|
||||
// unowned optional components are checked separately; gameplay fields stay fresh.
|
||||
const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions',
|
||||
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
|
||||
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy']);
|
||||
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy',
|
||||
'twoPlayerControllers']);
|
||||
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
|
||||
requireRule(keys.every(key => configuration.has(key) ||
|
||||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
|
||||
'Starting a base table cannot retain existing game resources, histories or continuations.');
|
||||
}
|
||||
function start(g: Game, advancedPreview = false): Game {
|
||||
twoPlayerControllersIntegrity(g);
|
||||
if (g.twoPlayerControllers) twoPlayerRule(() =>
|
||||
validateTwoPlayerControllersComplete(g.twoPlayerControllers, g.players.map(player => player.id)));
|
||||
requireRule(g.players.length >= 2, 'At least two players are needed.');
|
||||
requireRule(
|
||||
g.players.every((p) => p.ready),
|
||||
@@ -39867,6 +39920,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
@@ -40482,6 +40536,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
if (state.victoryContinuation) return state;
|
||||
bidPromiseIntegrity(state);
|
||||
@@ -42808,6 +42863,29 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (g.status === 'lobby') {
|
||||
if (t === 'twoPlayerMode') {
|
||||
requireRule(id === g.host, 'Only the host can change two-person setup.');
|
||||
requireRule(typeof action.enabled === 'boolean' &&
|
||||
Object.keys(action).every(key => key === 'type' || key === 'enabled'),
|
||||
'Choose whether each of two people controls two factions.');
|
||||
if (action.enabled === !!g.twoPlayerControllers) return g;
|
||||
if (action.enabled) {
|
||||
requireRule(g.players.length <= 2 && g.players.every(player => !player.bot),
|
||||
'Enable two-person setup with at most two human faction seats and no AI seats.');
|
||||
const seats = g.players.map(player => player.id);
|
||||
let mapping = createTwoPlayerControllers();
|
||||
for (const seat of seats) mapping = twoPlayerRule(() =>
|
||||
addTwoPlayerControlledSeat(mapping, seats, seat, seat));
|
||||
g.twoPlayerControllers = mapping;
|
||||
} else {
|
||||
requireRule(g.twoPlayerControllers!.controllers.every(controller => controller.seats.length === 1),
|
||||
'Two-person setup cannot be disabled after second faction seats are added.');
|
||||
delete g.twoPlayerControllers;
|
||||
}
|
||||
g.players.forEach(player => (player.ready = false));
|
||||
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
|
||||
return g;
|
||||
}
|
||||
if (t === 'rules') {
|
||||
requireRule(id === g.host, 'Only the host can change the rules mode.');
|
||||
requireRule(typeof action.advanced === 'boolean' &&
|
||||
@@ -44690,6 +44768,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
@@ -44988,6 +45067,12 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
version: g.version,
|
||||
host: g.host,
|
||||
status: g.status,
|
||||
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
||||
controlledFactionSeats: g.twoPlayerControllers
|
||||
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
|
||||
controller.seats.includes(id) && controller.seats.includes(player.id)))
|
||||
.map(player => ({ id: player.id, name: player.name, faction: player.faction }))
|
||||
: [],
|
||||
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
|
||||
+22
-1
@@ -4439,7 +4439,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
|
||||
steps: [
|
||||
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
|
||||
'The optional two-person setup with two factions per person is not yet available; ordinary two-seat games use the four-stronghold target.',
|
||||
'In the provisional two-person setup, each person controls two separately played factions and switches between their seats. They are not automatically allied and do not pool holdings unless they form an alliance. Each side needs four strongholds. Ordinary two-seat games also use four.',
|
||||
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
|
||||
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
|
||||
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',
|
||||
@@ -4460,6 +4460,27 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'faction-beneGesserit',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two-person-four-factions',
|
||||
title: 'Two people, two factions each',
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Two people each control two independent faction seats.',
|
||||
steps: [
|
||||
'The host enables the option before adding extra factions. Each person joins one seat and adds a second, then switches active faction to ready and play each seat.',
|
||||
'The provisional interpretation keeps the factions separate and does not automatically ally them. An individual faction or alliance needs four strongholds.',
|
||||
'This prototype requires human seats, cannot be disabled after a second seat is added, and does not yet establish every player-count interaction.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Two controllers own four independently authenticated seats; the shared victory quote uses the validated controller count. Other player-count rules remain provisional.', evidence: ['game/two-player-controllers.ts', 'game/engine.ts', 'game/victory-progress.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Lobby controls add and select seats, each with its own view and actions; wider accessibility and complete-game acceptance remain open.', evidence: ['components/two-player-controls.tsx', 'app/page.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing authorized human-seat autopilot remains available; independent bot seats and strategic calibration for this mode remain unfinished.', evidence: ['game/engine.ts', 'docs/AI_DEVELOPMENT_PLAN.md'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The provisional interpretation, sources and limitations are recorded.', evidence: ['docs/TWO_PLAYER_TWO_FACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused setup, first-turn, victory, seat selection, action and recovery cases exercise the prototype. Full games, combinations, broader custody and deployed acceptance remain open.', evidence: ['tests/two-player-engine.test.ts', 'tests/two-player-seat-http.test.ts', 'tests/victory-progress.test.ts'] },
|
||||
],
|
||||
related: ['victory', 'mentat'],
|
||||
},
|
||||
{
|
||||
id: 'fremen-ecaz-endgame',
|
||||
title: 'Fremen and Ecaz: the final-turn exception',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/** Explicit two-person setup: faction seats remain separate physical players.
|
||||
* Controller IDs are their original, independently authenticated first seats;
|
||||
* this public mapping is not itself a credential or an alliance. */
|
||||
export type TwoPlayerController = {
|
||||
id: string;
|
||||
seats: string[];
|
||||
};
|
||||
|
||||
export type TwoPlayerControllers = {
|
||||
version: 1;
|
||||
controllers: TwoPlayerController[];
|
||||
};
|
||||
|
||||
export class TwoPlayerControllersError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TwoPlayerControllersError';
|
||||
}
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value.trim() === value;
|
||||
function requireControllers(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new TwoPlayerControllersError(message);
|
||||
}
|
||||
function validateSeats(seats: readonly string[]): void {
|
||||
requireControllers(Array.isArray(seats) && seats.length <= 4 && seats.every(identifier) &&
|
||||
new Set(seats).size === seats.length,
|
||||
'The two-person setup needs at most four distinct seated faction IDs.');
|
||||
}
|
||||
|
||||
/** An empty mapping is useful while the lobby is being assembled. It does not
|
||||
* authorize any seat; the room layer must authenticate every seat separately. */
|
||||
export function createTwoPlayerControllers(): TwoPlayerControllers {
|
||||
return { version: 1, controllers: [] };
|
||||
}
|
||||
|
||||
/** Validate lobby-stage assignments. Unassigned seated factions are allowed
|
||||
* while the lobby is assembled, but every assignment must refer to a distinct
|
||||
* current seat. An existing controller's first seat is always its identity. */
|
||||
export function validateTwoPlayerControllersLobby(
|
||||
value: unknown,
|
||||
seats: readonly string[],
|
||||
): asserts value is TwoPlayerControllers {
|
||||
validateSeats(seats);
|
||||
requireControllers(record(value) && Object.keys(value).sort().join(',') === 'controllers,version' &&
|
||||
value.version === 1 && Array.isArray(value.controllers) && value.controllers.length <= 2,
|
||||
'The two-person controller mapping is malformed.');
|
||||
const identities = new Set<string>();
|
||||
const assigned = new Set<string>();
|
||||
for (const controller of value.controllers) {
|
||||
requireControllers(record(controller) && Object.keys(controller).sort().join(',') === 'id,seats' &&
|
||||
identifier(controller.id) && Array.isArray(controller.seats) &&
|
||||
controller.seats.length >= 1 && controller.seats.length <= 2 &&
|
||||
controller.seats.every(identifier) && controller.seats[0] === controller.id &&
|
||||
controller.seats.every(seat => seats.includes(seat)),
|
||||
'Each controller must retain its original primary seat and at most one other seated faction.');
|
||||
requireControllers(!identities.has(controller.id), 'Each controller must have a distinct primary seat.');
|
||||
identities.add(controller.id);
|
||||
for (const seat of controller.seats) {
|
||||
requireControllers(!assigned.has(seat), 'Each faction seat can belong to only one controller.');
|
||||
assigned.add(seat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Before play, require exactly two controllers, two factions each, and a
|
||||
* complete partition of the four actual faction seats. */
|
||||
export function validateTwoPlayerControllersComplete(
|
||||
value: unknown,
|
||||
seats: readonly string[],
|
||||
): asserts value is TwoPlayerControllers {
|
||||
validateTwoPlayerControllersLobby(value, seats);
|
||||
requireControllers(seats.length === 4 && value.controllers.length === 2 &&
|
||||
value.controllers.every(controller => controller.seats.length === 2),
|
||||
'Two-person play needs exactly two controllers with two faction seats each.');
|
||||
}
|
||||
|
||||
/** Add one actual faction to a controller without mutating either input.
|
||||
* A new controller can only be created by adding its own original seat first. */
|
||||
export function addTwoPlayerControlledSeat(
|
||||
value: TwoPlayerControllers,
|
||||
seats: readonly string[],
|
||||
controllerId: string,
|
||||
seatId: string,
|
||||
): TwoPlayerControllers {
|
||||
validateTwoPlayerControllersLobby(value, seats);
|
||||
requireControllers(identifier(controllerId) && identifier(seatId) && seats.includes(seatId),
|
||||
'Choose a currently seated faction and its actual controller.');
|
||||
requireControllers(!value.controllers.some(controller => controller.seats.includes(seatId)),
|
||||
'This faction seat already belongs to a controller.');
|
||||
const next: TwoPlayerControllers = { version: 1, controllers: value.controllers.map(controller =>
|
||||
({ id: controller.id, seats: [...controller.seats] })) };
|
||||
const existing = next.controllers.find(controller => controller.id === controllerId);
|
||||
if (existing) {
|
||||
requireControllers(existing.seats.length < 2, 'A controller already has two faction seats.');
|
||||
existing.seats.push(seatId);
|
||||
} else {
|
||||
requireControllers(next.controllers.length < 2 && controllerId === seatId,
|
||||
'Start each new controller with its own distinct primary seat.');
|
||||
next.controllers.push({ id: controllerId, seats: [seatId] });
|
||||
}
|
||||
validateTwoPlayerControllersLobby(next, seats);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Absence means the existing one-faction-per-person game. An explicit mapping
|
||||
* must be complete before it can influence a rules calculation. */
|
||||
export function physicalControllerCount(
|
||||
seats: readonly string[],
|
||||
value?: TwoPlayerControllers | null,
|
||||
): number {
|
||||
requireControllers(Array.isArray(seats) && seats.length >= 1 && seats.length <= 6 &&
|
||||
seats.every(identifier) && new Set(seats).size === seats.length,
|
||||
'Count distinct actual faction seats.');
|
||||
if (value === undefined || value === null) return seats.length;
|
||||
validateTwoPlayerControllersComplete(value, seats);
|
||||
return value.controllers.length;
|
||||
}
|
||||
|
||||
/** Source-backed two-player threshold. Allied sides keep their existing
|
||||
* four-stronghold target; the caller applies any agreed target increment. */
|
||||
export function controllerStrongholdTarget(
|
||||
seats: readonly string[],
|
||||
value: TwoPlayerControllers | null | undefined,
|
||||
allied: boolean,
|
||||
): 3 | 4 {
|
||||
requireControllers(typeof allied === 'boolean', 'Identify whether this victory side is allied.');
|
||||
const controllers = physicalControllerCount(seats, value);
|
||||
return allied || controllers === 2 ? 4 : 3;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { gameTerritories } from './board';
|
||||
import { fighterCount } from './advisors';
|
||||
import { TECH_TOKENS } from './tech-tokens';
|
||||
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
|
||||
import { controllerStrongholdTarget, type TwoPlayerControllers } from './two-player-controllers';
|
||||
|
||||
export type StrongholdProgress = {
|
||||
player: string;
|
||||
@@ -45,7 +46,10 @@ export function countsForStrongholdVictory(id: string) {
|
||||
* victory, combat resolution, resource mutation or Stronghold Card ownership is
|
||||
* considered. Mandatory advisor releases are returned for the caller to commit. */
|
||||
export function strongholdProgress(
|
||||
g: BoardContext & Pick<Game, 'techTokens'> & { victoryExtraStrongholds?: number },
|
||||
g: BoardContext & Pick<Game, 'techTokens'> & {
|
||||
victoryExtraStrongholds?: number;
|
||||
twoPlayerControllers?: TwoPlayerControllers;
|
||||
},
|
||||
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
|
||||
try {
|
||||
requireProgress(
|
||||
@@ -129,7 +133,9 @@ export function strongholdProgress(
|
||||
members.some((member) =>
|
||||
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
|
||||
);
|
||||
const target = (partner || players.length === 2 ? 4 : 3) + extra;
|
||||
const target = controllerStrongholdTarget(
|
||||
players.map((player) => player.id), g.twoPlayerControllers, !!partner,
|
||||
) + extra;
|
||||
return {
|
||||
player: id,
|
||||
members,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type StrongholdState,
|
||||
} from './stronghold-cards';
|
||||
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryContext, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
|
||||
import type { TwoPlayerControllers } from './two-player-controllers';
|
||||
|
||||
export class VictoryQuoteError extends Error {}
|
||||
function requireVictory(
|
||||
@@ -41,6 +42,7 @@ export type VictoryContext = Omit<BoardContext, 'players'> &
|
||||
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn'
|
||||
> & {
|
||||
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
|
||||
twoPlayerControllers?: TwoPlayerControllers;
|
||||
};
|
||||
export type VictoryQuote = {
|
||||
released: AdvisorRelease[];
|
||||
|
||||
Reference in new issue
Block a user