diff --git a/src/main/java/net/rptools/maptool/client/AppPreferences.java b/src/main/java/net/rptools/maptool/client/AppPreferences.java index 1dbfece17d..6b1e14b5bc 100644 --- a/src/main/java/net/rptools/maptool/client/AppPreferences.java +++ b/src/main/java/net/rptools/maptool/client/AppPreferences.java @@ -738,6 +738,13 @@ public static PreferenceStore getAppPreferenceStore() { "Preferences.label.macros.permissions.tooltip", false); + public static final Preference trustAllPlayers = + store.defineBoolean( + "trustAllPlayers", + "Preferences.label.macros.trustAllPlayers", + "Preferences.label.macros.trustAllPlayers.tooltip", + false); + public static final Preference loadMruCampaignAtStart = store.defineBoolean( "loadMRUCampaignAtStart", diff --git a/src/main/java/net/rptools/maptool/client/MapTool.java b/src/main/java/net/rptools/maptool/client/MapTool.java index d74a177ef5..60f7e95309 100644 --- a/src/main/java/net/rptools/maptool/client/MapTool.java +++ b/src/main/java/net/rptools/maptool/client/MapTool.java @@ -69,6 +69,7 @@ import net.rptools.maptool.client.MapToolConnection.HandshakeCompletionObserver; import net.rptools.maptool.client.events.ChatMessageAdded; import net.rptools.maptool.client.events.ServerDisconnected; +import net.rptools.maptool.client.functions.TurnTimerFunction; import net.rptools.maptool.client.functions.UserDefinedMacroFunctions; import net.rptools.maptool.client.swing.MapToolEventQueue; import net.rptools.maptool.client.swing.NoteFrame; @@ -922,6 +923,8 @@ public static MapToolLineParser getParser() { public static void setCampaign(Campaign campaign, @Nullable GUID defaultZoneId) { campaign = Objects.requireNonNullElseGet(campaign, Campaign::new); + TurnTimerFunction.cancelAll(); + // Load up the new client.setCampaign(campaign); diff --git a/src/main/java/net/rptools/maptool/client/MapToolExpressionParser.java b/src/main/java/net/rptools/maptool/client/MapToolExpressionParser.java index 452934a53c..e655ceb24c 100644 --- a/src/main/java/net/rptools/maptool/client/MapToolExpressionParser.java +++ b/src/main/java/net/rptools/maptool/client/MapToolExpressionParser.java @@ -85,6 +85,7 @@ public class MapToolExpressionParser extends ExpressionParser { TokenSpeechFunctions.getInstance(), TokenStateFunction.getInstance(), TokenVisibleFunction.getInstance(), + TurnTimerFunction.getInstance(), isVisibleFunction.getInstance(), getInfoFunction.getInstance(), TokenMoveFunctions.getInstance(), diff --git a/src/main/java/net/rptools/maptool/client/MapToolLineParser.java b/src/main/java/net/rptools/maptool/client/MapToolLineParser.java index 19a1b88d56..905ca8d969 100644 --- a/src/main/java/net/rptools/maptool/client/MapToolLineParser.java +++ b/src/main/java/net/rptools/maptool/client/MapToolLineParser.java @@ -1710,6 +1710,9 @@ public MacroLocation getMacroSource() { * @return if the macro context is trusted or not. */ public boolean isMacroTrusted() { + if (AppPreferences.trustAllPlayers.get()) { + return true; + } return !contextStack.isEmpty() && contextStack.peek().isTrusted(); } diff --git a/src/main/java/net/rptools/maptool/client/functions/TurnTimerFunction.java b/src/main/java/net/rptools/maptool/client/functions/TurnTimerFunction.java new file mode 100644 index 0000000000..e0f20e3e41 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/functions/TurnTimerFunction.java @@ -0,0 +1,271 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.functions; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.JsonArray; +import java.awt.EventQueue; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.MapToolVariableResolver; +import net.rptools.maptool.language.I18N; +import net.rptools.maptool.util.FunctionUtil; +import net.rptools.parser.Parser; +import net.rptools.parser.ParserException; +import net.rptools.parser.VariableResolver; +import net.rptools.parser.function.AbstractFunction; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Macro functions for arbitrary-length, non-blocking turn timers. Scheduling is backed by a single + * dedicated thread; expiry callbacks are bounced onto the EDT and dispatched via the macro parser + * so they execute in the same context as any other event-driven macro (e.g. {@code + * onInitiativeChange}). + * + *

Timers live in static memory only. They are not persisted with the campaign and do not survive + * a restart or {@code setCampaign} call. + */ +public class TurnTimerFunction extends AbstractFunction { + + private static final Logger LOGGER = LogManager.getLogger(TurnTimerFunction.class); + + /** Reserved timer name used by the initiative auto-restart template. */ + public static final String INITIATIVE_TIMER_NAME = "__initiative__"; + + private static final ThreadFactory THREAD_FACTORY = + new ThreadFactoryBuilder().setNameFormat("turn-timer-%d").setDaemon(true).build(); + + private static final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(THREAD_FACTORY); + + private static final ConcurrentHashMap timers = new ConcurrentHashMap<>(); + + private static volatile InitiativeTemplate initiativeTemplate; + + private TurnTimerFunction() { + super( + 0, + 4, + "startTimer", + "stopTimer", + "getTimerRemaining", + "getTimerStatus", + "getTimerNames", + "setInitiativeTimer", + "clearInitiativeTimer"); + } + + private static final TurnTimerFunction instance = new TurnTimerFunction(); + + public static TurnTimerFunction getInstance() { + return instance; + } + + @Override + public Object childEvaluate( + Parser parser, VariableResolver resolver, String functionName, List args) + throws ParserException { + return switch (functionName.toLowerCase()) { + case "starttimer" -> evalStart(args); + case "stoptimer" -> evalStop(args); + case "gettimerremaining" -> evalRemaining(args); + case "gettimerstatus" -> evalStatus(args); + case "gettimernames" -> evalNames(args); + case "setinitiativetimer" -> evalSetInitiative(args); + case "clearinitiativetimer" -> evalClearInitiative(args); + default -> + throw new ParserException( + I18N.getText("macro.function.general.unknownFunction", functionName)); + }; + } + + private Object evalStart(List args) throws ParserException { + FunctionUtil.blockUntrustedMacro("startTimer"); + FunctionUtil.checkNumberParam("startTimer", args, 2, 4); + String name = args.get(0).toString(); + double seconds = parseSeconds("startTimer", args.get(1)); + String callback = args.size() > 2 ? args.get(2).toString() : ""; + String callbackArgs = args.size() > 3 ? args.get(3).toString() : ""; + start(name, seconds, callback, callbackArgs); + return BigDecimal.ONE; + } + + private Object evalStop(List args) throws ParserException { + FunctionUtil.blockUntrustedMacro("stopTimer"); + FunctionUtil.checkNumberParam("stopTimer", args, 1, 1); + return stop(args.get(0).toString()) ? BigDecimal.ONE : BigDecimal.ZERO; + } + + private Object evalRemaining(List args) throws ParserException { + FunctionUtil.checkNumberParam("getTimerRemaining", args, 1, 1); + return BigDecimal.valueOf(remainingSeconds(args.get(0).toString())) + .setScale(3, RoundingMode.HALF_UP); + } + + private Object evalStatus(List args) throws ParserException { + FunctionUtil.checkNumberParam("getTimerStatus", args, 1, 1); + return timers.containsKey(args.get(0).toString()) ? "running" : "none"; + } + + private Object evalNames(List args) throws ParserException { + FunctionUtil.checkNumberParam("getTimerNames", args, 0, 0); + JsonArray arr = new JsonArray(); + timers.keySet().forEach(arr::add); + return arr; + } + + private Object evalSetInitiative(List args) throws ParserException { + FunctionUtil.blockUntrustedMacro("setInitiativeTimer"); + FunctionUtil.checkNumberParam("setInitiativeTimer", args, 1, 3); + double seconds = parseSeconds("setInitiativeTimer", args.get(0)); + if (seconds <= 0) { + clearInitiativeTimer(); + return BigDecimal.ONE; + } + String callback = args.size() > 1 ? args.get(1).toString() : ""; + String callbackArgs = args.size() > 2 ? args.get(2).toString() : ""; + initiativeTemplate = + new InitiativeTemplate(Math.round(seconds * 1000.0), callback, callbackArgs); + start(INITIATIVE_TIMER_NAME, seconds, callback, callbackArgs); + return BigDecimal.ONE; + } + + private Object evalClearInitiative(List args) throws ParserException { + FunctionUtil.blockUntrustedMacro("clearInitiativeTimer"); + FunctionUtil.checkNumberParam("clearInitiativeTimer", args, 0, 0); + clearInitiativeTimer(); + return BigDecimal.ONE; + } + + private static double parseSeconds(String functionName, Object raw) throws ParserException { + double seconds; + if (raw instanceof Number n) { + seconds = n.doubleValue(); + } else { + try { + seconds = Double.parseDouble(raw.toString()); + } catch (NumberFormatException e) { + throw new ParserException( + I18N.getText("macro.function.turnTimer.invalidDuration", functionName)); + } + } + if (Double.isNaN(seconds) || Double.isInfinite(seconds)) { + throw new ParserException( + I18N.getText("macro.function.turnTimer.invalidDuration", functionName)); + } + return seconds; + } + + /* -------- core scheduling (also reachable from tests) -------- */ + + static void start(String name, double seconds, String callback, String callbackArgs) + throws ParserException { + if (seconds <= 0) { + throw new ParserException( + I18N.getText("macro.function.turnTimer.invalidDuration", "startTimer")); + } + long delayMs = Math.round(seconds * 1000.0); + long expiresAt = System.currentTimeMillis() + delayMs; + ScheduledFuture future = + scheduler.schedule(() -> fire(name), delayMs, TimeUnit.MILLISECONDS); + TimerEntry prior = timers.put(name, new TimerEntry(future, expiresAt, callback, callbackArgs)); + if (prior != null) { + prior.future().cancel(false); + } + } + + static boolean stop(String name) { + TimerEntry removed = timers.remove(name); + if (removed == null) { + return false; + } + removed.future().cancel(false); + return true; + } + + static double remainingSeconds(String name) { + TimerEntry entry = timers.get(name); + if (entry == null) { + return 0.0; + } + long remaining = entry.expiresAtMillis() - System.currentTimeMillis(); + return Math.max(0L, remaining) / 1000.0; + } + + /** + * Called from {@code InitiativeList.handleInitiativeChangeCommitMacroEvent} after a successful + * initiative pass. Restarts the configured initiative timer, if one is set. + */ + public static void onInitiativeChanged() { + InitiativeTemplate tpl = initiativeTemplate; + if (tpl == null) { + return; + } + long expiresAt = System.currentTimeMillis() + tpl.millis(); + ScheduledFuture future = + scheduler.schedule(() -> fire(INITIATIVE_TIMER_NAME), tpl.millis(), TimeUnit.MILLISECONDS); + TimerEntry prior = + timers.put( + INITIATIVE_TIMER_NAME, new TimerEntry(future, expiresAt, tpl.callback(), tpl.args())); + if (prior != null) { + prior.future().cancel(false); + } + } + + /** Cancels every active timer and drops any initiative-timer template. */ + public static void cancelAll() { + initiativeTemplate = null; + timers.values().forEach(entry -> entry.future().cancel(false)); + timers.clear(); + } + + private static void clearInitiativeTimer() { + initiativeTemplate = null; + stop(INITIATIVE_TIMER_NAME); + } + + private static void fire(String name) { + TimerEntry entry = timers.remove(name); + if (entry == null || entry.callback().isEmpty()) { + return; + } + EventQueue.invokeLater( + () -> { + try { + MapTool.getParser() + .runMacro( + new MapToolVariableResolver(null), null, entry.callback(), entry.args(), false); + } catch (ParserException e) { + LOGGER.warn("Turn timer '{}' callback failed: {}", name, e.getMessage(), e); + MapTool.addLocalMessage( + I18N.getText("macro.function.turnTimer.callbackFailed", name, e.getMessage())); + } + }); + } + + private record TimerEntry( + ScheduledFuture future, long expiresAtMillis, String callback, String args) {} + + private record InitiativeTemplate(long millis, String callback, String args) {} +} diff --git a/src/main/java/net/rptools/maptool/client/ui/macrobuttons/dialog/MacroEditorDialog.java b/src/main/java/net/rptools/maptool/client/ui/macrobuttons/dialog/MacroEditorDialog.java index ee5d87c8b0..e614177956 100644 --- a/src/main/java/net/rptools/maptool/client/ui/macrobuttons/dialog/MacroEditorDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/macrobuttons/dialog/MacroEditorDialog.java @@ -24,6 +24,8 @@ import java.io.IOException; import java.util.*; import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.swing.*; @@ -75,6 +77,13 @@ public class MacroEditorDialog extends JDialog implements SearchListener { public static final String DEFAULT_COLOR_NAME = "default"; + /** + * Matches a "# NAME" directive on the first line. If present at save time, NAME is lifted into + * the macro's label and the line is stripped from the command body. + */ + private static final Pattern MACRO_NAME_DIRECTIVE = + Pattern.compile("\\A#[ \\t]+([^\\r\\n]+)\\r?\\n?"); + private static final long serialVersionUID = 8228617911117087993L; private static final Logger log = LogManager.getLogger(MacroEditorDialog.class); private final AbeillePanel panel; @@ -726,7 +735,30 @@ protected void executeAction() { } } + /** + * If the command's first line is a {@code # NAME} directive, lift NAME into the label field and + * strip the line from the command. Runs in-place on the dialog's UI so the save path picks up the + * rewritten values normally. + */ + private void applyNameDirective() { + String command = getCommandTextArea().getText(); + if (command == null || command.isEmpty()) { + return; + } + Matcher m = MACRO_NAME_DIRECTIVE.matcher(command); + if (!m.lookingAt()) { + return; + } + String name = m.group(1).trim(); + if (name.isEmpty()) { + return; + } + getLabelTextField().setText(name); + getCommandTextArea().setText(command.substring(m.end())); + } + private void save(boolean closeDialog) { + applyNameDirective(); callback.accept(getCommandTextArea().getText()); if (properties != null) { String hotKey = getHotKeyCombo().getSelectedItem().toString(); diff --git a/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialog.java b/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialog.java index 7bd8eda5ba..7aa5a718d2 100644 --- a/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialog.java @@ -361,6 +361,9 @@ public class PreferencesDialog extends AbeillePanel { private final JCheckBox allowExternalMacroAccessCheckBox = getCheckBox("allowExternalMacroAccess"); + /** Checkbox for trusting all players (dev/test convenience). */ + private final JCheckBox trustAllPlayersCheckBox = getCheckBox("trustAllPlayers"); + // Authentication /** Text area for displaying the public key for authentication. */ private final JTextArea publicKeyTextArea = (JTextArea) getComponent("publicKeyTextArea"); @@ -1057,6 +1060,8 @@ public void focusLost(FocusEvent e) { e -> AppPreferences.allowExternalMacroAccess.set( allowExternalMacroAccessCheckBox.isSelected())); + trustAllPlayersCheckBox.addActionListener( + e -> AppPreferences.trustAllPlayers.set(trustAllPlayersCheckBox.isSelected())); showDialogOnNewToken.addActionListener( e -> AppPreferences.showDialogOnNewToken.set(showDialogOnNewToken.isSelected())); autoSaveSpinner.addChangeListener( @@ -1756,6 +1761,7 @@ private void setInitialState() { upnpDiscoveryTimeoutTextField.setText( Integer.toString(AppPreferences.upnpDiscoveryTimeout.get())); allowExternalMacroAccessCheckBox.setSelected(AppPreferences.allowExternalMacroAccess.get()); + trustAllPlayersCheckBox.setSelected(AppPreferences.trustAllPlayers.get()); fileSyncPath.setText(AppPreferences.fileSyncPath.get()); // get JVM User Defaults/User override preferences diff --git a/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialogView.form b/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialogView.form index 2c9fbe2ae7..ee6319bf1a 100644 --- a/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialogView.form +++ b/src/main/java/net/rptools/maptool/client/ui/preferencesdialog/PreferencesDialogView.form @@ -1787,7 +1787,7 @@ - + @@ -1818,6 +1818,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/net/rptools/maptool/model/InitiativeList.java b/src/main/java/net/rptools/maptool/model/InitiativeList.java index 2d4a79e966..1f3a84c8e6 100644 --- a/src/main/java/net/rptools/maptool/model/InitiativeList.java +++ b/src/main/java/net/rptools/maptool/model/InitiativeList.java @@ -27,6 +27,7 @@ import java.util.stream.Collectors; import net.rptools.maptool.client.AppPreferences; import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.functions.TurnTimerFunction; import net.rptools.maptool.language.I18N; import net.rptools.maptool.model.library.Library; import net.rptools.maptool.model.library.LibraryManager; @@ -698,6 +699,7 @@ public void handleInitiativeChangeCommitMacroEvent( int oldRound, int newRound, InitiativeChangeDirection direction) { + TurnTimerFunction.onInitiativeChanged(); try { var libs = new LibraryManager() diff --git a/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java b/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java index f4e3600c4e..db60cd28b5 100644 --- a/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java +++ b/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java @@ -29,6 +29,7 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; import net.rptools.lib.MD5Key; +import net.rptools.maptool.client.AppPreferences; import net.rptools.maptool.client.MapTool; import net.rptools.maptool.client.MapToolMacroContext; import net.rptools.maptool.client.macro.MacroManager.MacroDetails; @@ -326,11 +327,15 @@ public CompletableFuture> getMTScriptMacroInfo(Strin } } + boolean trusted = + AppPreferences.trustAllPlayers.get() + || library.isOwnedByNone() + || !buttonProps.getAllowPlayerEdits(); return Optional.of( new MTScriptMacroInfo( macroName, buttonProps.getCommand(), - library.isOwnedByNone() || !buttonProps.getAllowPlayerEdits(), + trusted, !buttonProps.getAllowPlayerEdits() && buttonProps.getAutoExecute(), buttonProps.getEvaluatedToolTip())); }); diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index 8c4cd026d8..07a4ef15d8 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -959,6 +959,8 @@ Preferences.label.upnp.timeout = Discovery Timeout Preferences.label.upnp.timeout.tooltip = Timeout period in milliseconds to wait when looking for UPnP gateways. Preferences.label.macros.permissions = Enable External Macro Access Preferences.label.macros.permissions.tooltip = Enable macros to call functions that can access your drive and http services. The following functions will be enabled: getRequest, postRequest, exportData, getEnvironmentVariable. +Preferences.label.macros.trustAllPlayers = Trust All Players (insecure) +Preferences.label.macros.trustAllPlayers.tooltip = Insecure. When enabled, every macro runs in trusted context and players can create/edit macros anywhere — bypasses all macro permission checks. Intended for solo development and testing only. Preferences.label.chat.macrolinks = Suppress ToolTips for MacroLinks Preferences.label.chat.macrolinks.tooltip = MacroLinks show normally tooltips that state informations about the link target. This is a anti cheating device. This options let you disable this tooltips for aesthetic reasons. Preference.checkbox.chat.macrolinks.tooltip = Enabled: do not show tooltips for macroLink
Disabled (default): show tooltips for macroLinks @@ -2057,6 +2059,9 @@ macro.function.MacroFunctions.outOfRange = "{0}": Macro at index {1} d macro.function.TokenInit.notOnList = The token is not in the initiative list. # TokenInitFunctions macro.function.TokenInit.notOnListSet = The token is not in the initiative list so no value can be set. +# TurnTimerFunction +macro.function.turnTimer.invalidDuration = "{0}": Timer duration must be a positive number of seconds. +macro.function.turnTimer.callbackFailed = Turn timer "{0}" callback failed: {1} # abort Function # Note that I'm leaving off the double quotes on this one. I think it # will look better that way. diff --git a/src/test/java/net/rptools/maptool/client/functions/TurnTimerFunctionTest.java b/src/test/java/net/rptools/maptool/client/functions/TurnTimerFunctionTest.java new file mode 100644 index 0000000000..4b4f12376a --- /dev/null +++ b/src/test/java/net/rptools/maptool/client/functions/TurnTimerFunctionTest.java @@ -0,0 +1,86 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.functions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.rptools.parser.ParserException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Exercises the scheduling/lifecycle parts of {@link TurnTimerFunction} that do not require a + * running MapTool — registration, replacement, cancellation, remaining-time math, and the + * initiative auto-restart hook. Callback dispatch is intentionally not exercised here: it requires + * the live macro parser. Tests use empty-callback timers so {@code fire()} becomes a no-op when + * they elapse. + */ +class TurnTimerFunctionTest { + + @AfterEach + void clearTimers() { + TurnTimerFunction.cancelAll(); + } + + @Test + void startRegistersATimerThatCountsDown() throws Exception { + TurnTimerFunction.start("t1", 2.0, "", ""); + double remaining = TurnTimerFunction.remainingSeconds("t1"); + assertTrue(remaining > 1.5 && remaining <= 2.0, "expected ~2s remaining, was " + remaining); + } + + @Test + void stopRemovesTheTimer() throws Exception { + TurnTimerFunction.start("t2", 5.0, "", ""); + assertTrue(TurnTimerFunction.stop("t2")); + assertEquals(0.0, TurnTimerFunction.remainingSeconds("t2")); + assertFalse(TurnTimerFunction.stop("t2")); + } + + @Test + void startWithSameNameReplacesThePriorTimer() throws Exception { + TurnTimerFunction.start("dup", 60.0, "", ""); + double first = TurnTimerFunction.remainingSeconds("dup"); + TurnTimerFunction.start("dup", 2.0, "", ""); + double second = TurnTimerFunction.remainingSeconds("dup"); + assertTrue(first > 30, "first timer should have a long remaining; was " + first); + assertTrue(second <= 2.0, "replacement should shorten remaining; was " + second); + } + + @Test + void invalidDurationsAreRejected() { + assertThrows(ParserException.class, () -> TurnTimerFunction.start("bad", 0.0, "", "")); + assertThrows(ParserException.class, () -> TurnTimerFunction.start("bad", -1.0, "", "")); + } + + @Test + void timerElapsesToZero() throws Exception { + TurnTimerFunction.start("quick", 0.05, "", ""); + Thread.sleep(120); + assertEquals(0.0, TurnTimerFunction.remainingSeconds("quick")); + } + + @Test + void cancelAllClearsEverything() throws Exception { + TurnTimerFunction.start("a", 10.0, "", ""); + TurnTimerFunction.start("b", 10.0, "", ""); + TurnTimerFunction.cancelAll(); + assertEquals(0.0, TurnTimerFunction.remainingSeconds("a")); + assertEquals(0.0, TurnTimerFunction.remainingSeconds("b")); + } +}