Compare commits

...
2 Commits
Author SHA1 Message Date
dmg db87505142 feat(health): honor native ceilings without capping CON
Release / release (push) Successful in 5m14s
CI / build (push) Successful in 2m50s
2026-09-18 11:35:40 -04:00
dmg 9771d1e7c3 feat(sessions): reserve character and player session identities
Release / release (push) Successful in 5m13s
CI / build (push) Successful in 3m11s
2026-09-18 09:44:04 -04:00
7 changed files with 393 additions and 2 deletions

No files matched your search

+14
View File
@@ -101,6 +101,20 @@ 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.
Six unit tests cover exclusion, transfer/switch release, stale callbacks, invalid input, shutdown/restart boundaries, same-name identities, uncapped values and 24 rounds of 16 competing callbacks. The registry does no persistence or native work and does not impose a native-health cap. It must be shared across entry routes; separate processes do not coordinate. Callers still must load current authoritative state, validate sheet possession/context/deadlines, preflight native equipment/health, retain/release claims across their lifecycle and reconcile interrupted state. `isCurrent()` is a lifetime check, not saved-state or effect authorization. No fantasy activation is registered yet. The foundation passes 182 tests, clean/incremental packaging, audit and static analysis; remote publication is tracked separately.
## Native equipment adapter
`EquipmentStorage` preflights and moves nine hotbar/four armor slots into an actual named shulker in one free main-inventory slot. Item counts, metadata/damage/enchantments and legal nested contents remain native item data, accessible without this plugin. Eligibility is tested against the real vanilla shulker slot for every nonlegacy native item material; a main-inventory shulker does not prevent entry. Oversized or ineligible equipment and missing space produce unchanged refusals.
@@ -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,61 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.util.Optional;
import java.util.UUID;
/**
* Process-local pending/active session exclusion, not persisted character ownership or native activation.
* A player can own any number of sheets, but must finish one session before entering another.
* The caller retains the claim across asynchronous entry/active callbacks and releases it on failure/exit.
* Share one registry across entry routes in this plugin process; separate instances/processes do not coordinate.
* No cooldown, equipment, saved-state reconciliation or native health policy is supplied here.
*/
public final class SessionClaims implements AutoCloseable {
private final java.util.Map<UUID, Claim> players = new java.util.HashMap<>();
private final java.util.Map<UUID, Claim> characters = new java.util.HashMap<>();
private boolean closed;
/**
* The caller must supply current authoritative saved data, not data copied from an item, and validate
* sheet possession, lifecycle, switching deadline and native preflight. This method does no file I/O.
*/
public synchronized Optional<Claim> tryClaim(UUID player, CharacterRecord character) {
java.util.Objects.requireNonNull(player);
java.util.Objects.requireNonNull(character);
if (closed || character.dead() || character.health().signum() == 0
|| players.containsKey(player) || characters.containsKey(character.id())) return Optional.empty();
var claim = new Claim(player, character.id());
players.put(player, claim);
characters.put(character.id(), claim);
return Optional.of(claim);
}
public final class Claim implements AutoCloseable {
private final UUID player;
private final UUID character;
private final UUID session = UUID.randomUUID();
private Claim(UUID player, UUID character) { this.player = player; this.character = character; }
public UUID playerId() { return player; }
public UUID characterId() { return character; }
public UUID sessionId() { return session; }
/** Lifetime/identity check only: does not re-read saved liveness or authorize native effects. */
public boolean isCurrent() {
synchronized (SessionClaims.this) {
return players.get(player) == this && characters.get(character) == this;
}
}
@Override public void close() {
synchronized (SessionClaims.this) {
players.remove(player, this);
characters.remove(character, this);
}
}
}
@Override public synchronized void close() {
closed = true;
players.clear();
characters.clear();
}
}
@@ -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());
@@ -0,0 +1,161 @@
package games.dmg.fantasystick.session;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class SessionClaimsTest {
@Test
void releasingAllowsTransferAndSwitchingButAStaleReleaseCannotClearTheReplacement() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var old = claims.tryClaim(a, first).orElseThrow();
old.close();
assertFalse(old.isCurrent());
var transferred = claims.tryClaim(b, first).orElseThrow();
assertNotEquals(old.sessionId(), transferred.sessionId());
var switched = claims.tryClaim(a, second).orElseThrow();
old.close(); old.close();
assertTrue(transferred.isCurrent());
assertTrue(switched.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty());
transferred.close();
switched.close();
var resumed = claims.tryClaim(a, first).orElseThrow();
transferred.close();
assertTrue(resumed.isCurrent());
}
}
@Test
void deadOrZeroHealthRecordsAndInvalidArgumentsCannotReserveEitherIdentity() {
var live = new CharacterFactory().create(ProgressionRules.defaults());
var player = UUID.randomUUID();
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.TEN,
live.learned(), live.loadout(), live.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
var empty = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.ZERO,
live.learned(), live.loadout(), live.cooldowns(), false, null, null);
try (var claims = new SessionClaims()) {
assertTrue(claims.tryClaim(player, dead).isEmpty());
assertTrue(claims.tryClaim(player, empty).isEmpty());
assertThrows(NullPointerException.class, () -> claims.tryClaim(null, live));
assertThrows(NullPointerException.class, () -> claims.tryClaim(player, null));
assertTrue(claims.tryClaim(player, live).orElseThrow().isCurrent());
assertTrue(dead.dead());
assertEquals(java.math.BigDecimal.ZERO, empty.health());
}
}
@Test
@SuppressWarnings("try")
void shutdownInvalidatesAllTokensAndDoesNotPretendToPersistOwnershipAcrossRestart() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var old = claims.tryClaim(a, first).orElseThrow();
var other = claims.tryClaim(b, second).orElseThrow();
claims.close(); claims.close();
assertFalse(old.isCurrent());
assertFalse(other.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty());
try (var restarted = new SessionClaims()) {
var fresh = restarted.tryClaim(b, first).orElseThrow();
old.close(); other.close();
assertTrue(fresh.isCurrent());
assertNotEquals(old.sessionId(), fresh.sessionId());
// This only establishes an empty process-local registry, not recovery of native equipment/state.
}
}
}
@Test
void competingCallbacksHaveOneWinnerForEitherSharedCharacterOrSharedPlayer() throws Exception {
var factory = new CharacterFactory();
for (boolean sharedCharacter : new boolean[] {true, false}) {
for (int round = 0; round < 12; round++) {
var character = factory.create(ProgressionRules.defaults());
var player = UUID.randomUUID();
var ready = new java.util.concurrent.CountDownLatch(16);
var start = new java.util.concurrent.CountDownLatch(1);
try (var claims = new SessionClaims(); var workers = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
var pending = new java.util.ArrayList<java.util.concurrent.Future<java.util.Optional<SessionClaims.Claim>>>();
for (int index = 0; index < 16; index++) pending.add(workers.submit(() -> {
ready.countDown();
if (!start.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("Start gate timed out");
return claims.tryClaim(sharedCharacter ? UUID.randomUUID() : player,
sharedCharacter ? character : factory.create(ProgressionRules.defaults()));
}));
assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS));
start.countDown();
var won = new java.util.ArrayList<SessionClaims.Claim>();
for (var future : pending) future.get(5, java.util.concurrent.TimeUnit.SECONDS).ifPresent(won::add);
assertEquals(1, won.size());
assertTrue(won.getFirst().isCurrent());
won.getFirst().close();
assertFalse(won.getFirst().isCurrent());
} finally { start.countDown(); }
}
}
}
@Test
void identityNotNameControlsExclusionAndNoGameplayStatOrNativeHealthCapIsAdded() {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(original.stats());
stats.put(games.dmg.fantasystick.progression.Stat.CON, java.math.BigInteger.TEN.pow(100));
var health = new java.math.BigDecimal(stats.get(games.dmg.fantasystick.progression.Stat.CON).add(java.math.BigInteger.TEN));
var large = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Shared name", original.revision(),
stats, original.level(), original.experience(), original.unspentPoints(), health,
original.learned(), original.loadout(), original.cooldowns(), false, null, null);
var independent = new games.dmg.fantasystick.persistence.CharacterRecord(UUID.randomUUID(), large.name(), large.revision(),
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
var renamedCopy = new games.dmg.fantasystick.persistence.CharacterRecord(large.id(), "Different name", java.math.BigInteger.ONE,
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
try (var claims = new SessionClaims()) {
var held = claims.tryClaim(UUID.randomUUID(), large).orElseThrow();
assertTrue(claims.tryClaim(UUID.randomUUID(), independent).isPresent());
assertTrue(claims.tryClaim(UUID.randomUUID(), renamedCopy).isEmpty());
assertTrue(held.isCurrent());
assertEquals(health, large.health());
assertEquals(java.math.BigInteger.TEN.pow(100), large.stats().get(games.dmg.fantasystick.progression.Stat.CON));
// This registry does not project these values into native attributes or grant permission to activate them.
}
}
@Test
void excludesOverlappingCharactersAndPlayerModesWithoutRestrictingStoredCharacterAcquisition() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var held = claims.tryClaim(a, first).orElseThrow();
assertEquals(first.id(), held.characterId());
assertEquals(a, held.playerId());
assertNotNull(held.sessionId());
assertTrue(held.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty(), "Repeated entry is not a second session");
assertTrue(claims.tryClaim(b, first).isEmpty(), "Copied references cannot activate the same character twice");
assertTrue(claims.tryClaim(a, second).isEmpty(), "A sheet switch must finish exiting before claiming another active mode");
var independent = claims.tryClaim(b, second).orElseThrow();
assertTrue(independent.isCurrent());
assertNotEquals(held.sessionId(), independent.sessionId());
// Both records exist independently; this limits active/pending sessions, not sheets a player can own.
}
}
}