<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P071-leaderboard/ | updated: 2026-10-14 -->

# P071 Leaderboard

A small league: games give points and move Elo ratings, worked out in whole numbers with an exact 400th root; the standings rank players level on points the five usual ways - competition, modified competition, dense, ordinal and fractional.

EML-P project `projects/leaderboard` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/leaderboard` runs it and `eml project verify projects/leaderboard` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: rank-with-ties (https://efficientnewlanguage.org/cases/122-rank-with-ties/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
2
Zed
Ana
Ana
Ben
win

4
Ana
Ana Lee
Kim
50
3001
abc

3

5
```

Screen:

```text
== Leaderboard ==
A small league: 3 points for a win, 1 each for a draw, and Elo ratings with K = 32.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 0
Pick a number from 1 to 5.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> x
Pick a number from 1 to 5.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 2
player A> Zed
Nobody called Zed is in the league.
player A> Ana
player B> Ana
A player cannot play themselves.
player B> Ben
result (A, B or draw)> win
Type A, B or draw.
result (A, B or draw)> 
Cancelled.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 4
name> Ana
Ana is in the league already.
name> Ana Lee
Type a name of letters, up to 10.
name> Kim
starting rating (100 to 3000)> 50
Type a whole number from 100 to 3000.
starting rating (100 to 3000)> 3001
Type a whole number from 100 to 3000.
starting rating (100 to 3000)> abc
Type a whole number from 100 to 3000.
starting rating (100 to 3000)> 
Cancelled.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 3
player A> 
Cancelled.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 5
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
3
Ana
Gus
2
Dev
Ana
A
2
Ben
Chloe
draw
1
4
Hana
1500
3
Hana
Ana
5
```

Screen:

```text
== Leaderboard ==
A small league: 3 points for a win, 1 each for a draw, and Elo ratings with K = 32.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 1
player      points  rating   1224  1334  1223  1234  fractional
Ana              9    1620      1     1     1     1           1
Ben              7    1555      2     4     2     2           3
Chloe            7    1580      2     4     2     3           3
Dev              7    1490      2     4     2     4           3
Eli              4    1450      5     6     3     5         5.5
Fay              4    1530      5     6     3     6         5.5
Gus              1    1400      7     7     4     7           7
1224 competition: level players share the better rank, and the ranks after them are skipped.
1334 modified competition: level players share the worse rank. 1223 dense: no rank is skipped.
1234 ordinal: level players in the order they joined. Fractional: the average of the places they share.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 3
player A> Ana
player B> Gus
Ana (1620) is expected to score 78.0% against Gus (1400).
For Ana: a win +7, a draw -9, a loss -25.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 2
player A> Dev
player B> Ana
result (A, B or draw)> A
Before the game Dev (1490) was expected to score 32.1% against Ana (1620).
Dev +22 to 1512, Ana -22 to 1598.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 2
player A> Ben
player B> Chloe
result (A, B or draw)> draw
Before the game Ben (1555) was expected to score 46.4% against Chloe (1580).
Ben +1 to 1556, Chloe -1 to 1579.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 1
player      points  rating   1224  1334  1223  1234  fractional
Dev             10    1512      1     1     1     1           1
Ana              9    1598      2     2     2     2           2
Ben              8    1556      3     4     3     3         3.5
Chloe            8    1579      3     4     3     4         3.5
Eli              4    1450      5     6     4     5         5.5
Fay              4    1530      5     6     4     6         5.5
Gus              1    1400      7     7     5     7           7
1224 competition: level players share the better rank, and the ranks after them are skipped.
1334 modified competition: level players share the worse rank. 1223 dense: no rank is skipped.
1234 ordinal: level players in the order they joined. Fractional: the average of the places they share.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 4
name> Hana
starting rating (100 to 3000)> 1500
Hana joins on 0 points, rated 1500.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 3
player A> Hana
player B> Ana
Hana (1500) is expected to score 36.3% against Ana (1598).
For Hana: a win +20, a draw +4, a loss -12.

1) standings  2) record a game  3) what a game is worth  4) new player  5) quit
choice> 5
Bye.
```

## Modules

### main.eml

