Skip to content

Commit fcc0aa6

Browse files
committed
Merge commit 'eafa8990074b1e21be4b7b68593c7eb21362dcf8' into feat/bw-finalized
2 parents 7fbfd80 + eafa899 commit fcc0aa6

29 files changed

Lines changed: 467 additions & 139 deletions

File tree

pvp/.github/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ ProjectileSystem.install(polyp);
3939

4040
Requires the published `net.minestom:minestom` artifact (see `minestomVersion` in the build) — no Minestom fork.
4141

42+
## Custom player class
43+
44+
Polyp installs an `OptimizedPlayer` provider (self-echo filtering, per-client compat state, the dispatcher opt-out
45+
that shard domain ticking needs). To use your own player class, extend it and swap the factory — every
46+
`instanceof OptimizedPlayer` gate in the library keeps matching:
47+
48+
```java
49+
public class MyPlayer extends OptimizedPlayer {
50+
public MyPlayer(PlayerConnection connection, GameProfile profile) { super(connection, profile); }
51+
}
52+
53+
polyp.playerFactory = MyPlayer::new; // read per-connect, so before or after init() both work
54+
```
55+
56+
If you override `tick` or `refreshCurrentChunk`, call super — they carry the guards that keep a domain-ticked
57+
player from double-ticking in the global dispatcher.
58+
59+
Running a different provider entirely (`installPlayerProvider = false`, or another plugin owns it): packet-level
60+
compat (meta fix, pose interception, item rewrites, `PlayerConfig`) is inert for those players, but the plain-API
61+
behaviors still apply — sprint strip on sneak/use, movement collision revert, swim dampening — through
62+
profile-resolved fallback state. Domain ticking skips uncovered players instead of double-ticking them.
63+
4264
## Status
4365

4466
Pre-release. APIs move; presets are updated as captures refine the measured values.

pvp/src/main/java/io/github/term4/polyp/Polyp.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import net.minestom.server.entity.Player;
2525
import net.minestom.server.event.Event;
2626
import net.minestom.server.event.EventNode;
27+
import net.minestom.server.network.player.GameProfile;
28+
import net.minestom.server.network.player.PlayerConnection;
2729
import org.jetbrains.annotations.NotNull;
2830
import org.jetbrains.annotations.Nullable;
2931
import org.slf4j.Logger;
@@ -32,6 +34,7 @@
3234
import java.util.List;
3335
import java.util.Map;
3436
import java.util.concurrent.ConcurrentHashMap;
37+
import java.util.function.BiFunction;
3538

3639
/**
3740
* Main initialization class for the library: server-level options (trackers, metaFix), the node tree
@@ -51,12 +54,15 @@ public final class Polyp {
5154
/** Removes the pose-change stutter (sneak/sprint/...) 1.9+ clients show under high ping. Requires {@link #installPlayerProvider}. */
5255
public boolean metaFix = true;
5356
/**
54-
* Installs the {@code OptimizedPlayer} provider and scoped {@code PlayerConfig} application. Independent of
55-
* {@link #metaFix}. Disable only if you set your own player provider; extend {@code OptimizedPlayer} there to keep
56-
* PlayerConfig support.
57+
* Installs {@link #playerFactory} as the player provider + scoped {@code PlayerConfig} application. To customize
58+
* the player class set {@link #playerFactory} instead of disabling this - the packet-level compat keys on it.
5759
*/
5860
public boolean installPlayerProvider = true;
5961

