Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/desktop/addon/.luacheckrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
std = "lua51"
globals = {
"FraglinkPlayedDB", "C_Timer", "CreateFrame", "RequestTimePlayed",
"GetCurrentRegion", "GetRealmName", "UnitName", "UnitClass", "UnitLevel",
"BNGetInfo", "GetBuildInfo", "WOW_PROJECT_ID", "WOW_PROJECT_CAMELOT",
"WOW_PROJECT_MAINLINE", "IsBetaBuild", "IsPublicTestClient", "IsTestBuild",
"DEFAULT_CHAT_FRAME", "SLASH_FRAGQ1", "SlashCmdList", "NUM_CHAT_WINDOWS",
"time",
}
exclude_files = { "test_addon.lua", "test_flavours.lua" }
6 changes: 6 additions & 0 deletions apps/desktop/addon/FraglinkPlayed/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.1.0
- One addon for Retail and WoW Forever. It tells them apart when you log in and tags the saved file.
- Retail interface 12.1.5 and 12.1.7. Forever interface 1.60.1.
- A lower /played does not replace the total already saved on this PC.
- The saved file stays put if Forever uploads are not being accepted yet. Upload the same file again later. Retail is unchanged.

## 1.0.1
- Correct game version tags for 12.1.5 and 12.1.7.

Expand Down
50 changes: 35 additions & 15 deletions apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,23 @@ local function unmute()
muted = {}
end

local function later(seconds, fn)
if C_Timer and C_Timer.After then
C_Timer.After(seconds, fn)
end
end

local function ask()
if pending then return end
if pending or type(RequestTimePlayed) ~= "function" then return end
pending = true
mute()
RequestTimePlayed()
if not pcall(RequestTimePlayed) then
pending = false
unmute()
return
end
-- never leave the chat frames muted if the answer doesn't come
C_Timer.After(15, function()
later(15, function()
if pending then
pending = false
unmute()
Expand All @@ -70,12 +80,14 @@ local function client_facts()
local project = tonumber(WOW_PROJECT_ID) or 0
local camelot = tonumber(WOW_PROJECT_CAMELOT) or 18
local build = ""
if GetBuildInfo then
local _, b = GetBuildInfo()
build = tostring(b or "")
if type(GetBuildInfo) == "function" then
local ok, _, b = pcall(GetBuildInfo)
if ok then build = tostring(b or "") end
end
local saw = (IsBetaBuild ~= nil) or (IsPublicTestClient ~= nil) or (IsTestBuild ~= nil)
local beta = (IsBetaBuild and IsBetaBuild()) or (IsPublicTestClient and IsPublicTestClient()) or (IsTestBuild and IsTestBuild())
local beta = (IsBetaBuild and IsBetaBuild())
or (IsPublicTestClient and IsPublicTestClient())
or (IsTestBuild and IsTestBuild())
local flavour, confirmed = "unknown", 0
if project == camelot or project == 18 then
if beta then
Expand Down Expand Up @@ -109,9 +121,9 @@ local function save(total)
local played = math.floor(total or 0)
local at = time()
local battletag = ""
if BNGetInfo then
local _, bt = BNGetInfo()
battletag = bt or ""
if type(BNGetInfo) == "function" then
local ok, _, bt = pcall(BNGetInfo)
if ok then battletag = bt or "" end
end
if name == "" or played <= 0 then return false end
if not forever and realm == "" then return false end
Expand All @@ -130,6 +142,11 @@ local function save(total)
})
FraglinkPlayedDB.chars = FraglinkPlayedDB.chars or {}
local key = forever and (region .. "/forever/" .. name) or (region .. "/" .. realm .. "/" .. name)
local prev = FraglinkPlayedDB.chars[key]
-- A lower answer must not replace the total already saved on this PC.
if type(prev) == "table" and type(prev.played) == "number" and played < prev.played then
return false
end
FraglinkPlayedDB.chars[key] = e
FraglinkPlayedDB.version = 2
return true, forever and name or (name .. "-" .. realm)
Expand All @@ -147,7 +164,8 @@ end
SLASH_FRAGQ1 = "/fragq"
SlashCmdList.FRAGQ = function()
FraglinkPlayedDB.quiet = not FraglinkPlayedDB.quiet
DEFAULT_CHAT_FRAME:AddMessage("|cffd4a017Fraglink|r: login message " .. (FraglinkPlayedDB.quiet and "hidden. Type /fragq to show it again." or "shown."))
local state = FraglinkPlayedDB.quiet and "hidden. Type /fragq to show it again." or "shown."
DEFAULT_CHAT_FRAME:AddMessage("|cffd4a017Fraglink|r: login message " .. state)
end

