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:
@@ -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;
|
||||
}
|
||||
|
||||
+48
-15
@@ -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));
|
||||
|
||||
+24
-2
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user