diff --git a/docs/modding.md b/docs/modding.md index 2d9a348b..fb5b6e25 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -449,3 +449,23 @@ platform-bridge mod bundled only with that build's launcher, for example). Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` already falls straight through to the vanilla function when no mod has wrapped the name, at negligible cost. + +## Shared date and time presentation + +The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`, +`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`). +These preferences live in `options.lua`, so checkpoint restore never rewinds +them. `DEVICE` uses the process time locale when the platform provides one; +the portable fallback is `DD-MM-YYYY` plus 24-hour time. + +Mods format captured timestamps through the read-only public facade: + +```lua +local date = mod.datetime:date(game, createdAt) +local time = mod.datetime:time(game, createdAt) +local both = mod.datetime:dateTime(game, createdAt) +``` + +The live `game` supplies only the current option context. Formatting never +mutates the save, options, or timestamp, and invalid timestamps return +`"----"`. diff --git a/src/core/DateTime.lua b/src/core/DateTime.lua new file mode 100644 index 00000000..576f4033 --- /dev/null +++ b/src/core/DateTime.lua @@ -0,0 +1,90 @@ +-- Shared local date/time presentation for engine UI and mods. Preferences +-- live in options.lua, never checkpoint progress. "device" uses the process +-- time locale when the platform supplies one and otherwise falls back to the +-- deterministic DD-MM-YYYY / 24-hour convention. + +local DateTime = {} + +local DATE_FORMATS = { + dmy = "%d-%m-%Y", + mdy = "%m-%d-%Y", + ymd = "%Y-%m-%d", +} +local TIME_FORMATS = { + ["24h"] = "%H:%M", + ["12h"] = "%I:%M %p", +} + +local function validTimestamp(value) + return type(value) == "number" and value == value and value >= 0 + and value ~= math.huge and value ~= -math.huge +end + +local function render(timestamp, pattern) + local ok, value = pcall(os.date, pattern, math.floor(timestamp)) + if not ok or type(value) ~= "string" or value == "" then return nil end + return value +end + +local function currentLocale() + if not os.setlocale then return nil end + local ok, value = pcall(os.setlocale, nil, "time") + if not ok or type(value) ~= "string" then return nil end + return value +end + +local function deviceAvailable(localeName) + return type(localeName) == "string" and localeName ~= "" + and localeName ~= "C" and localeName ~= "POSIX" +end + +function DateTime.formatWithLocale(timestamp, datePreference, timePreference, localeName) + if not validTimestamp(timestamp) then return { date = "----", time = "----" } end + local datePattern = DATE_FORMATS[datePreference] + local timePattern = TIME_FORMATS[timePreference] + if not datePattern then + datePattern = deviceAvailable(localeName) and "%x" or DATE_FORMATS.dmy + end + if not timePattern then + if deviceAvailable(localeName) then + local sample = render(timestamp, "%X") or "" + local marker = render(timestamp, "%p") or "" + timePattern = marker ~= "" and sample:find(marker, 1, true) + and TIME_FORMATS["12h"] or TIME_FORMATS["24h"] + else + timePattern = TIME_FORMATS["24h"] + end + end + return { + date = render(timestamp, datePattern) or "----", + time = render(timestamp, timePattern) or "----", + } +end + +local function preferences(game) + local options = game and game.save and game.save.options + options = type(options) == "table" and options or {} + return options.dateFormat or "device", options.timeFormat or "device" +end + +function DateTime.date(game, timestamp) + local datePreference, timePreference = preferences(game) + return DateTime.formatWithLocale(timestamp, datePreference, timePreference, + currentLocale()).date +end + +function DateTime.time(game, timestamp) + local datePreference, timePreference = preferences(game) + return DateTime.formatWithLocale(timestamp, datePreference, timePreference, + currentLocale()).time +end + +function DateTime.dateTime(game, timestamp) + local datePreference, timePreference = preferences(game) + local value = DateTime.formatWithLocale(timestamp, datePreference, timePreference, + currentLocale()) + if value.date == "----" or value.time == "----" then return "----" end + return value.date .. " " .. value.time +end + +return DateTime diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7bdfcb93..f5bd53cd 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -341,6 +341,12 @@ function SaveData.defaultOptions() -- gets the tick without going looking for the row. Inert wherever the -- overlay never appears (desktop) or LOVE has no vibrator. haptics = "light", + -- Shared UI/mod timestamp presentation. DEVICE follows the process time + -- locale where the platform exposes one; otherwise DateTime falls back to + -- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never + -- rewind presentation preferences. + dateFormat = "device", -- device | dmy | mdy | ymd + timeFormat = "device", -- device | 24h | 12h } end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 3d833f46..66a920bb 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local DateTime = require("src.core.DateTime") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -917,6 +918,16 @@ function Loader:_api(mod) -- the widget toolkit facade (12 4.5) is one shared surface, not -- per-mod state; each widget inside it loads on first touch ui = ModUI, + -- Read-only shared timestamp presentation using current options.lua + -- preferences. The live game supplies only the current option context; + -- checkpoint/save data never changes as a side effect. + datetime = { + date = function(_, game, timestamp) return DateTime.date(game, timestamp) end, + time = function(_, game, timestamp) return DateTime.time(game, timestamp) end, + dateTime = function(_, game, timestamp) + return DateTime.dateTime(game, timestamp) + end, + }, -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index cca24cd9..ef21f209 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -49,6 +49,30 @@ local Rulesets = { modern_clean = require("src.battle.rulesets.modern_clean"), } local FILTERS = { "OFF", "1X", "2X", "3X" } +local DATE_FORMATS = { + { "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" }, + { "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" }, +} +local TIME_FORMATS = { + { "device", "DEVICE" }, { "24h", "24 HOUR" }, { "12h", "12 HOUR" }, +} + +local function preferenceIndex(rows, value) + for index, row in ipairs(rows) do + if row[1] == value then return index end + end + return 1 +end + +local function preferenceStep(rows, value, direction) + local index = preferenceIndex(rows, value) + direction = direction and direction < 0 and -1 or 1 + return rows[((index - 1 + direction) % #rows) + 1][1] +end + +local function preferenceLabel(rows, value) + return rows[preferenceIndex(rows, value)][2] +end local function speedIndex(game) -- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions @@ -439,6 +463,24 @@ local function buildRows(game) activate = function(g) require("src.ui.Screens").push(g, "BindingsMenu") end }, + { id = "dateFormat", label = Strings("DATE FORMAT"), + value = function(g) + return Strings(preferenceLabel(DATE_FORMATS, g.save.options.dateFormat)) + end, + step = function(g, dir) + g.save.options.dateFormat = preferenceStep( + DATE_FORMATS, g.save.options.dateFormat, dir) + return true + end }, + { id = "timeFormat", label = Strings("TIME FORMAT"), + value = function(g) + return Strings(preferenceLabel(TIME_FORMATS, g.save.options.timeFormat)) + end, + step = function(g, dir) + g.save.options.timeFormat = preferenceStep( + TIME_FORMATS, g.save.options.timeFormat, dir) + return true + end }, -- permanent on-screen pad toggle (#327); layout editing stays in the -- launcher. Hidden where the overlay never appears (desktop without -- POKEPORT_TOUCH), so the row costs a non-mobile install nothing. diff --git a/tests/engine/date_time.lua b/tests/engine/date_time.lua new file mode 100644 index 00000000..f97bf18d --- /dev/null +++ b/tests/engine/date_time.lua @@ -0,0 +1,41 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local DateTime = require("src.core.DateTime") +local SaveData = require("src.core.SaveData") + +local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 }) + +local defaults = SaveData.defaultOptions() +T.eq(defaults.dateFormat, "device", "date format defaults to device locale") +T.eq(defaults.timeFormat, "device", "time format defaults to device locale") + +local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } } +T.eq(DateTime.date(game, stamp), os.date("%d-%m-%Y", stamp), + "explicit DMY uses day-month-year") +T.eq(DateTime.time(game, stamp), os.date("%H:%M", stamp), + "explicit 24-hour time omits seconds") +T.eq(DateTime.dateTime(game, stamp), + os.date("%d-%m-%Y %H:%M", stamp), + "combined formatter composes exact date and time preferences") + +game.save.options.dateFormat = "mdy" +T.eq(DateTime.date(game, stamp), os.date("%m-%d-%Y", stamp), + "explicit MDY is available") +game.save.options.dateFormat = "ymd" +T.eq(DateTime.date(game, stamp), os.date("%Y-%m-%d", stamp), + "explicit YMD is available") +game.save.options.timeFormat = "12h" +T.eq(DateTime.time(game, stamp), os.date("%I:%M %p", stamp), + "explicit 12-hour time is available") + +local fallback = DateTime.formatWithLocale(stamp, "device", "device", "C") +T.eq(fallback.date, os.date("%d-%m-%Y", stamp), + "missing device locale falls back to requested DMY") +T.eq(fallback.time, os.date("%H:%M", stamp), + "missing device locale falls back to requested 24-hour time") + +local invalid = DateTime.date({}, -1) +T.eq(invalid, "----", "invalid timestamps fail closed") + +T.finish("date_time") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 51bde011..86be2093 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -288,7 +288,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", "faithfulRes", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", - "mods", "controls" } + "mods", "controls", "dateFormat", "timeFormat" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -408,6 +408,23 @@ check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP" "with no rebind the rows mirror the fixed map, key and pad both (#589)") check(cbGame.save.options.bindings == nil, "opening the screen alone writes nothing") + +-- shared date/time presentation stays in options.lua and is available to +-- engine UI and mods without becoming checkpoint progress +om.game.save.options.dateFormat = "device" +om.game.save.options.timeFormat = "device" +check(om.rows[26].value(om.game) == "DEVICE", + "DATE FORMAT defaults to device locale") +om.rows[26].step(om.game, 1) +check(om.game.save.options.dateFormat == "dmy" + and om.rows[26].value(om.game) == "DD-MM-YYYY", + "DATE FORMAT exposes deterministic DMY override") +check(om.rows[27].value(om.game) == "DEVICE", + "TIME FORMAT defaults to device locale") +om.rows[27].step(om.game, 1) +check(om.game.save.options.timeFormat == "24h" + and om.rows[27].value(om.game) == "24 HOUR", + "TIME FORMAT exposes deterministic 24-hour override") check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil, "no raw-input claim until a capture is armed") press(bm, "a") diff --git a/tests/modkit/cases/date_time.lua b/tests/modkit/cases/date_time.lua new file mode 100644 index 00000000..9a0d7d90 --- /dev/null +++ b/tests/modkit/cases/date_time.lua @@ -0,0 +1,37 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local FIXTURE = { + ["mods/date_probe/manifest.json"] = [[{ + "id": "date_probe", + "name": "Date Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/date_probe/main.lua"] = [[ + local mod = ... + mod.exports.datetime = mod.datetime + ]], +} + +local run = T.sdk.loadMods({ "mods/date_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "fixture mod loads cleanly") +local datetime = run.loader.exports.date_probe.datetime +T.eq(type(datetime), "table", "mod object exposes datetime formatting") +T.eq(type(datetime.date), "function", "public formatter exposes date") +T.eq(type(datetime.time), "function", "public formatter exposes time") +T.eq(type(datetime.dateTime), "function", "public formatter exposes date-time") + +local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 }) +local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } } +T.eq(datetime:date(game, stamp), os.date("%d-%m-%Y", stamp), + "mod date follows current global engine preference") +T.eq(datetime:time(game, stamp), os.date("%H:%M", stamp), + "mod time follows current global engine preference") +T.eq(datetime:dateTime(game, stamp), os.date("%d-%m-%Y %H:%M", stamp), + "mod date-time composes the same preference") + +run.release() +T.finish("date_time")