Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db87505142 |
No files matched your search
@@ -101,6 +101,14 @@ Vanilla retains responsibility for item movement, XP charging and anvil damage.
|
||||
|
||||
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation and fantasy sessions remain incomplete. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
|
||||
|
||||
## Native health ceiling (activation still unwired)
|
||||
|
||||
`CharacterProgress` now limits base maximum health to `min(10 + CON, supplied native limit)` without limiting CON purchases. A purchase adds only the actual maximum-health gain to current health: fractional gain when crossing the ceiling, zero gain once capped. Injury and revision/idempotency protections remain intact. The two-argument initializer uses the pinned runtime's default 1,024 HP; runtime adapters must pass their actual ceiling explicitly.
|
||||
|
||||
`NativeHealthLimit.read()` reads the current native ranged attribute through the pinned runtime's public Craft/native accessors, without setting attributes or changing configuration. It must run on the server thread after registry initialization. Incompatible accessors or nonpositive/nonfinite/unrepresentable native ranges fail explicitly instead of guessing. This is not a refusal based on CON. Tests cover core purchases and actual native attribute compatibility at 10.5, 1,024 and 2,048 HP; native tests restore their temporary in-process range changes in `finally`.
|
||||
|
||||
No virtual health, global configuration change, stored-data migration or active-player health adapter is introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility; bounded reload/revival, native floating-point round trips, unrelated modifiers and lifecycle application still need integration. Current-health gain is calculated against the core instance's fixed supplied limit; callers must obtain/revalidate the applicable limit at their native operation boundary. This slice passes 186 tests, clean/incremental packaging, audit and static analysis. Remote publication is tracked separately.
|
||||
|
||||
## Session claims (not wired to activation)
|
||||
|
||||
`SessionClaims` supplies process-local exclusion for pending/active character sessions. A single shared registry atomically prevents simultaneous claims for either the same character identity or the same player's active mode. This does not limit how many sheets a player may own or permanently bind a character to a holder. Released characters can transfer; a stale token cannot release a replacement claim. Private claim objects carry distinct session UUIDs for future equipment/callback correlation. Dead/zero-health DTOs refuse unchanged; shutdown invalidates all claims and refuses new ones.
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class CharacterProgress {
|
||||
|
||||
private final UUID characterId;
|
||||
private final ProgressionRules rules;
|
||||
private final BigDecimal nativeHealthLimit;
|
||||
private final EnumMap<Stat, BigInteger> stats = new EnumMap<>(Stat.class);
|
||||
private int level = 1;
|
||||
private BigInteger experience = BigInteger.ZERO;
|
||||
@@ -26,11 +27,19 @@ public final class CharacterProgress {
|
||||
private BigInteger revision = BigInteger.ZERO;
|
||||
private BigDecimal health = BigDecimal.TEN;
|
||||
|
||||
/** Uses the pinned runtime's default ceiling. Runtime adapters must supply their actual limit. */
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules) {
|
||||
this(characterId, rules, new BigDecimal("1024"));
|
||||
}
|
||||
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules, BigDecimal nativeHealthLimit) {
|
||||
this.characterId = Objects.requireNonNull(characterId);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.nativeHealthLimit = Objects.requireNonNull(nativeHealthLimit);
|
||||
if (nativeHealthLimit.signum() <= 0) throw new IllegalArgumentException("Native health limit must be positive");
|
||||
this.points = rules.startingPoints();
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
this.health = maximumHealth();
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
@@ -47,9 +56,10 @@ public final class CharacterProgress {
|
||||
if (!revision.equals(expectedRevision)) return Purchase.STALE_REVISION;
|
||||
if (health.signum() == 0) return Purchase.NOT_ALIVE;
|
||||
if (points.signum() == 0) return Purchase.NO_POINTS;
|
||||
BigDecimal previousMaximum = maximumHealth();
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
points = points.subtract(BigInteger.ONE);
|
||||
if (stat == Stat.CON) health = health.add(BigDecimal.ONE);
|
||||
if (stat == Stat.CON) health = health.add(maximumHealth().subtract(previousMaximum));
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
return Purchase.APPLIED;
|
||||
}
|
||||
@@ -80,6 +90,6 @@ public final class CharacterProgress {
|
||||
}
|
||||
|
||||
private BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(nativeHealthLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
|
||||
/**
|
||||
* Read-only access to the pinned runtime's health range; never changes server settings or attributes.
|
||||
* Call on the server thread after registry initialization. The API exposes no range getter, so use the
|
||||
* supported runtime's public Craft/native accessors and fail explicitly if they are incompatible.
|
||||
* This reads the current range on each call, not a guessed default or cached configuration value.
|
||||
* Native session/health application is separate and must revalidate its context before mutation.
|
||||
*/
|
||||
public final class NativeHealthLimit {
|
||||
private NativeHealthLimit() { }
|
||||
|
||||
public static BigDecimal read() {
|
||||
try {
|
||||
Object range = Attribute.MAX_HEALTH.getClass().getMethod("getHandle").invoke(Attribute.MAX_HEALTH);
|
||||
double maximum = ((Number) range.getClass().getMethod("getMaxValue").invoke(range)).doubleValue();
|
||||
if (!Double.isFinite(maximum) || maximum <= 0
|
||||
|| !Float.isFinite((float) maximum) || (float) maximum <= 0) {
|
||||
throw new IllegalStateException("Native maximum health must be positive and representable as Minecraft health");
|
||||
}
|
||||
return BigDecimal.valueOf(maximum);
|
||||
} catch (ReflectiveOperationException | ClassCastException failure) {
|
||||
throw new IllegalStateException("Cannot read the supported native maximum-health range", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.CharacterProgress;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.ai.attributes.RangedAttribute;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.craftbukkit.attribute.CraftAttributeInstance;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NativeHealthLimitTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void invalidNativeRangesFailExplicitlyRatherThanGuessingOrChangingTheRuntime() {
|
||||
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
|
||||
double original = range.getMaxValue();
|
||||
try {
|
||||
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY,
|
||||
Double.NEGATIVE_INFINITY, Double.MAX_VALUE, Double.MIN_VALUE}) {
|
||||
range.maxValue = invalid;
|
||||
assertThrows(IllegalStateException.class, NativeHealthLimit::read);
|
||||
assertEquals(invalid, range.getMaxValue());
|
||||
}
|
||||
} finally { range.maxValue = original; }
|
||||
assertEquals(0, BigDecimal.valueOf(original).compareTo(NativeHealthLimit.read()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheActualNativeCeilingRatherThanGuessingTheDefaultAndFeedsCappedProgression() {
|
||||
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
|
||||
double original = range.getMaxValue();
|
||||
try {
|
||||
// Isolated non-listening test runtime only. Never write an operator's config file.
|
||||
for (double ceiling : new double[] {10.5, 1024, 2048}) {
|
||||
range.maxValue = ceiling;
|
||||
BigDecimal limit = NativeHealthLimit.read();
|
||||
assertEquals(0, BigDecimal.valueOf(ceiling).compareTo(limit));
|
||||
assertEquals(ceiling, range.getMaxValue(), "Reading does not modify the native range");
|
||||
var progress = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(BigInteger.valueOf(3000), BigInteger.valueOf(100), 30), limit);
|
||||
progress.recordHealth(new BigDecimal("6.25"));
|
||||
var handle = new net.minecraft.world.entity.ai.attributes.AttributeInstance(Attributes.MAX_HEALTH, ignored -> { });
|
||||
var attribute = new CraftAttributeInstance(handle, Attribute.MAX_HEALTH);
|
||||
for (int index = 0; index < 2100; index++) {
|
||||
var before = progress.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.APPLIED,
|
||||
progress.buyStat(before.characterId(), before.revision(), Stat.CON));
|
||||
var after = progress.snapshot();
|
||||
attribute.setBaseValue(after.maximumHealth().doubleValue());
|
||||
assertEquals(after.maximumHealth().doubleValue(), attribute.getValue());
|
||||
assertEquals(0, new BigDecimal("3.75").compareTo(after.maximumHealth().subtract(after.currentHealth())));
|
||||
}
|
||||
assertEquals(BigInteger.valueOf(2100), progress.snapshot().stats().get(Stat.CON));
|
||||
assertEquals(0, limit.compareTo(progress.snapshot().maximumHealth()));
|
||||
}
|
||||
} finally { range.maxValue = original; }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,55 @@ class CharacterProgressTest {
|
||||
character.buyStat(before.characterId(), before.revision(), stat));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeHealthCeilingDoesNotCapConOrTurnFurtherPurchasesIntoHealing() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(integer(2000), integer(100), 30));
|
||||
for (int i = 0; i < 1013; i++) buy(character, Stat.CON);
|
||||
character.recordHealth(new BigDecimal("1019.25")); // 3.75 health points missing
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("1024"), character.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("1020.25"), character.snapshot().currentHealth());
|
||||
var before = character.snapshot();
|
||||
buy(character, Stat.CON);
|
||||
var capped = character.snapshot();
|
||||
assertEquals(new BigDecimal("1024"), capped.maximumHealth());
|
||||
assertEquals(before.currentHealth(), capped.currentHealth());
|
||||
assertEquals(integer(1015), capped.stats().get(Stat.CON));
|
||||
assertEquals(before.unspentPoints().subtract(BigInteger.ONE), capped.unspentPoints());
|
||||
assertEquals(CharacterProgress.Purchase.STALE_REVISION,
|
||||
character.buyStat(before.characterId(), before.revision(), Stat.CON));
|
||||
assertEquals(capped, character.snapshot());
|
||||
assertThrows(IllegalArgumentException.class, () -> character.recordHealth(new BigDecimal("1024.01")));
|
||||
assertEquals(capped, character.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppliedNativeLimitAllowsPartialConGainAndZeroGainOnceReached() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("10.5"));
|
||||
character.recordHealth(new BigDecimal("6.25"));
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("10.5"), character.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
|
||||
assertEquals(integer(2), character.snapshot().stats().get(Stat.CON));
|
||||
var low = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("5"));
|
||||
assertEquals(new BigDecimal("5"), low.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("5"), low.snapshot().currentHealth());
|
||||
var higher = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(integer(2000), integer(100), 30), new BigDecimal("2048"));
|
||||
for (int i = 0; i < 1015; i++) buy(higher, Stat.CON);
|
||||
assertEquals(new BigDecimal("1025"), higher.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("1025"), higher.snapshot().currentHealth());
|
||||
for (String invalid : new String[] {"0", "-1"}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> new CharacterProgress(UUID.randomUUID(),
|
||||
ProgressionRules.defaults(), new BigDecimal(invalid)));
|
||||
}
|
||||
assertThrows(NullPointerException.class, () -> new CharacterProgress(UUID.randomUUID(),
|
||||
ProgressionRules.defaults(), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void conAddsItsGainToBothPoolsPreservingTheFiveOfSevenToSevenOfNineHeartExample() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
|
||||
Reference in new issue
Block a user