diff --git a/build/scripts/commands/general.js b/build/scripts/commands/general.js index 1092bb99..649dc705 100644 --- a/build/scripts/commands/general.js +++ b/build/scripts/commands/general.js @@ -1069,7 +1069,7 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { perm: commands_1.Perm.none, handler: function (_a) { var output = _a.output; - output("[yellow]Use [white]/nextmap [lightgray] [yellow]to vote on a map.\n\n[blue]Available maps:\n_________________________\n".concat(Vars.maps.customMaps().toArray().map(function (map) { + output("[yellow]Use [white]/nextmap [lightgray][[map name] [yellow]to vote on a map.\n\n[blue]Available maps:\n_________________________\n".concat(Vars.maps.customMaps().toArray().map(function (map) { return "[yellow]".concat(map.name()); }).join("\n"))); } @@ -1084,6 +1084,115 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { var voteEndTime = -1; var voteDuration = funcs_1.Duration.minutes(1.5); var task = null; + var isThrowbackFile = function (file) { return config_1.throwbackMapFileNames.includes(file.name()); }; + var throwbackMaps = function () { return config_1.throwbackMapFileNames + .map(function (f) { return Vars.maps.customMaps().find(function (m) { return m.file.name() == f; }); }) + .filter(function (m) { return m != null; }); }; + var normalMaps = function () { return Vars.maps.customMaps().toArray().filter(function (m) { return !isThrowbackFile(m.file); }); }; + function showMapDetails(target, map) { + return __awaiter(this, void 0, void 0, function () { + var res; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, menus_1.Menu.buttons(target, map.name(), "[accent]Description: [white]".concat(map.description(), "\n[accent]Author: [white]").concat(map.author(), "\n[accent]Fastest Time: [white]").concat((0, utils_1.formatTime)(maps_1.FMap.getCreate(map).stats().shortestTime), "\n[accent]Runs: [white]").concat(maps_1.FMap.getCreate(map).stats().allRunCount, "\n[accent]Winrate: [white]").concat((maps_1.FMap.getCreate(map).stats().winRate * 100).toFixed(2), "%"), [[ + { data: "vote", text: "[green]Vote for this Map" } + ], [ + { data: "back", text: "[red]Back" } + ]], { onCancel: "null" })]; + case 1: + res = _a.sent(); + return [2 /*return*/, res !== null && res !== void 0 ? res : "back"]; + } + }); + }); + } + function pickMapFromList(target, title, maps) { + return __awaiter(this, void 0, void 0, function () { + var map, action; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, menus_1.Menu.pagedList(target, title, "Select a map to view more information.", maps, { optionStringifier: function (map) { return map.name(); }, rowsPerPage: 10, columns: 1 })]; + case 1: + map = _a.sent(); + return [4 /*yield*/, showMapDetails(target, map)]; + case 2: + action = _a.sent(); + if (action === "vote") + return [2 /*return*/, map]; + return [2 /*return*/, null]; + } + }); + }); + } + function currentMenu(target) { + return __awaiter(this, void 0, void 0, function () { + var result, _a, picked, throwback, picked, campaign, campaignMaps, picked; + var _b, _c, _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, menus_1.Menu.menu("Select a map", "[accent]---Current Map---\nMap Name: [white]".concat(Vars.state.map.name(), "\n[accent]Map Author: [white]").concat(Vars.state.map.author(), "\nFastest Time: [white]").concat((0, utils_1.formatTime)(maps_1.FMap.getCreate(Vars.state.map).stats().shortestTime), "\nCurrent Time: [white]").concat((0, utils_1.formatTime)((_e = (_c = (_b = maps_1.PartialMapRun.current) === null || _b === void 0 ? void 0 : _b.duration()) !== null && _c !== void 0 ? _c : (_d = maps_1.FMap.getCreate(Vars.state.map).runs.at(-1)) === null || _d === void 0 ? void 0 : _d.duration()) !== null && _e !== void 0 ? _e : 0)), ["[green]Current Maps", "[yellow]Throwback Maps", "[orange]Campaigns"], target, { columns: 1, includeCancel: "Close" })]; + case 1: + result = _f.sent(); + _a = result; + switch (_a) { + case '[green]Current Maps': return [3 /*break*/, 2]; + case '[yellow]Throwback Maps': return [3 /*break*/, 4]; + case '[orange]Campaigns': return [3 /*break*/, 6]; + } + return [3 /*break*/, 9]; + case 2: return [4 /*yield*/, pickMapFromList(target, "Current Maps", normalMaps())]; + case 3: + picked = _f.sent(); + if (picked) + return [2 /*return*/, picked]; + return [2 /*return*/, currentMenu(target)]; + case 4: + throwback = throwbackMaps(); + if (throwback.length == 0) + (0, commands_1.fail)("No throwback maps are currently available."); + return [4 /*yield*/, pickMapFromList(target, "Throwback Maps", throwback)]; + case 5: + picked = _f.sent(); + if (picked) + return [2 /*return*/, picked]; + return [2 /*return*/, currentMenu(target)]; + case 6: + if (config_1.campaigns.length == 0) + (0, commands_1.fail)("No campaigns are currently available."); + return [4 /*yield*/, menus_1.Menu.pagedList(target, "Campaigns", "Select a campaign to view its maps.", config_1.campaigns, { optionStringifier: function (c) { return c.name; }, rowsPerPage: 10, columns: 1 })]; + case 7: + campaign = _f.sent(); + campaignMaps = campaign.mapFileNames + .map(function (f) { return Vars.maps.customMaps().find(function (m) { return m.file.name() == f; }); }) + .filter(function (m) { return m != null; }); + if (campaignMaps.length == 0) + (0, commands_1.fail)("Campaign \"".concat(campaign.name, "\" has no available maps.")); + return [4 /*yield*/, pickMapFromList(target, "Campaign: ".concat(campaign.name), campaignMaps)]; + case 8: + picked = _f.sent(); + if (picked) + return [2 /*return*/, picked]; + return [2 /*return*/, currentMenu(target)]; + case 9: + ; + return [2 /*return*/]; + } + }); + }); + } + function sendVote(sender, map) { + votes.set(sender, map); + if (voteEndTime == -1) { + if ((Date.now() - lastVoteTime) < funcs_1.Duration.minutes(1)) + (0, commands_1.fail)("Please wait 1 minute before starting a new map vote."); + startVote(); + Call.sendMessage("[cyan]Next Map Vote: ".concat(sender.name, "[cyan] started a map vote, and voted for [yellow]").concat(map.name(), "[cyan]. Use [white]/nextmap ").concat(map.plainName(), "[] to add your vote, or run [white]/maps[] to see other available maps.")); + } + else { + Call.sendMessage("[cyan]Next Map Vote: ".concat(sender.name, "[cyan] voted for [yellow]").concat(map.name(), "[cyan]. Time left: [scarlet]").concat((0, utils_1.formatTimeRelative)(voteEndTime, true))); + showVotes(); + } + } function resetVotes() { votes.clear(); voteEndTime = -1; @@ -1093,7 +1202,7 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { return __spreadArray([], __read(votes.values()), false).reduce(function (acc, map) { return (acc.increment(map), acc); }, new ObjectIntMap()).entries().toArray(); } function showVotes() { - Call.sendMessage("[green]Current votes:\n------------------------------\n".concat(getMapData().map(function (_a) { + Call.sendMessage("[green]Current votes:\n-----------------------------\n".concat(getMapData().map(function (_a) { var map = _a.key, votes = _a.value; return "[cyan]".concat(map.name(), "[yellow]: ").concat(votes); }).toString("\n"))); @@ -1138,30 +1247,40 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { Events.on(EventType.GameOverEvent, resetVotes); Events.on(EventType.ServerLoadEvent, resetVotes); return { - args: ['map:mapOrRandom'], - description: 'Allows you to vote for the next map. Use /maps to see all available maps.', + args: ['map:mapOrRandom?'], + description: 'Allows you to vote for the next map.', perm: commands_1.Perm.play, data: { votes: votes, voteEndTime: function () { return voteEndTime; }, resetVotes: resetVotes, endVote: endVote }, requirements: [commands_1.Req.cooldown(10000)], handler: function (_a) { - var args = _a.args, sender = _a.sender; - var map = args.map === "random" ? random : args.map; - if (config_1.Gamemode.testsrv()) - (0, commands_1.fail)("Please use /forcenextmap instead."); - if (votes.get(sender)) - (0, commands_1.fail)("You have already voted."); - if (voteEndTime == -1) { - if ((Date.now() - lastVoteTime) < funcs_1.Duration.minutes(1)) - (0, commands_1.fail)("Please wait 1 minute before starting a new map vote."); - startVote(); - votes.set(sender, map); - Call.sendMessage("[cyan]Next Map Vote: ".concat(sender.name, "[cyan] started a map vote, and voted for [yellow]").concat(map.name(), "[cyan]. Use [white]/nextmap ").concat(map.plainName(), "[] to add your vote, or run [white]/maps[] to see other available maps.")); - } - else { - votes.set(sender, map); - Call.sendMessage("[cyan]Next Map Vote: ".concat(sender.name, "[cyan] voted for [yellow]").concat(map.name(), "[cyan]. Time left: [scarlet]").concat((0, utils_1.formatTimeRelative)(voteEndTime, true))); - showVotes(); - } + return __awaiter(this, arguments, void 0, function (_b) { + var _c, _d, map; + var _e; + var args = _b.args, sender = _b.sender; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: + if (config_1.Gamemode.testsrv()) + (0, commands_1.fail)("Please use /forcenextmap instead."); + if (votes.get(sender)) + (0, commands_1.fail)("You have already voted."); + if (!((_e = args.map) !== null && _e !== void 0)) return [3 /*break*/, 1]; + _c = _e; + return [3 /*break*/, 3]; + case 1: + _d = args; + return [4 /*yield*/, currentMenu(sender)]; + case 2: + _c = (_d.map = _f.sent()); + _f.label = 3; + case 3: + _c; + map = args.map === "random" ? random : args.map; + sendVote(sender, map); + return [2 /*return*/]; + } + }); + }); } }; }), surrender: (0, commands_1.command)(function () { diff --git a/build/scripts/config.js b/build/scripts/config.js index 0f8151f4..5c6bdeda 100644 --- a/build/scripts/config.js +++ b/build/scripts/config.js @@ -33,7 +33,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { return to.concat(ar || Array.prototype.slice.call(from)); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.rules = exports.tips = exports.FColor = exports.text = exports.prefixes = exports.GamemodeNames = exports.Gamemode = exports.FishServer = exports.mapRepoURLs = exports.Mode = exports.backendIP = exports.stopAntiEvadeTime = exports.heuristics = exports.adminNames = exports.multiCharSubstitutions = exports.substitutions = exports.bannedWords = void 0; +exports.noMultiVnwTag = exports.rules = exports.tips = exports.FColor = exports.text = exports.prefixes = exports.GamemodeNames = exports.Gamemode = exports.FishServer = exports.mapRepoURLs = exports.Mode = exports.campaigns = exports.Campaign = exports.throwbackMapFileNames = exports.backendIP = exports.stopAntiEvadeTime = exports.heuristics = exports.adminNames = exports.multiCharSubstitutions = exports.substitutions = exports.bannedWords = void 0; var globals_1 = require("/globals"); var ranks_1 = require("/ranks"); var funcs_1 = require("/funcs"); @@ -161,6 +161,26 @@ exports.heuristics = { }; exports.stopAntiEvadeTime = funcs_1.Duration.minutes(30); exports.backendIP = '45.79.202.111:5082'; +/** File names of throwback (retired) maps. These will not appear in the normal maps list, but will be available in the throwback maps tab. */ +exports.throwbackMapFileNames = []; +/** A campaign is a curated sequence of maps that overrides normal map rotation. */ +var Campaign = /** @class */ (function () { + function Campaign( + /** Display name of the campaign */ + name, + /** Short description shown in the menu */ + description, + /** Ordered list of map file names in the campaign */ + mapFileNames) { + this.name = name; + this.description = description; + this.mapFileNames = mapFileNames; + } + return Campaign; +}()); +exports.Campaign = Campaign; +/** Available campaigns. Maps are listed in order; when a campaign is active, rotation follows this sequence. */ +exports.campaigns = []; exports.Mode = { localDebug: new Fi("config/.debug").exists(), noBackend: new Fi("config/.debug").exists() && !exports.backendIP.startsWith("127.0.0.1:"), @@ -354,5 +374,8 @@ exports.rules = [ "# 8: [pink]No uncomfortable trolling or intentionally causing chaos. This includes any actions or messages that create an unpleasant atmosphere.", "Failure to follow these rules will result in consequences: likely a ".concat(exports.prefixes.marked, " tag for any game disruption, mute for broken chat rules, and bans for repeated offenses or bypasses.") ].map(function (r) { return "[white]".concat(r); }); +/** Map tag key for maps that opt out of multi-wave skipping (Issue #151). + * Maps with this tag set to "true" will only allow 1 wave to be skipped per /vnw call. */ +exports.noMultiVnwTag = "no-multi-vnw"; var templateObject_1, templateObject_2, templateObject_3, templateObject_4, templateObject_5, templateObject_6, templateObject_7, templateObject_8, templateObject_9; //#endregion diff --git a/build/scripts/frameworks/menus.js b/build/scripts/frameworks/menus.js index 6817282b..740c2f50 100644 --- a/build/scripts/frameworks/menus.js +++ b/build/scripts/frameworks/menus.js @@ -232,7 +232,7 @@ exports.Menu = { //Call.menu() with [[]] will cause a client crash, make sure to pass [] instead var arrangedOptions = (options.length == 0 && !includeCancel) ? [] : (0, funcs_1.to2DArray)(options, columns); if (includeCancel) { - arrangedOptions.push(["[red]Cancel[]"]); + arrangedOptions.push([(typeof includeCancel == "string" ? includeCancel : "[red]Cancel[]")]); //This is safe because cancelOptionId is set, //so the handler will never get called with "Cancel". cancelOptionId = options.length; diff --git a/build/scripts/utils.js b/build/scripts/utils.js index 4cc9fc5f..3e0bbe1e 100644 --- a/build/scripts/utils.js +++ b/build/scripts/utils.js @@ -612,6 +612,10 @@ function neutralGameover() { } /** Please validate requestedWaves to ensure it is not huge */ function skipWaves(requestedWaves, runIntermediateWaves) { + //Check if the current map has opted out of multi-wave skipping + if (Vars.state.rules.tags.get(config_1.noMultiVnwTag) == "true") { + requestedWaves = Math.min(requestedWaves, 1); + } var winWave = Vars.state.rules.winWave; if (winWave <= 0) winWave = Infinity; diff --git a/spec/src/env.ts b/spec/src/env.ts index 4841470d..da20ecc7 100644 --- a/spec/src/env.ts +++ b/spec/src/env.ts @@ -753,6 +753,11 @@ const Menus = { registerMenu(listener:BuiltinMenuListener){ this.menuListeners.add(listener); return this.menuListeners.size - 1; + }, + textInputListeners: new Seq(), + registerTextInput(listener:BuiltinTextInputListener){ + this.textInputListeners.add(listener); + return this.textInputListeners.size - 1; } }; const programStart = Date.now(); diff --git a/src/commands/general.ts b/src/commands/general.ts index 80af425c..74635e87 100644 --- a/src/commands/general.ts +++ b/src/commands/general.ts @@ -5,7 +5,7 @@ This file contains most in-game chat commands that can be run by untrusted playe import { Achievement, Achievements } from "/achievements"; import * as api from "/api"; -import { FColor, FishServer, Gamemode, rules, text } from "/config"; +import { campaigns as campaignConfig, FColor, FishServer, Gamemode, rules, text, throwbackMapFileNames } from "/config"; import { command, commandList, fail, formatArg, Perm, Req } from "/frameworks/commands"; import type { FishCommandData } from "/frameworks/commands/types"; import { Cancel, Menu } from "/frameworks/menus"; @@ -14,7 +14,7 @@ import { FishEvents, fishPlugin, fishState, ipPortPattern, recentWhispers, tileH import { FMap, PartialMapRun } from "/maps"; import { FishPlayer } from "/players"; import { Rank, RoleFlag } from "/ranks"; -import { formatTime, formatTimeRelative, getColor, logAction, nearbyEnemyTile, neutralGameover, skipWaves, teleportPlayer } from "/utils"; +import { formatTime, formatTimeRelative, getColor, logAction, nearbyEnemyTile, neutralGameover, outputMessage, skipWaves, teleportPlayer } from "/utils"; import { VoteManager } from "/votes"; export const commands = commandList({ @@ -904,12 +904,12 @@ Please stop attacking and [lime]build defenses[] first!` perm: Perm.none, handler({output}){ output(`\ -[yellow]Use [white]/nextmap [lightgray] [yellow]to vote on a map. +[yellow]Use [white]/nextmap [lightgray][[map name] [yellow]to vote on a map. [blue]Available maps: _________________________ ${Vars.maps.customMaps().toArray().map(map => -`[yellow]${map.name()}` + `[yellow]${map.name()}` ).join("\n")}` ); } @@ -928,6 +928,101 @@ ${Vars.maps.customMaps().toArray().map(map => const voteDuration = Duration.minutes(1.5); let task: TimerTask | null = null; + const isThrowbackFile = (file:Fi) => throwbackMapFileNames.includes(file.name()); + const throwbackMaps = () => throwbackMapFileNames + .map(f => Vars.maps.customMaps().find(m => m.file.name() == f)) + .filter((m): m is MMap => m != null); + const normalMaps = () => Vars.maps.customMaps().toArray().filter(m => !isThrowbackFile(m.file)); + + async function showMapDetails(target: FishPlayer, map: MMap): Promise<"vote" | "back"> { + const res = await Menu.buttons( + target, + map.name(), + `[accent]Description: [white]${map.description()} +[accent]Author: [white]${map.author()} +[accent]Fastest Time: [white]${formatTime(FMap.getCreate(map)!.stats().shortestTime)} +[accent]Runs: [white]${FMap.getCreate(map)!.stats().allRunCount} +[accent]Winrate: [white]${(FMap.getCreate(map)!.stats().winRate * 100).toFixed(2)}%`, + [[ + { data: "vote" as const, text: "[green]Vote for this Map" } + ], [ + { data: "back" as const, text: "[red]Back" } + ]], + { onCancel: "null" } + ); + return res ?? "back"; + } + + async function pickMapFromList(target: FishPlayer, title: string, maps: MMap[]): Promise { + const map = await Menu.pagedList( + target, + title, + "Select a map to view more information.", + maps, + { optionStringifier: map => map.name(), rowsPerPage: 10, columns: 1 } + ); + const action = await showMapDetails(target, map); + if(action === "vote") return map; + return null; + } + + async function currentMenu(target: FishPlayer): Promise { + const result = await Menu.menu( + "Select a map", + `[accent]---Current Map--- +Map Name: [white]${Vars.state.map.name()} +[accent]Map Author: [white]${Vars.state.map.author()} +Fastest Time: [white]${formatTime(FMap.getCreate(Vars.state.map)!.stats().shortestTime)} +Current Time: [white]${formatTime(PartialMapRun.current?.duration() ?? FMap.getCreate(Vars.state.map)!.runs.at(-1)?.duration() ?? 0)}`, + ["[green]Current Maps", "[yellow]Throwback Maps", "[orange]Campaigns"], + target, + { columns: 1, includeCancel: "Close" } + ); + switch (result) { + case '[green]Current Maps': { + const picked = await pickMapFromList(target, "Current Maps", normalMaps()); + if(picked) return picked; + return currentMenu(target); + } + case '[yellow]Throwback Maps': { + const throwback = throwbackMaps(); + if(throwback.length == 0) fail("No throwback maps are currently available."); + const picked = await pickMapFromList(target, "Throwback Maps", throwback); + if(picked) return picked; + return currentMenu(target); + } + case '[orange]Campaigns': { + if(campaignConfig.length == 0) fail("No campaigns are currently available."); + const campaign = await Menu.pagedList( + target, + "Campaigns", + "Select a campaign to view its maps.", + campaignConfig, + { optionStringifier: c => c.name, rowsPerPage: 10, columns: 1 } + ); + const campaignMaps = campaign.mapFileNames + .map(f => Vars.maps.customMaps().find(m => m.file.name() == f)) + .filter((m): m is MMap => m != null); + if(campaignMaps.length == 0) fail(`Campaign "${campaign.name}" has no available maps.`); + const picked = await pickMapFromList(target, `Campaign: ${campaign.name}`, campaignMaps); + if(picked) return picked; + return currentMenu(target); + } + }; + } + + function sendVote(sender:FishPlayer, map:MMap | Random){ + votes.set(sender, map); + if(voteEndTime == -1){ + if((Date.now() - lastVoteTime) < Duration.minutes(1)) fail(`Please wait 1 minute before starting a new map vote.`); + startVote(); + Call.sendMessage(`[cyan]Next Map Vote: ${sender.name}[cyan] started a map vote, and voted for [yellow]${map.name()}[cyan]. Use [white]/nextmap ${map.plainName()}[] to add your vote, or run [white]/maps[] to see other available maps.`); + } else { + Call.sendMessage(`[cyan]Next Map Vote: ${sender.name}[cyan] voted for [yellow]${map.name()}[cyan]. Time left: [scarlet]${formatTimeRelative(voteEndTime, true)}`); + showVotes(); + } + } + function resetVotes(){ votes.clear(); voteEndTime = -1; @@ -943,7 +1038,7 @@ ${Vars.maps.customMaps().toArray().map(map => function showVotes(){ Call.sendMessage(`\ [green]Current votes: ------------------------------- +----------------------------- ${getMapData().map(({key:map, value:votes}) => `[cyan]${map.name()}[yellow]: ${votes}` ).toString("\n")}` @@ -995,26 +1090,18 @@ ${highestVotedMaps.map(({key:map, value:votes}) => Events.on(EventType.ServerLoadEvent, resetVotes); return { - args: ['map:mapOrRandom'], - description: 'Allows you to vote for the next map. Use /maps to see all available maps.', + args: ['map:mapOrRandom?'], + description: 'Allows you to vote for the next map.', perm: Perm.play, data: {votes, voteEndTime: () => voteEndTime, resetVotes, endVote}, requirements: [Req.cooldown(10_000)], - handler({args, sender}){ - const map = args.map === "random" ? random : args.map; + async handler({args, sender}){ if(Gamemode.testsrv()) fail(`Please use /forcenextmap instead.`); if(votes.get(sender)) fail(`You have already voted.`); - - if(voteEndTime == -1){ - if((Date.now() - lastVoteTime) < Duration.minutes(1)) fail(`Please wait 1 minute before starting a new map vote.`); - startVote(); - votes.set(sender, map); - Call.sendMessage(`[cyan]Next Map Vote: ${sender.name}[cyan] started a map vote, and voted for [yellow]${map.name()}[cyan]. Use [white]/nextmap ${map.plainName()}[] to add your vote, or run [white]/maps[] to see other available maps.`); - } else { - votes.set(sender, map); - Call.sendMessage(`[cyan]Next Map Vote: ${sender.name}[cyan] voted for [yellow]${map.name()}[cyan]. Time left: [scarlet]${formatTimeRelative(voteEndTime, true)}`); - showVotes(); - } + + args.map ??= await currentMenu(sender); + const map = args.map === "random" ? random : args.map; + sendVote(sender, map); } }; }), diff --git a/src/config.ts b/src/config.ts index 44b45381..39f6e872 100644 --- a/src/config.ts +++ b/src/config.ts @@ -143,6 +143,21 @@ export const heuristics = { }; export const stopAntiEvadeTime = Duration.minutes(30); export const backendIP = '45.79.202.111:5082'; +/** File names of throwback (retired) maps. These will not appear in the normal maps list, but will be available in the throwback maps tab. */ +export const throwbackMapFileNames: string[] = []; +/** A campaign is a curated sequence of maps that overrides normal map rotation. */ +export class Campaign { + constructor( + /** Display name of the campaign */ + public name:string, + /** Short description shown in the menu */ + public description:string, + /** Ordered list of map file names in the campaign */ + public mapFileNames:string[], + ){} +} +/** Available campaigns. Maps are listed in order; when a campaign is active, rotation follows this sequence. */ +export const campaigns:Campaign[] = []; export const Mode = { localDebug: new Fi("config/.debug").exists(), noBackend: new Fi("config/.debug").exists() && !backendIP.startsWith("127.0.0.1:"), @@ -376,5 +391,8 @@ export const rules = [ `# 8: [pink]No uncomfortable trolling or intentionally causing chaos. This includes any actions or messages that create an unpleasant atmosphere.`, `Failure to follow these rules will result in consequences: likely a ${prefixes.marked} tag for any game disruption, mute for broken chat rules, and bans for repeated offenses or bypasses.` ].map(r => `[white]${r}`); +/** Map tag key for maps that opt out of multi-wave skipping (Issue #151). + * Maps with this tag set to "true" will only allow 1 wave to be skipped per /vnw call. */ +export const noMultiVnwTag = "no-multi-vnw"; //#endregion diff --git a/src/frameworks/menus.ts b/src/frameworks/menus.ts index 33f3094f..48927c96 100644 --- a/src/frameworks/menus.ts +++ b/src/frameworks/menus.ts @@ -74,8 +74,8 @@ type MenuConfirmProps = { type MenuCancelOption = "reject" | "null"; type MenuOptions = { - /** [red]Cancel[] will be added to the list of options. */ - includeCancel?: boolean; + /** The specified text, or "[red]Cancel[]", will be added to the list of options. */ + includeCancel?: boolean | string; optionStringifier?: (opt: TOption) => string; columns?: number; /** @@ -212,7 +212,7 @@ export const Menu = { const arrangedOptions = (options.length == 0 && !includeCancel) ? [] : to2DArray(options, columns); if(includeCancel){ - arrangedOptions.push(["[red]Cancel[]" as never]); + arrangedOptions.push([(typeof includeCancel == "string" ? includeCancel : "[red]Cancel[]") as never]); //This is safe because cancelOptionId is set, //so the handler will never get called with "Cancel". cancelOptionId = options.length; diff --git a/src/mindustryTypes.ts b/src/mindustryTypes.ts index f9d939c8..bf0a5ce9 100644 --- a/src/mindustryTypes.ts +++ b/src/mindustryTypes.ts @@ -67,6 +67,7 @@ class Rules { attackMode: boolean; pvp: boolean; editor: boolean; + tags: ObjectMap; copy(): Rules; dynamicColor: Color; planetBackground: any; diff --git a/src/utils.ts b/src/utils.ts index 0afcfddf..5ce465e2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,7 @@ For functions that don't need values from other files, see funcs.ts. */ import * as api from "/api"; -import { adminNames, bannedWords, Gamemode, GamemodeName, multiCharSubstitutions, substitutions, text } from "/config"; +import { adminNames, bannedWords, Gamemode, GamemodeName, multiCharSubstitutions, noMultiVnwTag, substitutions, text } from "/config"; import { CommandError, fail, PartialFormatString } from "/frameworks/commands"; import { Cancel } from "/frameworks/menus"; import { crash, escapeStringColorsServer, escapeTextDiscord, parseError, random, searchFixed, StringIO } from "/funcs"; @@ -460,6 +460,10 @@ export function neutralGameover(){ /** Please validate requestedWaves to ensure it is not huge */ export function skipWaves(requestedWaves: number, runIntermediateWaves: boolean){ + //Check if the current map has opted out of multi-wave skipping + if(Vars.state.rules.tags.get(noMultiVnwTag) == "true"){ + requestedWaves = Math.min(requestedWaves, 1); + } let winWave = Vars.state.rules.winWave; if(winWave <= 0) winWave = Infinity; const wavesToSkip = Math.min(requestedWaves, winWave - Vars.state.wave);