62+
/** Builds each connecting player. Swap for an {@code OptimizedPlayer} subclass to keep the whole
63+
* {@code instanceof}-gated compat/fixes layer; read per-connect, so setting it after init works too. */
64+
public BiFunction<PlayerConnection, GameProfile, ? extends OptimizedPlayer> playerFactory = OptimizedPlayer::new;
65+
6066
private final EventNode<@NotNull Event> root = EventNode.all("polyp:root");
6167
private final EventNode<@NotNull Event> apiEvents = EventNode.all("polyp:api-events");
6268
private EventNode<@NotNull Event> trackersNode;
@@ -119,14 +125,14 @@ private void doInit() {
119125

120126
if (metaFix && !installPlayerProvider) {
121127
LOG.warn("metaFix is enabled but installPlayerProvider is not - the meta fix needs the OptimizedPlayer"
122-
+ " provider and will be inert (set your own provider extending OptimizedPlayer).");
128+
+ " provider and will be inert (keep the provider on and set playerFactory instead).");
123129
}
124130
if (metaFix) MetaFix.installListeners();
131+
CompatMovement.install(this); // plain server API: covers foreign-provider players, so not provider-gated
125132
if (installPlayerProvider) {
126-
MinecraftServer.getConnectionManager().setPlayerProvider((conn, profile) ->
127-
new OptimizedPlayer(conn, profile));
133+
// reads the field per-connect, not bound at init: a factory set late still takes effect
134+
MinecraftServer.getConnectionManager().setPlayerProvider((conn, profile) -> playerFactory.apply(conn, profile));
128135
PlayerConfigApplier.install(this);
129-
CompatMovement.install(this);
130136
// inert unless CompatConfig.disableOffhand
131137
CompatOffhand.install(this);
132138
// inert unless CompatConfig.blockPlaceReach

pvp/src/main/java/io/github/term4/polyp/fx/Fx.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ private Fx() {}
4848
public static final Key THROW_SNOWBALL = Key.key("polyp:throw_snowball");
4949
public static final Key THROW_EGG = Key.key("polyp:throw_egg");
5050
public static final Key THROW_PEARL = Key.key("polyp:throw_pearl");
51-
/** Pearl landed and moved its thrower. 1.8 has none ({@code EntityEnderPearl} is silent; {@code mob.endermen.portal}
52-
* is the enderman's own), so this is a server addition there - unregistered in {@link #vanilla18()}. */
51+
/** Pearl landed and moved its thrower. 1.8 has no pearl sound, so {@link #vanilla18()} leaves it unregistered. */
5352
public static final Key PEARL_TELEPORT = Key.key("polyp:pearl_teleport");
5453
/** Fire charge thrown from the hand. */
5554
public static final Key THROW_FIREBALL = Key.key("polyp:throw_fireball");
@@ -62,6 +61,8 @@ private Fx() {}
6261
public static final Key ARROW_HIT = Key.key("polyp:arrow_hit");
6362
/** Arrow struck a target - the hit-marker "ding" to the SHOOTER only. Unregistered by default; a PvP preset registers it. */
6463
public static final Key ARROW_HIT_PLAYER = Key.key("polyp:arrow_hit_player");
64+
/** A burning entity doused by water (1.8 {@code random.fizz}); heard by viewers, never the doused player. */
65+
public static final Key FIRE_EXTINGUISH = Key.key("polyp:fire_extinguish");
6566
/** TNT ignited (primed TNT spawned). */
6667
public static final Key TNT_PRIME = Key.key("polyp:tnt_prime");
6768
/** The big explosion flash; played for power &gt;= 2 (the 1.8 client's hugeexplosion-vs-explode gate). */
@@ -106,6 +107,9 @@ public static void play(@NotNull Services services, @NotNull Key key, @NotNull F
106107
// 1.8 item pickup
107108
.register(ITEM_PICKUP, ctx -> ctx.sound(SoundEvent.ENTITY_ITEM_PICKUP, Sound.Source.PLAYER, 0.2f,
108109
jitterPitch(0.7f) * 2.0f))
110+
// 1.8 random.fizz 0.7F / 1.6F +- 0.4 dual-rand
111+
.register(FIRE_EXTINGUISH, ctx -> ctx.viewerSound(SoundEvent.ENTITY_GENERIC_EXTINGUISH_FIRE,
112+
Sound.Source.NEUTRAL, 0.7f, 0.6f + jitterPitch(0.4f)))
109113
.register(THROW_SNOWBALL, throwSound(SoundEvent.ENTITY_SNOWBALL_THROW, Sound.Source.NEUTRAL))
110114
.register(THROW_EGG, throwSound(SoundEvent.ENTITY_EGG_THROW, Sound.Source.PLAYER))
111115
.register(THROW_PEARL, throwSound(SoundEvent.ENTITY_ENDER_PEARL_THROW, Sound.Source.NEUTRAL))
@@ -136,11 +140,8 @@ public static void play(@NotNull Services services, @NotNull Key key, @NotNull F
136140
return ctx -> ctx.sound(SoundEvent.ENTITY_PLAYER_TELEPORT, Sound.Source.PLAYER, 1.0f, 1.0f);
137141
}
138142

139-
/**
140-
* Hypixel BEDWARS only: the pearl landing reaches the whole lobby at full volume, however far away the thrower
141-
* is - an audible tell that someone pearled. SkyWars and their other modes keep the positional
142-
* {@link #pearlTeleport()}, so this belongs on a per-mode registry, not the shared Hypixel one.
143-
*/
143+
/** Hypixel BEDWARS only (their other modes stay positional): the landing reaches the whole game at full
144+
* volume regardless of distance, so it rides a per-mode registry, not the shared Hypixel one. */
144145
public static @NotNull FxHandler pearlTeleportGameWide() {
145146
return ctx -> ctx.globalSound(SoundEvent.ENTITY_PLAYER_TELEPORT, Sound.Source.PLAYER, 1.0f, 1.0f);
146147
}

pvp/src/main/java/io/github/term4/polyp/fx/FxContext.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,8 @@ public void sourceSound(@NotNull SoundEvent sound, @NotNull Sound.Source src, fl
8383
p.sendPacket(new SoundEffectPacket(sound, src, source.getPosition(), volume, pitch, ThreadLocalRandom.current().nextLong()));
8484
}
8585

86-
/**
87-
* The sound to EVERY player in the world at full volume, positioned at each listener so distance never
88-
* attenuates it - a game-wide announcement rather than a thing that happened somewhere (Hypixel BedWars
89-
* pearls). One shared seed, so variant-picking sounds still pick the same variant for everyone.
90-
*/
86+
/** The sound to every player in the world, positioned at each LISTENER so distance never attenuates it.
87+
* One shared seed: variant-picking sounds pick the same variant for everyone. */
9188
public void globalSound(@NotNull SoundEvent sound, @NotNull Sound.Source src, float volume, float pitch) {
9289
long seed = ThreadLocalRandom.current().nextLong();
9390
for (Player p : world.players()) {

pvp/src/main/java/io/github/term4/polyp/mechanics/attribute/catalog/enchant/FireAspect.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import io.github.term4.polyp.mechanics.attribute.combat.HitContext;
66
import io.github.term4.polyp.mechanics.attribute.combat.OnHit;
77
import io.github.term4.polyp.mechanics.damage.DamageSystem;
8-
import io.github.term4.polyp.util.tick.TickScaler;
8+
import io.github.term4.polyp.mechanics.damage.types.burning.Ignite;
99
import net.kyori.adventure.key.Key;
1010

1111
import java.util.List;
@@ -30,8 +30,7 @@ private static final class FireAspectSource extends ItemSource implements OnHit
3030

3131
@Override public void onHit(HitContext ctx) {
3232
if (ctx.level() <= 0) return;
33-
// real-time duration: Minestom decrements fireTicks at server TPS
34-
ctx.victim().setFireTicks(TickScaler.duration(ctx.victim(), ctx.level() * TICKS_PER_LEVEL, DamageSystem.KEY));
33+
Ignite.ignite(ctx.victim(), ctx.level() * TICKS_PER_LEVEL, DamageSystem.KEY);
3534
}
3635
}
3736
}

pvp/src/main/java/io/github/term4/polyp/mechanics/damage/DamageSystem.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ public final class DamageSystem implements MechanicsModule {
100100
private final DamageCalculator calc;
101101
private final DamageTypeRegistry registry;
102102
private final Services services;
103+
104+
public Services services() { return services; }
103105
private final EventNode<@NotNull Event> node;
104106

105107
public DamageSystem(Polyp polyp, DamageConfig config) {
@@ -111,14 +113,9 @@ public DamageSystem(Polyp polyp, DamageConfig config) {
111113
this.registry = new DamageTypeRegistry(this, polyp).registerVanillaDefaults();
112114
// window stamps ride the victim's per-instance clock; the TickState future-guard misses a coinciding long-lived instance
113115
this.node.addListener(PlayerSpawnEvent.class, e -> clearDamageWindow(e.getPlayer()));
114-
// Vanilla sends the hurt sound to everyone EXCEPT the victim (1.8 EntityHuman.makeSound -> the
115-
// sendPacketNearby overload that skips them; modern Player.playSound passes itself as the excluded
116-
// player). The victim's own client covers it: EntityPlayerSP/LocalPlayer override playSound to play
117-
// directly, so the animation packet they also receive is enough. A REMOTE entity's hurt sound is NOT
118-
// predicted - that playSound sinks into a no-op in both eras (1.8 RenderGlobal.playSound is an empty
119-
// stub, modern ClientLevel.playSeededSound only fires when the excluded player is the local one).
120-
// Minestom sends it to viewers AND self, so the victim heard it twice; re-emit to viewers only rather
121-
// than dropping it, or an attacker never hears the hit land.
116+
// vanilla sends the hurt sound to everyone EXCEPT the victim, whose own client predicts it
117+
// (EntityPlayerSP/LocalPlayer override playSound; a remote entity's playSound is a client no-op in both
118+
// eras). Minestom sends viewers AND self - re-emit to viewers only, or the victim doubles / attackers hear nothing
122119
this.node.addListener(EntityDamageEvent.class, e -> {
123120
SoundEvent sound = e.getSound();
124121
if (sound == null || !e.shouldAnimate()) return;
@@ -275,6 +272,9 @@ public DamageOutcome apply(DamageSnapshot snap) {
275272
living.setTag(LAST_DAMAGE, Math.max(event.stored(), amount));
276273
living.setTag(LAST_DAMAGE_TYPE, type);
277274
applyDamage(living, type, finalSnap, applied, replacementSilent);
275+
// vanilla runs the post-hit enchant effects on ANY landed hit - an overdamage refresh included
276+
// (EntityHuman.attack applies fire aspect whenever damageEntity returns true)
277+
dispatchWeaponOnHit(living, finalSnap);
278278
fireDamageApplied(finalSnap, applied, DamageOutcome.OVERDAMAGE);
279279
return DamageOutcome.OVERDAMAGE;
280280
}

pvp/src/main/java/io/github/term4/polyp/mechanics/damage/types/burning/BurningTicker.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.term4.polyp.mechanics.damage.types.burning;
22

3+
import io.github.term4.polyp.fx.Fx;
4+
import io.github.term4.polyp.fx.FxContext;
35
import io.github.term4.polyp.mechanics.damage.DamageConfigResolver;
46
import io.github.term4.polyp.mechanics.damage.DamageSnapshot;
57
import io.github.term4.polyp.mechanics.damage.DamageSystem;
@@ -27,7 +29,7 @@
2729
*/
2830
final class BurningTicker implements EnvironmentalTickProducer {
2931

30-
private static final BurningTicker INSTANCE = new BurningTicker();
32+
static final BurningTicker INSTANCE = new BurningTicker();
3133
/** Consecutive ticks standing in the current fire/lava contact (reset on leave). */
3234
private static final Tag<Integer> CONTACT_TICKS = Tag.Transient("polyp:burn-contact-ticks");
3335
private static final int DEFAULT_BURN_INTERVAL = 20;
@@ -72,10 +74,14 @@ public void tick(LivingEntity living, DamageSystem sys) {
7274
else if (block.compare(Block.FIRE) || block.compare(Block.SOUL_FIRE)) contact[2] = true;
7375
return contact[0] && contact[1] && contact[2];
7476
});
75-
boolean wet = contact[0];
77+
boolean wet = contact[0]; // water contact only: vanilla's isWet also counts rain, which nothing here models
7678
boolean inHazard = contact[1] || contact[2];
7779

78-
if (wet && living.getFireTicks() > 0) living.setFireTicks(0);
80+
if (wet && living.getFireTicks() > 0) {
81+
living.setFireTicks(0);
82+
// viewers only, like 1.8's makeSound: the doused player hears nothing (their client forces fireTicks 0)
83+
Fx.play(sys.services(), Fx.FIRE_EXTINGUISH, FxContext.of(living));
84+
}
7985

8086
if (!inHazard) {
8187
living.removeTag(CONTACT_TICKS);
@@ -103,11 +109,10 @@ private static void contact(DamageSystem sys, LivingEntity living, DamageType ty
103109
Integer ignite = bc.igniteTicks(ctx);
104110
if (ignite != null && ignite > 0) {
105111
int invul = resolvedInvul(sys, ctx, bc);
106-
// fire duration + warmup are real-time windows; Minestom decrements fireTicks at server TPS, so scale them (identity at 20)
107112
int warmup = TickScaler.duration(living, bc.resolveIgniteWarmup(ctx, invul), DamageSystem.KEY);
108-
int scaledIgnite = TickScaler.duration(living, ignite, DamageSystem.KEY);
109113
boolean pin = living.getFireTicks() > 0 || contactTicks >= warmup;
110-
if (pin && living.getFireTicks() < scaledIgnite) living.setFireTicks(scaledIgnite);
114+
// every vanilla ignition routes through setOnFire, so Fire Protection cuts this one too
115+
if (pin) Ignite.ignite(living, ignite, DamageSystem.KEY);
111116
}
112117
}
113118

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.github.term4.polyp.mechanics.damage.types.burning;
2+
3+
import io.github.term4.polyp.item.Enchants;
4+
import io.github.term4.polyp.mechanics.attribute.catalog.enchant.ProtectionEnchant;
5+
import io.github.term4.polyp.util.tick.TickScaler;
6+
import net.kyori.adventure.key.Key;
7+
import net.minestom.server.entity.LivingEntity;
8+
import net.minestom.server.item.ItemStack;
9+
10+
/**
11+
* Vanilla {@code Entity.setOnFire}: Fire Protection cuts the duration 15% per level (highest armor piece),
12+
* and a shorter burn never overwrites a longer one - Minestom's raw {@code setFireTicks} does both wrong.
13+
*/
14+
public final class Ignite {
15+
16+
private Ignite() {}
17+
18+
/** {@code vanillaTicks} pre-scale; {@code scaleKey} is the calling system's TickScaler key. */
19+
public static void ignite(LivingEntity victim, int vanillaTicks, Key scaleKey) {
20+
int prot = maxFireProtection(victim);
21+
if (prot > 0) vanillaTicks -= (int) Math.floor(vanillaTicks * prot * 0.15f);
22+
int scaled = TickScaler.duration(victim, vanillaTicks, scaleKey);
23+
if (victim.getFireTicks() < scaled) victim.setFireTicks(scaled);
24+
}
25+
26+
// 1.8 EnchantmentManager.a: the HIGHEST level across equipment, not the sum
27+
private static int maxFireProtection(LivingEntity victim) {
28+
int max = 0;
29+
for (ItemStack armor : new ItemStack[]{
30+
victim.getHelmet(), victim.getChestplate(), victim.getLeggings(), victim.getBoots()}) {
31+
max = Math.max(max, Enchants.level(armor, ProtectionEnchant.FIRE_PROTECTION.key()));
32+
}
33+
return max;
34+
}
35+
}

pvp/src/main/java/io/github/term4/polyp/mechanics/projectile/entities/ManagedProjectile.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ protected boolean onHit(@NotNull Entity target) {
7575
case DESTROY -> { fireImpact(target); return true; }
7676
}
7777

78+
beforeEntityDamage(target);
7879
DamageSystem.DamageOutcome result = applyDamageAndKnockback(target, ev);
7980
// didn't land: the InvulnResponse picks by why - IMMUNE (creative/spectator) vs BLOCKED (invul window)
8081
if (!result.landed()) {
@@ -146,6 +147,9 @@ protected boolean onStuck() {
146147
}
147148

148149
/** Impact effect once a hit lands, after damage/knockback and before removal. {@code hitEntity} {@code null} = block hit. */
150+
/** Runs before the damage roll, so it fires even when the hit then i-frame-deflects (vanilla arrow flame order). */
151+
protected void beforeEntityDamage(@NotNull Entity target) {}
152+
149153
protected void onImpact(@Nullable Entity hitEntity) {}
150154

151155
private void fireImpact(@Nullable Entity hit) {

pvp/src/main/java/io/github/term4/polyp/mechanics/projectile/entities/ProjectileEntity.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ public abstract class ProjectileEntity extends Entity implements ExternallyTicka
104104
private ProjectileTypeConfig.WaterModel waterModel = ProjectileTypeConfig.WaterModel.LEGACY;
105105
private boolean legacyBlockRay = false;
106106
private boolean inWater;
107+
protected final boolean inWater() { return inWater; }
107108
/** Added before drag (vanilla fireball {@code mot += dir; mot *= drag}); ZERO for ballistic projectiles. */
108109
protected Vec acceleration = Vec.ZERO;
109110
/** Knockback origin: vanilla uses the shooter, not the projectile. */

0 commit comments

Comments
 (0)