Add random pattern selection (-r / --pattern random)
Pick a different movement pattern every time a sweep is triggered, so the motion varies across the day instead of repeating one shape. - strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`, `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a total lookup and `PATTERN_NAMES` keeps listing only real generators. The picker is a closure over `last`, giving a uniform draw that never returns the same pattern twice in a row. Building CANONICAL_PATTERNS from the selectable list makes both validation boundaries accept `random` (and loose spellings) for free, and extends the normalization-collision assertion to cover the sentinel. - cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing is a no-op while `-r -p arc` is rejected as contradictory. The flag folds into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are untouched. `parseCliArgs` now takes its argv as an optional parameter so the flag surface is testable without process.argv. - keeper.ts: resolve `random` via the picker once per trigger, before the loop-mode branch, so a pick holds for a whole loop run rather than changing mid-run. runKeeper builds one picker for the process, so the no-repeat memory spans sweeps minutes apart. Because the pick is a real strategy, --verbose logs the concrete pattern name and a pick with an infinite loopPath still bounces edge-to-edge under --loop. - config.ts / configFile.ts: accept the sentinel where a pattern is valid, and quote the selectable list in errors. No `random` boolean config key — the file spells it "pattern": "random". executor.ts and move.ts needed no changes. Tests: new tests/cli.test.ts (the file had no coverage before) covering the flag surface and the conflict rule; picker tests pinning the no-repeat and full-registry-coverage properties; keeper tests pinning once-per-trigger and once-per-loop-run.
This commit is contained in:
+54
-10
@@ -17,6 +17,10 @@
|
||||
* -c, --check-interval Cursor poll cadence (seconds).
|
||||
* -d, --step-delay Pause between synthetic steps (ms).
|
||||
* -p, --pattern Movement strategy name (see strategies.ts).
|
||||
* -r, --random Sugar for `--pattern random`: pick a different
|
||||
* pattern for each sweep. Folded into `pattern`
|
||||
* here, so nothing downstream knows the flag
|
||||
* exists. Conflicts with an explicit `--pattern`.
|
||||
* -V, --verbose Enable per-sweep / interrupt logging.
|
||||
* (`-V` capital because `-v` is `--version`.)
|
||||
* -l, --loop Loop mode: once triggered, keep moving
|
||||
@@ -33,7 +37,7 @@ import { parseArgs } from "node:util";
|
||||
|
||||
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
import { RANDOM_PATTERN, SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
|
||||
/**
|
||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||
@@ -48,7 +52,13 @@ export interface ParsedCliArgs {
|
||||
moveInterval: number | undefined; // seconds
|
||||
checkInterval: number | undefined; // seconds
|
||||
stepDelay: number | undefined; // milliseconds
|
||||
/** Movement strategy name, validated against the registry. */
|
||||
/**
|
||||
* Movement strategy name, validated against the registry — or the
|
||||
* `random` sentinel, which `-r/--random` also folds into this field.
|
||||
* There is deliberately no separate `random` boolean: the flag's entire
|
||||
* effect is the value here, so downstream layering (`ConfigOverrides`,
|
||||
* `resolveConfig`) needs no knowledge of it.
|
||||
*/
|
||||
pattern: string | undefined;
|
||||
/**
|
||||
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
||||
@@ -87,21 +97,48 @@ function parsePatternName(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const canonical: string | null = resolvePatternName(raw);
|
||||
if (canonical === null) {
|
||||
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
|
||||
throw new CliError(
|
||||
`invalid value for --pattern: '${raw}' (valid: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
||||
* `parseArgs` in strict mode so unknown flags and missing values surface
|
||||
* as `CliError`s that the entry point can turn into exit code 2.
|
||||
* Fold `-r/--random` and `--pattern` into the single pattern selection that
|
||||
* the rest of the program consumes.
|
||||
*
|
||||
* `-r` is defined as sugar for `--pattern random`, so passing both spellings
|
||||
* of the same request (`-r --pattern random`) is a harmless no-op. Any other
|
||||
* pairing states two different intentions at once, and silently honoring one
|
||||
* would hide the user's mistake — so it's rejected. The message quotes the
|
||||
* user's own spelling rather than the canonical name, since that's what they
|
||||
* need to find and fix on their command line.
|
||||
*
|
||||
* Exported so the conflict rule is testable without touching `process.argv`.
|
||||
*/
|
||||
export function parseCliArgs(): ParsedCliArgs {
|
||||
export function selectPattern(rawPattern: string | undefined, random: boolean): string | undefined {
|
||||
const canonical: string | undefined = parsePatternName(rawPattern);
|
||||
if (!random) return canonical;
|
||||
if (canonical !== undefined && canonical !== RANDOM_PATTERN) {
|
||||
throw new CliError(`-r/--random conflicts with --pattern '${rawPattern}' (pick one)`);
|
||||
}
|
||||
return RANDOM_PATTERN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command-line arguments into a typed `ParsedCliArgs`. Uses Node's
|
||||
* built-in `parseArgs` in strict mode so unknown flags and missing values
|
||||
* surface as `CliError`s that the entry point can turn into exit code 2.
|
||||
*
|
||||
* @param argv - Argument list to parse, defaulting to the real command line.
|
||||
* Injectable so the flag surface can be unit-tested directly.
|
||||
*/
|
||||
export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedCliArgs {
|
||||
let values: Record<string, string | boolean | undefined>;
|
||||
try {
|
||||
const result = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
args: argv,
|
||||
options: {
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
@@ -111,6 +148,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
"check-interval": { type: "string", short: "c" },
|
||||
"step-delay": { type: "string", short: "d" },
|
||||
pattern: { type: "string", short: "p" },
|
||||
random: { type: "boolean", short: "r" },
|
||||
verbose: { type: "boolean", short: "V" },
|
||||
loop: { type: "boolean", short: "l" },
|
||||
},
|
||||
@@ -133,7 +171,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||
pattern: parsePatternName(values.pattern as string | undefined),
|
||||
pattern: selectPattern(values.pattern as string | undefined, values.random === true),
|
||||
verbose: values.verbose === true ? true : undefined,
|
||||
loop: values.loop === true ? true : undefined,
|
||||
};
|
||||
@@ -178,8 +216,12 @@ Options:
|
||||
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
||||
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
||||
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
|
||||
One of: ${PATTERN_NAMES.join(", ")}.
|
||||
One of: ${SELECTABLE_PATTERN_NAMES.join(", ")}.
|
||||
Each pattern defines its own size and speed.
|
||||
-r, --random Shorthand for --pattern random. Picks a
|
||||
different pattern for each sweep, never the
|
||||
same one twice in a row. In loop mode the
|
||||
pick holds for the whole loop run.
|
||||
-V, --verbose Log every sweep and interrupt
|
||||
(default prints only the startup banner).
|
||||
-l, --loop Loop mode: once a sweep is triggered,
|
||||
@@ -194,6 +236,8 @@ Examples:
|
||||
move -m 300 -V
|
||||
move --pattern arc
|
||||
move --pattern diagonal --loop
|
||||
move -r
|
||||
move --pattern random --loop
|
||||
move --config ~/myprofile.json
|
||||
`);
|
||||
}
|
||||
|
||||
+7
-4
@@ -22,7 +22,7 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "./errors.ts";
|
||||
import { isPatternName, type PatternName } from "./strategies.ts";
|
||||
import { isSelectablePattern, type PatternName } from "./strategies.ts";
|
||||
|
||||
// Single source of truth for default values. The same file ships in the
|
||||
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
|
||||
@@ -44,7 +44,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
||||
* "interrupt" by moving the cursor. Milliseconds.
|
||||
* - `pattern` — name of the movement strategy to use (see
|
||||
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
|
||||
* pattern owns its own size and step count.
|
||||
* pattern owns its own size and step count. May also be
|
||||
* the `random` sentinel, which is not a registry key:
|
||||
* the keeper resolves it to a real strategy once per
|
||||
* sweep rather than looking it up here.
|
||||
* - `verbose` — whether per-sweep / interrupt events are logged. The
|
||||
* startup banner is always printed.
|
||||
* - `loop` — loop mode: once a sweep is triggered, keep
|
||||
@@ -71,7 +74,7 @@ interface SeedShape {
|
||||
moveInterval: number; // seconds
|
||||
checkInterval: number; // seconds
|
||||
stepDelay: number; // milliseconds
|
||||
pattern: string; // strategy name
|
||||
pattern: string; // strategy name, or the `random` sentinel
|
||||
verbose: boolean;
|
||||
loop: boolean;
|
||||
}
|
||||
@@ -87,7 +90,7 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
}
|
||||
if (typeof r.pattern !== "string" || !isPatternName(r.pattern)) {
|
||||
if (typeof r.pattern !== "string" || !isSelectablePattern(r.pattern)) {
|
||||
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
|
||||
}
|
||||
if (typeof r.verbose !== "boolean") {
|
||||
|
||||
+7
-3
@@ -10,10 +10,14 @@
|
||||
* moveInterval number seconds, positive
|
||||
* checkInterval number seconds, positive
|
||||
* stepDelay number milliseconds, positive
|
||||
* pattern string a registered strategy name
|
||||
* pattern string a registered strategy name, or "random"
|
||||
* verbose boolean
|
||||
* loop boolean
|
||||
*
|
||||
* There is no `random` boolean key: the CLI's `-r` is defined as sugar for
|
||||
* `--pattern random`, so the file expresses the same request as
|
||||
* `"pattern": "random"` rather than as a second, redundant switch.
|
||||
*
|
||||
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
||||
* `CliError` so the entry point can exit 2 (user error) with a clear
|
||||
* message pointing at the offending file. The removed `stepCount` /
|
||||
@@ -32,7 +36,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
|
||||
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
import { SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
|
||||
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"moveInterval",
|
||||
@@ -82,7 +86,7 @@ function requirePatternName(name: string, raw: unknown, path: string): string {
|
||||
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
|
||||
if (canonical === null) {
|
||||
throw new CliError(
|
||||
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
|
||||
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
|
||||
+38
-7
@@ -25,7 +25,14 @@
|
||||
|
||||
import { createNutDevice, type Device, type Point } from "./device.ts";
|
||||
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
|
||||
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
||||
import {
|
||||
createRandomPicker,
|
||||
DEFAULT_PATTERN,
|
||||
RANDOM_PATTERN,
|
||||
STRATEGIES,
|
||||
type MoveContext,
|
||||
type MovementStrategy,
|
||||
} from "./strategies.ts";
|
||||
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
@@ -54,6 +61,14 @@ function makeLogger(verbose: boolean): Logger {
|
||||
* `config.pattern` falls back to the default strategy defensively; validation
|
||||
* at the CLI / config-file boundary should prevent that from ever happening.
|
||||
*
|
||||
* `pattern: "random"` isn't a registry key — it asks for a fresh pattern per
|
||||
* sweep, so `pickRandom` supplies one here. The pick happens once, before the
|
||||
* loop-mode branch below, which is what makes a random selection hold for an
|
||||
* entire loop run rather than changing under the user mid-run; the picker's
|
||||
* own no-repeat memory then spans sweeps, since the keeper holds one picker
|
||||
* for the life of the process. Because the pick is a real strategy, the log
|
||||
* lines below and in `executePath` name the concrete pattern, not "random".
|
||||
*
|
||||
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
|
||||
* `executePath`, which owns on-screen reflection, pacing, interrupt
|
||||
* detection, and restore-on-clean — unchanged from before loop mode existed.
|
||||
@@ -66,10 +81,18 @@ function makeLogger(verbose: boolean): Logger {
|
||||
* position each cycle. Per-cycle event logs are suppressed to avoid unbounded
|
||||
* output — one line brackets the run at each end.
|
||||
*/
|
||||
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
|
||||
async function simulateActivity(
|
||||
config: Config,
|
||||
log: Logger,
|
||||
device: Device,
|
||||
pickRandom: () => MovementStrategy,
|
||||
): Promise<void> {
|
||||
const width: number = await device.width();
|
||||
const height: number = await device.height();
|
||||
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
||||
const strategy: MovementStrategy =
|
||||
config.pattern === RANDOM_PATTERN
|
||||
? pickRandom()
|
||||
: (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!);
|
||||
|
||||
if (!config.loop) {
|
||||
const start: Point = await device.getPosition();
|
||||
@@ -119,10 +142,18 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
|
||||
* cursor, and on a user-interrupted sweep the next iteration sees the
|
||||
* user's new position and correctly resets the clock.
|
||||
*
|
||||
* @param config - Resolved runtime config.
|
||||
* @param device - I/O device; defaults to the production nut.js device.
|
||||
* @param config - Resolved runtime config.
|
||||
* @param device - I/O device; defaults to the production nut.js device.
|
||||
* @param pickRandom - Supplies a strategy when `config.pattern` is `random`.
|
||||
* Created once here (not per sweep) so its no-repeat
|
||||
* memory spans the whole run; injectable so tests can
|
||||
* drive a deterministic sequence.
|
||||
*/
|
||||
export async function runKeeper(config: Config, device?: Device): Promise<void> {
|
||||
export async function runKeeper(
|
||||
config: Config,
|
||||
device?: Device,
|
||||
pickRandom: () => MovementStrategy = createRandomPicker(),
|
||||
): Promise<void> {
|
||||
const dev: Device = device ?? (await createNutDevice());
|
||||
|
||||
const log = makeLogger(config.verbose);
|
||||
@@ -144,7 +175,7 @@ export async function runKeeper(config: Config, device?: Device): Promise<void>
|
||||
}
|
||||
|
||||
if (now - lastActivity >= config.moveInterval) {
|
||||
await simulateActivity(config, log, dev);
|
||||
await simulateActivity(config, log, dev, pickRandom);
|
||||
// The sweep either restored the cursor to its start (clean) or
|
||||
// left it where the user moved it (interrupt). Either way, reset
|
||||
// the clock and require another full moveInterval of inactivity
|
||||
|
||||
+69
-7
@@ -281,9 +281,28 @@ export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
|
||||
/** Pattern used when neither the CLI nor the config file selects one. */
|
||||
export const DEFAULT_PATTERN = "line";
|
||||
|
||||
/** All valid pattern names, for validation messages and help text. */
|
||||
/** All registered strategy names. Real generators only — see `RANDOM_PATTERN`. */
|
||||
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
|
||||
|
||||
/**
|
||||
* The reserved name for "pick a different pattern each sweep".
|
||||
*
|
||||
* Deliberately NOT a registry entry: it has no path of its own, so there is
|
||||
* nothing for `STRATEGIES` to hold and nothing for the executor to drive. It
|
||||
* is a *selection* the user makes, resolved to a real strategy once per sweep
|
||||
* by the keeper (see `createRandomPicker`). Keeping it out of the registry is
|
||||
* what lets `STRATEGIES[name]` stay a total lookup for every key it contains.
|
||||
*/
|
||||
export const RANDOM_PATTERN = "random";
|
||||
|
||||
/**
|
||||
* Everything the user may pass to `--pattern` / the `pattern` config key:
|
||||
* the registry names plus the `random` sentinel. This is the list to quote in
|
||||
* help text and validation errors; `PATTERN_NAMES` is the narrower "real
|
||||
* generators" list that the keeper and the strategy tests care about.
|
||||
*/
|
||||
export const SELECTABLE_PATTERN_NAMES: readonly string[] = [...PATTERN_NAMES, RANDOM_PATTERN];
|
||||
|
||||
/**
|
||||
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
|
||||
* type. Kept as `string` at the type level (the registry is the runtime
|
||||
@@ -296,6 +315,16 @@ export function isPatternName(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(STRATEGIES, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `name` is something the user may legitimately select: a registered
|
||||
* strategy, or the `random` sentinel. This is the check for validating user
|
||||
* input; `isPatternName` remains the narrower "is this a real generator the
|
||||
* registry can hand back" question.
|
||||
*/
|
||||
export function isSelectablePattern(name: string): boolean {
|
||||
return isPatternName(name) || name === RANDOM_PATTERN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pattern name for lenient user-facing matching: lowercase and
|
||||
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
|
||||
@@ -304,16 +333,19 @@ export function isPatternName(name: string): boolean {
|
||||
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
|
||||
|
||||
/**
|
||||
* Map of normalized name -> canonical registry key. Built once at module
|
||||
* load. The assertion below guards against two registered names collapsing
|
||||
* to the same normalized form (e.g. a future `"figure_eight"` alongside
|
||||
* `"figureEight"`), which would otherwise let one silently shadow the other.
|
||||
* Map of normalized name -> canonical selectable name. Built once at module
|
||||
* load over `SELECTABLE_PATTERN_NAMES`, so the `random` sentinel normalizes
|
||||
* like any other name and both validation boundaries accept it without
|
||||
* special-casing. The assertion below guards against two selectable names
|
||||
* collapsing to the same normalized form (e.g. a future `"figure_eight"`
|
||||
* alongside `"figureEight"`, or a strategy named `"Random"`), which would
|
||||
* otherwise let one silently shadow the other.
|
||||
*/
|
||||
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
|
||||
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
|
||||
SELECTABLE_PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
|
||||
);
|
||||
|
||||
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
|
||||
if (CANONICAL_PATTERNS.size !== SELECTABLE_PATTERN_NAMES.length) {
|
||||
throw new Error(
|
||||
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
|
||||
);
|
||||
@@ -328,3 +360,33 @@ if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
|
||||
export function resolvePatternName(name: string): string | null {
|
||||
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the picker that backs `--pattern random` / `-r`: a uniform draw over
|
||||
* the registry that never returns the same pattern twice in a row.
|
||||
*
|
||||
* The `last` memory lives in the closure rather than in module scope so the
|
||||
* lifetime is the caller's to choose — the keeper creates exactly one picker
|
||||
* per process, which is what makes "never twice in a row" hold across sweeps
|
||||
* that are minutes apart. `rng` is injected for the same reason it is on
|
||||
* `MoveContext`: so tests can assert an exact sequence.
|
||||
*
|
||||
* Returns a `MovementStrategy`, not a name, because that's what the caller
|
||||
* needs; the pick is a real registry entry, so it carries its own `loopPath`
|
||||
* and drives through `executePath` exactly like an explicitly-chosen pattern.
|
||||
*/
|
||||
export function createRandomPicker(rng: () => number = Math.random): () => MovementStrategy {
|
||||
let last: string | null = null;
|
||||
return (): MovementStrategy => {
|
||||
const pool: readonly string[] = PATTERN_NAMES.filter((n) => n !== last);
|
||||
// A single-strategy registry leaves the filtered pool empty; fall back
|
||||
// to the full list so the no-repeat rule degrades to "always repeat"
|
||||
// instead of indexing off the end.
|
||||
const names: readonly string[] = pool.length > 0 ? pool : PATTERN_NAMES;
|
||||
// Math.min pins the index in range for an `rng` that returns exactly 1
|
||||
// (outside the documented [0, 1) contract, but cheap to survive).
|
||||
const name: string = names[Math.min(names.length - 1, Math.floor(rng() * names.length))]!;
|
||||
last = name;
|
||||
return STRATEGIES[name]!;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user