furhter updates to display when an admin invalidated a run instead of hiding it

This commit is contained in:
jenz
2026-09-30 15:32:22 +02:00
parent a3765a73d9
commit 54827b01ba
18 changed files with 364 additions and 44 deletions
+13 -4
View File
@@ -28,8 +28,8 @@ Points are calculated per category, from each player's **best valid** time only.
| Base points | n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut. | | Base points | n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut. |
| Bonus multiplier | Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark. | | Bonus multiplier | Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark. |
| CLASSIC RACETIMER | Final points ÷ 10. | | CLASSIC RACETIMER | Final points ÷ 10. |
| Invalid category | Still shown with positions, but gives 0 points. | | Invalid category | Still shown with positions, but gives 0 points. The admin and time are public (`invalidation`). |
| Invalid record | Listed separately under `invalidated` with no position and 0 points. Only that one run is removed; the player's previous valid time counts again. | | Invalid record | Still listed on the board in time order with `isInvalid: true`, position 0 and 0 points. Only that one run is removed from the ranking; the player's previous valid time counts again. The admin and time are public (`invalidation`). |
| Servers | ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. `dev`) are hidden. | | Servers | ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. `dev`) are hidden. |
The old flat +2500 bonus for small boards is gone. The old flat +2500 bonus for small boards is gone.
@@ -42,6 +42,15 @@ The old flat +2500 bonus for small boards is gone.
Without `?server=`, both endpoints use `defaultServerTag` (ze1). On ZE2, add `?server=ze2` to those two URLs so levels come from ZE2 points. Without `?server=`, both endpoints use `defaultServerTag` (ze1). On ZE2, add `?server=ze2` to those two URLs so levels come from ZE2 points.
Levels drop for CLASSIC data, as intended. Levels drop for CLASSIC data, as intended.
## Who invalidated what
Run `racetimer_invalidation_audit.sql` once on the racetimer database. It adds `invalidated_by_steam`, `invalidated_by_name` and `invalidated_at` to `timer_records` and `zone_categories`.
- Invalidating stores the signed-in admin's SteamID, name and the current time. Invalidating something that is already invalid keeps the first admin and time.
- Restoring sets `is_invalid = 0` and clears the three columns.
- Every invalid run and category in the API has `invalidation: {steamID, steamID64, name, at}` (`at` in Unix seconds). Player rows and history also have `categoryInvalidation`. Rows flagged before the columns existed have all fields `null`.
- Until the script is run, the site keeps working (a warning is logged) but cannot say who invalidated what, and the admin buttons return an error asking for the script.
## Endpoints ## Endpoints
`{steamid}` accepts `STEAM_0:x:y`, `STEAM_1:x:y`, `[U:1:n]` or a SteamID64. `{steamid}` accepts `STEAM_0:x:y`, `STEAM_1:x:y`, `[U:1:n]` or a SteamID64.
@@ -56,11 +65,11 @@ Errors are JSON: `{"statusCode": 404, "errorMessage": "..."}`.
| `GET timers/leaderboard/minified/{offset}?server=` | `[{name, PlayerPoints}]` (for `toplvl.sp`) | | `GET timers/leaderboard/minified/{offset}?server=` | `[{name, PlayerPoints}]` (for `toplvl.sp`) |
| `GET timers/player/{steamid}?server=` | One player, same fields as the leaderboard. 404 if unknown. | | `GET timers/player/{steamid}?server=` | One player, same fields as the leaderboard. 404 if unknown. |
| `GET timers/player/badges/{steamid}` | `{badgesUrls, badges: [{name, url}]}` | | `GET timers/player/badges/{steamid}` | `{badgesUrls, badges: [{name, url}]}` |
| `GET timers/player/maps/{steamid}/{offset}?server=` | 50 rows of the player's best per category. Fields: `recordId`, `categoryId`, `mapName`, `stage`, `categoryNumber`, `serverTag`, `isLegacy`, `categoryInvalid`, `time`, `position`, `completions`, `bonusMultiplier`, `points`, `recordedAt`. Without `server`, both servers are included. | | `GET timers/player/maps/{steamid}/{offset}?server=` | 50 rows of the player's best per category, plus any faster invalidated run (`isInvalid: true`, position 0). Fields: `recordId`, `categoryId`, `mapName`, `stage`, `categoryNumber`, `serverTag`, `isLegacy`, `categoryInvalid`, `isInvalid`, `time`, `position`, `completions`, `bonusMultiplier`, `points`, `recordedAt`. Without `server`, both servers are included. |
| `GET timers/player/history/{steamid}/{offset}?categoryId=` | 50 improvements, newest first. Fields: `time`, `previousTime`, `improvedBy` (seconds), `recordedAt`, `isInvalid`, `isCurrentBest`, plus the category fields. Legacy records are last, with `recordedAt: null`. | | `GET timers/player/history/{steamid}/{offset}?categoryId=` | 50 improvements, newest first. Fields: `time`, `previousTime`, `improvedBy` (seconds), `recordedAt`, `isInvalid`, `isCurrentBest`, plus the category fields. Legacy records are last, with `recordedAt: null`. |
| `GET timers/allmaps` | `[{mapName, allCategoriesInvalid, stages: [{stage, allCategoriesInvalid, categories: [category]}]}]` | | `GET timers/allmaps` | `[{mapName, allCategoriesInvalid, stages: [{stage, allCategoriesInvalid, categories: [category]}]}]` |
| `GET timers/map/{mapname}` | One map in the same shape (case-insensitive). | | `GET timers/map/{mapname}` | One map in the same shape (case-insensitive). |
| `GET timers/category/{id}/{offset}` | `{category, offset, pageSize, entries: [75], invalidated: [...]}` | | `GET timers/category/{id}/{offset}` | `{category, offset, pageSize, entries: [75], invalidated: [...]}`. `entries` holds valid times and invalidated runs together, fastest first; invalidated ones have `isInvalid: true`, `position: 0`, `points: 0`. `invalidated` repeats just those runs. |
| `GET timers/searchplayers/{text}?server=` | Up to 100 players, matched on name or Steam ID. | | `GET timers/searchplayers/{text}?server=` | Up to 100 players, matched on name or Steam ID. |
| `GET timers/searchmaps/{text}` | Maps whose name contains the text. | | `GET timers/searchmaps/{text}` | Maps whose name contains the text. |
@@ -124,8 +124,9 @@ public final class Settings {
if (racetimerPassword == null) { if (racetimerPassword == null) {
racetimerPassword = ""; racetimerPassword = "";
} }
publicBackendUrl = stripSlash(publicBackendUrl); publicBackendUrl = stripSlash(stripFragment(publicBackendUrl));
frontendUrl = stripSlash(frontendUrl); // The site uses #/... routes itself; a "#" or "#/" copied from the browser would break the redirect.
frontendUrl = stripSlash(stripFragment(frontendUrl));
if (rankedServerTags == null || rankedServerTags.isEmpty()) { if (rankedServerTags == null || rankedServerTags.isEmpty()) {
rankedServerTags = Arrays.asList("ze1", "ze2"); rankedServerTags = Arrays.asList("ze1", "ze2");
} }
@@ -170,6 +171,14 @@ public final class Settings {
} }
} }
private static String stripFragment(String s) {
if (s == null) {
return null;
}
int hash = s.indexOf('#');
return (hash >= 0 ? s.substring(0, hash) : s).trim();
}
private static String stripSlash(String s) { private static String stripSlash(String s) {
if (s == null) { if (s == null) {
return null; return null;
@@ -82,6 +82,17 @@ public final class Dto {
} }
} }
/** Who marked something invalid and when. Public, shown to everybody. */
public static final class InvalidationDTO {
/** Admin SteamID; null for rows invalidated before this was recorded. */
public String steamID;
public String steamID64;
/** Admin name at the time of the change. */
public String name;
/** Unix seconds; null if unknown. */
public Long at;
}
public static final class CategoryDTO { public static final class CategoryDTO {
public int id; public int id;
/** "Category N", the same number players see in-game. */ /** "Category N", the same number players see in-game. */
@@ -96,6 +107,8 @@ public final class Dto {
public boolean isLegacy; public boolean isLegacy;
/** Flagged invalid by an admin: shown, but gives no points. */ /** Flagged invalid by an admin: shown, but gives no points. */
public boolean isInvalid; public boolean isInvalid;
/** Who invalidated the category and when. Null when valid. */
public InvalidationDTO invalidation;
public boolean givesPoints; public boolean givesPoints;
public int completions; public int completions;
public Double fastestTime; public Double fastestTime;
@@ -116,6 +129,7 @@ public final class Dto {
public static final class BoardEntryDTO { public static final class BoardEntryDTO {
public long recordId; public long recordId;
/** 0 for an invalidated run. */
public int position; public int position;
public String steamID; public String steamID;
public String steamID64; public String steamID64;
@@ -129,6 +143,9 @@ public final class Dto {
/** Unix seconds. Null for legacy records (date unknown). */ /** Unix seconds. Null for legacy records (date unknown). */
public Long recordedAt; public Long recordedAt;
public boolean isLegacy; public boolean isLegacy;
/** Run invalidated by an admin: listed in time order, but no position and no points. */
public boolean isInvalid;
public InvalidationDTO invalidation;
} }
public static final class InvalidatedEntryDTO { public static final class InvalidatedEntryDTO {
@@ -141,14 +158,16 @@ public final class Dto {
public Long recordedAt; public Long recordedAt;
public boolean isLegacy; public boolean isLegacy;
public boolean isInvalid = true; public boolean isInvalid = true;
public InvalidationDTO invalidation;
} }
public static final class CategoryBoardDTO { public static final class CategoryBoardDTO {
public CategoryDTO category; public CategoryDTO category;
public int offset; public int offset;
public int pageSize; public int pageSize;
/** Valid entries and invalidated runs (isInvalid, position 0) together, fastest first. */
public List<BoardEntryDTO> entries = new ArrayList<>(); public List<BoardEntryDTO> entries = new ArrayList<>();
/** Invalidated records that would otherwise be a player's best. No position, no points. */ /** Only the invalidated runs of the whole board (also inside entries). Kept for older clients. */
public List<InvalidatedEntryDTO> invalidated = new ArrayList<>(); public List<InvalidatedEntryDTO> invalidated = new ArrayList<>();
} }
@@ -161,6 +180,10 @@ public final class Dto {
public String serverTag; public String serverTag;
public boolean isLegacy; public boolean isLegacy;
public boolean categoryInvalid; public boolean categoryInvalid;
/** This run was invalidated by an admin: position 0, no points. */
public boolean isInvalid;
public InvalidationDTO invalidation;
public InvalidationDTO categoryInvalidation;
public double time; public double time;
public int position; public int position;
public int completions; public int completions;
@@ -186,6 +209,8 @@ public final class Dto {
public Long recordedAt; public Long recordedAt;
/** This run was invalidated by an admin. */ /** This run was invalidated by an admin. */
public boolean isInvalid; public boolean isInvalid;
public InvalidationDTO invalidation;
public InvalidationDTO categoryInvalidation;
/** This run is the player's current best valid time in the category. */ /** This run is the player's current best valid time in the category. */
public boolean isCurrentBest; public boolean isCurrentBest;
} }
@@ -1,6 +1,10 @@
package racetimer.model; package racetimer.model;
/** A player's best valid record in one category, with its place and points. */ /**
* One row of a category leaderboard. Usually a player's best valid record with
* its place and points; for an invalidated run (record.invalid) the position
* is 0 and it gives no points, but it is still listed so it can be seen.
*/
public final class BoardEntry { public final class BoardEntry {
public final RecordRow record; public final RecordRow record;
public final Category category; public final Category category;
@@ -15,4 +19,13 @@ public final class BoardEntry {
this.points = points; this.points = points;
this.multiplier = multiplier; this.multiplier = multiplier;
} }
/** An invalidated run shown on the board: no position, no points. */
public static BoardEntry invalidated(RecordRow record, Category category) {
return new BoardEntry(record, category, 0, 0, 1.0);
}
public boolean isInvalid() {
return record.invalid;
}
} }
@@ -19,6 +19,8 @@ public final class Category {
/** Server tag gets points and is shown publicly (ze1/ze2, not dev). */ /** Server tag gets points and is shown publicly (ze1/ze2, not dev). */
public final boolean ranked; public final boolean ranked;
public final List<CvarParser.Cvar> cvars; public final List<CvarParser.Cvar> cvars;
/** Who invalidated this category and when; null if valid or unknown. */
public Invalidation invalidation;
/** "Category N" exactly as the plugin numbers it in-game. */ /** "Category N" exactly as the plugin numbers it in-game. */
public int number; public int number;
@@ -26,6 +28,8 @@ public final class Category {
public List<BoardEntry> entries = new ArrayList<>(); public List<BoardEntry> entries = new ArrayList<>();
/** Invalidated records that would otherwise be a player's best, fastest first. */ /** Invalidated records that would otherwise be a player's best, fastest first. */
public List<RecordRow> invalidated = new ArrayList<>(); public List<RecordRow> invalidated = new ArrayList<>();
/** What the website lists: valid entries and invalidated runs together, fastest first. */
public List<BoardEntry> board = new ArrayList<>();
public Category(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, public Category(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash,
boolean invalid, boolean legacy, boolean ranked) { boolean invalid, boolean legacy, boolean ranked) {
@@ -0,0 +1,25 @@
package racetimer.model;
/** Who marked a record or category invalid, and when. Public: everybody can see it. */
public final class Invalidation {
/** SteamID of the admin, e.g. STEAM_0:1:12345. May be null for rows invalidated before this was stored. */
public final String steamId;
/** The admin's name when they made the change. */
public final String name;
/** Unix seconds. */
public final Long at;
public Invalidation(String steamId, String name, Long at) {
this.steamId = steamId;
this.name = name;
this.at = at;
}
/** Null when nothing was stored (valid row, or invalidated before the audit columns existed). */
public static Invalidation of(String steamId, String name, Long at) {
if (steamId == null && name == null && at == null) {
return null;
}
return new Invalidation(steamId, name, at);
}
}
@@ -31,6 +31,8 @@ public final class PlayerInfo {
public List<Badge> badges = Collections.emptyList(); public List<Badge> badges = Collections.emptyList();
/** Best valid record per category, with position and points. */ /** Best valid record per category, with position and points. */
public final List<BoardEntry> bests = new ArrayList<>(); public final List<BoardEntry> bests = new ArrayList<>();
/** Invalidated runs faster than the player's valid best in that category (or with no valid time at all). */
public final List<BoardEntry> invalidatedBests = new ArrayList<>();
/** Every record the player ever set (all improvements, valid or not), oldest first. */ /** Every record the player ever set (all improvements, valid or not), oldest first. */
public final List<RecordRow> records = new ArrayList<>(); public final List<RecordRow> records = new ArrayList<>();
public final Map<String, ServerStats> servers = new HashMap<>(); public final Map<String, ServerStats> servers = new HashMap<>();
@@ -10,6 +10,8 @@ public final class RecordRow {
public final String steamName; public final String steamName;
/** Unix seconds from timer_improvements.recorded_at. */ /** Unix seconds from timer_improvements.recorded_at. */
public final long recordedAt; public final long recordedAt;
/** Who invalidated this run and when; null if valid or unknown. */
public Invalidation invalidation;
public RecordRow(long id, int categoryId, double time, boolean invalid, String steamAuth, String steamName, long recordedAt) { public RecordRow(long id, int categoryId, double time, boolean invalid, String steamAuth, String steamName, long recordedAt) {
this.id = id; this.id = id;
@@ -54,22 +54,39 @@ public class AdminResource {
boolean invalid = parse(body); boolean invalid = parse(body);
AuthUser user = (AuthUser) sc.getUserPrincipal(); AuthUser user = (AuthUser) sc.getUserPrincipal();
int changed; int changed;
// Invalidating stores who did it and when (shown publicly on the site).
// Restoring clears those again. Only rows whose flag actually changes are
// touched, so invalidating twice keeps the first admin and time.
String sql = invalid
? "UPDATE " + table + " SET is_invalid = 1, invalidated_by_steam = ?, invalidated_by_name = ?, "
+ "invalidated_at = NOW() WHERE id = ? AND is_invalid = 0"
: "UPDATE " + table + " SET is_invalid = 0, invalidated_by_steam = NULL, invalidated_by_name = NULL, "
+ "invalidated_at = NULL WHERE id = ? AND is_invalid = 1";
try (Connection con = DataSources.racetimer(); try (Connection con = DataSources.racetimer();
PreparedStatement ps = con.prepareStatement("UPDATE " + table + " SET is_invalid = ? WHERE id = ?")) { PreparedStatement ps = con.prepareStatement(sql)) {
ps.setInt(1, invalid ? 1 : 0); int i = 1;
ps.setLong(2, id); if (invalid) {
ps.setString(i++, user.steamId);
ps.setString(i++, user.name);
}
ps.setLong(i, id);
changed = ps.executeUpdate(); changed = ps.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
if (e.getErrorCode() == 1054) { // unknown column
LOG.severe("Cannot save who invalidated " + what + " " + id
+ ": run racetimer_invalidation_audit.sql on the racetimer database first");
throw new InternalServerErrorException(
"The database is missing the invalidated_by columns. Run racetimer_invalidation_audit.sql first.");
}
LOG.log(Level.SEVERE, "Could not update " + table + " " + id, e); LOG.log(Level.SEVERE, "Could not update " + table + " " + id, e);
throw new InternalServerErrorException("Could not save the change"); throw new InternalServerErrorException("Could not save the change");
} }
if (changed == 0) { if (changed == 0) {
// MySQL reports 0 when the value was already set, so check the row exists. // 0 rows when the flag already had this value, so check the row exists.
if (!exists(table, id)) { if (!exists(table, id)) {
throw new NotFoundException("No " + what + " with id " + id); throw new NotFoundException("No " + what + " with id " + id);
} }
} }
// No audit table in the schema, so the server log is the record of who did what.
LOG.info("ADMIN " + user.name + " (" + user.steamId + ") set " + what + " " + id + " invalid=" + invalid); LOG.info("ADMIN " + user.name + " (" + user.steamId + ") set " + what + " " + id + " invalid=" + invalid);
SnapshotService.refreshNow(); SnapshotService.refreshNow();
return Json.write(new Dto.InvalidFlagResultDTO(id, invalid, user.steamId)); return Json.write(new Dto.InvalidFlagResultDTO(id, invalid, user.steamId));
@@ -72,7 +72,7 @@ public class AuthResource {
try { try {
admin = SourceBansAdmins.lookup(steam2); admin = SourceBansAdmins.lookup(steam2);
} catch (Exception e) { } catch (Exception e) {
LOG.log(Level.SEVERE, "SourceBans lookup failed", e); LOG.log(Level.SEVERE, "SourceBans lookup failed for " + steam2 + " (check sourcebansURL, user, password and sourcebansPrefix)", e);
return fail("admin_check_unavailable"); return fail("admin_check_unavailable");
} }
if (admin == null) { if (admin == null) {
@@ -88,7 +88,7 @@ public class AuthResource {
body.put("token", token); body.put("token", token);
return Response.ok(Json.write(body), MediaType.APPLICATION_JSON).build(); return Response.ok(Json.write(body), MediaType.APPLICATION_JSON).build();
} }
return Response.seeOther(URI.create(frontend + "/#token=" + token)).build(); return redirect(frontend + "/#token=" + token);
} }
@GET @GET
@@ -113,7 +113,19 @@ public class AuthResource {
return Response.status(Response.Status.FORBIDDEN).type(MediaType.APPLICATION_JSON) return Response.status(Response.Status.FORBIDDEN).type(MediaType.APPLICATION_JSON)
.entity(Json.write(new Dto.ErrorDTO(403, reason))).build(); .entity(Json.write(new Dto.ErrorDTO(403, reason))).build();
} }
return Response.seeOther(URI.create(frontend + "/#loginError=" + reason)).build(); return redirect(frontend + "/#loginError=" + reason);
}
/** Redirect to the website; never throws, even if frontendUrl is malformed. */
private static Response redirect(String url) {
try {
return Response.seeOther(URI.create(url)).build();
} catch (IllegalArgumentException e) {
LOG.severe("frontendUrl is not a valid URL: " + Settings.get().frontendUrl);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).type(MediaType.APPLICATION_JSON)
.entity(Json.write(new Dto.ErrorDTO(500, "frontendUrl in the backend settings is not a valid URL")))
.build();
}
} }
private static String displayName(String steam2, SourceBansAdmins.Admin admin) { private static String displayName(String steam2, SourceBansAdmins.Admin admin) {
@@ -23,6 +23,7 @@ import racetimer.model.RecordRow;
import racetimer.model.Snapshot; import racetimer.model.Snapshot;
import racetimer.service.AvatarService; import racetimer.service.AvatarService;
import racetimer.service.HistoryService; import racetimer.service.HistoryService;
import racetimer.service.SnapshotBuilder;
import racetimer.service.SnapshotService; import racetimer.service.SnapshotService;
import racetimer.util.SteamIds; import racetimer.util.SteamIds;
@@ -87,7 +88,10 @@ public class TimerResource {
return Json.write(Views.badges(findPlayer(SnapshotService.get(), steamid))); return Json.write(Views.badges(findPlayer(SnapshotService.get(), steamid)));
} }
/** The player's best valid time in every public category, sorted by map, stage, category. */ /**
* The player's best valid time in every public category, sorted by map, stage, category.
* Invalidated runs faster than that best are listed too, with isInvalid = true.
*/
@GET @GET
@Path("player/maps/{steamid}/{offset}") @Path("player/maps/{steamid}/{offset}")
public String playerMaps(@PathParam("steamid") String steamid, @PathParam("offset") int offset, public String playerMaps(@PathParam("steamid") String steamid, @PathParam("offset") int offset,
@@ -95,7 +99,10 @@ public class TimerResource {
PlayerInfo p = findPlayer(SnapshotService.get(), steamid); PlayerInfo p = findPlayer(SnapshotService.get(), steamid);
String onlyTag = server == null || server.isEmpty() ? null : server(server); String onlyTag = server == null || server.isEmpty() ? null : server(server);
List<BoardEntry> rows = new ArrayList<>(); List<BoardEntry> rows = new ArrayList<>();
for (BoardEntry e : p.bests) { List<BoardEntry> all = new ArrayList<>(p.bests);
all.addAll(p.invalidatedBests);
Collections.sort(all, SnapshotBuilder.PLAYER_ROW_ORDER);
for (BoardEntry e : all) {
if (e.category.ranked && (onlyTag == null || onlyTag.equals(e.category.serverTag))) { if (e.category.ranked && (onlyTag == null || onlyTag.equals(e.category.serverTag))) {
rows.add(e); rows.add(e);
} }
@@ -153,7 +160,8 @@ public class TimerResource {
if (c == null || !c.ranked) { if (c == null || !c.ranked) {
throw new NotFoundException("No category with id " + id); throw new NotFoundException("No category with id " + id);
} }
List<BoardEntry> page = page(c.entries, offset, CATEGORY_PAGE); // Valid entries and invalidated runs together, fastest first; invalid ones have position 0.
List<BoardEntry> page = page(c.board, offset, CATEGORY_PAGE);
List<Long> ids = new ArrayList<>(); List<Long> ids = new ArrayList<>();
for (BoardEntry e : page) { for (BoardEntry e : page) {
ids.add(steam64(s, e.record.steamAuth)); ids.add(steam64(s, e.record.steamAuth));
@@ -5,15 +5,17 @@ import java.util.Map;
import racetimer.dto.Dto; import racetimer.dto.Dto;
import racetimer.model.BoardEntry; import racetimer.model.BoardEntry;
import racetimer.model.Category; import racetimer.model.Category;
import racetimer.model.Invalidation;
import racetimer.model.MapInfo; import racetimer.model.MapInfo;
import racetimer.model.PlayerInfo; import racetimer.model.PlayerInfo;
import racetimer.model.RecordRow; import racetimer.model.RecordRow;
import racetimer.model.Snapshot; import racetimer.model.Snapshot;
import racetimer.service.AvatarService; import racetimer.service.AvatarService;
import racetimer.util.CvarParser; import racetimer.util.CvarParser;
import racetimer.util.SteamIds;
/** Converts snapshot objects into the JSON DTOs. */ /** Converts snapshot objects into the JSON DTOs. */
final class Views { public final class Views {
private Views() { private Views() {
} }
@@ -66,6 +68,7 @@ final class Views {
} }
d.isLegacy = c.legacy; d.isLegacy = c.legacy;
d.isInvalid = c.invalid; d.isInvalid = c.invalid;
d.invalidation = invalidation(c.invalid, c.invalidation);
d.givesPoints = c.givesPoints(); d.givesPoints = c.givesPoints();
d.completions = c.completions(); d.completions = c.completions();
d.fastestTime = c.entries.isEmpty() ? null : c.entries.get(0).record.time; d.fastestTime = c.entries.isEmpty() ? null : c.entries.get(0).record.time;
@@ -107,6 +110,8 @@ final class Views {
d.bonusMultiplier = e.multiplier; d.bonusMultiplier = e.multiplier;
d.isLegacy = e.category.legacy; d.isLegacy = e.category.legacy;
d.recordedAt = e.category.legacy ? null : e.record.recordedAt; d.recordedAt = e.category.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
d.invalidation = invalidation(e.isInvalid(), e.record.invalidation);
return d; return d;
} }
@@ -121,6 +126,7 @@ final class Views {
d.time = r.time; d.time = r.time;
d.isLegacy = c.legacy; d.isLegacy = c.legacy;
d.recordedAt = c.legacy ? null : r.recordedAt; d.recordedAt = c.legacy ? null : r.recordedAt;
d.invalidation = invalidation(true, r.invalidation);
return d; return d;
} }
@@ -141,6 +147,28 @@ final class Views {
d.bonusMultiplier = e.multiplier; d.bonusMultiplier = e.multiplier;
d.points = e.points; d.points = e.points;
d.recordedAt = c.legacy ? null : e.record.recordedAt; d.recordedAt = c.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
d.invalidation = invalidation(e.isInvalid(), e.record.invalidation);
d.categoryInvalidation = invalidation(c.invalid, c.invalidation);
return d;
}
/**
* Who invalidated it and when; null for valid rows. An invalid row without a
* stored admin (flagged before this was recorded) gets an empty object.
*/
public static Dto.InvalidationDTO invalidation(boolean invalid, Invalidation inv) {
if (!invalid) {
return null;
}
Dto.InvalidationDTO d = new Dto.InvalidationDTO();
if (inv != null) {
d.steamID = inv.steamId;
long s64 = inv.steamId == null ? 0 : SteamIds.toSteam64(inv.steamId);
d.steamID64 = s64 == 0 ? null : Long.toString(s64);
d.name = inv.name;
d.at = inv.at;
}
return d; return d;
} }
} }
@@ -13,6 +13,7 @@ import racetimer.model.Category;
import racetimer.model.PlayerInfo; import racetimer.model.PlayerInfo;
import racetimer.model.RecordRow; import racetimer.model.RecordRow;
import racetimer.model.Snapshot; import racetimer.model.Snapshot;
import racetimer.rest.Views;
/** /**
* A player's improvement history: every run that was saved (each one was a * A player's improvement history: every run that was saved (each one was a
@@ -50,6 +51,8 @@ public final class HistoryService {
d.categoryInvalid = c.invalid; d.categoryInvalid = c.invalid;
d.time = r.time; d.time = r.time;
d.isInvalid = r.invalid; d.isInvalid = r.invalid;
d.invalidation = Views.invalidation(r.invalid, r.invalidation);
d.categoryInvalidation = Views.invalidation(c.invalid, c.invalidation);
d.recordedAt = c.legacy ? null : r.recordedAt; d.recordedAt = c.legacy ? null : r.recordedAt;
Double prev = bestSoFar.get(c.id); Double prev = bestSoFar.get(c.id);
d.previousTime = prev; d.previousTime = prev;
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
import racetimer.model.BoardEntry; import racetimer.model.BoardEntry;
import racetimer.model.Category; import racetimer.model.Category;
import racetimer.model.Invalidation;
import racetimer.model.MapInfo; import racetimer.model.MapInfo;
import racetimer.model.PlayerInfo; import racetimer.model.PlayerInfo;
import racetimer.model.RecordRow; import racetimer.model.RecordRow;
@@ -32,6 +33,8 @@ public final class SnapshotBuilder {
public final String serverCvars; public final String serverCvars;
public final long cvarsHash; public final long cvarsHash;
public final boolean invalid; public final boolean invalid;
/** Who invalidated it and when; null if valid or unknown. */
public Invalidation invalidation;
public CategoryRow(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, boolean invalid) { public CategoryRow(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, boolean invalid) {
this.id = id; this.id = id;
@@ -44,6 +47,27 @@ public final class SnapshotBuilder {
} }
} }
/** Map, stage, category; within one category the faster row first, valid first on equal time. */
public static final Comparator<BoardEntry> PLAYER_ROW_ORDER = new Comparator<BoardEntry>() {
@Override
public int compare(BoardEntry a, BoardEntry b) {
int x = String.CASE_INSENSITIVE_ORDER.compare(a.category.mapName, b.category.mapName);
if (x != 0) {
return x;
}
x = Integer.compare(a.category.stage, b.category.stage);
if (x != 0) {
return x;
}
x = Integer.compare(a.category.number, b.category.number);
if (x != 0) {
return x;
}
x = Double.compare(a.record.time, b.record.time);
return x != 0 ? x : Boolean.compare(a.isInvalid(), b.isInvalid());
}
};
private final Set<String> rankedTags; private final Set<String> rankedTags;
private final String classicServerCvars; private final String classicServerCvars;
@@ -64,6 +88,7 @@ public final class SnapshotBuilder {
boolean legacy = classicServerCvars != null && classicServerCvars.equals(r.serverCvars); boolean legacy = classicServerCvars != null && classicServerCvars.equals(r.serverCvars);
Category c = new Category(r.id, r.mapName, r.stage, r.serverTag, r.serverCvars, r.cvarsHash, Category c = new Category(r.id, r.mapName, r.stage, r.serverTag, r.serverCvars, r.cvarsHash,
r.invalid, legacy, rankedTags.contains(r.serverTag)); r.invalid, legacy, rankedTags.contains(r.serverTag));
c.invalidation = r.invalid ? r.invalidation : null;
categories.put(c.id, c); categories.put(c.id, c);
String key = r.mapName + '\u0000' + r.stage; String key = r.mapName + '\u0000' + r.stage;
List<Category> list = byMapStage.get(key); List<Category> list = byMapStage.get(key);
@@ -198,6 +223,26 @@ public final class SnapshotBuilder {
} }
Collections.sort(shown, byTime); Collections.sort(shown, byTime);
c.invalidated = shown; c.invalidated = shown;
// The public board lists invalidated runs too, marked, in time order.
List<BoardEntry> board = new ArrayList<>(entries);
for (RecordRow r : shown) {
BoardEntry e = BoardEntry.invalidated(r, c);
board.add(e);
players.get(r.steamAuth).invalidatedBests.add(e);
}
Collections.sort(board, new Comparator<BoardEntry>() {
@Override
public int compare(BoardEntry a, BoardEntry b) {
int x = Double.compare(a.record.time, b.record.time);
if (x != 0) {
return x;
}
x = Boolean.compare(a.isInvalid(), b.isInvalid()); // valid first on equal time
return x != 0 ? x : RecordRow.byTime(a.record, b.record);
}
});
c.board = board;
} }
// --- Leaderboards per ranked server --- // --- Leaderboards per ranked server ---
@@ -269,17 +314,8 @@ public final class SnapshotBuilder {
// Player's category list in a stable, readable order. // Player's category list in a stable, readable order.
for (PlayerInfo p : players.values()) { for (PlayerInfo p : players.values()) {
Collections.sort(p.bests, new Comparator<BoardEntry>() { Collections.sort(p.bests, PLAYER_ROW_ORDER);
@Override Collections.sort(p.invalidatedBests, PLAYER_ROW_ORDER);
public int compare(BoardEntry a, BoardEntry b) {
int x = String.CASE_INSENSITIVE_ORDER.compare(a.category.mapName, b.category.mapName);
if (x != 0) {
return x;
}
x = Integer.compare(a.category.stage, b.category.stage);
return x != 0 ? x : Integer.compare(a.category.number, b.category.number);
}
});
} }
return new Snapshot(categories, maps, mapsByName, players, leaderboards, System.currentTimeMillis()); return new Snapshot(categories, maps, mapsByName, players, leaderboards, System.currentTimeMillis());
@@ -14,6 +14,7 @@ import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import racetimer.config.Settings; import racetimer.config.Settings;
import racetimer.db.DataSources; import racetimer.db.DataSources;
import racetimer.model.Invalidation;
import racetimer.model.PlayerInfo; import racetimer.model.PlayerInfo;
import racetimer.model.RecordRow; import racetimer.model.RecordRow;
import racetimer.model.Snapshot; import racetimer.model.Snapshot;
@@ -57,12 +58,19 @@ public final class SnapshotLoader {
List<RecordRow> records = new ArrayList<>(); List<RecordRow> records = new ArrayList<>();
try (Connection con = DataSources.racetimer()) { try (Connection con = DataSources.racetimer()) {
boolean audit = hasAuditColumns(con);
try (PreparedStatement ps = con.prepareStatement( try (PreparedStatement ps = con.prepareStatement(
"SELECT id, map_name, stage, server_tag, server_cvars, cvars_hash, is_invalid FROM zone_categories"); "SELECT id, map_name, stage, server_tag, server_cvars, cvars_hash, is_invalid"
+ (audit ? ", invalidated_by_steam, invalidated_by_name, UNIX_TIMESTAMP(invalidated_at)" : "")
+ " FROM zone_categories");
ResultSet rs = ps.executeQuery()) { ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
categories.add(new SnapshotBuilder.CategoryRow(rs.getInt(1), rs.getString(2), rs.getInt(3), SnapshotBuilder.CategoryRow row = new SnapshotBuilder.CategoryRow(rs.getInt(1), rs.getString(2),
rs.getString(4), rs.getString(5), rs.getLong(6), rs.getBoolean(7))); rs.getInt(3), rs.getString(4), rs.getString(5), rs.getLong(6), rs.getBoolean(7));
if (audit) {
row.invalidation = invalidation(rs, 8);
}
categories.add(row);
} }
} }
try (PreparedStatement ps = con.prepareStatement("SELECT steam_auth, name FROM players"); try (PreparedStatement ps = con.prepareStatement("SELECT steam_auth, name FROM players");
@@ -74,14 +82,19 @@ public final class SnapshotLoader {
// Streamed: this is the big one (every improvement ever made). // Streamed: this is the big one (every improvement ever made).
try (PreparedStatement ps = con.prepareStatement( try (PreparedStatement ps = con.prepareStatement(
"SELECT tr.id, tr.zone_category_id, tr.time_value, tr.is_invalid, ti.steam_auth, ti.steam_name, " "SELECT tr.id, tr.zone_category_id, tr.time_value, tr.is_invalid, ti.steam_auth, ti.steam_name, "
+ "UNIX_TIMESTAMP(ti.recorded_at) " + "UNIX_TIMESTAMP(ti.recorded_at)"
+ "FROM timer_records tr JOIN timer_improvements ti ON ti.id = tr.improvement_id", + (audit ? ", tr.invalidated_by_steam, tr.invalidated_by_name, UNIX_TIMESTAMP(tr.invalidated_at)" : "")
+ " FROM timer_records tr JOIN timer_improvements ti ON ti.id = tr.improvement_id",
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(Integer.MIN_VALUE); ps.setFetchSize(Integer.MIN_VALUE);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
records.add(new RecordRow(rs.getLong(1), rs.getInt(2), rs.getDouble(3), rs.getBoolean(4), RecordRow r = new RecordRow(rs.getLong(1), rs.getInt(2), rs.getDouble(3), rs.getBoolean(4),
rs.getString(5), rs.getString(6), rs.getLong(7))); rs.getString(5), rs.getString(6), rs.getLong(7));
if (audit && r.invalid) {
r.invalidation = invalidation(rs, 8);
}
records.add(r);
} }
} }
} }
@@ -95,6 +108,32 @@ public final class SnapshotLoader {
return snap; return snap;
} }
/**
* Whether the invalidated_by_* columns exist (see racetimer_invalidation_audit.sql).
* Without them the site still works, it just cannot say who invalidated what.
*/
static boolean hasAuditColumns(Connection con) throws SQLException {
String sql = "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() "
+ "AND TABLE_NAME IN ('timer_records', 'zone_categories') "
+ "AND COLUMN_NAME IN ('invalidated_by_steam', 'invalidated_by_name', 'invalidated_at')";
try (Statement st = con.createStatement(); ResultSet rs = st.executeQuery(sql)) {
boolean ok = rs.next() && rs.getInt(1) == 6;
if (!ok) {
LOG.warning("timer_records / zone_categories have no invalidated_by_* columns yet. "
+ "Run racetimer_invalidation_audit.sql so the site can show who invalidated what.");
}
return ok;
}
}
private static Invalidation invalidation(ResultSet rs, int first) throws SQLException {
String steam = rs.getString(first);
String name = rs.getString(first + 1);
long at = rs.getLong(first + 2);
Long when = rs.wasNull() ? null : at;
return Invalidation.of(steam, name, when);
}
/** Forum badges; failures only cost the badges, never the whole refresh. */ /** Forum badges; failures only cost the badges, never the whole refresh. */
private static Map<String, List<PlayerInfo.Badge>> loadBadges(Settings s) { private static Map<String, List<PlayerInfo.Badge>> loadBadges(Settings s) {
Map<String, List<PlayerInfo.Badge>> out = new HashMap<>(); Map<String, List<PlayerInfo.Badge>> out = new HashMap<>();
@@ -22,6 +22,7 @@ import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import racetimer.TestData; import racetimer.TestData;
import racetimer.config.Settings; import racetimer.config.Settings;
import racetimer.model.Invalidation;
import racetimer.security.AuthUser; import racetimer.security.AuthUser;
import racetimer.security.JwtService; import racetimer.security.JwtService;
import racetimer.service.SnapshotService; import racetimer.service.SnapshotService;
@@ -46,11 +47,12 @@ public class ApiTest {
.category(3, "ze_a", 1, "dev", "sv_gravity 800", 6L, false) .category(3, "ze_a", 1, "dev", "sv_gravity 800", 6L, false)
.category(4, "ze_b", 1, "ze1", "sv_gravity 800, tickrate 66", 9L, true) .category(4, "ze_b", 1, "ze1", "sv_gravity 800, tickrate 66", 9L, true)
.player(A, "jenz").player(B, "bob"); .player(A, "jenz").player(B, "bob");
d.categories.get(3).invalidation = new Invalidation(A, "jenz", 7000L);
d.record(1, A, 30.0, 1000); d.record(1, A, 30.0, 1000);
d.record(1, B, 31.0, 1000); d.record(1, B, 31.0, 1000);
d.record(2, A, 20.0, 2000); d.record(2, A, 20.0, 2000);
d.record(2, A, 18.0, 3000); d.record(2, A, 18.0, 3000);
d.record(2, B, 9.0, 3500, true); d.record(2, B, 9.0, 3500, true).invalidation = new Invalidation(A, "jenz", 6000L);
d.record(2, B, 19.0, 3600); d.record(2, B, 19.0, 3600);
d.record(3, A, 1.0, 4000); d.record(3, A, 1.0, 4000);
d.record(4, A, 5.0, 5000); d.record(4, A, 5.0, 5000);
@@ -137,14 +139,63 @@ public class ApiTest {
assertEquals(0, json(call("GET", "timers/leaderboard/100?server=ze2", null, null)).getAsJsonArray().size()); assertEquals(0, json(call("GET", "timers/leaderboard/100?server=ze2", null, null)).getAsJsonArray().size());
} }
@Test
public void invalidCategoryShowsWhoInvalidatedIt() throws Exception {
JsonObject b = json(call("GET", "timers/category/4/0", null, null)).getAsJsonObject();
JsonObject c = b.getAsJsonObject("category");
assertTrue(c.get("isInvalid").getAsBoolean());
assertEquals("jenz", c.getAsJsonObject("invalidation").get("name").getAsString());
assertEquals(7000, c.getAsJsonObject("invalidation").get("at").getAsLong());
assertEquals(0, b.getAsJsonArray("entries").get(0).getAsJsonObject().get("points").getAsInt());
JsonArray rows = json(call("GET", "timers/player/maps/" + A + "/0", null, null)).getAsJsonArray();
boolean seen = false;
for (JsonElement e : rows) {
JsonObject r = e.getAsJsonObject();
if (r.get("categoryId").getAsInt() == 4) {
seen = true;
assertEquals("jenz", r.getAsJsonObject("categoryInvalidation").get("name").getAsString());
assertEquals(0, r.get("points").getAsInt());
}
}
assertTrue(seen);
JsonArray hist = json(call("GET", "timers/player/history/" + B + "/0", null, null)).getAsJsonArray();
boolean inv = false;
for (JsonElement e : hist) {
JsonObject h = e.getAsJsonObject();
if (h.get("isInvalid").getAsBoolean()) {
inv = true;
assertEquals(6000, h.getAsJsonObject("invalidation").get("at").getAsLong());
}
}
assertTrue(inv);
}
@Test @Test
public void categoryBoardShowsInvalidatedRunsAndLegacyDates() throws Exception { public void categoryBoardShowsInvalidatedRunsAndLegacyDates() throws Exception {
JsonObject b = json(call("GET", "timers/category/2/0", null, null)).getAsJsonObject(); JsonObject b = json(call("GET", "timers/category/2/0", null, null)).getAsJsonObject();
assertEquals(2, b.getAsJsonObject("category").get("completions").getAsInt()); assertEquals(2, b.getAsJsonObject("category").get("completions").getAsInt());
assertEquals(2, b.getAsJsonObject("category").getAsJsonArray("cvars").size()); assertEquals(2, b.getAsJsonObject("category").getAsJsonArray("cvars").size());
JsonArray entries = b.getAsJsonArray("entries"); JsonArray entries = b.getAsJsonArray("entries");
assertEquals(18.0, entries.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9); // The invalidated 9.0 is listed in time order, marked, without position or points.
assertEquals(3000, entries.get(0).getAsJsonObject().get("recordedAt").getAsLong()); JsonObject invalidRow = entries.get(0).getAsJsonObject();
assertEquals(9.0, invalidRow.get("time").getAsDouble(), 1e-9);
assertTrue(invalidRow.get("isInvalid").getAsBoolean());
assertEquals(0, invalidRow.get("position").getAsInt());
assertEquals(0, invalidRow.get("points").getAsInt());
// Everybody (no token here) sees who invalidated it and when.
JsonObject by = invalidRow.getAsJsonObject("invalidation");
assertEquals("jenz", by.get("name").getAsString());
assertEquals(A, by.get("steamID").getAsString());
assertEquals("76561198029832363", by.get("steamID64").getAsString());
assertEquals(6000, by.get("at").getAsLong());
JsonObject firstValid = entries.get(1).getAsJsonObject();
assertEquals(18.0, firstValid.get("time").getAsDouble(), 1e-9);
assertEquals(1, firstValid.get("position").getAsInt());
assertTrue(!firstValid.get("isInvalid").getAsBoolean());
assertTrue(!firstValid.has("invalidation") || firstValid.get("invalidation").isJsonNull());
assertEquals(3000, firstValid.get("recordedAt").getAsLong());
JsonArray inv = b.getAsJsonArray("invalidated"); JsonArray inv = b.getAsJsonArray("invalidated");
assertEquals(1, inv.size()); assertEquals(1, inv.size());
assertEquals(9.0, inv.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9); assertEquals(9.0, inv.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9);
@@ -212,6 +263,28 @@ public class ApiTest {
assertEquals("https://racetimer.example.com/#loginError=steam_verification_failed", cb.getLocation().toString()); assertEquals("https://racetimer.example.com/#loginError=steam_verification_failed", cb.getLocation().toString());
} }
@Test
public void frontendUrlWithHashIsCleanedUp() throws Exception {
Settings original = Settings.get();
try {
java.lang.reflect.Method load = Settings.class.getDeclaredMethod("load", java.nio.file.Path.class);
load.setAccessible(true);
java.nio.file.Path f = java.nio.file.Files.createTempFile("settings", ".json");
java.nio.file.Files.write(f, ("{\"racetimerURL\":\"jdbc:mysql://x/y\",\"racetimerUser\":\"u\","
+ "\"frontendUrl\":\"https://racetimerweb.unloze.com/#/\",\"jwtSecret\":\"0123456789abcdef0123456789abcdef-x\","
+ "\"publicBackendUrl\":\"https://racebackend.example.com/racetimer_endpoints-1.0\"}")
.getBytes(StandardCharsets.UTF_8));
Settings s = (Settings) load.invoke(null, f);
assertEquals("https://racetimerweb.unloze.com", s.frontendUrl);
Settings.override(s);
ContainerResponse cb = call("GET", "auth/steam/callback?openid.mode=cancel", null, null);
assertEquals(303, cb.getStatus());
assertEquals("https://racetimerweb.unloze.com/#loginError=steam_verification_failed", cb.getLocation().toString());
} finally {
Settings.override(original);
}
}
@Test @Test
public void corsPreflight() throws Exception { public void corsPreflight() throws Exception {
ContainerResponse r = call("OPTIONS", "admin/records/1", null, null); ContainerResponse r = call("OPTIONS", "admin/records/1", null, null);
@@ -76,6 +76,14 @@ public class SnapshotBuilderTest {
assertEquals(3, a.records.size()); assertEquals(3, a.records.size());
assertEquals(1, a.bests.size()); assertEquals(1, a.bests.size());
assertEquals(1, a.servers.get("ze1").points); // 2nd of 2 assertEquals(1, a.servers.get("ze1").points); // 2nd of 2
// The website board still lists the cheated run, marked invalid, in time order.
assertEquals(3, c.board.size());
assertTrue(c.board.get(0).isInvalid());
assertEquals(0, c.board.get(0).position);
assertEquals(0, c.board.get(0).points);
assertEquals(1, c.board.get(1).position);
assertEquals(1, a.invalidatedBests.size());
assertEquals(5.0, a.invalidatedBests.get(0).record.time, 1e-9);
} }
@Test @Test
+10 -3
View File
@@ -19,11 +19,14 @@ CREATE TABLE `zone_categories` (
`server_cvars` text NOT NULL, `server_cvars` text NOT NULL,
`cvars_hash` int(11) NOT NULL, `cvars_hash` int(11) NOT NULL,
`is_invalid` tinyint(1) NOT NULL DEFAULT 0, `is_invalid` tinyint(1) NOT NULL DEFAULT 0,
`invalidated_by_steam` varchar(32) DEFAULT NULL,
`invalidated_by_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT NULL,
`invalidated_at` timestamp NULL DEFAULT NULL,
`first_recorded_at` timestamp NOT NULL DEFAULT current_timestamp(), `first_recorded_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uq_zone_cvarset` (`map_name`,`stage`,`cvars_hash`), UNIQUE KEY `uq_zone_cvarset` (`map_name`,`stage`,`cvars_hash`),
KEY `idx_zone_category_order` (`map_name`,`stage`,`first_recorded_at`,`id`) KEY `idx_zone_category_order` (`map_name`,`stage`,`first_recorded_at`,`id`)
) ENGINE=InnoDB AUTO_INCREMENT=447 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB AUTO_INCREMENT=6546 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- unloze_racetimer_css_2026.timer_improvements definition -- unloze_racetimer_css_2026.timer_improvements definition
@@ -38,6 +41,8 @@ CREATE TABLE `timer_improvements` (
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- unloze_racetimer_css_2026.timer_records definition
-- unloze_racetimer_css_2026.timer_records definition -- unloze_racetimer_css_2026.timer_records definition
CREATE TABLE `timer_records` ( CREATE TABLE `timer_records` (
@@ -46,11 +51,13 @@ CREATE TABLE `timer_records` (
`zone_category_id` int(10) unsigned NOT NULL, `zone_category_id` int(10) unsigned NOT NULL,
`time_value` decimal(10,3) NOT NULL, `time_value` decimal(10,3) NOT NULL,
`is_invalid` tinyint(1) NOT NULL DEFAULT 0, `is_invalid` tinyint(1) NOT NULL DEFAULT 0,
`invalidated_by_steam` varchar(32) DEFAULT NULL,
`invalidated_by_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT NULL,
`invalidated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_leaderboard` (`zone_category_id`,`time_value`), KEY `idx_leaderboard` (`zone_category_id`,`time_value`),
KEY `idx_improvement` (`improvement_id`), KEY `idx_improvement` (`improvement_id`),
CONSTRAINT `fk_record_category` FOREIGN KEY (`zone_category_id`) REFERENCES `zone_categories` (`id`), CONSTRAINT `fk_record_category` FOREIGN KEY (`zone_category_id`) REFERENCES `zone_categories` (`id`),
CONSTRAINT `fk_record_improvement` FOREIGN KEY (`improvement_id`) REFERENCES `timer_improvements` (`id`) CONSTRAINT `fk_record_improvement` FOREIGN KEY (`improvement_id`) REFERENCES `timer_improvements` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB AUTO_INCREMENT=324066 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;