diff --git a/docs/server-list/README.md b/docs/server-list/README.md new file mode 100644 index 0000000..6146cf7 --- /dev/null +++ b/docs/server-list/README.md @@ -0,0 +1,2 @@ + +Native client follow-up: the supplied 1.21.11 and 26.1–26.3 profiles have 301 logical pixels of content inside the 305-pixel selection outline, so MOTD alignment uses 267 pixels for protocols 774–777. Other protocol IDs retain the existing 271-pixel width until separately mapped. `modernClientAlignment` in live-verification.json proves right-alignment over actual protocol-777 status requests on both proxies, alongside the older protocol-767 case. diff --git a/docs/server-list/live-verification.json b/docs/server-list/live-verification.json new file mode 100644 index 0000000..fdc7798 --- /dev/null +++ b/docs/server-list/live-verification.json @@ -0,0 +1,508 @@ +{ + "initial": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 500, + "sample": [ + { + "name": "\u00a768 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "gold", + "text": "eu 8/500" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 500, + "sample": [ + { + "name": "\u00a768 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "gold", + "text": "na 8/500" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "updated": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed eu 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed na 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "maintenance": [ + { + "version": { + "protocol": -1, + "name": "\u00a7cMaintenance" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed eu 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": -1, + "name": "\u00a7cMaintenance" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed na 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "ended": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed eu 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed na 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "failedRefresh": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed eu 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed na 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "invalidIcon": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed eu 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "\u00a7610 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "\u00a7aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "color": "green", + "text": "Changed na 10/20" + }, + "favicon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAiUlEQVR4Xu3QIQEAQBCEwC35/aPceyqAGINk93ZmY7BpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYNYLBpAINNAxhsGsBg0wAGmwYw2DSAwaYBDDYf7DIyLAxCEbAAAAAASUVORK5CYII=" + } + ], + "resolvedAlignment": [ + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 100, + "sample": [] + }, + "description": { + "extra": [ + " ", + { + "extra": [ + { + "color": "gold", + "text": "e" + }, + { + "color": "gold", + "text": "u" + }, + { + "color": "gold", + "text": ":" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "8" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "/" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "1" + }, + { + "color": "gold", + "text": "0" + }, + { + "color": "gold", + "text": "0" + } + ], + "text": "" + } + ], + "text": "" + } + }, + { + "version": { + "protocol": 767, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 100, + "sample": [] + }, + "description": { + "extra": [ + " ", + { + "extra": [ + { + "color": "gold", + "text": "n" + }, + { + "color": "gold", + "text": "a" + }, + { + "color": "gold", + "text": ":" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "8" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "/" + }, + { + "color": "gold", + "text": " " + }, + { + "color": "gold", + "text": "1" + }, + { + "color": "gold", + "text": "0" + }, + { + "color": "gold", + "text": "0" + } + ], + "text": "" + } + ], + "text": "" + } + } + ], + "modernClientAlignment": [ + { + "version": { + "protocol": 777, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 100, + "sample": [] + }, + "description": { + "extra": [ + " ", + { + "extra": [ + "A" + ], + "text": "" + } + ], + "text": "" + } + }, + { + "version": { + "protocol": 777, + "name": "Velocity 1.7.2-26.3" + }, + "players": { + "online": 8, + "max": 100, + "sample": [] + }, + "description": { + "extra": [ + " ", + { + "extra": [ + "A" + ], + "text": "" + } + ], + "text": "" + } + } + ], + "addresses": [ + "127.0.0.1:25611", + "127.0.0.1:25612" + ], + "localCounts": [ + 3, + 5 + ], + "networkCount": 8, + "runtime": "Velocity 4.2.0", + "sharedConfig": "/tmp/grounds-motd-proxies/shared.json", + "iconSha256": "be19b61f591a7776d20e1792a2682b1a27f2768fda4bc0d4b7b3fa1cbb2ff7e9" +} \ No newline at end of file diff --git a/tools/server-list/README.md b/tools/server-list/README.md new file mode 100644 index 0000000..10a394b --- /dev/null +++ b/tools/server-list/README.md @@ -0,0 +1,11 @@ +# Real proxy validation + +Build with Java 25: `./gradlew :velocity:testClasses :velocity:shadowJar`. +Run `JAVA_HOME=/path/to/jdk python3 tools/server-list/live-harness.py /path/to/velocity.jar /tmp/grounds-motd-proxies --stay`. +Then `python3 tools/server-list/verify-live.py /tmp/grounds-motd-proxies`. + +The harness replaces only the test JAR's plugin descriptor. It registers the real production `ServerListPingListener` with separate MotdManagers and stores, a common local configuration file and network presence count. Both real Velocity processes bind only loopback, use no backend game servers, and disable test telemetry. Local baseline counts are 3 and 5; network count is 8. No service tokens or production writes are involved. Ctrl-C terminates the test processes. + +Recorded on 2026-10-21 with the locally available Velocity 4.2.0 JAR, SHA-256 `35a5596a5468a035d8a32c8de5ebb0dc6b8d8f0cc3ff5169d514aca762af8aa8`. Responses are retained in `docs/server-list/live-verification.json`. A dedicated test entry point supplies the config and presence inputs; this does not validate live NATS delivery or a production config service deployment. + +The final flow also probes the current semantic-template path: placeholders are resolved before native color effects and 271-pixel alignment. Both process responses match the JavaScript reference text including leading spaces. diff --git a/tools/server-list/live-harness.py b/tools/server-list/live-harness.py new file mode 100644 index 0000000..d473a42 --- /dev/null +++ b/tools/server-list/live-harness.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build a test-only plugin JAR and start two isolated Velocity processes. +Usage: live-harness.py /absolute/path/to/velocity.jar [working-directory] +Only the descriptor is replaced; the production listener/evaluator classes are unchanged. +""" +import pathlib,sys,json,zipfile,subprocess,hashlib,os,shutil,time +root=pathlib.Path(__file__).resolve().parents[2] +work=pathlib.Path(sys.argv[2] if len(sys.argv)>2 else '/tmp/grounds-motd-proxies');work.mkdir(parents=True,exist_ok=True) +source=root/'velocity/build/libs/plugin-proxy-velocity.jar' +plugin=work/'motd-test-harness.jar' +with zipfile.ZipFile(source) as src,zipfile.ZipFile(plugin,'w',zipfile.ZIP_DEFLATED) as out: + for n in src.namelist(): + if n!='velocity-plugin.json' and not n.endswith('/'):out.writestr(n,src.read(n)) + classes=root/'velocity/build/classes/kotlin/test' + for f in classes.rglob('ServerListLiveHarness*.class'):out.write(f,f.relative_to(classes)) + out.writestr('velocity-plugin.json',json.dumps({'id':'motd-validation','name':'MOTD validation harness','version':'0.1.0','main':'gg.grounds.proxy.velocity.motd.ServerListLiveHarness','authors':[]})) +fixtures=json.loads((root/'velocity/src/test/resources/server-list/contract.json').read_text()) +shared=work/'shared.json';shared.write_text(json.dumps({'networkPlayers':8,'motd':{'text':'{{region}} {{players}}/{{max}}','serverList':fixtures[2]['template']},'maintenance':None})) +java=str(pathlib.Path(os.environ['JAVA_HOME'])/'bin/java') if os.environ.get('JAVA_HOME') else shutil.which('java') +processes=[];live=[] +for name,port,region,local in [('a',25611,'eu',3),('b',25612,'na',5)]: + d=work/name;d.mkdir(exist_ok=True);(d/'plugins').mkdir(exist_ok=True);(d/'plugins/bStats').mkdir(exist_ok=True);(d/'plugins/bStats/config.txt').write_text('enabled=false\n');(d/'plugins/motd-test-harness.jar').write_bytes(plugin.read_bytes()) + (d/'forwarding.secret').write_text('local-validation-only') + (d/'velocity.toml').write_text(f'''config-version = "2.7" +bind = "127.0.0.1:{port}" +motd = "Test base" +show-max-players = 500 +online-mode = false +force-key-authentication = false +player-info-forwarding-mode = "NONE" +forwarding-secret-file = "forwarding.secret" +announce-forge = false +kick-existing-players = false +ping-passthrough = "DISABLED" +sample-players-in-ping = true +enable-player-address-logging = false +[servers] +lobby = "127.0.0.1:9" +factions = "127.0.0.1:9" +minigames = "127.0.0.1:9" +try = [] +[advanced] +compression-threshold = 256 +compression-level = -1 +login-ratelimit = 0 +connection-timeout = 5000 +read-timeout = 30000 +haproxy-protocol = false +tcp-fast-open = false +bungee-plugin-message-channel = true +show-ping-requests = false +failover-on-unexpected-server-disconnect = true +announce-proxy-commands = false +log-command-executions = false +log-player-connections = false +accepts-transfers = false +''') + log=open(d/'proxy.log','wb') + p=subprocess.Popen([java,'-Xms128m','-Xmx256m',f'-Dmotd.harness.document={shared}',f'-Dmotd.harness.region={region}',f'-Dmotd.harness.local={local}','-jar',str(pathlib.Path(sys.argv[1]).resolve())],cwd=d,stdout=log,stderr=subprocess.STDOUT,stdin=subprocess.PIPE) + live.append(p) + processes.append({'name':name,'pid':p.pid,'address':f'127.0.0.1:{port}','localPlayers':local,'region':region}) +(work/'processes.json').write_text(json.dumps(processes,indent=2));print(json.dumps({'work':str(work),'velocitySha256':hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest(),'processes':processes},indent=2)) + +if '--stay' in sys.argv: + sys.stdout.flush() + try: + while True:time.sleep(1) + except KeyboardInterrupt: + for p in processes: + try:os.kill(p['pid'],15) + except ProcessLookupError:pass diff --git a/tools/server-list/verify-live.py b/tools/server-list/verify-live.py new file mode 100644 index 0000000..e4d75ec --- /dev/null +++ b/tools/server-list/verify-live.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Probe two running live-harness.py instances over real Minecraft status TCP packets.""" +import pathlib,json,socket,struct,time,zlib,base64,hashlib,sys +work=pathlib.Path(sys.argv[1] if len(sys.argv)>1 else '/tmp/grounds-motd-proxies');shared=work/'shared.json' +def varint(n): + b=bytearray() + while True: + part=n&127;n>>=7;b.append(part|(128 if n else 0)) + if not n:return bytes(b) +def readvar(s): + n=0 + for i in range(5): + b=s.recv(1) + if not b:raise EOFError() + n|=(b[0]&127)<<(i*7) + if b[0]<128:return n + raise ValueError('varint') +def ping(port,protocol=767): + with socket.create_connection(('127.0.0.1',port),timeout=3) as s: + h=b'localhost';packet=b'\0'+varint(protocol)+varint(len(h))+h+struct.pack('>H',port)+b'\1';s.sendall(varint(len(packet))+packet+b'\1\0');readvar(s);assert readvar(s)==0;size=readvar(s);body=b'' + while len(body)deadline:raise AssertionError(json.dumps(result)[:1000]) + time.sleep(.1) +def plain(c): + if isinstance(c,str):return c + if isinstance(c,list):return ''.join(map(plain,c)) + return c.get('text','')+''.join(map(plain,c.get('extra',[]))) +def chunk(k,v):return struct.pack('>I',len(v))+k+v+struct.pack('>I',zlib.crc32(k+v)&0xffffffff) +png=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',64,64,8,6,0,0,0))+chunk(b'IDAT',zlib.compress((b'\0'+b'\xff\x55\x00\xff'*64)*64))+chunk(b'IEND',b'') +data=json.loads(shared.read_text());data['motd']['serverList']['icon']={'pngBase64':base64.b64encode(png).decode()};write(data) +initial=until(lambda ps:all(p['players']['online']==8 and p.get('favicon') for p in ps));assert plain(initial[0]['description'])=='eu 8/500';assert plain(initial[1]['description'])=='na 8/500';assert initial[0]['favicon']==initial[1]['favicon'];assert '8 players' in initial[0]['players']['sample'][0]['name'] +evidence={'initial':initial} +data['motd']['text']='Changed {{region}} {{players}}/{{max}}';data['motd']['serverList']['players']['online']={'mode':'offset','value':2};data['motd']['serverList']['players']['maximum']={'mode':'headroom','value':10};write(data) +updated=until(lambda ps:all(p['players']['online']==10 and p['players']['max']==20 for p in ps));assert '10/20' in plain(updated[0]['description']);assert '10 players' in updated[1]['players']['sample'][0]['name'];evidence['updated']=updated +maintenance={'schemaVersion':1,'enabled':True,'id':'00000000-0000-0000-0000-000000000001','startsAt':'2020-01-01T00:00:00Z','endsAt':None,'estimatedEndAt':None,'reason':'Test','joinMessage':'Closed','motd':'','kickExisting':False,'whitelist':[],'announcementSeconds':[],'announcementMessage':'Soon','serverList':json.loads(json.dumps(data['motd']['serverList'])),'serverListFallbackText':'','serverListOverrides':['version']} +maintenance['serverList']['version']={'mode':'always','text':{'revision':0,'nodes':[{'id':'v','type':'group','style':{'color':'#ff5555'},'children':[{'id':'vt','type':'text','value':'Maintenance'}]}]}};data['maintenance']=maintenance;write(data) +active=until(lambda ps:all(p['version']['protocol']==-1 and 'Maintenance' in p['version']['name'] for p in ps));assert [p['favicon'] for p in active]==[p['favicon'] for p in updated];assert all(p['players']['online']==10 for p in active);evidence['maintenance']=active +maintenance['endsAt']='2020-01-02T00:00:00Z';write(data);ended=until(lambda ps:[p['version'] for p in ps]==[p['version'] for p in updated]);evidence['ended']=ended +shared.write_text('{broken');time.sleep(.5);cached=both();assert [p['version'] for p in cached]==[p['version'] for p in ended];assert all(p['players']['online']==10 for p in cached);evidence['failedRefresh']=cached +bad=json.loads(json.dumps(data));bad['motd']['serverList']['icon']={'pngBase64':'iVBORw0KGgo='};write(bad);time.sleep(.5);retained=both();assert [p['favicon'] for p in retained]==[p['favicon'] for p in ended];evidence['invalidIcon']=retained +# Exercise the current compiled runtime path, measuring placeholders after resolution. +fixtures=json.loads((pathlib.Path(__file__).resolve().parents[2]/'velocity/src/test/resources/server-list/contract.json').read_text());aligned=next(f for f in fixtures if f['name']=='aligned-placeholder');data['maintenance']=None;data['motd']={'text':aligned['source'],'serverListFallbackText':aligned['source'],'serverList':aligned['template'],'maxPlayers':100};write(data);alignedPings=until(lambda ps:all('8 / 100' in plain(p['description']) for p in ps));assert plain(alignedPings[0]['description'])==aligned['expectedPlain'];assert '{{' not in plain(alignedPings[1]['description']);evidence['resolvedAlignment']=alignedPings +# Native 1.21.11/26.x content width is 301, leaving 267 logical pixels for MOTD. +modern=json.loads(json.dumps(data));modern['motd']['serverList']['motd']={'text':{'revision':0,'nodes':[{'id':'a','type':'text','value':'A'}]},'alignments':['right']};modern['motd']['text']='A';modern['motd']['serverListFallbackText']='A';write(modern) +until(lambda ps:all(plain(p['description'])==' '*66+'A' for p in ps)) +modernPings=[ping(port,777) for port in [25611,25612]] +assert all(plain(p['description'])==' '*65+'A' for p in modernPings) +evidence['modernClientAlignment']=modernPings +evidence['addresses']=['127.0.0.1:25611','127.0.0.1:25612'];evidence['localCounts']=[3,5];evidence['networkCount']=8;evidence['runtime']='Velocity 4.2.0';evidence['sharedConfig']=str(shared);evidence['iconSha256']=hashlib.sha256(png).hexdigest();(work/'verification.json').write_text(json.dumps(evidence,indent=2));print('PASS: real TCP pings on both proxies; shared 8 → displayed 10/20; regions, icon, hover, maintenance restoration, failed refresh and invalid icon cache retained.') diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 286a61f..77d0ae1 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { // with the Netty the proxy runs on. testImplementation("org.junit.jupiter:junit-jupiter-api:6.1.3") + testImplementation("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") // Adventure reaches the plugin through Velocity at runtime, which is compileOnly here and so // absent from the test classpath. The tests render real Components, so they need it themselves. testImplementation("net.kyori:adventure-api:5.2.0") diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index caee0f3..9ce9ec0 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -15,7 +15,6 @@ import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier -import com.velocitypowered.api.proxy.server.ServerPing import com.velocitypowered.api.scheduler.ScheduledTask import gg.grounds.BuildInfo import gg.grounds.i18n.Translations @@ -524,30 +523,18 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { * the player-facing network, where that plugin is not installed, and where a project name would * be the wrong thing to show anyway. */ - @Subscribe - fun onProxyPing(event: ProxyPingEvent) { - val count = networkPlayerCount - val motd = motdManager - val now = Instant.now() - val maintenanceMotd = maintenanceStore?.let { maintenance.motd(now) } - val maintenanceProtocol = maintenanceStore?.let { maintenance.protocolName(now) } - if (count == null && motd == null && maintenanceProtocol == null) return - - val builder = event.ping.asBuilder() - count?.let { builder.onlinePlayers(it) } - motd?.let { manager -> - manager.maxPlayers()?.let { builder.maximumPlayers(it) } - // The numbers this very ping is about to report, so a MOTD that mentions them cannot - // disagree with the pair printed next to it. - manager.render(builder.onlinePlayers, builder.maximumPlayers)?.let { - builder.description(it) - } - } - maintenanceMotd?.let { builder.description(MiniMessage.miniMessage().deserialize(it)) } - maintenanceProtocol?.let { builder.version(ServerPing.Version(-1, it)) } - event.ping = builder.build() + private val serverListPingListener by lazy { + gg.grounds.proxy.velocity.motd.ServerListPingListener( + manager = { motdManager }, + networkPlayers = { networkPlayerCount }, + maintenance = { if (maintenanceStore != null) maintenance else null }, + region = { RegionCatalog.currentCode() }, + continent = { env("CONTINENT") }, + ) } + @Subscribe fun onProxyPing(event: ProxyPingEvent) = serverListPingListener.onProxyPing(event) + @Subscribe(order = PostOrder.LAST) fun onLogin(event: LoginEvent) { if (!event.result.isAllowed) return diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt index 30363d4..c86a168 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt @@ -186,7 +186,7 @@ class MotdCommand( return } } - store(source, document.copy(maxPlayers = value)) { + store(source, document.withMaximum(value)) { messages.send( source, if (value == null) ProxySystemMessage.MOTD_MAX_PLAYERS_CLEARED diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt index 232ae28..4dcdbf2 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt @@ -204,6 +204,11 @@ internal constructor( uuids.toSet(), seconds, announcement, + root + .getAsJsonObject("serverList") + ?.let(gg.grounds.proxy.velocity.motd.ServerListTemplate::parse), + root.get("serverListFallbackText")?.asString, + root.getAsJsonArray("serverListOverrides")?.map { it.asString }?.toSet(), ) } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt index ede605c..dfb7065 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt @@ -90,6 +90,24 @@ class MaintenanceEngine(private val clock: Clock = Clock.systemUTC()) { return crossed.minOrNull()?.let(::listOf) ?: emptyList() } + @Synchronized + fun serverListValues(now: Instant = clock.instant()): Map = + mapOf( + "reason" to document?.reason.orEmpty(), + "end" to + (document?.endsAt ?: document?.estimatedEndAt)?.toString().orEmpty().ifEmpty { + "to be announced" + }, + "remaining" to + formatRemaining( + (document?.startsAt?.epochSecond ?: now.epochSecond) - now.epochSecond + ), + ) + + @Synchronized + fun serverListDocument(now: Instant = clock.instant()): MaintenanceDocument? = + document?.takeIf { activeAt(now) } + @Synchronized fun protocolName(now: Instant = clock.instant()): String? = if (activeAt(now)) "§cWartungsarbeiten" else null @@ -158,4 +176,7 @@ data class MaintenanceDocument( val whitelist: Set, val announcementSeconds: List, val announcementMessage: String, + val serverList: gg.grounds.proxy.velocity.motd.ServerListTemplate? = null, + val serverListFallbackText: String? = null, + val serverListOverrides: Set? = null, ) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt new file mode 100644 index 0000000..a34d18c --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt @@ -0,0 +1,88 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.Style +import net.kyori.adventure.text.format.TextDecoration + +/** Splits resolved components without carrying implicit style across independent client values. */ +internal fun componentLines(component: Component): List { + val lines = mutableListOf() + var line: Component = Component.empty() + fun walk(node: Component, inherited: Style) { + val style = node.style().merge(inherited, Style.Merge.Strategy.IF_ABSENT_ON_TARGET) + if (node is TextComponent) { + node.content().codePoints().forEach { point -> + if (point == 10) { + lines.add(line) + line = Component.empty() + } else + line = + line.append(Component.text(String(Character.toChars(point))).style(style)) + } + } else line = line.append(node.children(emptyList()).style(style)) + node.children().forEach { walk(it, style) } + } + walk(component, Style.empty()) + lines.add(line) + return lines +} + +/** Resolve native effects/placeholders before applying the shared profile's pixel spacing. */ +class CompiledMotd(document: JsonObject, private val alignments: List) { + private val text = CompiledServerText(document) + + fun render(values: Map, lineWidth: Int = 271): Component { + val rendered = text.render(values) + if (alignments.all { it == "left" }) return rendered + var result: Component = Component.empty() + for ((index, line) in componentLines(rendered).withIndex()) { + var width = 0 + fun measure(node: Component, inherited: Style) { + val style = node.style().merge(inherited, Style.Merge.Strategy.IF_ABSENT_ON_TARGET) + if (node is TextComponent) + node.content().codePoints().forEach { point -> + val char = String(Character.toChars(point)) + width += + (advances[char] ?: 6) + + (if ( + char != " " && + style.decoration(TextDecoration.BOLD) == + TextDecoration.State.TRUE + ) + 1 + else 0) + } + node.children().forEach { measure(it, style) } + } + measure(line, Style.empty()) + val remaining = (lineWidth - width).coerceAtLeast(0) + val alignment = alignments.getOrNull(index) ?: "left" + val spaces = + when (alignment) { + "center" -> kotlin.math.floor(remaining / 8.0 + .5).toInt() + "right" -> kotlin.math.floor(remaining / 4.0 + .5).toInt() + else -> 0 + } + if (index > 0) result = result.append(Component.newline()) + result = result.append(Component.text(" ".repeat(spaces))).append(line) + } + return result + } + + companion object { + private val advances: Map = + CompiledMotd::class + .java + .getResourceAsStream("/motd/java-1.21.1-advances.json")!! + .reader() + .use { reader -> + JsonParser.parseReader(reader).asJsonObject.entrySet().associate { (key, value) + -> + key to value.asInt + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdDocument.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdDocument.kt index 41c8d1d..eba4e8c 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdDocument.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdDocument.kt @@ -25,8 +25,34 @@ data class MotdDocument( val source: String? = null, val updatedBy: String? = null, val updatedAt: String? = null, + val serverList: ServerListTemplate? = null, + val editor: JsonObject? = null, + val serverListFallbackText: String? = null, ) { + val compiledFallback = CompiledServerText.fromMiniMessage(text) + + fun withMaximum(value: Int?): MotdDocument { + val template = + serverList + ?: return copy(maxPlayers = value, editor = null, serverListFallbackText = null) + val json = template.json.deepCopy() + val rule = JsonObject() + rule.addProperty("mode", if (value == null) "inherit" else "fixed") + value?.let { rule.addProperty("value", it) } + json.getAsJsonObject("players").add("maximum", rule) + val metadata = editor?.deepCopy() + metadata + ?.getAsJsonObject("document") + ?.getAsJsonObject("players") + ?.add("maximum", rule.deepCopy()) + return copy( + maxPlayers = value, + serverList = ServerListTemplate.parse(json), + editor = metadata, + ) + } + fun toJson(): String { val json = JsonObject() json.addProperty("text", text) @@ -34,6 +60,9 @@ data class MotdDocument( source?.let { json.addProperty("source", it) } updatedBy?.let { json.addProperty("updatedBy", it) } updatedAt?.let { json.addProperty("updatedAt", it) } + serverList?.let { json.add("serverList", it.json.deepCopy()) } + editor?.let { json.add("editor", it.deepCopy()) } + serverListFallbackText?.let { json.addProperty("serverListFallbackText", it) } return json.toString() } @@ -62,6 +91,9 @@ data class MotdDocument( ?: throw MotdFormatException("stored MOTD has no 'text'") return MotdDocument( text = text, + serverList = json.getAsJsonObject("serverList")?.let(ServerListTemplate::parse), + editor = json.getAsJsonObject("editor")?.deepCopy(), + serverListFallbackText = json.get("serverListFallbackText")?.asString, maxPlayers = json.get("maxPlayers")?.takeIf { it.isJsonPrimitive }?.asInt, source = json.get("source")?.takeIf { it.isJsonPrimitive }?.asString, updatedBy = json.get("updatedBy")?.takeIf { it.isJsonPrimitive }?.asString, diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdManager.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdManager.kt index 334cc67..fd41ab0 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdManager.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/MotdManager.kt @@ -69,7 +69,15 @@ class MotdManager( * command instead of leaving them believing it took. */ fun set(document: MotdDocument, actor: String) { - val stamped = document.copy(updatedBy = actor, updatedAt = Instant.now().toString()) + val changedText = + document.editor?.get("fallbackText")?.asString?.let { it != document.text } == true + val stamped = + document.copy( + updatedBy = actor, + updatedAt = Instant.now().toString(), + editor = if (changedText) null else document.editor, + serverListFallbackText = if (changedText) null else document.serverListFallbackText, + ) store.write(stamped, actor) adopt(stamped) loaded = true diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluator.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluator.kt new file mode 100644 index 0000000..c20aafa --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluator.kt @@ -0,0 +1,81 @@ +package gg.grounds.proxy.velocity.motd + +import com.velocitypowered.api.proxy.server.ServerPing +import java.util.UUID +import net.kyori.adventure.text.minimessage.MiniMessage + +/** Only cached data is consumed here; no configuration or presence requests on the ping path. */ +object ServerListEvaluator { + fun evaluate( + base: ServerPing, + template: ServerListTemplate?, + networkPlayers: Int?, + clientProtocol: Int, + fallbackText: String?, + region: String?, + continent: String?, + compiledFallback: CompiledServerText? = null, + compiledMotd: CompiledMotd? = null, + runtimeValues: Map = emptyMap(), + ): ServerPing { + val builder = base.asBuilder() + networkPlayers?.let { builder.onlinePlayers(it) } + template?.let { + val online = it.online.apply(builder.onlinePlayers) + val maximum = it.maximum.apply(builder.maximumPlayers, online) + builder.onlinePlayers(online).maximumPlayers(maximum) + } + val players = builder.onlinePlayers + val max = builder.maximumPlayers + val values = + runtimeValues + + mapOf( + "players" to players.toString(), + "max" to max.toString(), + "region" to region.orEmpty(), + "continent" to continent.orEmpty(), + "localzone" to continent.orEmpty(), + ) + if (compiledMotd != null) + builder.description( + compiledMotd.render(values, if (clientProtocol in 774..777) 267 else 271) + ) + else if (compiledFallback != null) builder.description(compiledFallback.render(values)) + else + fallbackText?.let { + builder.description( + MiniMessage.miniMessage() + .deserialize( + MotdPlaceholders.render( + it, + MotdPlaceholders.Context(region, continent, players, max), + ) + ) + ) + } + template?.let { + val incompatible = base.version.protocol != clientProtocol + if (it.versionMode == "always" || it.versionMode == "when-incompatible" && incompatible) + builder.version( + ServerPing.Version( + if (clientProtocol == -1) -2 else -1, + it.versionText!!.legacy(values), + ) + ) + it.icon?.let(builder::favicon) + when (it.hoverMode) { + "disabled" -> builder.clearSamplePlayers() + "custom" -> + builder + .clearSamplePlayers() + .samplePlayers( + it.hoverText!!.legacyLines(values).map { name -> + ServerPing.SamplePlayer(name, UUID(0, 0)) + } + ) + } + if (it.hidden) builder.nullPlayers() + } + return builder.build() + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListPingListener.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListPingListener.kt new file mode 100644 index 0000000..3f32cda --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListPingListener.kt @@ -0,0 +1,101 @@ +package gg.grounds.proxy.velocity.motd + +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.proxy.ProxyPingEvent +import com.velocitypowered.api.proxy.server.ServerPing +import gg.grounds.proxy.velocity.maintenance.MaintenanceEngine +import java.time.Instant + +/** + * Production handler; test instances inject independent cached stores and shared network presence. + */ +class ServerListPingListener( + private val manager: () -> MotdManager?, + private val networkPlayers: () -> Int?, + private val maintenance: () -> MaintenanceEngine?, + private val region: () -> String?, + private val continent: () -> String?, + private val now: () -> Instant = Instant::now, +) { + @Subscribe + fun onProxyPing(event: ProxyPingEvent) { + val sampledNow = now() + val normal = manager()?.current() + val engine = maintenance() + val active = engine?.serverListDocument(sampledNow) + val base = event.ping.asBuilder() + normal?.maxPlayers?.let(base::maximumPlayers) + var template = normal?.serverList + val override = active?.serverList + var fallback = normal?.text + var compiled = normal?.compiledFallback + var compiledMotd = + normal?.serverList?.motd?.takeIf { normal.serverListFallbackText == normal.text } + if (active != null) { + if (active.motd.isNotBlank()) { + fallback = engine.motd(sampledNow) + compiled = null + compiledMotd = + override?.motd?.takeIf { active.serverListFallbackText == active.motd } + } + if (override != null) { + val fields = + active.serverListOverrides ?: setOf("version", "icon", "players", "hover") + val inherited = + template + ?: override.copy( + versionMode = "automatic", + versionText = null, + icon = null, + hidden = false, + online = ServerCountRule("inherit"), + maximum = ServerCountRule("inherit"), + hoverMode = "inherit", + hoverText = null, + ) + template = + inherited.copy( + versionMode = + if ("version" in fields) override.versionMode + else inherited.versionMode, + versionText = + if ("version" in fields) override.versionText + else inherited.versionText, + icon = if ("icon" in fields) override.icon else inherited.icon, + hidden = if ("players" in fields) override.hidden else inherited.hidden, + online = + if ("players" in fields && override.online.mode != "inherit") + override.online + else inherited.online, + maximum = + if ("players" in fields && override.maximum.mode != "inherit") + override.maximum + else inherited.maximum, + hoverMode = + if ("hover" in fields) override.hoverMode else inherited.hoverMode, + hoverText = + if ("hover" in fields) override.hoverText else inherited.hoverText, + ) + } + } + event.ping = + ServerListEvaluator.evaluate( + base.build(), + template, + networkPlayers(), + event.connection.protocolVersion.protocol, + fallback, + region(), + continent(), + compiled, + compiledMotd, + if (active != null) engine.serverListValues(sampledNow) else emptyMap(), + ) + // Preserve the legacy maintenance marker only for documents without a status template. + if (active != null && override == null) { + engine.protocolName(sampledNow)?.let { + event.ping = event.ping.asBuilder().version(ServerPing.Version(-1, it)).build() + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListTemplate.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListTemplate.kt new file mode 100644 index 0000000..ca8cb28 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/ServerListTemplate.kt @@ -0,0 +1,303 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonObject +import com.velocitypowered.api.util.Favicon +import java.io.ByteArrayInputStream +import java.util.Base64 +import javax.imageio.ImageIO +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer + +/** Validated immutable runtime snapshot. Images and static text are decoded at refresh time. */ +data class ServerListTemplate( + val json: JsonObject, + val versionMode: String, + val versionText: CompiledServerText?, + val icon: Favicon?, + val hidden: Boolean, + val online: ServerCountRule, + val maximum: ServerCountRule, + val hoverMode: String, + val hoverText: CompiledServerText?, + val motd: CompiledMotd, +) { + companion object { + fun parse(json: JsonObject): ServerListTemplate { + require(json.toString().length <= 262144) { "Server-list template is too large" } + require(json.get("schemaVersion").asInt == 1) { "Unsupported server-list schema" } + val version = json.getAsJsonObject("version") + val versionMode = version.get("mode").asString + require(versionMode in setOf("automatic", "when-incompatible", "always")) + val players = json.getAsJsonObject("players") + val hover = json.getAsJsonObject("hover") + val hoverMode = hover.get("mode").asString + require(hoverMode in setOf("inherit", "disabled", "custom")) + val icon = + json + .get("icon") + ?.takeUnless { it.isJsonNull } + ?.asJsonObject + ?.let { + val encoded = it.get("pngBase64").asString + require(encoded.length <= 131072) + val bytes = Base64.getDecoder().decode(encoded) + require( + bytes.size >= 24 && + bytes.take(8) == + listOf(137, 80, 78, 71, 13, 10, 26, 10).map(Int::toByte) + ) + // Bound dimensions before ImageIO allocates image memory. + val header = java.nio.ByteBuffer.wrap(bytes) + require(header.getInt(16) == 64 && header.getInt(20) == 64) + val image = + ImageIO.read(ByteArrayInputStream(bytes)) ?: error("Invalid PNG") + require(image.width == 64 && image.height == 64) + Favicon.create(image) + } + return ServerListTemplate( + json.deepCopy(), + versionMode, + if (versionMode == "automatic") null + else CompiledServerText(version.getAsJsonObject("text")), + icon, + players.get("hidden").asBoolean, + ServerCountRule.parse(players.getAsJsonObject("online"), false), + ServerCountRule.parse(players.getAsJsonObject("maximum"), true), + hoverMode, + if (hoverMode == "custom") CompiledServerText(hover.getAsJsonObject("text")) + else null, + CompiledMotd( + json.getAsJsonObject("motd").getAsJsonObject("text"), + json.getAsJsonObject("motd").getAsJsonArray("alignments").map { it.asString }, + ), + ) + } + } +} + +data class ServerCountRule(val mode: String, val value: Double = 0.0) { + fun apply(base: Int, online: Int = base): Int { + val result = + when (mode) { + "fixed" -> value + "offset" -> base + value + "percent" -> kotlin.math.ceil(base * value / 100) + "headroom" -> online + value + else -> base.toDouble() + } + return result.coerceIn(0.0, Int.MAX_VALUE.toDouble()).toInt() + } + + companion object { + fun parse(json: JsonObject, maximum: Boolean): ServerCountRule { + val mode = json.get("mode").asString + require( + mode in setOf("inherit", "fixed", "offset", "percent") || + maximum && mode == "headroom" + ) + val value = if (mode == "inherit") 0.0 else json.get("value").asDouble + require(value.isFinite() && (mode == "offset" || value >= 0)) + require(mode == "percent" || value % 1 == 0.0) + return ServerCountRule(mode, value) + } + } +} + +/** Native Adventure handles protected MiniMessage subtrees. Templates never contain callbacks. */ +class CompiledServerText private constructor(private val source: String) { + constructor(document: JsonObject) : this(compile(document)) + + private val static = if (PLACEHOLDER.containsMatchIn(source)) null else MM.deserialize(source) + + fun render(values: Map): Component = + static + ?: MM.deserialize( + PLACEHOLDER.replace(source) { match -> + values[match.groupValues[1].ifEmpty { match.groupValues[2] }]?.let( + MM::escapeTags + ) ?: match.value + } + ) + + fun legacy(values: Map): String = + LegacyComponentSerializer.legacySection().serialize(render(values)) + + fun legacyLines(values: Map): List = + componentLines(render(values)).map { + LegacyComponentSerializer.legacySection().serialize(it) + } + + companion object { + private val PLACEHOLDER = Regex("\\{\\{([A-Za-z0-9_]+)}}|\\{(reason|end|remaining)}") + + fun fromMiniMessage(source: String) = CompiledServerText(source) + + private val MM = MiniMessage.miniMessage() + + private fun compile(document: JsonObject): String { + var budget = 0 + fun node(n: JsonObject, depth: Int): String { + require(++budget <= 100000 && depth <= 128) + return when (n.get("type").asString) { + "text" -> escape(n.get("value").asString) + "newline" -> "" + "placeholder" -> "{{${n.get("name").asString}}}" + "opaque" -> + when (n.get("format").asString) { + "minimessage" -> n.get("raw").asString + "legacy-section" -> + MM.serialize( + LegacyComponentSerializer.legacySection() + .deserialize(n.get("raw").asString) + ) + "legacy-ampersand" -> + MM.serialize( + LegacyComponentSerializer.legacyAmpersand() + .deserialize(n.get("raw").asString) + ) + else -> escape(n.get("fallback")?.asString ?: n.get("raw").asString) + } + "group" -> { + val style = n.getAsJsonObject("style") + var text = + n.getAsJsonArray("children").joinToString("") { + node(it.asJsonObject, depth + 1) + } + style.get("color")?.asString?.let { text = "<$it>$text" } + style.getAsJsonArray("gradient")?.let { stops -> + require(stops.size() >= 2) + val colors = + stops.joinToString(":") { it.asJsonObject.get("color").asString } + val phase = style.get("gradientPhase")?.asDouble ?: 0.0 + // Native MiniMessage is authoritative for uniform gradients. + val uneven = + stops.withIndex().any { (i, s) -> + kotlin.math.abs( + s.asJsonObject.get("position").asDouble - + i.toDouble() / (stops.size() - 1) + ) > 1e-6 + } + if (uneven && phase == 0.0) { + val rendered = MM.deserialize(text) + val flat = + mutableListOf< + Pair + >() + fun flatten( + c: Component, + inherited: net.kyori.adventure.text.format.Style, + ) { + val effective = + c.style() + .merge( + inherited, + net.kyori.adventure.text.format.Style.Merge.Strategy + .IF_ABSENT_ON_TARGET, + ) + if (c is net.kyori.adventure.text.TextComponent) + c.content().codePoints().forEach { + flat.add(String(Character.toChars(it)) to effective) + } + c.children().forEach { flatten(it, effective) } + } + flatten(rendered, net.kyori.adventure.text.format.Style.empty()) + val visible = flat.count { it.first != "\n" } + var index = 0 + text = + MM.serialize( + flat.fold(Component.empty() as Component) { + acc, + (char, oldStyle) -> + if (char == "\n") + acc.append(Component.text(char).style(oldStyle)) + else { + val t = + if (visible <= 1) 0.0 + else index++.toDouble() / (visible - 1) + val right = + (1 until stops.size()).firstOrNull { + t <= + stops[it] + .asJsonObject + .get("position") + .asDouble + } ?: stops.size() - 1 + val a = stops[right - 1].asJsonObject + val b = stops[right].asJsonObject + val start = a.get("position").asDouble + val end = b.get("position").asDouble + val fraction = + ((t - start) / (end - start)).coerceIn(0.0, 1.0) + val from = + Integer.parseInt( + a.get("color").asString.drop(1), + 16, + ) + val to = + Integer.parseInt( + b.get("color").asString.drop(1), + 16, + ) + fun channel(shift: Int) = + kotlin.math + .floor( + ((from shr shift) and 255) + + ((((to shr shift) and 255) - + ((from shr shift) and 255)) * + fraction) + + 0.5000000001 + ) + .toInt() + val color = + net.kyori.adventure.text.format.TextColor.color( + channel(16), + channel(8), + channel(0), + ) + acc.append( + Component.text(char) + .style( + if ( + oldStyle.color() == null || + oldStyle + .color() + ?.asHexString() == + style.get("color")?.asString + ) + oldStyle.color(color) + else oldStyle + ) + ) + } + } + ) + } else text = "$text" + } + style.getAsJsonObject("rainbow")?.let { r -> + text = + "$text" + } + mapOf( + "bold" to "b", + "italic" to "i", + "underlined" to "u", + "strikethrough" to "st", + "obfuscated" to "obf", + ) + .forEach { (key, tag) -> + style.get(key)?.asBoolean?.let { + text = "<${if(it)"" else "!"}$tag>$text" + } + } + text + } + else -> error("Invalid text node") + } + } + return document.getAsJsonArray("nodes").joinToString("") { node(it.asJsonObject, 0) } + } + + private fun escape(text: String) = text.replace("\\", "\\\\").replace("<", "\\<") + } +} diff --git a/velocity/src/main/resources/motd/README.txt b/velocity/src/main/resources/motd/README.txt new file mode 100644 index 0000000..b8243c0 --- /dev/null +++ b/velocity/src/main/resources/motd/README.txt @@ -0,0 +1,5 @@ +Java 1.21.1 glyph advances are shared with @groundsgg/motd. +Source: independently recreated IdreesInc/Minecraft-Font, SIL OFL 1.1; +Grounds adjusts odd horizontal advances to the client pixel grid. +Only integer glyph-width measurements are included here, no game/font images. +See library-motd/assets/java-1.21.1/provenance.json for source and hashes. diff --git a/velocity/src/main/resources/motd/java-1.21.1-advances.json b/velocity/src/main/resources/motd/java-1.21.1-advances.json new file mode 100644 index 0000000..a4a4e00 --- /dev/null +++ b/velocity/src/main/resources/motd/java-1.21.1-advances.json @@ -0,0 +1 @@ +{"0":6,"1":6,"2":6,"3":6,"4":6,"5":6,"6":6,"7":6,"8":6,"9":6," ":4,"!":2,"\"":4,"#":6,"$":6,"%":6,"&":6,"'":2,"(":4,")":4,"*":4,"+":6,",":2,"-":6,".":2,"/":6,":":2,";":2,"<":5,"=":6,">":5,"?":6,"@":7,"A":6,"B":6,"C":6,"D":6,"E":6,"F":6,"G":6,"H":6,"I":4,"J":6,"K":6,"L":6,"M":6,"N":6,"O":6,"P":6,"Q":6,"R":6,"S":6,"T":6,"U":6,"V":6,"W":6,"X":6,"Y":6,"Z":6,"[":4,"\\":6,"]":4,"^":6,"_":6,"`":3,"a":6,"b":6,"c":6,"d":6,"e":6,"f":5,"g":6,"h":6,"i":2,"j":6,"k":5,"l":3,"m":6,"n":6,"o":6,"p":6,"q":6,"r":6,"s":6,"t":4,"u":6,"v":6,"w":6,"x":6,"y":6,"z":6,"{":4,"|":2,"}":4,"~":7," ":1,"¡":2,"¢":6,"£":6,"¤":8,"¥":6,"¦":2,"§":6,"¨":4,"©":8,"ª":5,"«":7,"¬":6,"®":8,"¯":6,"°":5,"±":6,"²":5,"³":5,"´":3,"µ":6,"¶":7,"·":2,"¸":4,"¹":4,"º":5,"»":7,"¼":8,"½":8,"¾":8,"¿":6,"À":6,"Á":6,"Â":6,"Ã":6,"Ä":6,"Å":6,"Æ":10,"Ç":6,"È":6,"É":6,"Ê":6,"Ë":6,"Ì":4,"Í":4,"Î":4,"Ï":4,"Ð":7,"Ñ":6,"Ò":6,"Ó":6,"Ô":6,"Õ":6,"Ö":6,"×":6,"Ø":6,"Ù":6,"Ú":6,"Û":6,"Ü":6,"Ý":6,"Þ":6,"ß":6,"à":6,"á":6,"â":6,"ã":6,"ä":6,"å":6,"æ":10,"ç":6,"è":6,"é":6,"ê":6,"ë":6,"ì":3,"í":3,"î":4,"ï":4,"ð":6,"ñ":6,"ò":6,"ó":6,"ô":6,"õ":6,"ö":6,"÷":6,"ø":6,"ù":6,"ú":6,"û":6,"ü":6,"ý":6,"€":7,"☺":6,"☻":8,"♥":6," ":16,"￿":5} \ No newline at end of file diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluatorTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluatorTest.kt new file mode 100644 index 0000000..7f7b6d9 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListEvaluatorTest.kt @@ -0,0 +1,202 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonParser +import com.velocitypowered.api.proxy.server.ServerPing +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ServerListEvaluatorTest { + private fun canonicalLegacy(source: String) = + net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection().let { + it.serialize(it.deserialize(source)) + } + + @Test + fun `evaluates displayed count before resolving placeholders`() { + val template = + ServerListTemplate.parse( + JsonParser.parseString( + """{"schemaVersion":1,"motd":{"text":{"nodes":[]},"alignments":[]},"version":{"mode":"automatic"},"icon":null,"players":{"hidden":false,"online":{"mode":"offset","value":2},"maximum":{"mode":"headroom","value":10}},"hover":{"mode":"inherit"}}""" + ) + .asJsonObject + ) + val base = + ServerPing.builder() + .version(ServerPing.Version(767, "1.21.1")) + .onlinePlayers(3) + .maximumPlayers(100) + .description(Component.empty()) + .build() + val result = + ServerListEvaluator.evaluate( + base, + template, + 8, + 767, + "{{players}} / {{max}}", + "eu", + "Europe", + ) + assertEquals(10, result.players.get().online) + assertEquals(20, result.players.get().max) + assertEquals( + "10 / 20", + PlainTextComponentSerializer.plainText().serialize(result.descriptionComponent!!), + ) + } + + @Test + fun `aligns MOTD to the actual modern client content width`() { + val template = + ServerListTemplate.parse( + JsonParser.parseString( + """{"schemaVersion":1,"motd":{"text":{"nodes":[{"type":"text","value":"A"}]},"alignments":["right"]},"version":{"mode":"automatic"},"icon":null,"players":{"hidden":false,"online":{"mode":"inherit"},"maximum":{"mode":"inherit"}},"hover":{"mode":"inherit"}}""" + ) + .asJsonObject + ) + val base = + ServerPing.builder() + .version(ServerPing.Version(777, "26.3")) + .description(Component.empty()) + .onlinePlayers(8) + .maximumPlayers(100) + .build() + for ((protocol, spaces) in listOf(767 to 66, 774 to 65, 775 to 65, 776 to 65, 777 to 65)) { + val result = + ServerListEvaluator.evaluate( + base, + template, + 8, + protocol, + "A", + "eu", + "Europe", + compiledMotd = template.motd, + ) + assertEquals( + " ".repeat(spaces) + "A", + PlainTextComponentSerializer.plainText().serialize(result.descriptionComponent!!), + "protocol $protocol", + ) + } + } + + @Test + fun `agrees with independently generated JavaScript status fixtures`() { + val fixtures = + JsonParser.parseReader( + javaClass.getResourceAsStream("/server-list/contract.json")!!.reader() + ) + .asJsonArray + for (entry in fixtures) { + val f = entry.asJsonObject + val ctx = f.getAsJsonObject("context") + val expected = f.getAsJsonObject("expected") + val template = ServerListTemplate.parse(f.getAsJsonObject("template")) + val base = + ServerPing.builder() + .version(ServerPing.Version(767, "1.21.1")) + .onlinePlayers(3) + .maximumPlayers(100) + .samplePlayers( + ServerPing.SamplePlayer( + "Ada", + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), + ) + ) + .description(Component.empty()) + .build() + val actual = + ServerListEvaluator.evaluate( + base, + template, + 8, + ctx.get("clientProtocol").asInt, + f.get("source").asString, + "eu", + "Europe", + compiledMotd = template.motd, + ) + assertEquals( + expected.getAsJsonObject("version").get("protocol").asInt, + actual.version.protocol, + f.get("name").asString, + ) + assertEquals( + canonicalLegacy(expected.getAsJsonObject("version").get("name").asString), + canonicalLegacy(actual.version.name), + ) + if (expected.has("players")) { + val p = expected.getAsJsonObject("players") + assertEquals(p.get("online").asInt, actual.players.get().online) + assertEquals(p.get("max").asInt, actual.players.get().max) + assertEquals( + p.getAsJsonArray("sample").map { + canonicalLegacy(it.asJsonObject.get("name").asString) + }, + actual.players.get().sample.map { canonicalLegacy(it.name) }, + ) + } else assertTrue(actual.players.isEmpty) + assertEquals( + f.get("expectedPlain").asString, + PlainTextComponentSerializer.plainText().serialize(actual.descriptionComponent!!), + ) + } + } + + @Test + fun `sample lines carry inherited colors independently`() { + val text = + CompiledServerText( + com.google.gson.JsonParser.parseString( + """{"nodes":[{"type":"group","style":{"color":"#ff5555"},"children":[{"type":"text","value":"first"},{"type":"newline"},{"type":"text","value":"second"}]}]}""" + ) + .asJsonObject + ) + assertTrue(text.legacyLines(emptyMap())[1].contains("§c")) + } + + @Test + fun `runtime alignment resolves placeholders before measuring text`() { + val json = + JsonParser.parseReader( + javaClass.getResourceAsStream("/server-list/contract.json")!!.reader() + ) + .asJsonArray[0] + .asJsonObject + .getAsJsonObject("template") + .deepCopy() + json + .getAsJsonObject("motd") + .add("alignments", JsonParser.parseString("""["center","left"]""")) + val template = ServerListTemplate.parse(json) + val rendered = + PlainTextComponentSerializer.plainText() + .serialize( + template.motd.render(mapOf("region" to "eu", "players" to "8", "max" to "100")) + ) + assertFalse(rendered.contains("{{")) + assertTrue(rendered.startsWith(" ")) + assertEquals("eu: 8 / 100", rendered.trim()) + } + + @Test + fun `legacy maximum commands update runtime and editor rules`() { + val root = + JsonParser.parseReader( + javaClass.getResourceAsStream("/server-list/contract.json")!!.reader() + ) + .asJsonArray[0] + .asJsonObject + val template = root.getAsJsonObject("template").deepCopy() + template + .getAsJsonObject("players") + .add("maximum", JsonParser.parseString("""{"mode":"fixed","value":500}""")) + val original = MotdDocument("text", serverList = ServerListTemplate.parse(template)) + val changed = original.withMaximum(200) + assertEquals(200, changed.serverList!!.maximum.apply(100, 8)) + assertEquals("inherit", changed.withMaximum(null).serverList!!.maximum.mode) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListLiveHarness.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListLiveHarness.kt new file mode 100644 index 0000000..e648b2a --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListLiveHarness.kt @@ -0,0 +1,61 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonParser +import com.google.inject.Inject +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent +import com.velocitypowered.api.event.proxy.ProxyPingEvent +import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.proxy.velocity.maintenance.MaintenanceConfigStore +import gg.grounds.proxy.velocity.maintenance.MaintenanceEngine +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import org.slf4j.Logger + +/** Test-only entry point. The validation JAR replaces the production plugin descriptor. */ +class ServerListLiveHarness +@Inject +constructor(private val proxy: ProxyServer, private val logger: Logger) { + private val shared = Path.of(System.getProperty("motd.harness.document")) + private val region = System.getProperty("motd.harness.region") + private val local = System.getProperty("motd.harness.local").toInt() + @Volatile private var network = 0 + private val maintenance = MaintenanceEngine() + private val store = + object : MotdStore { + override fun read(): MotdDocument? { + val root = JsonParser.parseString(Files.readString(shared)).asJsonObject + network = root.get("networkPlayers").asInt + val m = root.get("maintenance") + maintenance.adopt( + if (m == null || m.isJsonNull) null + else MaintenanceConfigStore.parse(m.toString()) + ) + return MotdDocument.fromJson(root.get("motd").toString()) + } + + override fun write(document: MotdDocument, updatedBy: String) = + error("read-only harness") + + override fun clear(deletedBy: String): Boolean = error("read-only harness") + } + private val manager = MotdManager(store, region, region, logger) + private val listener = + ServerListPingListener({ manager }, { network }, { maintenance }, { region }, { region }) + + @Subscribe + fun initialize(event: ProxyInitializeEvent) { + manager.refresh() + proxy.scheduler + .buildTask(this, Runnable { manager.refresh() }) + .repeat(Duration.ofMillis(200)) + .schedule() + } + + @Subscribe + fun ping(event: ProxyPingEvent) { + event.ping = event.ping.asBuilder().onlinePlayers(local).build() + listener.onProxyPing(event) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListNetworkTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListNetworkTest.kt new file mode 100644 index 0000000..9608348 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/ServerListNetworkTest.kt @@ -0,0 +1,214 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonParser +import com.velocitypowered.api.event.proxy.ProxyPingEvent +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.InboundConnection +import com.velocitypowered.api.proxy.server.ServerPing +import gg.grounds.proxy.velocity.maintenance.MaintenanceDocument +import gg.grounds.proxy.velocity.maintenance.MaintenanceEngine +import java.lang.reflect.Proxy +import java.time.Instant +import java.util.UUID +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory + +class ServerListNetworkTest { + private class Store : MotdStore { + var document: MotdDocument? = null + var failed = false + + override fun read(): MotdDocument? { + if (failed) error("offline") + return document + } + + override fun write(document: MotdDocument, updatedBy: String) { + this.document = document + } + + override fun clear(deletedBy: String): Boolean { + val had = document != null + document = null + return had + } + } + + private fun connection() = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(InboundConnection::class.java)) { + _, + method, + _ -> + when (method.name) { + "getProtocolVersion" -> ProtocolVersion.MINECRAFT_1_21 + "toString" -> "test connection" + else -> null + } + } as InboundConnection + + private fun ping(listener: ServerListPingListener, local: Int): ServerPing { + val event = + ProxyPingEvent( + connection(), + ServerPing.builder() + .version(ServerPing.Version(767, "1.21.1")) + .onlinePlayers(local) + .maximumPlayers(500) + .description(Component.empty()) + .build(), + ) + listener.onProxyPing(event) + return event.ping + } + + private fun legacyMaintenance(start: Instant, end: Instant) = + MaintenanceDocument( + enabled = true, + id = UUID.randomUUID(), + startsAt = start, + endsAt = end, + estimatedEndAt = null, + reason = "Work", + joinMessage = "Closed", + motd = "", + kickExisting = false, + whitelist = emptySet(), + announcementSeconds = emptyList(), + announcementMessage = "Soon", + ) + + @Test + fun `one ping uses one instant when maintenance ends during evaluation`() { + val start = Instant.parse("2026-10-21T12:00:00Z") + val end = start.plusSeconds(60) + val engine = MaintenanceEngine() + engine.adopt(legacyMaintenance(start, end), start) + var reads = 0 + val listener = + ServerListPingListener( + { null }, + { null }, + { engine }, + { null }, + { null }, + { if (reads++ == 0) end.minusNanos(1) else end }, + ) + + val result = ping(listener, 3) + + assertEquals(-1, result.version.protocol) + assertEquals(1, reads) + } + + @Test + fun `maintenance refresh during one ping cannot crash legacy status evaluation`() { + val now = Instant.parse("2026-10-21T12:00:00Z") + val engine = MaintenanceEngine() + engine.adopt(legacyMaintenance(now.minusSeconds(60), now.plusSeconds(60)), now) + val listener = + ServerListPingListener( + { null }, + { + engine.adopt(null, now) + null + }, + { engine }, + { null }, + { null }, + { now }, + ) + + val result = ping(listener, 3) + + assertEquals(767, result.version.protocol) + } + + @Test + fun `two independent proxy stores serve shared counts retain cache and restore normal after maintenance`() { + val a = Store() + val b = Store() + val fixture = + JsonParser.parseReader( + javaClass.getResourceAsStream("/server-list/contract.json")!!.reader() + ) + .asJsonArray[2] + .asJsonObject + val settings = + MotdDocument( + text = "{{region}} {{players}}/{{max}}", + serverList = ServerListTemplate.parse(fixture.getAsJsonObject("template")), + ) + a.document = settings + b.document = MotdDocument.fromJson(settings.toJson()) + val ma = MotdManager(a, "eu", "Europe", LoggerFactory.getLogger(javaClass)) + val mb = MotdManager(b, "na", "North America", LoggerFactory.getLogger(javaClass)) + ma.refresh() + mb.refresh() + val now = Instant.parse("2026-10-21T12:00:00Z") + val ea = MaintenanceEngine() + val eb = MaintenanceEngine() + val la = ServerListPingListener({ ma }, { 8 }, { ea }, { "eu" }, { "Europe" }, { now }) + val lb = + ServerListPingListener({ mb }, { 8 }, { eb }, { "na" }, { "North America" }, { now }) + fun text(p: ServerPing) = + PlainTextComponentSerializer.plainText().serialize(p.descriptionComponent!!) + assertEquals(8, ping(la, 3).players.get().online) + assertEquals(8, ping(lb, 5).players.get().online) + assertEquals("eu 8/500", text(ping(la, 3))) + assertEquals("na 8/500", text(ping(lb, 5))) + assertEquals( + ping(la, 3).players.get().sample.map { it.name }, + ping(lb, 5).players.get().sample.map { it.name }, + ) + val forced = fixture.getAsJsonObject("template").deepCopy() + forced.add( + "version", + JsonParser.parseString( + """{"mode":"always","text":{"nodes":[{"id":"v","type":"text","value":"Maintenance"}]}}""" + ), + ) + val maintenance = + MaintenanceDocument( + true, + UUID.randomUUID(), + now.minusSeconds(60), + now.plusSeconds(60), + null, + "Work", + "Closed", + "", + false, + emptySet(), + emptyList(), + "", + ServerListTemplate.parse(forced), + "", + setOf("version"), + ) + ea.adopt(maintenance, now) + eb.adopt(maintenance, now) + assertEquals(-1, ping(la, 3).version.protocol) + assertEquals(-1, ping(lb, 5).version.protocol) + a.failed = true + b.failed = true + ma.refresh() + mb.refresh() + assertEquals(8, ping(la, 3).players.get().online) + ea.adopt(maintenance.copy(enabled = false), now) + eb.adopt(maintenance.copy(enabled = false), now) + assertEquals(767, ping(la, 3).version.protocol) + assertEquals(767, ping(lb, 5).version.protocol) + ma.set( + settings.copy( + text = "legacy update", + editor = JsonParser.parseString("""{"fallbackText":"old"}""").asJsonObject, + ), + "test", + ) + assertNull(ma.current()!!.editor) + assertNotNull(ma.current()!!.serverList) + } +} diff --git a/velocity/src/test/resources/server-list/contract.json b/velocity/src/test/resources/server-list/contract.json new file mode 100644 index 0000000..1b64063 --- /dev/null +++ b/velocity/src/test/resources/server-list/contract.json @@ -0,0 +1,2462 @@ +[ + { + "name": "offset-headroom", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "offset", + "value": 2 + }, + "maximum": { + "mode": "headroom", + "value": 10 + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "offset", + "value": 2 + }, + "maximum": { + "mode": "headroom", + "value": 10 + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 10, + "max": 20, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "10" + }, + { + "text": " / " + }, + { + "text": "20" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 10 / 20" + }, + { + "name": "hidden", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": true, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": true, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "custom-hover", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "custom", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "players" + }, + { + "id": "n2", + "type": "text", + "value": " players" + }, + { + "id": "n3", + "type": "newline" + }, + { + "id": "n4", + "type": "group", + "style": { + "color": "#55ff55" + }, + "children": [ + { + "id": "n5", + "type": "text", + "value": "Welcome" + } + ] + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{players}} playersWelcome", + "revision": 0 + } + } + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "custom", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "players" + }, + { + "id": "n2", + "type": "text", + "value": " players" + }, + { + "id": "n3", + "type": "newline" + }, + { + "id": "n4", + "type": "group", + "style": { + "color": "#55ff55" + }, + "children": [ + { + "id": "n5", + "type": "text", + "value": "Welcome" + } + ] + } + ] + } + ] + } + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "§r§68 players", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "§r§aWelcome", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "forced-version", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "always", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Maintenance" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "Maintenance", + "revision": 0 + } + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "always", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Maintenance" + } + ] + } + ] + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": -1, + "name": "§r§cMaintenance" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "compatible-version", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "when-incompatible", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Update required" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "Update required", + "revision": 0 + } + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "when-incompatible", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Update required" + } + ] + } + ] + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "incompatible-version", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "when-incompatible", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Update required" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "Update required", + "revision": 0 + } + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "when-incompatible", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "Update required" + } + ] + } + ] + } + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 766, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": -1, + "name": "§r§cUpdate required" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "percent", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "percent", + "value": 25 + }, + "maximum": { + "mode": "offset", + "value": 5 + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "percent", + "value": 25 + }, + "maximum": { + "mode": "offset", + "value": 5 + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 2, + "max": 105, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "2" + }, + { + "text": " / " + }, + { + "text": "105" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 2 / 105" + }, + { + "name": "inherited-hover-style", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "custom", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "first" + }, + { + "id": "n2", + "type": "newline" + }, + { + "id": "n3", + "type": "text", + "value": "second" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "firstsecond", + "revision": 0 + } + } + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "left", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "custom", + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ff5555" + }, + "children": [ + { + "id": "n1", + "type": "text", + "value": "first" + }, + { + "id": "n2", + "type": "newline" + }, + { + "id": "n3", + "type": "text", + "value": "second" + } + ] + } + ] + } + } + }, + "source": "{{region}}: {{players}} / {{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "§r§cfirst", + "id": "00000000-0000-0000-0000-000000000000" + }, + { + "name": "§r§csecond", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": "", + "color": "#ffaa00", + "extra": [ + { + "text": "eu" + }, + { + "text": ": " + }, + { + "text": "8" + }, + { + "text": " / " + }, + { + "text": "100" + } + ] + } + ] + } + }, + "expectedPlain": "eu: 8 / 100" + }, + { + "name": "aligned-placeholder", + "document": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ], + "source": { + "format": "minimessage", + "value": "{{region}}: {{players}} / {{max}}", + "revision": 0 + } + }, + "alignments": [ + "center", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + }, + "extensions": {} + }, + "template": { + "schemaVersion": 1, + "motd": { + "text": { + "revision": 0, + "nodes": [ + { + "id": "n0", + "type": "group", + "style": { + "color": "#ffaa00" + }, + "children": [ + { + "id": "n1", + "type": "placeholder", + "name": "region" + }, + { + "id": "n2", + "type": "text", + "value": ": " + }, + { + "id": "n3", + "type": "placeholder", + "name": "players" + }, + { + "id": "n4", + "type": "text", + "value": " / " + }, + { + "id": "n5", + "type": "placeholder", + "name": "max" + } + ] + } + ] + }, + "alignments": [ + "center", + "left" + ] + }, + "version": { + "mode": "automatic" + }, + "icon": null, + "players": { + "hidden": false, + "online": { + "mode": "inherit" + }, + "maximum": { + "mode": "inherit" + } + }, + "hover": { + "mode": "inherit" + } + }, + "source": "<#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffffff> <#ffaa00>{{region}}<#ffaa00>:<#ffaa00> <#ffaa00>{{players}}<#ffaa00> <#ffaa00>/<#ffaa00> <#ffaa00>{{max}}", + "context": { + "base": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": "" + }, + "clientProtocol": 767, + "values": { + "region": "eu", + "continent": "Europe", + "localzone": "Europe" + } + }, + "expected": { + "version": { + "protocol": 767, + "name": "1.21.1" + }, + "players": { + "online": 8, + "max": 100, + "sample": [ + { + "name": "Ada", + "id": "00000000-0000-0000-0000-000000000001" + } + ] + }, + "description": { + "text": "", + "extra": [ + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffffff", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "e", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "u", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": ":", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "8", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "/", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": " ", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "1", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "0", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + }, + { + "text": "0", + "color": "#ffaa00", + "bold": false, + "italic": false, + "underlined": false, + "strikethrough": false, + "obfuscated": false + } + ] + } + }, + "expectedPlain": " eu: 8 / 100" + } +]