local f = CreateFrame("Frame")
Expand All @@ -161,16 +179,18 @@ f:SetScript("OnEvent", function(_, event, a1, a2)
if pending then
pending = false
-- re-enable chat after this event has gone past every frame
C_Timer.After(0, unmute)
if C_Timer and C_Timer.After then later(0, unmute) else unmute() end
end
elseif event == "PLAYER_ENTERING_WORLD" then
if a1 or a2 then -- first login or /reload
if a1 then announced = false end -- the chat line is once per login, not per /reload
C_Timer.After(5, ask)
later(5, ask)
end
elseif event == "PLAYER_LEVEL_UP" then
C_Timer.After(2, ask)
later(2, ask)
end
end)

C_Timer.NewTicker(REFRESH, ask)
if C_Timer and C_Timer.NewTicker then
C_Timer.NewTicker(REFRESH, ask)
end
4 changes: 3 additions & 1 deletion apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.toc
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Retail 12.1.5 (120105), Retail test 12.1.7 (120107), Forever 1.60.1 (16001).
# One toc. Both clients load this file when no flavour toc is beside it.
## Interface: 120105, 120107, 16001
## Title: Fraglink /played
## Notes: Saves each character's /played total for the Fraglink app. Nothing leaves your PC from the game.
## Notes: Saves each character's /played for Fraglink. Retail and WoW Forever. Nothing leaves your PC from the game.
## Author: Fraglink
## Version: 1.1.0
## X-Website: https://playfraglink.com
Expand Down
11 changes: 0 additions & 11 deletions apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Camelot.toc

This file was deleted.

11 changes: 0 additions & 11 deletions apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Forever.toc

This file was deleted.

6 changes: 6 additions & 0 deletions apps/desktop/addon/curseforge/changelog-1.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## 1.1.0

- One addon for Retail and WoW Forever. It tells them apart when you log in and tags the saved file.
- Retail interface 12.1.5 and 12.1.7. Forever interface 1.60.1.
- A lower /played does not replace the total already saved on this PC.
- The saved file stays put if Forever uploads are not being accepted yet. Upload the same file again later. Retail is unchanged.
19 changes: 19 additions & 0 deletions apps/desktop/addon/curseforge/description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Fraglink /played saves each character's /played time on your PC. You upload that file at [playfraglink.com](https://playfraglink.com). The addon does not send anything while you are in game.

It is one addon for Retail and WoW Forever. When you log in, it asks the game for /played and marks the saved file Retail or Forever.

An upload only adds time. A file with a lower total is refused, and the hours already stored are left as they are. Retail and Forever stay separate.

## Install

1. Download the zip.
2. Unzip it into `World of Warcraft/_retail_/Interface/AddOns` and `World of Warcraft/_forever_/Interface/AddOns`. Each should contain a `FraglinkPlayed` folder.
3. Restart the game, or type `/reload`. Enable Fraglink /played on the addon list if it is off.

## Use

