128 lines
12 KiB
JavaScript
128 lines
12 KiB
JavaScript
// retoor <retoor@molodetz.nl>
|
||
|
||
/** Declarative coverage of the existing game API; no command runs system code. */
|
||
export class GameCommands {
|
||
static API = Object.freeze([
|
||
{"command":"home","method":"GET","path":"/game","args":[],"description":"Read your own farm overview."},
|
||
{"command":"state","method":"GET","path":"/game/state","args":[],"description":"Read every farm field as JSON."},
|
||
{"command":"leaderboard","method":"GET","path":"/game/leaderboard","args":["board"],"defaults":{"board":"score"},"params":{"board":"board"},"description":"Show a leaderboard; defaults to score."},
|
||
{"command":"view","method":"GET","path":"/game/farm/{username}","args":["username"],"description":"Inspect another player's rack."},
|
||
{"command":"plant","method":"POST","path":"/game/plant","args":["server","crop"],"params":{"slot":"server","crop":"crop"},"action":"plant","description":"Spin up a workload on an idle server."},
|
||
{"command":"harvest","method":"POST","path":"/game/harvest","args":["server"],"params":{"slot":"server"},"action":"harvest","bulk":true,"description":"Harvest a server, or harvest all ready servers."},
|
||
{"command":"stop","method":"POST","path":"/game/plot/stop","args":["server"],"params":{"slot":"server"},"fixed":{"stop":"true"},"action":"plot-stop","description":"Stop auto-replant after this server's next harvest."},
|
||
{"command":"resume","method":"POST","path":"/game/plot/stop","args":["server"],"params":{"slot":"server"},"fixed":{"stop":"false"},"action":"plot-stop","description":"Resume this server's auto-replant."},
|
||
{"command":"buy server","method":"POST","path":"/game/buy-plot","args":[],"action":"buy-plot","description":"Install the next server at its current price."},
|
||
{"command":"ci upgrade","method":"POST","path":"/game/upgrade","args":[],"action":"upgrade","description":"Upgrade your CI tier."},
|
||
{"command":"fertilize","method":"POST","path":"/game/fertilize","args":["server"],"params":{"slot":"server"},"action":"fertilize","description":"Pay to speed up a growing build."},
|
||
{"command":"daily","method":"POST","path":"/game/daily","args":[],"action":"daily","description":"Claim the daily bonus."},
|
||
{"command":"grant","method":"POST","path":"/game/grant","args":[],"action":"grant","description":"Claim your community grant if eligible."},
|
||
{"command":"perk","method":"POST","path":"/game/perk","args":["key"],"catalog":"perks","params":{"perk":"key"},"action":"perk","description":"Upgrade a perk. Run perks to see keys and costs."},
|
||
{"command":"prestige","method":"POST","path":"/game/prestige","args":[],"action":"prestige","confirm":true,"description":"Refactor your farm after confirming the live fee and reset."},
|
||
{"command":"legacy","method":"POST","path":"/game/legacy","args":["key"],"catalog":"legacy","params":{"key":"key"},"action":"legacy","description":"Buy a permanent Legacy upgrade with Stars."},
|
||
{"command":"mastery","method":"POST","path":"/game/mastery","args":["key"],"catalog":"mastery","params":{"key":"key"},"action":"mastery","description":"Spend Mastery points on an upgrade."},
|
||
{"command":"claim","method":"POST","path":"/game/quests/claim","args":["quest","scope"],"defaults":{"scope":"daily"},"params":{"quest":"quest","scope":"scope"},"action":"claim-quest","description":"Claim a daily quest or weekly contract."},
|
||
{"command":"defense upgrade","method":"POST","path":"/game/defense/upgrade","args":[],"action":"defense","description":"Upgrade defense and its daily upkeep."},
|
||
{"command":"defense downgrade","method":"POST","path":"/game/defense/downgrade","args":[],"action":"defense-downgrade","confirm":true,"description":"Reduce defense and upkeep after confirmation."},
|
||
{"command":"infra","method":"POST","path":"/game/infrastructure/buy","args":["key"],"catalog":"infrastructure","params":{"key":"key"},"action":"infrastructure","confirm":true,"description":"Buy infrastructure at its current coin price."},
|
||
{"command":"cosmetic buy","method":"POST","path":"/game/cosmetics/buy","args":["key"],"catalog":"cosmetics","params":{"key":"key"},"action":"cosmetic-buy","confirm":true,"description":"Buy a cosmetic after confirming its price."},
|
||
{"command":"cosmetic equip","method":"POST","path":"/game/cosmetics/equip","args":["key"],"catalog":"cosmetics","params":{"key":"key"},"action":"cosmetic-equip","description":"Equip an owned title."},
|
||
{"command":"water","method":"POST","path":"/game/farm/{username}/water","args":["username","server"],"params":{"slot":"server"},"action":"water","social":true,"description":"Water another player's growing server."},
|
||
{"command":"steal","method":"POST","path":"/game/farm/{username}/steal","args":["username","server"],"params":{"slot":"server"},"action":"steal","social":true,"confirm":true,"description":"Raid another player's ready server after confirmation."}
|
||
]);
|
||
|
||
static BOARDS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"];
|
||
static ALIASES = { spinup: "plant", boost: "fertilize", refactor: "prestige", ls: "servers" };
|
||
static LOCAL = {
|
||
help: "Show commands and examples.", status: "Show the current rack's status.", servers: "List current servers and countdowns.",
|
||
server: "Select a server: server 3.", crops: "List workloads, prices, rewards, locks and market state.",
|
||
perks: "List perk keys, levels and costs.", legacy: "List permanent Legacy upgrades.", mastery: "List Mastery upgrades.",
|
||
infra: "List infrastructure and ownership.", defense: "Show defense and upkeep.", cosmetics: "List cosmetics and titles.",
|
||
quests: "List daily quests and weekly contracts.", ci: "Show your CI tier.", stats: "Show lifetime, weekly and Era statistics.",
|
||
api: "Show every game API route and its command.", clear: "Clear the terminal output."
|
||
};
|
||
|
||
static usage(spec) {
|
||
return [spec.command, ...spec.args.map((arg) => Object.hasOwn(spec.defaults || {}, arg) ? `[${arg}]` : `<${arg}>`)].join(" ");
|
||
}
|
||
|
||
static server(value, farm, remote = false) {
|
||
if (!/^\d+$/.test(value || "")) throw new Error("Use a server number, for example 1.");
|
||
const slot = Number(value) - 1;
|
||
if (slot < 0 || slot >= farm.max_plots || (!remote && !farm.plots.some((plot) => plot.slot === slot))) {
|
||
throw new Error(`Server ${value} is not available. Run servers to see your installed servers.`);
|
||
}
|
||
return slot;
|
||
}
|
||
|
||
static parse(line, farm) {
|
||
if (!farm) throw new Error("The rack is still loading. Try again in a moment.");
|
||
if (line.length > 256) throw new Error("Commands must be 256 characters or fewer.");
|
||
const tokens = line.trim().split(/\s+/);
|
||
tokens[0] = tokens[0].toLowerCase();
|
||
if (Object.hasOwn(GameCommands.ALIASES, tokens[0])) tokens[0] = GameCommands.ALIASES[tokens[0]];
|
||
const matching = GameCommands.API.filter((spec) => spec.command.split(" ").every((word, index) => tokens[index]?.toLowerCase() === word));
|
||
const spec = matching.find((item) => {
|
||
const count = tokens.length - item.command.split(" ").length;
|
||
return count >= item.args.filter((arg) => !Object.hasOwn(item.defaults || {}, arg)).length && count <= item.args.length;
|
||
});
|
||
if (!spec) {
|
||
if (Object.hasOwn(GameCommands.LOCAL, tokens[0]) && (tokens.length === 1 || tokens[0] === "server" && tokens.length === 2)) {
|
||
return { local: tokens[0], slot: tokens[0] === "server" ? GameCommands.server(tokens[1], farm) : undefined };
|
||
}
|
||
if (matching.length) throw new Error(`Usage: ${GameCommands.usage(matching[0])}`);
|
||
throw new Error("Unknown command. Type help for commands and examples.");
|
||
}
|
||
if (spec.method === "POST" && !spec.social && !farm.is_owner) throw new Error("Open your own rack at /game to use this command.");
|
||
const raw = tokens.slice(spec.command.split(" ").length);
|
||
const values = { ...spec.defaults };
|
||
spec.args.forEach((arg, index) => { if (raw[index] !== undefined) values[arg] = raw[index]; });
|
||
for (const [arg, value] of Object.entries(values)) {
|
||
if (arg === "server") {
|
||
values[arg] = spec.bulk && value.toLowerCase() === "all" ? "all" : GameCommands.server(value, farm, spec.social);
|
||
} else if (arg === "username") {
|
||
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(value)) throw new Error("Use a valid username (3–32 letters, numbers, hyphens or underscores).");
|
||
} else {
|
||
values[arg] = value.toLowerCase();
|
||
if (!/^[a-z][a-z0-9_]{0,39}$/.test(values[arg])) throw new Error(`Invalid ${arg}. Type help for the accepted syntax.`);
|
||
if (arg === "board" && !GameCommands.BOARDS.includes(values[arg])) throw new Error(`Boards: ${GameCommands.BOARDS.join(", ")}`);
|
||
if (arg === "scope" && !["daily", "weekly"].includes(values[arg])) throw new Error("Quest scope must be daily or weekly.");
|
||
const catalog = arg === "crop" ? farm.crops : arg === "key" ? farm[spec.catalog] : null;
|
||
if (catalog && !catalog.some((entry) => entry.key === values[arg])) throw new Error(`Unknown ${arg}: ${value}. Run ${arg === "crop" ? "crops" : spec.catalog} to see available keys.`);
|
||
}
|
||
}
|
||
const params = { ...spec.fixed };
|
||
for (const [field, arg] of Object.entries(spec.params || {})) params[field] = values[arg];
|
||
let url = spec.path.replace("{username}", encodeURIComponent(values.username || ""));
|
||
if (spec.method === "GET" && Object.keys(params).length) url += `?${new URLSearchParams(params)}`;
|
||
return { spec, url, params, values, bulk: values.server === "all" };
|
||
}
|
||
|
||
static completions(line, farm) {
|
||
const tokens = line.trimStart().split(/\s+/);
|
||
const first = tokens[0]?.toLowerCase();
|
||
const command = Object.hasOwn(GameCommands.ALIASES, first) ? GameCommands.ALIASES[first] : first;
|
||
const index = tokens.length - 1;
|
||
const prefix = tokens[index].toLowerCase();
|
||
let choices = [];
|
||
if (index === 0) choices = [...Object.keys(GameCommands.LOCAL), ...GameCommands.API.map((spec) => spec.command.split(" ")[0]), ...Object.keys(GameCommands.ALIASES)];
|
||
else if (index === 1) {
|
||
if (["plant", "harvest", "fertilize", "stop", "resume", "server"].includes(command)) choices = (farm.plots || []).map((plot) => String(plot.slot + 1));
|
||
if (command === "harvest") choices.push("all");
|
||
const collections = { perk: "perks", legacy: "legacy", mastery: "mastery", infra: "infrastructure" };
|
||
if (collections[command]) choices = (farm[collections[command]] || []).map((entry) => entry.key);
|
||
if (command === "leaderboard") choices = GameCommands.BOARDS;
|
||
if (command === "buy") choices = ["server"];
|
||
if (command === "ci") choices = ["upgrade"];
|
||
if (command === "defense") choices = ["upgrade", "downgrade"];
|
||
if (command === "cosmetic") choices = ["buy", "equip"];
|
||
if (command === "claim") choices = [...new Set((farm.quests || []).map((quest) => quest.kind))];
|
||
} else if (index === 2) {
|
||
if (command === "plant") choices = (farm.crops || []).map((crop) => crop.key);
|
||
if (command === "cosmetic") choices = (farm.cosmetics || []).map((entry) => entry.key);
|
||
if (["water", "steal"].includes(command)) choices = Array.from({ length: farm.max_plots }, (_, slot) => String(slot + 1));
|
||
if (command === "claim") choices = ["daily", "weekly"];
|
||
}
|
||
return [...new Set(choices)].filter((choice) => choice.startsWith(prefix)).sort();
|
||
}
|
||
}
|