```eml
# P071 leaderboard: a small league. Games give points (3 for a win, 1 each
# for a draw) and move Elo ratings; the standings show the five usual ways
# to rank players who are level on points.
import ranking

def trim(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def number(s):
    # A whole number of up to 4 digits, or -1.
    if len(s) == 0 or len(s) > 4:
        return -1
    0 => n
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n * 10 + int(c) => n
    return n

def is_name(s):
    if len(s) == 0 or len(s) > 10:
        return False
    for c in s:
        if not ((c >= "a" and c <= "z") or (c >= "A" and c <= "Z")):
            return False
    return True

def pad(s, n):
    while len(s) < n:
        s + " " => s
    return s

def lpad(s, n):
    while len(s) < n:
        " " + s => s
    return s

def find(players, name):
    for i in [0:len(players) - 1]:
        if players[i][0] == name:
            return i
    return -1

def signed(n):
    if n > 0:
        return "+" + str(n)
    return str(n)

def percent(permille):
    # 640 -> "64.0%"
    0 => whole
    permille => r
    while r >= 10:
        r - 10 => r
        whole + 1 => whole
    return str(whole) + "." + str(r) + "%"

def by_points(players):
    # Indices in table order: most points first; level players keep the
    # order they joined the league.
    [0] * len(players) => out
    for i in [0:len(players) - 1]:
        i => out[i]
    for i in [1:len(out) - 1]:
        out[i] => x
        i - 1 => j
        while j >= 0 and players[out[j]][1] < players[x][1]:
            out[j] => out[j + 1]
            j - 1 => j
        x => out[j + 1]
    return out

def standings(players):
    [0] * len(players) => pts
    for i in [0:len(players) - 1]:
        players[i][1] => pts[i]
    "player      points  rating   1224  1334  1223  1234  fractional" ^0
    for i in by_points(players):
        (pad(players[i][0], 12) + lpad(str(players[i][1]), 6) + lpad(str(players[i][2]), 8) + lpad(str(ranking.competition(pts, i)), 7) + lpad(str(ranking.modified(pts, i)), 6) + lpad(str(ranking.dense(pts, i)), 6) + lpad(str(ranking.ordinal(pts, i)), 6) + lpad(ranking.fractional(pts, i), 12)) ^0
    "1224 competition: level players share the better rank, and the ranks after them are skipped." ^0
    "1334 modified competition: level players share the worse rank. 1223 dense: no rank is skipped." ^0
    "1234 ordinal: level players in the order they joined. Fractional: the average of the places they share." ^0

def ask_player(players, prompt):
    while True:
        trim(input(prompt)) => name
        if name == "":
            return -1
        find(players, name) => i
        if i >= 0:
            return i
        ("Nobody called " + name + " is in the league.") ^0

def game(players):
    ask_player(players, "player A> ") => a
    if a == -1:
        "Cancelled." ^0
        return players
    while True:
        ask_player(players, "player B> ") => b
        if b == -1:
            "Cancelled." ^0
            return players
        if b != a:
            while True:
                trim(input("result (A, B or draw)> ")) => r
                if r == "":
                    "Cancelled." ^0
                    return players
                -1 => s2
                if r == "A" or r == "a":
                    2 => s2
                elif r == "B" or r == "b":
                    0 => s2
                elif r == "draw":
                    1 => s2
                if s2 >= 0:
                    players[a] => pa
                    players[b] => pb
                    ranking.elo(pa[2], pb[2], s2, 32) => e
                    ("Before the game " + pa[0] + " (" + str(pa[2]) + ") was expected to score " + percent(e[0]) + " against " + pb[0] + " (" + str(pb[2]) + ").") ^0
                    0 => ga
                    0 => gb
                    if s2 == 2:
                        3 => ga
                    elif s2 == 0:
                        3 => gb
                    else:
                        1 => ga
                        1 => gb
                    [pa[0], pa[1] + ga, pa[2] + e[1]] => players[a]
                    [pb[0], pb[1] + gb, pb[2] - e[1]] => players[b]
                    (pa[0] + " " + signed(e[1]) + " to " + str(pa[2] + e[1]) + ", " + pb[0] + " " + signed(0 - e[1]) + " to " + str(pb[2] - e[1]) + ".") ^0
                    return players
                "Type A, B or draw." ^0
        "A player cannot play themselves." ^0

def join(players):
    while True:
        trim(input("name> ")) => name
        if name == "":
            "Cancelled." ^0
            return players
        if not is_name(name):
            "Type a name of letters, up to 10." ^0
        elif find(players, name) >= 0:
            (name + " is in the league already.") ^0
        else:
            while True:
                trim(input("starting rating (100 to 3000)> ")) => s
                if s == "":
                    "Cancelled." ^0
                    return players
                number(s) => r
                if r >= 100 and r <= 3000:
                    (name + " joins on 0 points, rated " + str(r) + ".") ^0
                    return players + [[name, 0, r]]
                "Type a whole number from 100 to 3000." ^0

def odds(players):
    ask_player(players, "player A> ") => a
    if a == -1:
        "Cancelled." ^0
        return
    while True:
        ask_player(players, "player B> ") => b
        if b == -1:
            "Cancelled." ^0
            return
        if b != a:
            players[a] => pa
            players[b] => pb
            ranking.elo(pa[2], pb[2], 2, 32) => win
            ranking.elo(pa[2], pb[2], 1, 32) => draw
            ranking.elo(pa[2], pb[2], 0, 32) => loss
            (pa[0] + " (" + str(pa[2]) + ") is expected to score " + percent(win[0]) + " against " + pb[0] + " (" + str(pb[2]) + ").") ^0
            ("For " + pa[0] + ": a win " + signed(win[1]) + ", a draw " + signed(draw[1]) + ", a loss " + signed(loss[1]) + ".") ^0
            return
        "A player cannot play themselves." ^0

[
    ["Ana", 9, 1620],
    ["Ben", 7, 1555],
    ["Chloe", 7, 1580],
    ["Dev", 7, 1490],
    ["Eli", 4, 1450],
    ["Fay", 4, 1530],
    ["Gus", 1, 1400],
] => players

"== Leaderboard ==" ^0
"A small league: 3 points for a win, 1 each for a draw, and Elo ratings with K = 32." ^0
True => running
while running:
    "" ^0
    "1) standings  2) record a game  3) what a game is worth  4) new player  5) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        standings(players)
    elif choice == "2":
        game(players) => players
    elif choice == "3":
        odds(players)
    elif choice == "4":
        join(players) => players
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import ranking

def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def number(s):
    if len(s) == 0 or len(s) > 4:
        return -1
    n = 0
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n = n * 10 + int(c)
    return n

def is_name(s):
    if len(s) == 0 or len(s) > 10:
        return False
    for c in s:
        if not (c >= "a" and c <= "z" or c >= "A" and c <= "Z"):
            return False
    return True

def pad(s, n):
    while len(s) < n:
        s = s + " "
    return s

def lpad(s, n):
    while len(s) < n:
        s = " " + s
    return s

def find(players, name):
    for i in range(0, len(players)):
        if players[i][0] == name:
            return i
    return -1

def signed(n):
    if n > 0:
        return "+" + str(n)
    return str(n)

def percent(permille):
    whole = 0
    r = permille
    while r >= 10:
        r = r - 10
        whole = whole + 1
    return str(whole) + "." + str(r) + "%"

def by_points(players):
    out = [0] * len(players)
    for i in range(0, len(players)):
        out[i] = i
    for i in range(1, len(out)):
        x = out[i]
        j = i - 1
        while j >= 0 and players[out[j]][1] < players[x][1]:
            out[j + 1] = out[j]
            j = j - 1
        out[j + 1] = x
    return out

def standings(players):
    pts = [0] * len(players)
    for i in range(0, len(players)):
        pts[i] = players[i][1]
    print("player      points  rating   1224  1334  1223  1234  fractional")
    for i in by_points(players):
        print(pad(players[i][0], 12) + lpad(str(players[i][1]), 6) + lpad(str(players[i][2]), 8) + lpad(str(ranking.competition(pts, i)), 7) + lpad(str(ranking.modified(pts, i)), 6) + lpad(str(ranking.dense(pts, i)), 6) + lpad(str(ranking.ordinal(pts, i)), 6) + lpad(ranking.fractional(pts, i), 12))
    print("1224 competition: level players share the better rank, and the ranks after them are skipped.")
    print("1334 modified competition: level players share the worse rank. 1223 dense: no rank is skipped.")
    print("1234 ordinal: level players in the order they joined. Fractional: the average of the places they share.")

def ask_player(players, prompt):
    while True:
        name = trim(input(prompt))
        if name == "":
            return -1
        i = find(players, name)
        if i >= 0:
            return i
        print("Nobody called " + name + " is in the league.")

def game(players):
    a = ask_player(players, "player A> ")
    if a == -1:
        print("Cancelled.")
        return players
    while True:
        b = ask_player(players, "player B> ")
        if b == -1:
            print("Cancelled.")
            return players
        if b != a:
            while True:
                r = trim(input("result (A, B or draw)> "))
                if r == "":
                    print("Cancelled.")
                    return players
                s2 = -1
                if r == "A" or r == "a":
                    s2 = 2
                elif r == "B" or r == "b":
                    s2 = 0
                elif r == "draw":
                    s2 = 1
                if s2 >= 0:
                    pa = players[a]
                    pb = players[b]
                    e = ranking.elo(pa[2], pb[2], s2, 32)
                    print("Before the game " + pa[0] + " (" + str(pa[2]) + ") was expected to score " + percent(e[0]) + " against " + pb[0] + " (" + str(pb[2]) + ").")
                    ga = 0
                    gb = 0
                    if s2 == 2:
                        ga = 3
                    elif s2 == 0:
                        gb = 3
                    else:
                        ga = 1
                        gb = 1
                    players[a] = [pa[0], pa[1] + ga, pa[2] + e[1]]
                    players[b] = [pb[0], pb[1] + gb, pb[2] - e[1]]
                    print(pa[0] + " " + signed(e[1]) + " to " + str(pa[2] + e[1]) + ", " + pb[0] + " " + signed(0 - e[1]) + " to " + str(pb[2] - e[1]) + ".")
                    return players
                print("Type A, B or draw.")
        print("A player cannot play themselves.")

def join(players):
    while True:
        name = trim(input("name> "))
        if name == "":
            print("Cancelled.")
            return players
        if not is_name(name):
            print("Type a name of letters, up to 10.")
        elif find(players, name) >= 0:
            print(name + " is in the league already.")
        else:
            while True:
                s = trim(input("starting rating (100 to 3000)> "))
                if s == "":
                    print("Cancelled.")
                    return players
                r = number(s)
                if r >= 100 and r <= 3000:
                    print(name + " joins on 0 points, rated " + str(r) + ".")
                    return players + [[name, 0, r]]
                print("Type a whole number from 100 to 3000.")

def odds(players):
    a = ask_player(players, "player A> ")
    if a == -1:
        print("Cancelled.")
        return
    while True:
        b = ask_player(players, "player B> ")
        if b == -1:
            print("Cancelled.")
            return
        if b != a:
            pa = players[a]
            pb = players[b]
            win = ranking.elo(pa[2], pb[2], 2, 32)
            draw = ranking.elo(pa[2], pb[2], 1, 32)
            loss = ranking.elo(pa[2], pb[2], 0, 32)
            print(pa[0] + " (" + str(pa[2]) + ") is expected to score " + percent(win[0]) + " against " + pb[0] + " (" + str(pb[2]) + ").")
            print("For " + pa[0] + ": a win " + signed(win[1]) + ", a draw " + signed(draw[1]) + ", a loss " + signed(loss[1]) + ".")
            return
        print("A player cannot play themselves.")

players = [["Ana", 9, 1620], ["Ben", 7, 1555], ["Chloe", 7, 1580], ["Dev", 7, 1490], ["Eli", 4, 1450], ["Fay", 4, 1530], ["Gus", 1, 1400]]
print("== Leaderboard ==")
print("A small league: 3 points for a win, 1 each for a draw, and Elo ratings with K = 32.")
running = True
while running:
    print("")
    print("1) standings  2) record a game  3) what a game is worth  4) new player  5) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        standings(players)
    elif choice == "2":
        players = game(players)
    elif choice == "3":
        odds(players)
    elif choice == "4":
        players = join(players)
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")
```