1. Log in to each character once. Type `/reload` or log out so the game writes the file.
2. On [playfraglink.com](https://playfraglink.com), open the WoW page, pick Retail or Forever, and upload `WTF/Account/<ACCOUNT>/SavedVariables/FraglinkPlayed.lua`.
3. Link Battle.net so a Retail character can be verified. Forever characters stay unverified until that check is turned on. The file on your PC is kept either way, so you can upload it again later.

A line in chat says when a character was recorded. `/fragq` hides that line.
10 changes: 10 additions & 0 deletions apps/desktop/addon/curseforge/game-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Select these on the CurseForge file. Do not publish from this note.

- Game: World of Warcraft
- Retail: 12.1.5 (interface 120105) and 12.1.7 (interface 120107)
- Forever: 1.60.1 (interface 16001)
- Flavours: Retail and Forever

Do not select Classic Era, Burning Crusade, Wrath, Cataclysm, or Mists.

The numbers are from the Warcraft Wiki TOC format page (Standard 120105, Standard test 120107, Forever 16001) and the Forever 1.60.1 launch note on the same page. One zip lists all three interface numbers. CurseForge still needs both flavours ticked on the file, because the zip does not contain a separate Forever toc.
1 change: 1 addition & 0 deletions apps/desktop/addon/curseforge/summary.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Saves Retail and WoW Forever /played on your PC for Fraglink. Uploads only add time and never lower your hours.
41 changes: 41 additions & 0 deletions apps/desktop/addon/pack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/sh
# CurseForge zip: the addon folder at the root, one shared toc, no flavour copies.
set -eu
cd "$(dirname "$0")"
ver=$(sed -n 's/^## Version: //p' FraglinkPlayed/FraglinkPlayed.toc)
test "$ver" = "1.1.0"
if ls FraglinkPlayed/*_*.toc >/dev/null 2>&1; then
echo "flavour toc files must not ship" >&2
exit 1
fi
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/FraglinkPlayed"
cp FraglinkPlayed/FraglinkPlayed.lua FraglinkPlayed/FraglinkPlayed.toc \
FraglinkPlayed/CHANGELOG.md FraglinkPlayed/icon.tga "$stage/FraglinkPlayed/"
out="$PWD/FraglinkPlayed-$ver.zip"
rm -f "$out"
(cd "$stage" && zip -r -X "$out" FraglinkPlayed >/dev/null)
python3 - "$out" <<'PY'
import sys, zipfile
z = zipfile.ZipFile(sys.argv[1])
names = set(z.namelist())
need = {
"FraglinkPlayed/FraglinkPlayed.lua",
"FraglinkPlayed/FraglinkPlayed.toc",
"FraglinkPlayed/CHANGELOG.md",
"FraglinkPlayed/icon.tga",
}
missing = need - names
extra = [n for n in names if n.endswith(".toc") and n != "FraglinkPlayed/FraglinkPlayed.toc"]
if missing or extra:
raise SystemExit(f"bad zip missing={missing} extra_tocs={extra} names={sorted(names)}")
toc = z.read("FraglinkPlayed/FraglinkPlayed.toc").decode()
for needle in ("## Version: 1.1.0", "## Interface: 120105, 120107, 16001"):
if needle not in toc:
raise SystemExit("toc missing " + needle)
print("zip ok", sys.argv[1])
PY
if [ -d /opt/cursor/artifacts ]; then
cp "$out" /opt/cursor/artifacts/
fi
110 changes: 110 additions & 0 deletions apps/desktop/addon/test_flavours.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
-- Mock both clients and check the one addon tags Retail and Forever correctly.
-- lua5.1 apps/desktop/addon/test_flavours.lua
local timers, handlers = {}, nil
local chat = { registered = true }
function chat:IsEventRegistered() return self.registered end
function chat:UnregisterEvent() self.registered = false end
function chat:RegisterEvent() self.registered = true end
ChatFrame1 = chat
NUM_CHAT_WINDOWS = 1
DEFAULT_CHAT_FRAME = { AddMessage = function() end }
SlashCmdList = {}
C_Timer = {
After = function(_, fn) timers[#timers + 1] = fn end,
NewTicker = function() end,
}
function CreateFrame()
return { RegisterEvent = function() end, SetScript = function(_, _, fn) handlers = fn end }
end
function GetCurrentRegion() return 3 end
function GetRealmName() return "Area 52" end
function UnitClass() return "Mage", "MAGE" end
function UnitLevel() return 80 end
function BNGetInfo() return 1, "Sample#1234" end
function time() return 1793000000 end
function GetBuildInfo() return "12.1.5", "64000", "Oct 13 2026", 120105 end
WOW_PROJECT_MAINLINE = 1
WOW_PROJECT_CAMELOT = 18
WOW_PROJECT_ID = 1
function IsBetaBuild() return false end
function IsPublicTestClient() return false end
function IsTestBuild() return false end
local asked = 0
function RequestTimePlayed() asked = asked + 1 end
local function run_timers()
local t = timers; timers = {}
for _, fn in ipairs(t) do fn() end
end
local function fail(msg)
io.stderr:write(msg .. "\n")
os.exit(1)
end
local function eq(got, want, msg)
if got ~= want then fail(msg .. ": got " .. tostring(got) .. " want " .. tostring(want)) end
end

dofile(arg and arg[0]:gsub("test_flavours%.lua$", "") .. "FraglinkPlayed/FraglinkPlayed.lua")

local function play(name, total, project)
UnitName = function() return name end
WOW_PROJECT_ID = project
timers = {}
handlers(nil, "PLAYER_ENTERING_WORLD", true, false)
run_timers()
handlers(nil, "TIME_PLAYED_MSG", total, 10)
run_timers()
end

play("Mockrend", 7200, 1)
local retail = FraglinkPlayedDB.chars["eu/Area 52/Mockrend"]
if not retail then fail("retail key missing") end
eq(retail.flavour, "retail", "retail flavour")
eq(retail.project, 1, "retail project")
eq(retail.realm, "Area 52", "retail realm")
eq(retail.confirmed, 1, "retail confirmed")
eq(retail.first, "", "retail has no forever first name")
eq(asked > 0, true, "retail asked for /played")

GetBuildInfo = function() return "1.60.1", "70790", "Nov 4 2026", 16001 end
play("Vale Mocken", 3600, 18)
local forever = FraglinkPlayedDB.chars["eu/forever/Vale Mocken"]
if not forever then fail("forever key missing") end
eq(forever.flavour, "forever", "forever flavour")
eq(forever.project, 18, "forever project")
eq(forever.realm, "", "forever realm blank")
eq(forever.first, "Vale", "forever first")
eq(forever.surname, "Mocken", "forever surname")
eq(forever.beta, 0, "forever not beta")
eq(forever.confirmed, 1, "forever confirmed")
eq(retail.played, 7200, "retail row left alone")

IsBetaBuild = function() return true end
play("Beta Kin", 1000, 18)
eq(FraglinkPlayedDB.chars["eu/forever/Beta Kin"].flavour, "forever_beta", "beta flavour")
eq(FraglinkPlayedDB.chars["eu/forever/Beta Kin"].beta, 1, "beta flag")

IsBetaBuild, IsPublicTestClient, IsTestBuild = nil, nil, nil
play("Quiet Kin", 1000, 18)
eq(FraglinkPlayedDB.chars["eu/forever/Quiet Kin"].flavour, "forever_unconfirmed", "unconfirmed flavour")
eq(FraglinkPlayedDB.chars["eu/forever/Quiet Kin"].confirmed, 0, "unconfirmed flag")

IsBetaBuild = function() return false end
IsPublicTestClient = function() return false end
IsTestBuild = function() return false end
play("Vale Mocken", 5000, 18)
eq(FraglinkPlayedDB.chars["eu/forever/Vale Mocken"].played, 5000, "higher total kept")
play("Vale Mocken", 4000, 18)
eq(FraglinkPlayedDB.chars["eu/forever/Vale Mocken"].played, 5000, "lower total ignored")

local before = FraglinkPlayedDB.chars["eu/Area 52/Mockrend"].played
RequestTimePlayed = nil
C_Timer = nil
local ok = pcall(function()
handlers(nil, "PLAYER_ENTERING_WORLD", true, false)
handlers(nil, "PLAYER_LEVEL_UP")
handlers(nil, "TIME_PLAYED_MSG", 100, 1)
end)
eq(ok, true, "missing timer and request does not error")
eq(FraglinkPlayedDB.chars["eu/Area 52/Mockrend"].played, before, "retail hours unchanged")

io.write("flavours ok\n")
15 changes: 15 additions & 0 deletions apps/server/migrations/0058_wow_default_flavour.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Profile WoW display. default_flavour null means Retail opens first.
-- profile_show null means Both. Existing preference rows stay null.
-- Character rows, snapshots and hours are not touched.

ALTER TABLE wow_prefs ADD COLUMN IF NOT EXISTS default_flavour text;
-- Null means Both: Retail and Forever, with the toggle. Existing rows stay null.
ALTER TABLE wow_prefs ADD COLUMN IF NOT EXISTS profile_show text;

ALTER TABLE wow_prefs DROP CONSTRAINT IF EXISTS wow_prefs_default_flavour_check;
ALTER TABLE wow_prefs ADD CONSTRAINT wow_prefs_default_flavour_check
CHECK (default_flavour IS NULL OR default_flavour IN ('wow', 'wow_forever'));

ALTER TABLE wow_prefs DROP CONSTRAINT IF EXISTS wow_prefs_profile_show_check;
ALTER TABLE wow_prefs ADD CONSTRAINT wow_prefs_profile_show_check
CHECK (profile_show IS NULL OR profile_show IN ('wow', 'wow_forever', 'both'));
Loading
Loading