Enforce Salusa free Sardaukar revival penalty
This commit is contained in:
1 parent
1f92affea7
commit
533c651b07
24 files changed
+962
-213
No files matched your search
+5
-6
@@ -1703,8 +1703,9 @@ function policyActions(g: GameView): Action[] {
|
||||
leader: dead.id,
|
||||
},
|
||||
];
|
||||
const amount = Math.min(3, me.tanks);
|
||||
const elite = Math.min(amount, me.elites?.tanks ?? 0);
|
||||
const eliteStock = me.elites?.tanks ?? 0;
|
||||
const amount = Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? eliteStock : 0));
|
||||
const elite = g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, eliteStock);
|
||||
if (amount === 3 || (level === 0 && amount > 0))
|
||||
return [
|
||||
{ type: 'card', mode: 'special', card: specialCard.id, amount, elite },
|
||||
@@ -2110,10 +2111,7 @@ function policyActions(g: GameView): Action[] {
|
||||
: { ...action, decline: true }];
|
||||
// Minimal legal participation: return as many Tanks counters as the
|
||||
// printed maximum allows, spending the single elite option first.
|
||||
const elite = Math.min(
|
||||
revival.eliteTanks,
|
||||
Math.max(0, 1 - revival.eliteRevived),
|
||||
);
|
||||
const elite = revival.eliteMaximum;
|
||||
const forces = Math.min(revival.maximum, revival.tanks);
|
||||
return [{ ...action, forces, elite: Math.min(elite, forces) }];
|
||||
}
|
||||
@@ -3455,6 +3453,7 @@ function policyActions(g: GameView): Action[] {
|
||||
g.revival.discount,
|
||||
undefined,
|
||||
g.revival.choamBlocked,
|
||||
!!g.revival.sardaukarFreeBlock,
|
||||
);
|
||||
if (
|
||||
quote.cost === 0 ||
|
||||
|
||||
+28
-8
@@ -240,6 +240,7 @@ import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuild
|
||||
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
|
||||
import {
|
||||
homeworldLowBonus,
|
||||
homeworldSardaukarFreeRevivalBlock,
|
||||
homeworldSardaukarFreeSupport,
|
||||
homeworldSardaukarGholaBlock,
|
||||
homeworldSardaukarGholaCrossing,
|
||||
@@ -18372,7 +18373,9 @@ function kullIntegrity(g: Game): void {
|
||||
'The interrupted Karama has no proven Truthtrance continuation.');
|
||||
} else {
|
||||
requireRule(frame.intent?.kind === 'special', 'The Kull attempt has no validated intent.');
|
||||
const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(frame.intent.use));
|
||||
const original = frame.intent.use;
|
||||
const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(original),
|
||||
{ legacySalusaIntent: original.kind === 'emperorForces' && original.salusaFreePolicy === undefined });
|
||||
requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use),
|
||||
'The saved special Karama selection has changed.');
|
||||
}
|
||||
@@ -25008,10 +25011,11 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
p.reserves = quote.after.reserves;
|
||||
const salusaBlock = quote.elite > 0 ? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) : null;
|
||||
requireRule(!salusaBlock, salusaBlock ?? 'Choose eligible forces for the free Ambassador return.');
|
||||
addNativeReserves(g, p, quote.amount, quote.elite);
|
||||
p.tanks = quote.after.tanks;
|
||||
if (p.elites) {
|
||||
p.elites.reserves = quote.after.eliteReserves;
|
||||
p.elites.tanks = quote.after.eliteTanks;
|
||||
p.elites.revived = quote.eliteRevivedNext;
|
||||
}
|
||||
@@ -30016,7 +30020,8 @@ function gholaOptions(g: Game, p: Player) {
|
||||
strength,
|
||||
}));
|
||||
const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead);
|
||||
let eliteBlock = homeworldSardaukarGholaBlock(g, p.id);
|
||||
let eliteBlock = homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) ??
|
||||
homeworldSardaukarGholaBlock(g, p.id);
|
||||
let eliteRemaining = eliteBlock ? 0 : Math.min(
|
||||
p.elites?.tanks ?? 0,
|
||||
eliteRevivalRemaining(p, g.advanced),
|
||||
@@ -30237,7 +30242,8 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
'Forces',
|
||||
);
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
const eliteBlock = elite > 0 ? homeworldSardaukarGholaBlock(g, p.id) : null;
|
||||
const eliteBlock = elite > 0 ? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) ??
|
||||
homeworldSardaukarGholaBlock(g, p.id) : null;
|
||||
requireRule(!eliteBlock, eliteBlock ?? 'Choose an eligible Ghola return.');
|
||||
requireRule(
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
@@ -39042,7 +39048,7 @@ export type SpecialKaramaIntent = {
|
||||
}
|
||||
| { kind: 'guild'; target: string; shipment: PendingShipment }
|
||||
| { kind: 'guildHomeworld'; target: string; shipment: PendingHomeworldShipment }
|
||||
| { kind: 'emperorForces'; amount: number; elite: number }
|
||||
| { kind: 'emperorForces'; amount: number; elite: number; salusaFreePolicy?: 1 }
|
||||
| { kind: 'emperorLeader'; leader: string }
|
||||
| { kind: 'harkonnen'; target: string; amount: number }
|
||||
| { kind: 'ecaz'; event: string }
|
||||
@@ -39054,6 +39060,7 @@ export function prepareSpecialKaramaIntent(
|
||||
g: Game,
|
||||
playerId: string,
|
||||
action: Action,
|
||||
options: { legacySalusaIntent?: boolean } = {},
|
||||
): SpecialKaramaIntent {
|
||||
const p = getPlayer(g, playerId);
|
||||
requireRule(action.emperorNexus === undefined || p.faction === 'richese',
|
||||
@@ -39303,7 +39310,11 @@ export function prepareSpecialKaramaIntent(
|
||||
}
|
||||
const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
return { ...base, kind: 'emperorForces', amount: n, elite };
|
||||
const eliteBlock = elite > 0 && !options.legacySalusaIntent
|
||||
? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) : null;
|
||||
requireRule(!eliteBlock, eliteBlock ?? 'Choose eligible forces for the special revival.');
|
||||
return { ...base, kind: 'emperorForces', amount: n, elite,
|
||||
...(g.homeworlds?.custody && !options.legacySalusaIntent ? { salusaFreePolicy: 1 as const } : {}) };
|
||||
} else if (p.faction === 'harkonnen') {
|
||||
requireRule(
|
||||
g.phase === 3 && !g.pendingExchange,
|
||||
@@ -39405,6 +39416,7 @@ export function executeSpecialKaramaIntent(
|
||||
g,
|
||||
intent.owner,
|
||||
specialKaramaAction(intent),
|
||||
{ legacySalusaIntent: intent.kind === 'emperorForces' && intent.salusaFreePolicy === undefined },
|
||||
);
|
||||
requireRule(
|
||||
prepared.kind === intent.kind,
|
||||
@@ -45111,10 +45123,17 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
revival:
|
||||
entry.stage === 'revival' && id === entry.beneficiary
|
||||
? {
|
||||
maximum: Math.min(4, me.tanks),
|
||||
maximum: Math.min(4, me.tanks - (me.elites?.tanks ?? 0) +
|
||||
(homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)) ? 0 :
|
||||
me.faction === 'ixians' ? (me.elites?.tanks ?? 0) :
|
||||
Math.min(me.elites?.tanks ?? 0, Math.max(0, 1 - (me.elites?.revived ?? 0))))),
|
||||
tanks: me.tanks,
|
||||
eliteTanks: me.elites?.tanks ?? 0,
|
||||
eliteRevived: me.elites?.revived ?? 0,
|
||||
eliteMaximum: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)) ? 0 :
|
||||
me.faction === 'ixians' ? (me.elites?.tanks ?? 0) :
|
||||
Math.min(me.elites?.tanks ?? 0, Math.max(0, 1 - (me.elites?.revived ?? 0))),
|
||||
eliteBlock: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)),
|
||||
leaders: tleilaxuAmbassadorLeaderChoices(g, me),
|
||||
blocked:
|
||||
ambassadorEffectBlock(
|
||||
@@ -45576,6 +45595,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
orderBlocked: nativeRevivalOrderBlock(g,id),
|
||||
pending: !!g.pendingRevival,
|
||||
specialKaramaBlock: null,
|
||||
sardaukarFreeBlock: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)),
|
||||
leaders: leaderRevivals.leaders,
|
||||
kwisatz: leaderRevivals.kwisatz,
|
||||
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
|
||||
|
||||
@@ -92,7 +92,7 @@ export const FACTION_RULES: Record<
|
||||
advanced: [
|
||||
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
|
||||
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
|
||||
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit.',
|
||||
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. With Homeworlds, a low Salusa prohibits free Sardaukar returns; normal forces and eligible leaders remain available.',
|
||||
'An elite token counts as one force for ordinary revival. Only one Sardaukar may be ordinarily revived per turn; the Emperor’s special Karama is separate.',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Game } from './engine';
|
||||
import { homeworldForceGroups } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import {
|
||||
quoteStableHomeworldOccupation,
|
||||
type StableHomeworldOccupationContext,
|
||||
} from './homeworld-stable-occupation';
|
||||
|
||||
type HomeworldBenefitContext = Pick<
|
||||
Game,
|
||||
@@ -59,6 +64,43 @@ export function homeworldSardaukarFreeSupport(
|
||||
);
|
||||
}
|
||||
|
||||
/** The Low Salusa penalty applies to free Sardaukar revival independently of
|
||||
* the revival source and its limits. An established occupation retains that
|
||||
* penalty after native repopulation until the original occupier leaves. */
|
||||
export function homeworldSardaukarFreeRevivalBlock(
|
||||
context: HomeworldBenefitContext & StableHomeworldOccupationContext,
|
||||
playerId: string,
|
||||
): string | null {
|
||||
if (
|
||||
!context.advanced ||
|
||||
!context.homeworlds?.custody ||
|
||||
!context.players.some((player) => player.id === playerId && player.faction === 'emperor')
|
||||
) return null;
|
||||
|
||||
const physical = homeworldContext(context);
|
||||
const salusa = homeworldPopulations(physical, context.homeworlds.custody).find(
|
||||
(home) => home.native === playerId && home.card === 'salusa_secundus',
|
||||
);
|
||||
if (!salusa) return null;
|
||||
const penalty = 'Salusa Secundus prohibits free Sardaukar revival while its low-population penalty applies.';
|
||||
if (salusa.side === 'low') return penalty;
|
||||
|
||||
// Older profiles do not have the occupation history needed to distinguish
|
||||
// a retained occupier from an unqualified visitor. Do not infer either.
|
||||
if (!context.homeworldOccupationPreview) {
|
||||
const world = homeworldForceGroups(physical, context.homeworlds.custody).find(
|
||||
(home) => home.id === salusa.location,
|
||||
);
|
||||
if (world && Object.entries(world.forces).some(([id, forces]) =>
|
||||
id !== playerId && forces.normal + forces.elite > 0,
|
||||
)) return 'Salusa occupation eligibility requires the original Homeworld qualification history.';
|
||||
return null;
|
||||
}
|
||||
const occupation = quoteStableHomeworldOccupation(context, 'salusa_secundus');
|
||||
if (occupation.blocked) return occupation.blocked;
|
||||
return occupation.entitlement ? penalty : null;
|
||||
}
|
||||
|
||||
/** One returned star crosses Salusa's current threshold during its native
|
||||
* player's battle. The caller still owns card timing and actual return count. */
|
||||
export function homeworldSardaukarGholaCrossing(
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const HELP = {
|
||||
'The table pauses before this power resolves. Other factions can use a legal Karama cancellation; everyone can allow the power.',
|
||||
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded.',
|
||||
prescience:
|
||||
'Only the requested element becomes fixed. Advanced Salusa Ghola may renew an impossible answer before either full plan is sealed; answer that same field again. Other plan elements stay private until reveal.',
|
||||
'Only the requested element becomes fixed. Other plan elements stay private until reveal. Low Salusa prohibits free Sardaukar revival, including Ghola; other legal Ghola choices remain available.',
|
||||
};
|
||||
|
||||
const phaseDetails = [
|
||||
|
||||
@@ -28,9 +28,12 @@ export type RevivalCancellationContext = Pick<
|
||||
| 'turn'
|
||||
| 'advanced'
|
||||
| 'homeworlds'
|
||||
| 'homeworldOccupationHistory'
|
||||
| 'homeworldOccupationPreview'
|
||||
| 'players'
|
||||
| 'revivalRules'
|
||||
| 'freeRevival'
|
||||
| 'recruits'
|
||||
| 'dukeVidal'
|
||||
| 'pendingRevival'
|
||||
| 'emperorExtra'
|
||||
@@ -273,9 +276,15 @@ export function quoteRevivalCancellation(
|
||||
nonnegative(p.revived),
|
||||
'The pending force revival has invalid physical counts.',
|
||||
);
|
||||
if (original.salusaFreePolicy !== undefined)
|
||||
requireRevival(original.salusaFreePolicy === 1 && g.advanced &&
|
||||
p.faction === 'emperor' && !!g.homeworlds?.custody && !original.emperorExtra &&
|
||||
original.payer === undefined &&
|
||||
original.free === forceRevivalQuote(g, p, original.amount!, original.elite ?? 0).free,
|
||||
'The pending Salusa revival no longer matches its original free allocation.');
|
||||
} else {
|
||||
requireRevival(
|
||||
original.free === 0 &&
|
||||
original.salusaFreePolicy === undefined && original.free === 0 &&
|
||||
original.amount === undefined &&
|
||||
original.elite === undefined &&
|
||||
(original.kind === 'kwisatz'
|
||||
|
||||
+12
-3
@@ -1,6 +1,6 @@
|
||||
import type { Decision, Game, ResponseWindow } from './engine';
|
||||
import type { PendingRevival } from './revival';
|
||||
import { forceRevivalRemaining, freeRevivalRemaining } from './revival';
|
||||
import { forceRevivalQuote, forceRevivalRemaining, legacyFreeRevivalRemaining } from './revival';
|
||||
import {
|
||||
continuationCustody,
|
||||
RevivalCancellationError,
|
||||
@@ -26,11 +26,14 @@ export type RevivalResumeContext = Pick<
|
||||
| 'advanced'
|
||||
| 'expansions'
|
||||
| 'homeworlds'
|
||||
| 'homeworldOccupationHistory'
|
||||
| 'homeworldOccupationPreview'
|
||||
| 'homeworldRevival'
|
||||
| 'players'
|
||||
| 'pendingRevival'
|
||||
| 'revivalRules'
|
||||
| 'freeRevival'
|
||||
| 'recruits'
|
||||
| 'emperorExtra'
|
||||
| 'techTokens'
|
||||
| 'revivalFreeIncome'
|
||||
@@ -203,6 +206,11 @@ export function quoteRevivalResume(
|
||||
);
|
||||
if (original.nexusEmperorEvent !== undefined)
|
||||
validateNexusEmperorSource(g, original);
|
||||
if (original.salusaFreePolicy !== undefined)
|
||||
requireResume(original.salusaFreePolicy === 1 && original.kind === 'forces' &&
|
||||
p.faction === 'emperor' && g.advanced && !!g.homeworlds?.custody &&
|
||||
!original.emperorExtra && original.nexusEmperorEvent === undefined,
|
||||
'The saved Salusa revival policy does not match its ordinary Emperor source.');
|
||||
if (original.kind === 'forces') {
|
||||
requireResume(
|
||||
count(original.amount) &&
|
||||
@@ -222,8 +230,9 @@ export function quoteRevivalResume(
|
||||
if (g.homeworlds && !original.emperorExtra &&
|
||||
original.nexusEmperorEvent === undefined)
|
||||
requireResume(
|
||||
original.free ===
|
||||
Math.min(original.amount, freeRevivalRemaining(g, p)),
|
||||
original.free === (original.salusaFreePolicy === 1
|
||||
? forceRevivalQuote(g, p, original.amount, original.elite ?? 0).free
|
||||
: Math.min(original.amount, legacyFreeRevivalRemaining(g, p))),
|
||||
'The saved Homeworld revival free allocation does not match its current eligible group.',
|
||||
);
|
||||
const elite = original.elite ?? 0;
|
||||
|
||||
+23
-3
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
|
||||
import type { Leader } from './cards';
|
||||
import { faction, type FactionId } from './catalog';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { homeworldLowBonus } from './homeworld-benefits';
|
||||
import { homeworldLowBonus, homeworldSardaukarFreeRevivalBlock } from './homeworld-benefits';
|
||||
import { recruitsRevivalAllowance } from './recruits';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { ecazDukeRevivalBlock, quoteEcazDukeRevival, type EcazDukeRevivalContext } from './ecaz-duke-revival';
|
||||
@@ -15,6 +15,8 @@ type RevivalRateContext = Pick<
|
||||
| 'advanced'
|
||||
| 'players'
|
||||
| 'homeworlds'
|
||||
| 'homeworldOccupationHistory'
|
||||
| 'homeworldOccupationPreview'
|
||||
| 'revivalRules'
|
||||
| 'freeRevival'
|
||||
| 'turn'
|
||||
@@ -85,8 +87,10 @@ export function normalForceRevivalLimit(
|
||||
export function normalRevivalAllowance(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
||||
legacySalusaUsage = false,
|
||||
) {
|
||||
const currentFreeRate = freeRevivalRate(g, p);
|
||||
const salusaLedger = !legacySalusaUsage && g.advanced && p.faction === 'emperor' && !!g.homeworlds?.custody;
|
||||
const native = recruitsRevivalAllowance(g, {
|
||||
currentFreeRate,
|
||||
currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
|
||||
@@ -94,7 +98,7 @@ export function normalRevivalAllowance(
|
||||
// A Recruits activation is rejected when this ledger is absent and use is
|
||||
// nonzero. The fallback preserves old-room behavior while the card is idle.
|
||||
freeAllowanceUsed:
|
||||
g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
|
||||
g.recruits?.turn === g.turn || salusaLedger ? (p.freeForcesRevived ?? p.revived) : p.revived,
|
||||
freeRateCap: 20,
|
||||
});
|
||||
if (g.revivalRules?.nexusFremen?.turn !== g.turn ||
|
||||
@@ -113,6 +117,13 @@ export function freeRevivalRemaining(
|
||||
) {
|
||||
return normalRevivalAllowance(g, p).freeRemaining;
|
||||
}
|
||||
/** Existing unstamped pending requests retain the earlier allowance calculation. */
|
||||
export function legacyFreeRevivalRemaining(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
||||
) {
|
||||
return normalRevivalAllowance(g, p, true).freeRemaining;
|
||||
}
|
||||
export function forceRevivalQuote(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
||||
@@ -121,7 +132,8 @@ export function forceRevivalQuote(
|
||||
freeElite?: number,
|
||||
) {
|
||||
const free = Math.min(amount, freeRevivalRemaining(g, p));
|
||||
return forceRevivalPrice(
|
||||
const salusaPolicy = g.advanced && p.faction === 'emperor' && !!g.homeworlds?.custody;
|
||||
const quote = forceRevivalPrice(
|
||||
p.faction,
|
||||
amount,
|
||||
elite,
|
||||
@@ -129,7 +141,9 @@ export function forceRevivalQuote(
|
||||
!!revivalDiscount(g, p),
|
||||
freeElite,
|
||||
!!g.revivalRules?.choamBlocked,
|
||||
salusaPolicy && !!homeworldSardaukarFreeRevivalBlock(g, p.id),
|
||||
);
|
||||
return { ...quote, ...(salusaPolicy ? { salusaFreePolicy: 1 as const } : {}) };
|
||||
}
|
||||
|
||||
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
|
||||
@@ -319,6 +333,9 @@ export type PendingRevival = {
|
||||
/** Frozen physical Ix HIGH grant, additional to ordinary allowance. Absent
|
||||
* on requests accepted before this effect was implemented. */
|
||||
ixSuboidBonus?: number;
|
||||
/** New Emperor Homeworld requests record the Salusa/free-ledger policy.
|
||||
* Its absence preserves already accepted legacy request semantics. */
|
||||
salusaFreePolicy?: 1;
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
leader?: string;
|
||||
@@ -369,7 +386,10 @@ export function forceRevivalPrice(
|
||||
discount: boolean,
|
||||
freeElite?: number,
|
||||
choamBlocked = false,
|
||||
sardaukarFreeBlocked = false,
|
||||
) {
|
||||
if (faction === 'emperor' && sardaukarFreeBlocked)
|
||||
free = Math.min(free, Math.max(0, amount - elite));
|
||||
const freeCyborgs =
|
||||
faction === 'ixians' ? (freeElite ?? Math.min(free, elite)) : 0;
|
||||
const normalCost = paidForceRevivalCost(
|
||||
|
||||
Reference in new issue
Block a user