### ranking.eml

```eml
# P071 leaderboard - ranking with ties five ways, and Elo ratings worked
# out in whole numbers.

def competition(scores, i):
    # Standard competition ranking, "1224": 1 + how many scores are higher
    # (the corpus case rank-with-ties). Tied players share the better rank
    # and the next rank is skipped.
    1 => r
    for s in scores:
        if s > scores[i]:
            r + 1 => r
    return r

def modified(scores, i):
    # Modified competition ranking, "1334": how many scores are at least
    # this one, so tied players share the worse rank.
    0 => r
    for s in scores:
        if s >= scores[i]:
            r + 1 => r
    return r

def dense(scores, i):
    # Dense ranking, "1223": 1 + how many DIFFERENT scores are higher.
    {} => seen
    1 => r
    for s in scores:
        if s > scores[i] and not s in seen:
            True => seen[s]
            r + 1 => r
    return r

def ordinal(scores, i):
    # Ordinal ranking, "1234": everyone a different rank; between equal
    # scores, whoever is earlier in the list comes first.
    1 => r
    for k in [0:len(scores) - 1]:
        if scores[k] > scores[i] or (scores[k] == scores[i] and k < i):
            r + 1 => r
    return r

def fractional(scores, i):
    # Fractional ranking, "1 2.5 2.5 4": tied players share the average of
    # the ordinal ranks they cover - competition rank c to modified rank m,
    # so (c + m) / 2. Returned as text, since it can end in .5.
    competition(scores, i) + modified(scores, i) => t
    0 => half
    t => left
    while left >= 2:
        left - 2 => left
        half + 1 => half
    if left == 1:
        return str(half) + ".5"
    return str(half)

def power(b, e):
    # b ** e for whole numbers, by repeated squaring.
    1 => out
    while e > 0:
        if e % 2 == 1:
            out * b => out
        b * b => b
        # e is a few thousand at most, far below 2^53, so the float
        # division is exact
        int((e - e % 2) / 2) => e
    return out

def tenth_power_scaled(r):
    # floor(10^(12 + r/400)) for 0 <= r < 400, exactly: the whole number y
    # with y^400 <= 10^(4800 + r) < (y + 1)^400, found by halving the range
    # 10^12 to 10^13.
    power(10, 4800 + r) => target
    power(10, 12) => lo
    power(10, 13) => hi
    while hi - lo > 1:
        # lo + hi < 2 x 10^13, below 2^53: halving it as a float is exact
        int((lo + hi - (lo + hi) % 2) / 2) => mid
        if power(mid, 400) <= target:
            mid => lo
        else:
            mid => hi
    return lo

def ten_to(d):
    # 10^(d/400) x 10^12 for a whole d >= 0, to 12 digits: d = 400 k + r.
    0 => k
    d => r
    while r >= 400:
        r - 400 => r
        k + 1 => k
    return tenth_power_scaled(r) * power(10, k)

def rounded(n, d):
    # n / d to the nearest whole number, halves away from zero (d > 0).
    0 => q
    if n < 0:
        return 0 - rounded(0 - n, d)
    2 * n + d => a
    2 * d => b
    while a >= b:
        b => m
        1 => k
        while m + m <= a:
            m + m => m
            k + k => k
        a - m => a
        q + k => q
    return q

def elo(ra, rb, score2, k):
    # A rated ra plays B rated rb; score2 is A's score doubled (2 win, 1
    # draw, 0 loss). A's expected score is E = 1 / (1 + 10^((rb - ra)/400)),
    # and A's rating moves by k x (score - E), B's by the opposite.
    # Returns [A's expected score in tenths of a percent, A's change].
    if rb < ra:
        elo(rb, ra, 2 - score2, k) => back
        return [1000 - back[0], 0 - back[1]]
    power(10, 12) => one
    ten_to(rb - ra) => x
    # E = one / (one + x); change = k (score2 / 2 - E)
    rounded(1000 * one, one + x) => permille
    rounded(k * (score2 * (one + x) - 2 * one), 2 * (one + x)) => change
    return [permille, change]
```

