Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
+366
-4
@@ -19,15 +19,20 @@ import {
|
||||
HAND_LIMIT,
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
enhancementRule,
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
CardId,
|
||||
Facility,
|
||||
GameState,
|
||||
GridCoord,
|
||||
@@ -272,6 +277,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'ILLEGAL_MOVE';
|
||||
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
||||
const card = areaOf(s, player).grid.get(coordKey(here));
|
||||
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
|
||||
// The order must be a permutation of the current consist.
|
||||
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
|
||||
@@ -307,6 +331,78 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
|
||||
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
case 'mainline.modify': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
|
||||
const rule = mainlineModifierRule(card.kind.key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
const node = s.division.nodes[i.node];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
const on = node.modifiers ?? [];
|
||||
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
|
||||
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
|
||||
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
|
||||
// restriction exists to prevent.
|
||||
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
|
||||
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
|
||||
return 'NO_PLACEMENT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
||||
// Mainline card, which is the only place a rear-ender can happen.
|
||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
|
||||
// of the move and why it beats a normal spot.
|
||||
const adjacent =
|
||||
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
|
||||
if (!adjacent) return 'NOT_CONNECTED';
|
||||
const fsArea = areaOf(s, player);
|
||||
const target = fsArea.grid.get(coordKey(i.to));
|
||||
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
||||
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'draw.end': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
@@ -478,10 +574,29 @@ function checkPlay(
|
||||
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'enhancement': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
}
|
||||
|
||||
case 'mainlineModifier':
|
||||
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
|
||||
// with the same placement and the same effect. It is one card printed twice, so the mainline
|
||||
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
|
||||
if (card.kind.key === 'facingPointLocksMainline') {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
|
||||
}
|
||||
// The rest are laid on a Mainline card, which is not a grid coordinate — see
|
||||
// `mainline.modify`.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'maneuver':
|
||||
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
|
||||
// the source", so there is nothing to implement.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'spaceUse':
|
||||
case 'action':
|
||||
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
|
||||
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
|
||||
@@ -552,6 +667,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const here = trayCoord(s, i.trayId)!;
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
case 'draw.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -607,6 +736,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: card.kind.tier,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
||||
events.push({
|
||||
type: 'enhancementPlaced',
|
||||
player,
|
||||
key: card.kind.key,
|
||||
at: i.placement,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'extraTrain') {
|
||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||
// to do nothing at all, which made all four Extra cards dead weight in the deck.
|
||||
@@ -635,6 +772,47 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'card.discard':
|
||||
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
|
||||
|
||||
case 'mainline.modify': {
|
||||
const card = s.cards.get(i.cardId)!;
|
||||
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
|
||||
const node = s.division.nodes[i.node];
|
||||
const became =
|
||||
key === 'realignment' && node?.kind === 'mainline'
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// §A.3 — cars come off the back, same as a normal drop.
|
||||
const stock = tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
return [
|
||||
{
|
||||
type: 'trackLaid',
|
||||
player,
|
||||
geometry: i.geometry,
|
||||
hand: i.hand,
|
||||
at: i.placement,
|
||||
variant: i.variant ?? 0,
|
||||
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'freightAgent.stockOutbound':
|
||||
return [
|
||||
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
||||
@@ -787,6 +965,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'carsDropped': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
@@ -833,6 +1019,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
applyModifier(area, e.placement, card.kind.modifier);
|
||||
} else if (card.kind.kind !== 'office') {
|
||||
@@ -841,6 +1028,52 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mainlineModified': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
if (e.became) node.card = e.became as MainlineKind;
|
||||
else node.modifiers = [...(node.modifiers ?? []), e.key];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'redFlagsSet': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'flyingSwitch': {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
const area = areaOf(s, e.player);
|
||||
const card = area.grid.get(coordKey(e.to));
|
||||
if (tray && card) {
|
||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||
const track = card.facility?.industryTrack;
|
||||
if (track) track.cars.push(...e.stock);
|
||||
else card.standing.push(...e.stock);
|
||||
}
|
||||
s.turn.movesRemaining -= 1;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'trackLaid': {
|
||||
const area = areaOf(s, e.player);
|
||||
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
|
||||
s.turn.laidThisTurn = true;
|
||||
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
|
||||
if (built) {
|
||||
area.grid.set(coordKey(e.at), built);
|
||||
extendLimitsIfNeeded(area, e.at);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -897,6 +1130,19 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enhancementPlaced': {
|
||||
const rule = enhancementRule(e.key);
|
||||
if (rule?.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[e.at.col];
|
||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||
if (node?.kind === 'mainline') node.absSignals = true;
|
||||
} else {
|
||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||
if (card) card.enhancements.push(e.key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extraQueued':
|
||||
s.pendingExtras.push(e.trainNumber);
|
||||
break;
|
||||
@@ -1032,7 +1278,7 @@ function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const base = { standing: [], facility: null, modifiers: [] };
|
||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||
|
||||
if (kind.kind === 'track') {
|
||||
const geometry = kind.geometry as TrackGeometry;
|
||||
@@ -1117,6 +1363,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
|
||||
* their placement and state are implemented and their effect is read at the point of attack:
|
||||
*
|
||||
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
|
||||
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
|
||||
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
|
||||
*
|
||||
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
|
||||
* queried here rather than being special-cased at each attack site.
|
||||
*/
|
||||
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
|
||||
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
}
|
||||
|
||||
/** Facing Point Locks blocks a Derail played at this district. */
|
||||
export function isProtectedFromDerail(area: OfficeArea): boolean {
|
||||
return hasDistrictEnhancement(area, 'facingPointLocks');
|
||||
}
|
||||
|
||||
/** A Water Column lets its owner clear a Watertower off their own grid. */
|
||||
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
|
||||
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
|
||||
const out: GridCoord[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhancements have per-card placement rules (implications.md §7):
|
||||
* - Interlocking, Water Column, Telegraph → a Running Track straight
|
||||
* - Yard Office, Small Yard → a Secondary Track straight
|
||||
* - Telephone / Radio → stacked on the card below them
|
||||
* - Facing Point Locks → needs an Interlocking in the district
|
||||
* - ABS Signals → a Mainline card, not the Office Area
|
||||
*/
|
||||
export function checkEnhancementPlacement(
|
||||
s: GameState,
|
||||
area: OfficeArea,
|
||||
key: string,
|
||||
placement: GridCoord,
|
||||
): RejectionCode | null {
|
||||
const rule = enhancementRule(key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
|
||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
||||
if (rule.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[placement.col];
|
||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const card = area.grid.get(coordKey(placement));
|
||||
if (!card) return 'NOT_CONNECTED';
|
||||
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
|
||||
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
if (rule.requiresInDistrict) {
|
||||
const present = [...area.grid.values()].some((c) =>
|
||||
c.enhancements.includes(rule.requiresInDistrict!),
|
||||
);
|
||||
if (!present) return 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const onRunning = placement.row === area.runningRow;
|
||||
const isStraight =
|
||||
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
|
||||
|
||||
switch (rule.placement) {
|
||||
case 'runningTrackStraight':
|
||||
return onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'secondaryTrackStraight':
|
||||
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'onCard':
|
||||
return null;
|
||||
default:
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
s.decks.salvageYard.push(cardId);
|
||||
}
|
||||
|
||||
/** A track piece from the player's own supply, at the chosen rotation. */
|
||||
function protoTrackCard(
|
||||
geometry: TrackGeometry,
|
||||
hand: Hand,
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const options = variantsFor(geometry);
|
||||
const v = options[variant ?? 0];
|
||||
if (!v) return null;
|
||||
return {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(hand !== 'none' ? { hand } : {}),
|
||||
},
|
||||
baseOperationalRail: geometry !== 'turnout',
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Q4 — would building `kind` here conflict with something already in the district?
|
||||
* The relation is symmetric, so checking either direction is enough.
|
||||
|
||||
Reference in New Issue
Block a user