mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Use screaming goat voice events from the saved flag
Select the ambient, hurt and death event names through the same goat state used by vanilla. Add a dedicated-server packet regression with adult, baby and silent controls. Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
1 parent
7cc36d34c3
commit
6d8fae1d71
5 files changed
+299
-4
No files matched your search
@@ -1920,8 +1920,15 @@ pub fn speed_bonus(m: &Mob) -> f64 {
|
||||
}
|
||||
/// The ambient, hurt and death sounds of species that pick them by state
|
||||
/// (`getHurtSound` / `getDeathSound` overrides); the type's otherwise.
|
||||
/// `Goat.getAmbientSound`, `Goat.getHurtSound`, and `Goat.getDeathSound`
|
||||
/// select the screaming voices from the goat's saved flag.
|
||||
pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
|
||||
match m.species() {
|
||||
Species::Goat if m.extra.screaming => [
|
||||
"entity.goat.screaming.ambient",
|
||||
"entity.goat.screaming.hurt",
|
||||
"entity.goat.screaming.death",
|
||||
],
|
||||
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m),
|
||||
Species::SulfurCube => super::sulfur_cube::sounds(m),
|
||||
Species::Armadillo => super::armadillo::sounds(m),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundDisguisedChatPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSoundEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** The sound packets of the frozen goat scene, with server-chat phase barriers. */
|
||||
final class GoatVoiceTranscript {
|
||||
private final Map<Integer, Vec3> goats = new HashMap<>();
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private String phase = "setup";
|
||||
private String pendingAck;
|
||||
private boolean tracked;
|
||||
private boolean done;
|
||||
|
||||
void accept(Packet<?> packet) {
|
||||
if (packet instanceof ClientboundAddEntityPacket p
|
||||
&& BuiltInRegistries.ENTITY_TYPE.getKey(p.getType()).toString().equals("minecraft:goat")) {
|
||||
// Keep spawn positions through removal: a death sound can name the removed entity.
|
||||
goats.put(p.getId(), new Vec3(p.getX(), p.getY(), p.getZ()));
|
||||
}
|
||||
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) {
|
||||
for (String name : List.of("ambient", "hurt", "death")) {
|
||||
if (text.endsWith("goat_voice_" + name)) {
|
||||
phase = name;
|
||||
pendingAck = "goat voice phase " + name;
|
||||
}
|
||||
}
|
||||
if (text.endsWith("goat_voice_done")) done = true;
|
||||
}
|
||||
if (packet instanceof ClientboundSoundPacket p) {
|
||||
sound(p.getSound().value().location().toString(), p.getSource().ordinal(),
|
||||
p.getVolume(), p.getPitch(), new Vec3(p.getX(), p.getY(), p.getZ()));
|
||||
} else if (packet instanceof ClientboundSoundEntityPacket p) {
|
||||
sound(p.getSound().value().location().toString(), p.getSource().ordinal(),
|
||||
p.getVolume(), p.getPitch(), goats.get(p.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
private void sound(String name, int category, float volume, float pitch, Vec3 at) {
|
||||
if (at != null && !phase.equals("setup") && name.startsWith("minecraft:entity.goat.")) {
|
||||
lines.add(phase + " " + name + " " + category + " " + volume + " " + pitch
|
||||
+ " " + at.x + " " + at.z);
|
||||
}
|
||||
}
|
||||
|
||||
String acknowledgement() {
|
||||
if (!tracked && goats.size() >= 18) {
|
||||
tracked = true;
|
||||
return "goat voices tracked";
|
||||
}
|
||||
String ack = pendingAck;
|
||||
pendingAck = null;
|
||||
return ack;
|
||||
}
|
||||
|
||||
boolean done() { return done; }
|
||||
List<String> lines() { return lines; }
|
||||
}
|
||||
@@ -116,6 +116,7 @@ public final class VanillaProtocolClient {
|
||||
private String state;
|
||||
/** With a fifth argument {@code entities}: the entity transcript mode (EntityTranscript). */
|
||||
private EntityTranscript entities;
|
||||
private GoatVoiceTranscript goatVoices;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
@@ -125,6 +126,10 @@ public final class VanillaProtocolClient {
|
||||
Path output = Path.of(args[3]);
|
||||
VanillaProtocolClient client = new VanillaProtocolClient();
|
||||
if (args.length > 4 && args[4].equals("entities")) client.entities = new EntityTranscript();
|
||||
if (args.length > 4 && args[4].equals("goat-voices")) {
|
||||
client.entities = new EntityTranscript();
|
||||
client.goatVoices = new GoatVoiceTranscript();
|
||||
}
|
||||
try {
|
||||
client.status(host, port);
|
||||
client.join(host, port, name);
|
||||
@@ -452,7 +457,7 @@ public final class VanillaProtocolClient {
|
||||
boolean loaded = false;
|
||||
PositionMoveRotation position = new PositionMoveRotation(Vec3.ZERO, Vec3.ZERO, 0, 0);
|
||||
long lastChunk = 0;
|
||||
long deadline = System.currentTimeMillis() + 150_000;
|
||||
long deadline = System.currentTimeMillis() + (goatVoices == null ? 150_000 : 600_000);
|
||||
int step = 0;
|
||||
long stepAt = 0;
|
||||
// The death the console deals and the respawn after it (PlayerList.respawn): from the
|
||||
@@ -510,6 +515,7 @@ public final class VanillaProtocolClient {
|
||||
Packet<?> packet = r.packet;
|
||||
counts.merge(r.type, 1, Integer::sum);
|
||||
if (entities != null && !entities.acceptSelf(packet) && step >= ENTITIES) entities.accept(packet);
|
||||
if (goatVoices != null) goatVoices.accept(packet);
|
||||
if (r.type.equals("minecraft:chunk_batch_start")) ordered = false;
|
||||
if (respawning && !respawned) {
|
||||
String line = respawnLine(r, playerId);
|
||||
@@ -671,14 +677,20 @@ public final class VanillaProtocolClient {
|
||||
// Entity mode: the harness's console summons the scene once it reads this
|
||||
// line; everything tracked from then on is recorded until the client leaves.
|
||||
record("play-chunks", String.valueOf(chunks.size()));
|
||||
send(new ServerboundChatPacket("summon the entities", Instant.now(), 0L, Optional.empty(),
|
||||
send(new ServerboundChatPacket(goatVoices == null ? "summon the entities" : "summon the goats", Instant.now(), 0L, Optional.empty(),
|
||||
new LastSeenMessages.Update(0, new BitSet(), (byte) 0)));
|
||||
step = ENTITIES;
|
||||
stepAt = now;
|
||||
} else if (step == ENTITIES && now - stepAt > ENTITY_WAIT) {
|
||||
for (String line : entities.lines()) record("play-entities", line);
|
||||
} else if (step == ENTITIES && (goatVoices == null ? now - stepAt > ENTITY_WAIT : goatVoices.done())) {
|
||||
if (goatVoices == null) for (String line : entities.lines()) record("play-entities", line);
|
||||
else for (String line : goatVoices.lines()) record("play-goat-voice", line);
|
||||
return;
|
||||
}
|
||||
if (goatVoices != null) {
|
||||
String ack = goatVoices.acknowledgement();
|
||||
if (ack != null) send(new ServerboundChatPacket(ack, Instant.now(), 0L, Optional.empty(),
|
||||
new LastSeenMessages.Update(0, new BitSet(), (byte) 0)));
|
||||
}
|
||||
// Once the chunks stop coming: chat, then /list, then leave.
|
||||
if (entities == null && loaded && lastChunk > 0 && now - lastChunk > 3000 && step == 0) {
|
||||
record("play-chunks", chunks.size() + " " + chunks);
|
||||
|
||||
@@ -75,6 +75,9 @@ Goat.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::breed t
|
||||
Goat.customServerAiStep inline crates/horizon-client/src/mobs/ai.rs::generic goals stand in for the brain: Float, Panic 2.0, Breed, Tempt, FollowParent, stroll and look (ai.rs generic) and the ram goal (goat.rs); the ram cooldown counts down in goat.rs::tick
|
||||
Goat.setYHeadRot port crates/horizon-client/src/mobs/control.rs::look_control_tick the head is clamped to `getMaxHeadYRot` (15 degrees) from the body after every look update, walking or standing
|
||||
Goat.playEatingSound port crates/horizon-client/src/mobs/ai.rs::eating_sound screaming or normal eat sound at 0.8 to 1.2 pitch
|
||||
Goat.getAmbientSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming ambient event selected by IsScreamingGoat
|
||||
Goat.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming hurt event selected by IsScreamingGoat
|
||||
Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
|
||||
Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize gap: player: live spawns use spawning.rs::finalize, which has no goat arm: no screaming goats (2 percent), no one-horned goats (10 percent), and no starting ram cooldown, so a fresh goat can ram at once; Ctx::finalize only runs for structure mobs and the probe
|
||||
Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
|
||||
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn
|
||||
|
||||
|
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Goat voice event names and metadata on real Java/Horizon dedicated servers.
|
||||
|
||||
Silent ambient suppression is a separate gap: record those two controls, but
|
||||
gate only their hurt/death phases. Death follows 21 actual cooldown ticks.
|
||||
"""
|
||||
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'
|
||||
CASES = [('adult', False, False, False), ('screaming_adult', True, False, False),
|
||||
('baby', False, True, False), ('screaming_baby', True, True, False),
|
||||
('silent', False, False, True), ('screaming_silent', True, False, True)]
|
||||
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'goat_voice_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() + 180
|
||||
while game_time(server) < start + ticks:
|
||||
assert time.monotonic() < deadline, f'{label}: tick step stalled'
|
||||
assert game_time(server) == start + ticks
|
||||
|
||||
def phase(name):
|
||||
server.console(f'say goat_voice_{name}')
|
||||
server.wait_for(rf'<{PLAYER}> goat voice phase {name}', timeout=120)
|
||||
|
||||
flags = []
|
||||
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), 'goat-voices'], cwd=lab.ROOT,
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
server.wait_for(rf'<{PLAYER}> summon the goats', 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('fill 0 219 0 15 219 15 minecraft:stone')
|
||||
reply(f'tp {PLAYER} 8.5 220 11.5')
|
||||
for i, (_, screaming, baby, silent) in enumerate(CASES):
|
||||
for j in range(3):
|
||||
tag = f'goat_voice_{i}_{j}'
|
||||
nbt = ('{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,'
|
||||
f'Age:{-24000 if baby else 0},Silent:{int(silent)}b,'
|
||||
f'IsScreamingGoat:{int(screaming)}b,Tags:["goat_voice_{i}","{tag}"]}}')
|
||||
reply(f'summon minecraft:goat {2.5 + i * 2} 220 {2.5 + j * 2} {nbt}')
|
||||
observed = reply('execute if entity @e[tag=' + tag + ',nbt={'
|
||||
f'IsScreamingGoat:{int(screaming)}b' + '}]')
|
||||
assert any('Test passed' in line for line in observed), (tag, observed)
|
||||
flags.append(tag)
|
||||
advance(1)
|
||||
server.wait_for(rf'<{PLAYER}> goat voices tracked', timeout=120)
|
||||
reply('tick rate 200')
|
||||
phase('ambient')
|
||||
advance(2400)
|
||||
phase('hurt')
|
||||
for i in range(len(CASES)):
|
||||
reply(f'damage @e[tag=goat_voice_{i},limit=1] 1 minecraft:generic')
|
||||
# A one-tick interval leaves hurt immunity active and invalidates death controls.
|
||||
advance(21)
|
||||
phase('death')
|
||||
for i in range(len(CASES)):
|
||||
reply(f'damage @e[tag=goat_voice_{i},limit=1] 100 minecraft:generic')
|
||||
advance(1)
|
||||
server.console('say goat_voice_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:]
|
||||
sounds = []
|
||||
for line in lines:
|
||||
if not line.startswith('play-goat-voice '):
|
||||
continue
|
||||
phase_name, name, category, volume, pitch, x, z = line.split()[1:]
|
||||
index = round((float(x) - 2.5) / 2)
|
||||
assert 0 <= index < len(CASES) and abs(float(x) - (2.5 + 2 * index)) < 0.2, line
|
||||
sounds.append({'case': CASES[index][0], 'phase': phase_name, 'name': name,
|
||||
'category': int(category), 'volume': float(volume), 'pitch': float(pitch)})
|
||||
return {'flags': flags, 'sounds': sounds}
|
||||
|
||||
|
||||
def checks(result):
|
||||
failures, excluded = [], {}
|
||||
for name, screaming, baby, silent in CASES:
|
||||
for phase in ('ambient', 'hurt', 'death'):
|
||||
sounds = [s for s in result['sounds'] if s['case'] == name and s['phase'] == phase
|
||||
and s['name'].endswith(('.ambient',) if phase == 'ambient' else ('.hurt', '.death'))]
|
||||
if silent and phase == 'ambient':
|
||||
excluded[name] = sounds
|
||||
continue
|
||||
if silent:
|
||||
valid = not sounds
|
||||
else:
|
||||
expected = 'minecraft:entity.goat.' + ('screaming.' if screaming else '') + phase
|
||||
lo, hi = (1.3, 1.7) if baby else (0.8, 1.2)
|
||||
valid = bool(sounds) and all(s['name'] == expected and s['category'] == 6
|
||||
and s['volume'] == 1 and lo - 1e-6 <= s['pitch'] <= hi + 1e-6
|
||||
for s in sounds)
|
||||
if not valid:
|
||||
failures.append({'case': name, 'phase': phase, 'sounds': sounds})
|
||||
return {'checks': len(result['flags']) + 16, 'failures': failures, 'excluded_silent_ambient': excluded}
|
||||
|
||||
|
||||
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 / 'goat-voices-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()}
|
||||
report = {label: checks(result) for label, result in results.items()}
|
||||
lab.write_json(out / 'records.json', results)
|
||||
lab.write_json(out / 'report.json', {'schema': 1, 'minecraft': lock['minecraft']['version'], **report})
|
||||
assert not report['vanilla']['failures'], report['vanilla']
|
||||
for label, case in report.items():
|
||||
print(f"{label}: {case['checks'] - len(case['failures'])}/{case['checks']} scoped checks pass", flush=True)
|
||||
for failure in case['failures']:
|
||||
print(json.dumps(failure), flush=True)
|
||||
assert not report['horizon']['failures'], f'scoped goat voice failures; see {out}'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user