Python projection of ranking.eml:

```python
def competition(scores, i):
    r = 1
    for s in scores:
        if s > scores[i]:
            r = r + 1
    return r

def modified(scores, i):
    r = 0
    for s in scores:
        if s >= scores[i]:
            r = r + 1
    return r

def dense(scores, i):
    seen = {}
    r = 1
    for s in scores:
        if s > scores[i] and not s in seen:
            seen[s] = True
            r = r + 1
    return r

def ordinal(scores, i):
    r = 1
    for k in range(0, len(scores)):
        if scores[k] > scores[i] or scores[k] == scores[i] and k < i:
            r = r + 1
    return r

def fractional(scores, i):
    t = competition(scores, i) + modified(scores, i)
    half = 0
    left = t
    while left >= 2:
        left = left - 2
        half = half + 1
    if left == 1:
        return str(half) + ".5"
    return str(half)

def power(b, e):
    out = 1
    while e > 0:
        if e % 2 == 1:
            out = out * b
        b = b * b
        e = int((e - e % 2) / 2)
    return out

def tenth_power_scaled(r):
    target = power(10, 4800 + r)
    lo = power(10, 12)
    hi = power(10, 13)
    while hi - lo > 1:
        mid = int((lo + hi - (lo + hi) % 2) / 2)
        if power(mid, 400) <= target:
            lo = mid
        else:
            hi = mid
    return lo

def ten_to(d):
    k = 0
    r = d
    while r >= 400:
        r = r - 400
        k = k + 1
    return tenth_power_scaled(r) * power(10, k)

def rounded(n, d):
    q = 0
    if n < 0:
        return 0 - rounded(0 - n, d)
    a = 2 * n + d
    b = 2 * d
    while a >= b:
        m = b
        k = 1
        while m + m <= a:
            m = m + m
            k = k + k
        a = a - m
        q = q + k
    return q

def elo(ra, rb, score2, k):
    if rb < ra:
        back = elo(rb, ra, 2 - score2, k)
        return [1000 - back[0], 0 - back[1]]
    one = power(10, 12)
    x = ten_to(rb - ra)
    permille = rounded(1000 * one, one + x)
    change = rounded(k * (score2 * (one + x) - 2 * one), 2 * (one + x))
    return [permille, change]
```

