feat(SNNBot): upgrade bot API, fix debug overlay and aiming lines
- Upgrade to robocode_tankroyale_botapi v1.0.7 (SVG viewBox fix) - Fix Y-axis mirror in aiming line toXY helper - Resize debug panel to half arena width/height - Replace neuron boxes with barcode-style spike lines - Thickness-encoded weight connections (all 216 lines) - Add aiming line legend (top-left, white text) - Dynamic aiming line length (distance + 50px overshoot)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
nimble.develop
|
||||||
|
nimble.paths
|
||||||
|
nimbledeps
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
# Package
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "Davide Cappellini"
|
||||||
|
description = "SNN prototype — population-coded LIF neurons, aiming loop, no learning"
|
||||||
|
license = "MIT"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["SNNBot"]
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
requires "nim >= 2.0.0"
|
||||||
|
requires "robocode_tankroyale_botapi >= 1.0.7"
|
||||||
|
# radar_lock is vendored in-tree (common_libs/) and wired via config.nims --path
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "SNNBot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": ["Davide Cappellini"],
|
||||||
|
"description": "SNN prototype — population-coded LIF neurons aiming toward enemy",
|
||||||
|
"gameTypes": ["classic", "1v1"],
|
||||||
|
"platform": "Nim",
|
||||||
|
"programmingLang": "Nim"
|
||||||
|
}
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec ./out/SNNBot >> /tmp/snnbot_stdout.log 2>> /tmp/snnbot_stderr.log
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
--path:"../common_libs"
|
||||||
|
switch("outdir", "out")
|
||||||
|
switch("path", thisDir() & "/src")
|
||||||
|
# begin Nimble config (version 2)
|
||||||
|
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||||
|
include "nimble.paths"
|
||||||
|
# end Nimble config
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "SNNBot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": ["Davide Cappellini"],
|
||||||
|
"description": "SNN prototype — population-coded LIF neurons aiming toward enemy",
|
||||||
|
"homepage": "",
|
||||||
|
"countryCodes": ["IT"],
|
||||||
|
"gameTypes": ["classic", "1v1"],
|
||||||
|
"platform": "Nim",
|
||||||
|
"programmingLang": "Nim"
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# SNNBot — SNN core + aiming loop prototype (issue #152).
|
||||||
|
# 36-input population-coded → 6 LIF hidden → 1 membrane-readout output.
|
||||||
|
# No movement, no firing, no learning. Proves plumbing.
|
||||||
|
#
|
||||||
|
# State machine:
|
||||||
|
# DECIDE → feed bearing into SNN, store targetAngle, → WAITING
|
||||||
|
# WAITING → aimTo() each tick; when error < 2° → EVALUATE
|
||||||
|
# EVALUATE → measure error, compute reward, log, → DECIDE
|
||||||
|
|
||||||
|
import std/[math, random, os]
|
||||||
|
import robocode_tankroyale_botapi
|
||||||
|
import radar_lock/radar_lock as radar_lock
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const botJsonPath = currentSourcePath().parentDir / "SNNBot.json"
|
||||||
|
|
||||||
|
const
|
||||||
|
N_IN = 36 # input neurons (10°-wide bands, -180..+180)
|
||||||
|
N_HID = 6 # hidden LIF neurons
|
||||||
|
BAND_DEG = 10.0 # degrees per input band
|
||||||
|
LEAK = 0.9 # LIF membrane leak factor
|
||||||
|
THRESH = 1.0 # LIF spike threshold
|
||||||
|
MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees)
|
||||||
|
AIM_TOL = 2.0 # arrive tolerance (degrees)
|
||||||
|
|
||||||
|
# ── SNN types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
SNN = object
|
||||||
|
wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights
|
||||||
|
who: array[N_HID, float] # 6×1 hidden→output weights
|
||||||
|
vHid: array[N_HID, float] # hidden membrane potentials
|
||||||
|
vOut: float # output membrane potential (readout)
|
||||||
|
|
||||||
|
proc initSNN(snn: var SNN) =
|
||||||
|
# ponytail: uniform random init, fine until STDP training added in #155
|
||||||
|
randomize()
|
||||||
|
for w in snn.wih.mitems: w = rand(1.0) - 0.5
|
||||||
|
for w in snn.who.mitems: w = rand(1.0) - 0.5
|
||||||
|
|
||||||
|
proc encodeInput(bearing: float): array[N_IN, float] =
|
||||||
|
## Population-code bearing into N_IN neurons.
|
||||||
|
## Each neuron owns a 10° band centred at -175, -165, …, +175.
|
||||||
|
## The band that contains the bearing fires 1.0; both neighbours get
|
||||||
|
## linear interpolation for smoother encoding.
|
||||||
|
let norm = ((bearing + 180.0) / BAND_DEG) # 0..36
|
||||||
|
let lo = int(norm) mod N_IN
|
||||||
|
let hi = (lo + 1) mod N_IN
|
||||||
|
let frac = norm - float(int(norm)) # fractional position in band
|
||||||
|
result[lo] = 1.0 - frac
|
||||||
|
result[hi] = frac
|
||||||
|
|
||||||
|
proc forward(snn: var SNN, inputs: array[N_IN, float]): float =
|
||||||
|
## One SNN tick. Returns target angle in degrees (-180..+180).
|
||||||
|
# Hidden layer: LIF update
|
||||||
|
var spikes: array[N_HID, float]
|
||||||
|
for h in 0 ..< N_HID:
|
||||||
|
var wsum = 0.0
|
||||||
|
for i in 0 ..< N_IN:
|
||||||
|
wsum += inputs[i] * snn.wih[i * N_HID + h]
|
||||||
|
snn.vHid[h] = LEAK * snn.vHid[h] + wsum
|
||||||
|
if snn.vHid[h] >= THRESH:
|
||||||
|
spikes[h] = 1.0
|
||||||
|
snn.vHid[h] = 0.0
|
||||||
|
else:
|
||||||
|
spikes[h] = 0.0
|
||||||
|
|
||||||
|
# Output layer: membrane readout (no threshold)
|
||||||
|
var osum = 0.0
|
||||||
|
for h in 0 ..< N_HID:
|
||||||
|
osum += spikes[h] * snn.who[h]
|
||||||
|
snn.vOut = LEAK * snn.vOut + osum
|
||||||
|
|
||||||
|
# Scale vOut to [-180, +180]. vOut is unbounded so clamp after tanh-like squash.
|
||||||
|
# ponytail: simple linear scale; replace with proper output neuron tuning in #155
|
||||||
|
result = tanh(snn.vOut) * 180.0
|
||||||
|
|
||||||
|
# ── Bot state machine ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
Phase = enum DECIDE, WAITING, EVALUATE
|
||||||
|
|
||||||
|
SNNBot = ref object of Bot
|
||||||
|
snn: SNN
|
||||||
|
phase: Phase
|
||||||
|
targetAngle: float # SNN output (absolute bearing)
|
||||||
|
enemyBearing: float # last known enemy bearing
|
||||||
|
enemyDist: float # last known distance to enemy
|
||||||
|
hasContact: bool
|
||||||
|
tick: int
|
||||||
|
|
||||||
|
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc aimTo(targetAngle, gunDir: float) {.inline.} =
|
||||||
|
let delta = normalizeRelativeAngle(targetAngle - gunDir)
|
||||||
|
setGunTurnRate(delta.clamp(-MAX_GUN_TURN, MAX_GUN_TURN))
|
||||||
|
|
||||||
|
# ── Debug overlay ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawOverlay(bot: SNNBot, myX, myY, gunDir, enemyBearing, targetAngle: float) =
|
||||||
|
let LINE_LEN = bot.enemyDist + 50.0
|
||||||
|
# Convert degree bearing to SVG direction (SVG y-axis is inverted; 0=east, CCW+)
|
||||||
|
template toXY(bearing, len: float): (float, float) =
|
||||||
|
(myX + cos(degToRad(bearing)) * len,
|
||||||
|
myY + sin(degToRad(bearing)) * len)
|
||||||
|
|
||||||
|
# Green: enemy direction (truth)
|
||||||
|
setStrokeColor(GREEN)
|
||||||
|
setStrokeWidth(2.0)
|
||||||
|
let (ex, ey) = toXY(enemyBearing, LINE_LEN)
|
||||||
|
drawLine(myX, myY, ex, ey)
|
||||||
|
|
||||||
|
# Red: gun direction
|
||||||
|
setStrokeColor(RED)
|
||||||
|
let (gx, gy) = toXY(gunDir, LINE_LEN)
|
||||||
|
drawLine(myX, myY, gx, gy)
|
||||||
|
|
||||||
|
# Yellow: SNN target angle
|
||||||
|
setStrokeColor(YELLOW)
|
||||||
|
let (tx, ty) = toXY(targetAngle, LINE_LEN)
|
||||||
|
drawLine(myX, myY, tx, ty)
|
||||||
|
|
||||||
|
# ── Neuron panel — barcode style (top-left corner at 10,10) ──────────────
|
||||||
|
const
|
||||||
|
PX = 10.0 # panel x
|
||||||
|
PY = 10.0 # panel y
|
||||||
|
let
|
||||||
|
panelW = getArenaWidth().float / 2.0
|
||||||
|
panelH = getArenaHeight().float / 2.0
|
||||||
|
CH = (panelH - PY - 20.0) / 7.0 # row height (same formula as before)
|
||||||
|
LAYER_V = CH * 2.0 # inter-layer gap
|
||||||
|
IN_W = panelW # input layer spans full panel width
|
||||||
|
IN_STEP = IN_W / float(N_IN) # spacing between input lines
|
||||||
|
HID_STEP = IN_W / float(N_HID) # spacing between hidden lines
|
||||||
|
# Anchor x of each hidden line — centred within IN_W
|
||||||
|
HID_OFF = 0.0
|
||||||
|
|
||||||
|
# Input layer: vertical line per neuron, visible when activation > 0
|
||||||
|
let inputs = encodeInput(normalizeRelativeAngle(enemyBearing - gunDir))
|
||||||
|
setStrokeWidth(1.0)
|
||||||
|
for i in 0 ..< N_IN:
|
||||||
|
let alpha = uint8(inputs[i] * 255.0)
|
||||||
|
if alpha > 0:
|
||||||
|
let lx = PX + (float(i) + 0.5) * IN_STEP
|
||||||
|
setStrokeColor(fromRgba(0, 200, 255, alpha))
|
||||||
|
drawLine(lx, PY, lx, PY + CH)
|
||||||
|
|
||||||
|
# Hidden layer: vertical line per neuron, visible when spiking
|
||||||
|
let hidY = PY + CH + LAYER_V
|
||||||
|
setStrokeWidth(1.5)
|
||||||
|
for h in 0 ..< N_HID:
|
||||||
|
let v = bot.snn.vHid[h].clamp(0.0, THRESH) / THRESH
|
||||||
|
if v > 0.0:
|
||||||
|
let lx = PX + HID_OFF + (float(h) + 0.5) * HID_STEP
|
||||||
|
let alpha = uint8(v * 255.0)
|
||||||
|
setStrokeColor(fromRgba(255, 140, 0, alpha))
|
||||||
|
drawLine(lx, hidY, lx, hidY + CH)
|
||||||
|
|
||||||
|
# Weight lines: input → hidden (sample every 4th input to avoid clutter)
|
||||||
|
# Fixed light-blue color; thickness proportional to normalized weight magnitude.
|
||||||
|
var maxAbsWih = 0.0
|
||||||
|
for w in bot.snn.wih: maxAbsWih = max(maxAbsWih, abs(w))
|
||||||
|
setStrokeColor(fromRgba(180, 220, 255, 180))
|
||||||
|
for i in 0 ..< N_IN:
|
||||||
|
let ix = PX + (float(i) + 0.5) * IN_STEP
|
||||||
|
let iy = PY + CH
|
||||||
|
for h in 0 ..< N_HID:
|
||||||
|
let hx = PX + HID_OFF + (float(h) + 0.5) * HID_STEP
|
||||||
|
let hy = hidY
|
||||||
|
let w = bot.snn.wih[i * N_HID + h]
|
||||||
|
let norm = if maxAbsWih > 0.0: abs(w) / maxAbsWih else: 0.0
|
||||||
|
setStrokeWidth(0.5 + norm * 2.5)
|
||||||
|
drawLine(ix, iy, hx, hy)
|
||||||
|
|
||||||
|
# Output neuron: single vertical line centred in panel, visible when active
|
||||||
|
let outY = hidY + CH + LAYER_V
|
||||||
|
let outV = (tanh(bot.snn.vOut) + 1.0) / 2.0 # 0..1 for display
|
||||||
|
if outV > 0.0:
|
||||||
|
let lx = PX + IN_W / 2.0
|
||||||
|
let alpha = uint8(outV * 255.0)
|
||||||
|
setStrokeColor(fromRgba(200, 0, 255, alpha))
|
||||||
|
setStrokeWidth(2.0)
|
||||||
|
drawLine(lx, outY, lx, outY + CH)
|
||||||
|
|
||||||
|
# ── Aiming-line legend ────────────────────────────────────────────────────
|
||||||
|
# Arena Y=0 is bottom; top = getArenaHeight(). LEG_Y is the bottom edge of
|
||||||
|
# the legend block so it sits near the top of the screen.
|
||||||
|
const
|
||||||
|
LEG_X = 10.0 # left margin
|
||||||
|
LEG_SQ = 8.0 # coloured square side
|
||||||
|
LEG_GAP = 4.0 # gap between square and text
|
||||||
|
LEG_ROW = 14.0 # row height
|
||||||
|
LEG_PAD = 6.0 # inner padding of background rect
|
||||||
|
let LEG_Y = getArenaHeight().float - 60.0 # near top of arena
|
||||||
|
|
||||||
|
# Semi-transparent background
|
||||||
|
setFillColor(fromRgba(0, 0, 0, 160))
|
||||||
|
fillRectangle(LEG_X - LEG_PAD,
|
||||||
|
LEG_Y - LEG_PAD,
|
||||||
|
LEG_SQ + LEG_GAP + 80.0 + LEG_PAD,
|
||||||
|
3.0 * LEG_ROW + LEG_PAD)
|
||||||
|
|
||||||
|
# Row 0 — Green: Enemy bearing
|
||||||
|
setFillColor(GREEN)
|
||||||
|
fillRectangle(LEG_X, LEG_Y, LEG_SQ, LEG_SQ)
|
||||||
|
setFillColor(WHITE)
|
||||||
|
drawText("Enemy bearing", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + LEG_SQ)
|
||||||
|
|
||||||
|
# Row 1 — Red: Gun direction
|
||||||
|
setFillColor(RED)
|
||||||
|
fillRectangle(LEG_X, LEG_Y + LEG_ROW, LEG_SQ, LEG_SQ)
|
||||||
|
setFillColor(WHITE)
|
||||||
|
drawText("Gun direction", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + LEG_ROW + LEG_SQ)
|
||||||
|
|
||||||
|
# Row 2 — Yellow: SNN target
|
||||||
|
setFillColor(YELLOW)
|
||||||
|
fillRectangle(LEG_X, LEG_Y + 2.0 * LEG_ROW, LEG_SQ, LEG_SQ)
|
||||||
|
setFillColor(WHITE)
|
||||||
|
drawText("SNN target", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + 2.0 * LEG_ROW + LEG_SQ)
|
||||||
|
|
||||||
|
# ── Event handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) =
|
||||||
|
let bx = getX(); let by = getY()
|
||||||
|
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
|
||||||
|
bot.enemyDist = distanceTo(bx, by, e.x, e.y)
|
||||||
|
bot.hasContact = true
|
||||||
|
|
||||||
|
method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
||||||
|
setAdjustGunForBodyTurn(true)
|
||||||
|
setAdjustRadarForBodyTurn(true)
|
||||||
|
setAdjustRadarForGunTurn(true)
|
||||||
|
radar_lock.init()
|
||||||
|
bot.hasContact = false
|
||||||
|
bot.phase = DECIDE
|
||||||
|
bot.tick = 0
|
||||||
|
setTargetSpeed(0.0)
|
||||||
|
setTurnRate(0.0)
|
||||||
|
|
||||||
|
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||||||
|
initSNN(bot.snn)
|
||||||
|
|
||||||
|
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
method run*(bot: SNNBot) =
|
||||||
|
while isRunning():
|
||||||
|
inc bot.tick
|
||||||
|
setTargetSpeed(0.0)
|
||||||
|
setTurnRate(0.0)
|
||||||
|
|
||||||
|
if not bot.hasContact:
|
||||||
|
setRadarTurnRate(45.0)
|
||||||
|
go()
|
||||||
|
continue
|
||||||
|
|
||||||
|
let myX = getX()
|
||||||
|
let myY = getY()
|
||||||
|
let gunDir = getGunDirection()
|
||||||
|
|
||||||
|
case bot.phase
|
||||||
|
of DECIDE:
|
||||||
|
let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir)
|
||||||
|
let inputs = encodeInput(relBearing)
|
||||||
|
bot.targetAngle = bot.snn.forward(inputs)
|
||||||
|
bot.phase = WAITING
|
||||||
|
|
||||||
|
of WAITING:
|
||||||
|
aimTo(bot.targetAngle, gunDir)
|
||||||
|
let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir))
|
||||||
|
if err < AIM_TOL:
|
||||||
|
bot.phase = EVALUATE
|
||||||
|
|
||||||
|
of EVALUATE:
|
||||||
|
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
||||||
|
let reward = 1.0 / (1.0 + err)
|
||||||
|
echo "tick=" & $bot.tick & " error=" & $err & "° reward=" & $reward
|
||||||
|
bot.phase = DECIDE
|
||||||
|
|
||||||
|
# Radar lock
|
||||||
|
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
|
||||||
|
|
||||||
|
# Debug overlay
|
||||||
|
drawOverlay(bot, myX, myY, gunDir, bot.enemyBearing, bot.targetAngle)
|
||||||
|
|
||||||
|
go()
|
||||||
|
|
||||||
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
var bot = SNNBot(phase: DECIDE)
|
||||||
|
start(bot, botJsonPath)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
--path:"../../common_libs"
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,66 @@
|
|||||||
|
## Integration test: SNNBot vs SittingDuck.
|
||||||
|
## Requires a running TR server (set TR_SERVER_JAR / TR_BATTLE_RUNNER env vars).
|
||||||
|
## Also includes an offline parseServerOutput unit test — no Java needed.
|
||||||
|
|
||||||
|
import std/[os, unittest]
|
||||||
|
import test_framework/battle_result
|
||||||
|
|
||||||
|
# ---------- offline unit test (no server needed) ----------
|
||||||
|
|
||||||
|
suite "parseServerOutput":
|
||||||
|
test "parses a 1-round battle fixture":
|
||||||
|
const fixture = """
|
||||||
|
{"event":"game_started","bots":["SNNBot","SittingDuck"]}
|
||||||
|
{"event":"round_ended","round":1,"results":[{"name":"SNNBot","score":100,"rank":1,"survived":true},{"name":"SittingDuck","score":0,"rank":2,"survived":false}]}
|
||||||
|
{"event":"battle_ended","results":[{"name":"SNNBot","totalScore":100,"rank":1,"firstPlaces":1,"survivalCount":1},{"name":"SittingDuck","totalScore":0,"rank":2,"firstPlaces":0,"survivalCount":0}]}
|
||||||
|
"""
|
||||||
|
let r = parseServerOutput(fixture)
|
||||||
|
|
||||||
|
check r.bots == @["SNNBot", "SittingDuck"]
|
||||||
|
check r.rounds.len == 1
|
||||||
|
check r.rounds[0].results[0].name == "SNNBot"
|
||||||
|
check r.rounds[0].results[0].survived == true
|
||||||
|
check r.results.len == 2
|
||||||
|
check r.winners == @["SNNBot"]
|
||||||
|
|
||||||
|
# ---------- integration tests (require TR server + BattleRunner JARs) ----------
|
||||||
|
|
||||||
|
const
|
||||||
|
DefaultServerJar = "/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar"
|
||||||
|
DefaultRunnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"
|
||||||
|
|
||||||
|
let serverJar = getEnv("TR_SERVER_JAR", DefaultServerJar)
|
||||||
|
let runnerJar = getEnv("TR_BATTLE_RUNNER", DefaultRunnerJar)
|
||||||
|
if not fileExists(serverJar) or not fileExists(runnerJar):
|
||||||
|
echo "Skipping integration tests: JAR files not found (set TR_SERVER_JAR / TR_BATTLE_RUNNER to override)"
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
import test_framework/test_framework
|
||||||
|
|
||||||
|
const
|
||||||
|
snnbotDir = currentSourcePath().parentDir.parentDir
|
||||||
|
sittingDuckDir = currentSourcePath().parentDir.parentDir.parentDir /
|
||||||
|
"common_libs" / "test_framework" / "adversaries" / "SittingDuck"
|
||||||
|
|
||||||
|
suite "SNNBot integration battles":
|
||||||
|
test "completes 1 round vs SittingDuck without crashing":
|
||||||
|
let r = runBattle(@[snnbotDir, sittingDuckDir], rounds = 1)
|
||||||
|
|
||||||
|
check r.rounds.len == 1
|
||||||
|
check r.results.len == 2
|
||||||
|
|
||||||
|
var snn, duck: BotResult
|
||||||
|
for b in r.results:
|
||||||
|
if b.name == "SNNBot": snn = b
|
||||||
|
if b.name == "SittingDuck": duck = b
|
||||||
|
check snn.name == "SNNBot"
|
||||||
|
check duck.name == "SittingDuck"
|
||||||
|
|
||||||
|
# Just assert it completed and scored something — no aiming quality check
|
||||||
|
check snn.totalScore >= 0
|
||||||
|
check duck.totalScore >= 0
|
||||||
|
|
||||||
|
for rnd in r.rounds:
|
||||||
|
check rnd.results.len == 2
|
||||||
|
for br in rnd.results:
|
||||||
|
check br.score >= 0
|
||||||
Reference in New Issue
Block a user