further changes to backend for adjusting points, showing timestamps, and how categories are listed. uploaded build of react and actual frontend source

This commit is contained in:
jenz
2026-09-30 16:02:33 +02:00
parent 54827b01ba
commit 6019e6854d
40 changed files with 5256 additions and 21 deletions
+6 -1
View File
@@ -18,6 +18,10 @@ All paths below are under `https://<backend>/racetimer_endpoints-1.0/api/`.
The settings path can also be given with `-Dracetimer.settings=/path/file.json` or the `RACETIMER_SETTINGS` environment variable.
## Category numbers
`categoryNumber` is counted per map and stage. Categories with exactly the same `server_cvars` share a number, so the same settings on ZE1 and ZE2 are both Category 1 and are told apart by `serverTag`. Different settings are numbered in order of their lowest `cvars_hash`. Hidden servers (dev) never take a number away from ze1/ze2.
## Points
Points are calculated per category, from each player's **best valid** time only. Tied times share a position.
@@ -27,7 +31,8 @@ Points are calculated per category, from each player's **best valid** time only.
| Who gets points | Everyone while a board has fewer than 200 completions. From 200 on, only the faster half (200 → top 100, 210 → top 105). |
| 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. |
| CLASSIC RACETIMER | Final points ÷ 10. |
| CLASSIC RACETIMER | Final points ÷ 10, rounded. |
| Small-board touch-ups | Everyone who gets points gets at least 1, and each of the top 10 places gets at least 1 point more than the place below it (ties share). This only ever raises points, and in practice only changes Classic boards: a 9-record Classic board gives 9, 8, 7 … 1 instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. Raised entries have `pointsRaised: true`. |
| Invalid category | Still shown with positions, but gives 0 points. The admin and time are public (`invalidation`). |
| 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. |
@@ -0,0 +1,13 @@
-- Stores which admin marked a record or category invalid, and when.
-- Run once on the racetimer database before deploying the new WAR.
-- Restoring (making it valid again) clears these columns.
ALTER TABLE unloze_racetimer_css_2026.timer_records
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
ALTER TABLE unloze_racetimer_css_2026.zone_categories
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
@@ -140,6 +140,8 @@ public final class Dto {
public int points;
/** 1.0 = no bonus. Up to 4.0 for #1 on boards with 100+ completions. */
public double bonusMultiplier;
/** Points were raised so small Classic boards keep 1 point between the top 10 places (and at least 1). */
public boolean pointsRaised;
/** Unix seconds. Null for legacy records (date unknown). */
public Long recordedAt;
public boolean isLegacy;
@@ -189,6 +191,8 @@ public final class Dto {
public int completions;
public double bonusMultiplier;
public int points;
/** See BoardEntryDTO.pointsRaised. */
public boolean pointsRaised;
public Long recordedAt;
}
@@ -11,13 +11,21 @@ public final class BoardEntry {
public final int position;
public final int points;
public final double multiplier;
/** Points were raised by the Classic touch-ups (minimum 1, top-10 places 1 apart). */
public final boolean pointsRaised;
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier) {
this(record, category, position, points, multiplier, false);
}
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier,
boolean pointsRaised) {
this.record = record;
this.category = category;
this.position = position;
this.points = points;
this.multiplier = multiplier;
this.pointsRaised = pointsRaised;
}
/** An invalidated run shown on the board: no position, no points. */
@@ -22,7 +22,7 @@ public final class Category {
/** 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" on the website. Same server_cvars on ZE1 and ZE2 share the number. */
public int number;
/** Valid best per player, fastest first. */
public List<BoardEntry> entries = new ArrayList<>();
@@ -108,6 +108,7 @@ public final class Views {
d.time = e.record.time;
d.points = e.points;
d.bonusMultiplier = e.multiplier;
d.pointsRaised = e.pointsRaised;
d.isLegacy = e.category.legacy;
d.recordedAt = e.category.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
@@ -146,6 +147,7 @@ public final class Views {
d.completions = c.completions();
d.bonusMultiplier = e.multiplier;
d.points = e.points;
d.pointsRaised = e.pointsRaised;
d.recordedAt = c.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
d.invalidation = invalidation(e.isInvalid(), e.record.invalidation);
@@ -15,11 +15,19 @@ package racetimer.service;
* 4x at #1 down to 2x at the top-1% mark, then 2x down to 1x at the
* top-5% mark, 1x after that.
* 4. CLASSIC RACETIMER (migrated) categories: final points divided by 10.
* 5. Whole-board touch-ups (boardPoints), which only ever raise points and in
* practice only change Classic boards, where dividing by 10 and rounding
* would squash places together:
* - everyone who gets points (step 1) gets at least 1;
* - each of the top 10 places gets at least 1 point more than the place
* below it (tied times still share their points).
*/
public final class Points {
public static final int HALF_CUTOFF_FROM = 200;
public static final int BONUS_FROM = 100;
/** Places 1..TOP_GAP_PLACES are always at least one point apart. */
public static final int TOP_GAP_PLACES = 10;
private Points() {
}
@@ -62,6 +70,45 @@ public final class Points {
}
/** The multiplier as shown to players, e.g. 3.2 or 1.65. */
/**
* Final points for a whole board. positions[i] is the position of the i-th
* entry, fastest first (ties repeat the same position). Applies step 5 on
* top of finalPoints. The result has the same length and order.
*/
public static int[] boardPoints(int[] positions, int n, boolean classic) {
int[] pts = new int[positions.length];
int pointed = pointedCount(n);
for (int i = 0; i < positions.length; i++) {
int p = finalPoints(positions[i], n, classic);
if (positions[i] >= 1 && positions[i] <= pointed && p < 1) {
p = 1;
}
pts[i] = p;
}
// Walk up from the bottom, one group of equal positions at a time.
int below = 0; // points of the next lower place that earns points (0 = none)
int i = positions.length - 1;
while (i >= 0) {
int j = i;
while (j > 0 && positions[j - 1] == positions[i]) {
j--;
}
int pos = positions[i];
if (pos >= 1 && pos <= pointed) {
int p = pts[i];
if (pos <= TOP_GAP_PLACES && p < below + 1) {
p = below + 1;
}
for (int k = j; k <= i; k++) {
pts[k] = p;
}
below = p;
}
i = j - 1;
}
return pts;
}
public static double displayMultiplier(int position, int n) {
return Math.round(multiplier(position, n) * 100.0) / 100.0;
}
@@ -68,6 +68,37 @@ public final class SnapshotBuilder {
}
};
/**
* Numbers the categories of one map+stage. Categories with exactly the same
* server_cvars share a number, so the same settings on ZE1 and ZE2 are both
* "Category 1" (told apart by their server tag). Distinct settings are
* numbered by their lowest cvars_hash. Hidden servers (dev) never take a
* number away from ze1/ze2: they reuse a matching number or come after.
*/
void numberByCvars(List<Category> list) {
Collections.sort(list, new Comparator<Category>() {
@Override
public int compare(Category a, Category b) {
int x = Boolean.compare(!a.ranked, !b.ranked); // public servers first
if (x != 0) {
return x;
}
x = Long.compare(a.cvarsHash, b.cvarsHash);
return x != 0 ? x : Integer.compare(a.id, b.id);
}
});
Map<String, Integer> numbers = new HashMap<>();
for (Category c : list) {
String key = c.serverCvars == null ? "" : c.serverCvars.trim();
Integer n = numbers.get(key);
if (n == null) {
n = numbers.size() + 1;
numbers.put(key, n);
}
c.number = n;
}
}
private final Set<String> rankedTags;
private final String classicServerCvars;
@@ -81,7 +112,7 @@ public final class SnapshotBuilder {
Collection<RecordRow> records,
Map<String, List<PlayerInfo.Badge>> badges) {
// --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash ---
// --- Categories, numbered per map+stage by their server settings ---
Map<Integer, Category> categories = new HashMap<>();
Map<String, List<Category>> byMapStage = new HashMap<>();
for (CategoryRow r : categoryRows) {
@@ -99,16 +130,7 @@ public final class SnapshotBuilder {
list.add(c);
}
for (List<Category> list : byMapStage.values()) {
Collections.sort(list, new Comparator<Category>() {
@Override
public int compare(Category a, Category b) {
int x = Long.compare(a.cvarsHash, b.cvarsHash);
return x != 0 ? x : Integer.compare(a.id, b.id);
}
});
for (int i = 0; i < list.size(); i++) {
list.get(i).number = i + 1;
}
numberByCvars(list);
}
// --- Players ---
@@ -188,6 +210,7 @@ public final class SnapshotBuilder {
Collections.sort(sorted, byTime);
int n = sorted.size();
List<BoardEntry> entries = new ArrayList<>(n);
int[] positions = new int[n];
int position = 0;
double previousTime = Double.NaN;
for (int i = 0; i < n; i++) {
@@ -196,9 +219,15 @@ public final class SnapshotBuilder {
position = i + 1; // tied times share the position of the first one
}
previousTime = r.time;
int points = c.givesPoints() ? Points.finalPoints(position, n, c.legacy) : 0;
double mult = c.givesPoints() ? Points.displayMultiplier(position, n) : 1.0;
BoardEntry e = new BoardEntry(r, c, position, points, mult);
positions[i] = position;
}
int[] boardPoints = c.givesPoints() ? Points.boardPoints(positions, n, c.legacy) : new int[n];
for (int i = 0; i < n; i++) {
RecordRow r = sorted.get(i);
int points = boardPoints[i];
boolean raised = c.givesPoints() && points > Points.finalPoints(positions[i], n, c.legacy);
double mult = c.givesPoints() ? Points.displayMultiplier(positions[i], n) : 1.0;
BoardEntry e = new BoardEntry(r, c, positions[i], points, mult, raised);
entries.add(e);
PlayerInfo p = players.get(r.steamAuth);
p.bests.add(e);
@@ -292,7 +321,11 @@ public final class SnapshotBuilder {
@Override
public int compare(Category a, Category b) {
int x = Integer.compare(a.stage, b.stage);
return x != 0 ? x : Integer.compare(a.number, b.number);
if (x != 0) {
return x;
}
x = Integer.compare(a.number, b.number);
return x != 0 ? x : a.serverTag.compareTo(b.serverTag); // same settings: ze1 before ze2
}
});
for (Category c : publicCategories) {
@@ -102,8 +102,8 @@ public class ApiTest {
ContainerResponse r = call("GET", "timers/player/" + A, null, null);
assertEquals(200, r.getStatus());
JsonObject p = json(r).getAsJsonObject();
// CLASSIC board with 2 finishers: A is #1 -> 2 points / 10 = 0 (rounded)
assertEquals(0, p.get("PlayerPoints").getAsInt());
// CLASSIC board with 2 finishers: 2/10 would round to 0, but #2 gets at least 1 and #1 one more
assertEquals(2, p.get("PlayerPoints").getAsInt());
assertEquals("ze1", p.get("server").getAsString());
assertEquals("76561198029832363", p.get("steamID64").getAsString());
assertEquals(1, p.getAsJsonObject("servers").getAsJsonObject("ze2").get("rank").getAsInt());
@@ -1,5 +1,6 @@
package racetimer.service;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
@@ -72,6 +73,54 @@ public class PointsTest {
}
}
private static int[] seq(int n) {
int[] p = new int[n];
for (int i = 0; i < n; i++) {
p[i] = i + 1;
}
return p;
}
@Test
public void smallClassicBoardKeepsOnePointBetweenPlaces() {
// The real 9-record Classic board: was 1,1,1,1,1,0,0,0,0.
assertArrayEquals(new int[] {9, 8, 7, 6, 5, 4, 3, 2, 1}, Points.boardPoints(seq(9), 9, true));
}
@Test
public void onlyTopTenGetTheGapButEveryoneWithPointsGetsAtLeastOne() {
int[] p = Points.boardPoints(seq(30), 30, true);
// Places 11..30: rounded n/10 values, at least 1.
assertEquals(2, p[10]);
assertEquals(1, p[29]);
for (int i = 0; i < 10; i++) {
assertEquals("place " + (i + 1), p[i + 1] + 1 <= p[i], true);
}
assertEquals(12, p[0]);
}
@Test
public void tiesShareRaisedPoints() {
int[] p = Points.boardPoints(new int[] {1, 2, 2, 4}, 4, true);
assertArrayEquals(new int[] {3, 2, 2, 1}, p);
}
@Test
public void bigBoardsAndNewRecordsAreUnchanged() {
for (int n : new int[] {5, 150, 1000, 1470}) {
int[] pos = seq(n);
int[] newPts = Points.boardPoints(pos, n, false);
for (int i = 0; i < n; i++) {
assertEquals(Points.finalPoints(i + 1, n, false), newPts[i]);
}
}
int[] classic = Points.boardPoints(seq(1470), 1470, true);
for (int i = 0; i < 735; i++) {
assertEquals(Points.finalPoints(i + 1, 1470, true), classic[i]);
}
assertEquals(0, classic[735]); // past the 50% cut still 0
}
@Test
public void emptyBoard() {
assertEquals(0, Points.pointedCount(0));
@@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.List;
import org.junit.Test;
import racetimer.TestData;
import racetimer.model.BoardEntry;
@@ -18,6 +19,27 @@ public class SnapshotBuilderTest {
private static final String B = TestData.steam(2);
private static final String C = TestData.steam(3);
@Test
public void sameCvarsOnZe1AndZe2ShareACategoryNumber() {
// The real ze_pirates_port_royal_v3_6 stage 1 rows.
String a = "sv_gravity 800, sv_enablebunnyhopping 1, sv_autobunnyhopping 0, sv_accelerate 5, sv_airaccelerate 10, "
+ "tickrate 100, sv_friction 4, client_speed 1.00, client_gravity 1.00";
String b = a.replace("sv_enablebunnyhopping 1", "sv_enablebunnyhopping 0");
TestData d = new TestData()
.category(87, "ze_p", 1, "ze1", TestData.CLASSIC, -706992435L, false)
.category(1963, "ze_p", 1, "ze1", a, -1995318401L, false)
.category(4412, "ze_p", 1, "ze2", a, -1978540782L, false)
.category(4564, "ze_p", 1, "ze1", b, 113025096L, false);
Snapshot s = d.build();
assertEquals(1, s.categories.get(1963).number);
assertEquals(1, s.categories.get(4412).number); // same settings, other server
assertEquals(2, s.categories.get(87).number);
assertEquals(3, s.categories.get(4564).number);
List<Category> shown = s.mapsByName.get("ze_p").stages.get(1);
assertEquals("ze1", shown.get(0).serverTag); // Category 1 ZE1, then Category 1 ZE2
assertEquals("ze2", shown.get(1).serverTag);
}
@Test
public void categoriesAreNumberedByCvarsHashAcrossServers() {
TestData d = new TestData()
@@ -29,8 +51,8 @@ public class SnapshotBuilderTest {
Snapshot s = d.build();
assertEquals(1, s.categories.get(12).number); // most negative hash first
assertEquals(2, s.categories.get(10).number);
assertEquals(3, s.categories.get(14).number); // dev still takes a number, like in-game
assertEquals(4, s.categories.get(11).number);
assertEquals(3, s.categories.get(11).number);
assertEquals(4, s.categories.get(14).number); // hidden dev server does not take a public number
assertEquals(1, s.categories.get(13).number);
// dev is hidden from the public map list
assertEquals(3, s.mapsByName.get("ze_map").stages.get(1).size());