## README

# P071 - Leaderboard

A small league. A game gives 3 points for a win and 1 each for a draw,
and moves both players' Elo ratings. The standings show the five usual
ways to rank players who are level on points. The program can also say
what a game between two players is worth before it is played. It starts
with seven players, three of them level on 7 points.

- `main.eml` - the menu, the standings table, recording games, and new
  players
- `ranking.eml` - the five rankings, and Elo in whole numbers

How each part works:

- Rankings, for the scores 9, 7, 7, 7, 4, 4, 1:
  - competition, "1224": 1 + how many scores are higher, as in the corpus
    case `rank-with-ties` - 1, 2, 2, 2, 5, 5, 7;
  - modified competition, "1334": how many scores are at least this one -
    1, 4, 4, 4, 6, 6, 7;
  - dense, "1223": 1 + how many different scores are higher, also from
    the corpus case - 1, 2, 2, 2, 3, 3, 4;
  - ordinal, "1234": level players in the order they joined;
  - fractional: the average of the places level players share, from the
    competition rank to the modified one - 1, 3, 3, 3, 5.5, 5.5, 7.
- Elo: A's expected score against B is E = 1 / (1 + 10^((B - A)/400)),
  and A's rating moves by K x (score - E) with K = 32, rounded to a whole
  number, halves away from zero; B moves by the opposite.
