Merge pull request #9210 from Ragbie/fix/silent-dragon-death

🤖 Agent: a dragon saved or summoned with Silent:1b no longer plays the global 1028 death roar at tick 1 of its death; Silent is read from the compound Horizon keeps for unmodelled fields, and the new dragon_death_roar harness compares the level events against vanilla's. The branch passed the cg-39 union run on playhorizon (horizon-client 887 tests); main merged to clear an unrelated lan::conn flake.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-04 20:31:56 +01:00
commit 1989b1b595
5 files changed
+265 -7

No files matched your search

+35 -2
View File
@@ -2389,6 +2389,16 @@ impl Game<'_> {
d.in_wall = in_wall;
}
}
/// `Entity.isSilent` of the dragon: its `Silent`, kept with the fields
/// Horizon models nowhere (`remember_tags`: `/summon`, a load).
fn dragon_silent(&self) -> bool {
let raw = self
.dragon
.as_ref()
.filter(|d| d.id != 0)
.and_then(|d| self.persist.raw.get(&d.id));
saved_silent(raw)
}
/// `tickDeath`: rising for 200 ticks, explosions, experience, then the
/// exit portal, the egg and a gateway.
fn tick_dragon_death(&mut self) {
@@ -2423,9 +2433,9 @@ impl Game<'_> {
if t > 150 && t % 5 == 0 && drops {
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
}
if t == 1 {
if t == 1 && !self.dragon_silent() {
// EnderDragon.tickDeath: global level event 1028, the death
// roar, heard everywhere.
// roar, heard everywhere, unless the dragon is silent.
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
self.global_level_event(1028, at, 0);
}
@@ -3254,6 +3264,11 @@ impl Game<'_> {
}
}
}
/// `Entity.isSilent` from an entity's saved compound: `Silent`, written only
/// when set.
fn saved_silent(raw: Option<&Tag>) -> bool {
matches!(raw.and_then(|t| t.get("Silent")), Some(Tag::Byte(b)) if *b != 0)
}
/// Saving and loading the dragon and the crystals as chunk entities.
impl Dragon {
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
@@ -4344,6 +4359,24 @@ mod tests {
)
}
/// `EnderDragon.tickDeath` roars (`globalLevelEvent(1028)`) only when
/// `!isSilent()`; `Silent` is saved only when set, as a byte.
#[test]
fn a_dragon_is_silent_only_with_a_set_silent_byte() {
use horizon_world::nbt::{Compound, Tag};
let with = |v: Option<i8>| {
let mut map = Compound::new();
if let Some(b) = v {
map.insert("Silent".into(), Tag::Byte(b));
}
Tag::Compound(map)
};
assert!(!super::saved_silent(None));
assert!(!super::saved_silent(Some(&with(None))));
assert!(!super::saved_silent(Some(&with(Some(0)))));
assert!(super::saved_silent(Some(&with(Some(1)))));
}
#[test]
fn breath_lifetime_excludes_the_wait() {
assert!(!breath_expired(219, 200));
+10 -4
View File
@@ -1292,15 +1292,21 @@ impl Game<'_> {
let uuid = saved_uuid(&self.persist, d.id);
out.entry(chunk_of(d.position))
.or_default()
.push(d.to_nbt(uuid, raw.as_ref()));
// The fields kept with it (`remember_tags`: Silent, Tags, a
// name...) once it has flown from where it was loaded.
.push(with_raw(
d.to_nbt(uuid, raw.as_ref()),
self.persist.raw.get(&d.id),
));
}
for c in &self.crystals {
if keys.contains(&chunk_of(c.position)) {
let raw = anon_raw(&self.persist, "minecraft:end_crystal", c.position);
let uuid = saved_uuid(&self.persist, c.id);
out.entry(chunk_of(c.position))
.or_default()
.push(c.to_nbt(uuid, raw.as_ref()));
out.entry(chunk_of(c.position)).or_default().push(with_raw(
c.to_nbt(uuid, raw.as_ref()),
self.persist.raw.get(&c.id),
));
}
}
for s in &self.ominous_spawners {
@@ -0,0 +1,54 @@
package dev.horizonlab.parity;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundDisguisedChatPacket;
import net.minecraft.network.protocol.game.ClientboundLevelEventPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
/**
* The level events a client is sent (ClientboundLevelEventPacket: type, position, data, global), per server-chat
* phase: the console says "level_events_NAME" to start a phase and "level_events_done" at the end; the client
* answers each phase with "level events phase NAME".
*/
final class LevelEventTranscript implements PhasedTranscript {
private final List<String> lines = new ArrayList<>();
private String phase = "setup";
private String pendingAck;
private boolean done;
@Override
public void accept(Packet<?> packet) {
String text = null;
if (packet instanceof ClientboundSystemChatPacket p) text = p.content().getString();
else if (packet instanceof ClientboundDisguisedChatPacket p) text = p.message().getString();
if (text != null) {
int at = text.lastIndexOf("level_events_");
if (at >= 0) {
String name = text.substring(at + "level_events_".length());
if (name.equals("done")) done = true;
else if (name.matches("[a-z_]+")) {
phase = name;
pendingAck = "level events phase " + name;
}
}
}
if (packet instanceof ClientboundLevelEventPacket p && !phase.equals("setup")) {
lines.add(phase + " " + p.getType() + " " + p.getPos().toShortString().replace(", ", ",")
+ " " + p.getData() + " " + p.isGlobalEvent());
}
}
@Override
public String acknowledgement() {
String ack = pendingAck;
pendingAck = null;
return ack;
}
@Override public boolean done() { return done; }
@Override public List<String> lines() { return lines; }
@Override public String request() { return "summon the scene"; }
@Override public String record() { return "play-level-event"; }
}
@@ -118,7 +118,7 @@ public final class VanillaProtocolClient {
private EntityTranscript entities;
/** With {@code ride}: the entity mode, plus the order of what the player sees of a ride through a portal (RideTranscript). */
private RideTranscript ride;
/** With {@code goat-voices} or {@code elder-curse}: the entity mode, plus a phased scene (PhasedTranscript). */
/** With {@code goat-voices}, {@code elder-curse} or {@code level-events}: the entity mode, plus a phased scene (PhasedTranscript). */
private PhasedTranscript phased;
public static void main(String[] args) throws Exception {
@@ -141,6 +141,10 @@ public final class VanillaProtocolClient {
client.entities = new EntityTranscript();
client.phased = new ElderCurseTranscript();
}
if (args.length > 4 && args[4].equals("level-events")) {
client.entities = new EntityTranscript();
client.phased = new LevelEventTranscript();
}
try {
client.status(host, port);
client.join(host, port, name);
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""The ender dragon's death roar, and a silent dragon's lack of one, against vanilla's dedicated server.
A vanilla dedicated server and Horizon's each serve a fresh flat world; a vanilla-codec client
(VanillaProtocolClient, level-events mode: LevelEventTranscript) joins. With the ticks frozen the console summons a
dead dragon (Health 0, NoAI, in the overworld: no fight; EnderDragon.kill would remove it with no death, and
/damage on a dragon is a gap of Horizon's) and steps 210 ticks, through its death (LivingEntity.baseTick runs
tickDeath while isDeadOrDying) and removal: at the death's first tick EnderDragon.tickDeath sends the global level event 1028 (the roar heard everywhere) unless the
dragon is silent (`dragonDeathTime == 1 && !isSilent()`). Once with a loud dragon, then with a `Silent:1b` one.
Each side's 1028 events (phase, position, data, global) must match vanilla's: one for the loud dragon, none for
the silent one.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server, free_port, game_time
from .test_server_world_gen_parity import escape
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server, free_port, game_time
from test_server_world_gen_parity import escape
PLAYER = 'Tester'
# The phase and the dragon's Silent flag.
PHASES = [('loud', False), ('silent', True)]
# Past the 200 death ticks, and the removal after them.
STEPS = 210
MESSAGE = re.compile(r'\[Server thread/INFO\]: (?:System chat: )?(.*)$')
def run_side(label, command, client_command, out):
directory = out / label
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
port = free_port()
props = {'server-ip': '127.0.0.1', 'server-port': str(port), 'online-mode': 'false', 'white-list': 'false',
'level-type': 'minecraft:flat', 'generate-structures': 'false', 'level-seed': '42',
'gamemode': 'creative', 'force-gamemode': 'true', 'allow-flight': 'true',
'view-distance': '2', 'simulation-distance': '2', 'spawn-protection': '0',
'pause-when-empty-seconds': '-1', 'max-tick-time': '-1'}
(directory / 'server.properties').write_text(
''.join(f'{k}={escape(v)}\n' for k, v in props.items()), encoding='iso-8859-1')
(directory / 'eula.txt').write_text('eula=true\n')
transcript = out / f'{label}-packets.txt'
server = Server(command, directory, label)
client = None
serial = 0
def reply(text):
nonlocal serial
serial += 1
marker = f'dragon_roar_barrier_{serial}'
start = len(server.lines)
server.console(text)
server.console(f'say {marker}')
server.wait_for(re.escape(f'[Server] {marker}') + '$', timeout=180)
return [m.group(1) for line in server.lines[start:] if (m := MESSAGE.search(line))
and not m.group(1).endswith(f'[Server] {marker}')]
def advance(ticks):
start = game_time(server)
reply(f'tick step {ticks}')
deadline = time.monotonic() + 300
while game_time(server) < start + ticks:
assert time.monotonic() < deadline, f'{label}: tick step stalled'
assert game_time(server) == start + ticks
try:
server.wait_for(r'Done \(')
with (out / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER,
str(transcript), 'level-events'], cwd=lab.ROOT,
stdout=log, stderr=subprocess.STDOUT)
server.wait_for(rf'<{PLAYER}> summon the scene', timeout=180)
reply('tick freeze')
reply('gamerule minecraft:spawn_mobs false')
reply('kill @e[type=!minecraft:player]')
reply('forceload add 0 0 15 15')
deadline = time.monotonic() + 180
while not any('Test passed' in line for line in reply('execute if loaded 0 220 0')):
assert time.monotonic() < deadline, f'{label}: scene chunk did not load'
reply(f'tp {PLAYER} 8.5 220 11.5')
reply('tick rate 200')
for name, silent in PHASES:
reply('summon minecraft:ender_dragon 8.5 230 3.5 '
f'{{NoAI:1b,Health:0.0f,Silent:{int(silent)}b,Tags:["dragon_roar"]}}')
# A dead entity is in no selector (EntitySelector: isAlive): the loud phase's roar, matched
# with vanilla's, is what shows the dragon was summoned dying on both sides.
server.console(f'say level_events_{name}')
server.wait_for(rf'<{PLAYER}> level events phase {name}', timeout=120)
advance(STEPS)
server.console('say level_events_done')
client.wait(timeout=180)
finally:
if client is not None and client.poll() is None:
client.kill()
client.wait()
server.stop()
lines = transcript.read_text().splitlines()
assert not any(line.startswith('error ') for line in lines), lines[-10:]
events = []
for line in lines:
if line.startswith('play-level-event '):
phase, kind, at, data, global_event = line.split()[1:]
events.append({'phase': phase, 'type': int(kind), 'at': at, 'data': int(data),
'global': global_event == 'true'})
return {'roars': [e for e in events if e['type'] == 1028]}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path, help='prebuilt Horizon game (otherwise build fast)')
parser.add_argument('--out', type=Path, default=lab.LOCAL / 'dragon-death-roar-parity')
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
if args.binary:
binary = args.binary.resolve(strict=True)
else:
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client',
'--bin', 'horizon-client'], cwd=lab.ROOT, check=True)
metadata = json.loads(subprocess.check_output(
['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'fast' / (
'horizon-client.exe' if os.name == 'nt' else 'horizon-client')
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
classes = out / 'classes'
classes.mkdir(exist_ok=True)
source = lab.ROOT / 'mods/horizon/src/test/java'
cp = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-sourcepath', source, '-cp', cp, '-d', classes,
source / 'dev/horizonlab/parity/VanillaProtocolClient.java'], check=True)
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + cp,
'dev.horizonlab.parity.VanillaProtocolClient']
commands = {'vanilla': ['java', '-Xmx2g', '-cp', cp, 'net.minecraft.server.Main', 'nogui'],
'horizon': [binary, '--resources', lab.RESOURCES.resolve(),
'--dedicated', '--nogui']}
results = {label: run_side(label, command, client, out) for label, command in commands.items()}
lab.write_json(out / 'records.json', results)
vanilla, horizon = results['vanilla']['roars'], results['horizon']['roars']
print(f'vanilla roars: {vanilla}', flush=True)
print(f'horizon roars: {horizon}', flush=True)
# The spec as read from EnderDragon.tickDeath: one global roar for the loud dragon, none for the silent one.
assert [e['phase'] for e in vanilla] == ['loud'] and vanilla[0]['global'], vanilla
assert horizon == vanilla, f'death roars differ from vanilla; see {out}'
print('dragon death roar: matches vanilla', flush=True)
if __name__ == '__main__':
main()