- 10^(d/400) is worked out without floats: d = 400 k + r, and
  floor(10^(12 + r/400)) is the whole number y with
  y^400 <= 10^(4800 + r) < (y + 1)^400, found by halving the range from
  10^12 to 10^13. That is 12 correct digits. The expected score (shown to
  a tenth of a percent) and the change are then exact divisions of whole
  numbers. Against 60-digit decimals, every rating difference from -3000
  to 3000, with every result, comes out the same; no change there comes
  within 0.0001 of a half, far more than the method's error.

What is checked: menu choices 1 to 5; players who are in the league, and
not the same player twice; a result of A, B or draw; a new name of
letters, up to 10, not already used; a starting rating from 100 to 3000.
An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the standings: Ben, Chloe and Dev level on 7, Eli and Fay on 4;
- what Ana against Gus is worth: 78.0%, so a win gains only 7 and a loss
  costs 25;
- Dev beating Ana (+22 each way), and Ben drawing with Chloe (+1 to Ben,
  the lower rated);
- the new standings, Hana joining at 1500, and what Hana against Ana is
  worth.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- an unknown player, a player against themselves, and the result "win",
  then a cancel;
- a name already taken and "Ana Lee"; ratings 50, 3001 and abc, then a
  cancel;
- a cancelled question.

Built on the verified corpus case `rank-with-ties` (competition and dense
ranking by counting, so ties need no special case).
