Skip to content

dev v0.0.16 - ship stats, compat, bugfixes#25

Open
def9a2a4 wants to merge 99 commits into
mainfrom
dev-stats
Open

dev v0.0.16 - ship stats, compat, bugfixes#25
def9a2a4 wants to merge 99 commits into
mainfrom
dev-stats

Conversation

@def9a2a4

@def9a2a4 def9a2a4 commented May 12, 2026

Copy link
Copy Markdown
Owner

v0.0.16: Ship Stats, Sails & Engines, more block types, wider compatibility

Notes

def9a2a4 added 14 commits May 11, 2026 21:23
…d/rotation scaling

Ships now derive their speed, acceleration, and rotation from a power-to-mass
ratio based on sail composition. Wool blocks (3 pts) and banners (7 pts)
provide sail power; every ship gets 2 free base points. The ratio linearly
interpolates between absolute floors (1 block/sec speed, 30s/revolution
rotation) and caps (1.5x default stats), with ratio 0.7 mapping to current
defaults. Sails cap at ratio 0.8 — engines (not yet implemented) will be
needed to push past it.

Airship vertical speed scales with density magnitude rather than the
horizontal ratio.

Also adds ship_engine custom item definition (8 copper + blast furnace recipe)
and updates the ship info display to show wool/banner counts, power ratio, and
effective speed percentage.
Engine system: tagged blast furnace crafted from 8 copper + blast furnace.
BlockPlaceEvent listener transfers PDC tag to TileState, vanilla smelting
suppressed on engine blocks. Scanner and detection count engines via PDC
check. Engine power (30 pts/engine) wired into the ratio calculation for
both horizontal and airship vertical stats. Ship info display and detection
chat messages now show engine count and engine-adjusted power ratio.

Also extracts hardcoded help book content from ShipWheelMenu into
HelpBookContent.java + help_book.yml, loaded once at plugin startup.
Adds the full engine fuel system on top of the engine detection foundation.
Engines now require fuel to contribute power to the ship's ratio. Includes:

- EngineMenuGUI: 3 fuel slots per engine, opened by right-clicking the
  engine block on an assembled ship. Validates fuel-only items, saves on close.
- Fuel consumption: burns 1 tick per game tick while W held. Auto-consumes
  next fuel item when current burns out. Recomputes effective stats on change.
- Smoke particles: CAMPFIRE_SIGNAL_SMOKE at fueled engine positions every
  5 ticks while ship has a driver.
- Fuel state persistence: per-engine fuel slots and burn ticks serialized
  to ship_wheels.yml via Base64-encoded ItemStack bytes.
- Engine block indices and local positions tracked in ShipModel for click
  detection and particle spawning.
- ShipInstance now holds a wheelData reference (set during assembly) for
  fuel state access in the physics loop.

Note: DisplayShip.java and config.yml include unrelated changes from a
concurrent Captain's Manual fix branch (shapeless recipes, help lore).
- Reload help book content on /blockships reload
- Support shapeless recipes in ItemUtil.registerItemRecipe()
- Replace verbose help lore (full book dump) with terse controls summary
- Remove unused getHelpSections() from ShipWheelMenu
- Fix speed percentage display: divide by sail cap (0.8) not default (0.7)
  so 100% means "max sails" rather than "default speed"
- Bump floor-acceleration from 0.005 to 0.015 (less sluggish minimum)
…ne glint

- Simplify ship info book hover to show only speed % (detailed breakdown
  moved to a new Ship Stats banner item at slot 20)
- Speed % now uses sail cap (0.8) as 100% baseline instead of default
  ratio (0.7) — over 100% means engines are contributing
- Stats banner shows wool/banner/engine counts, sail power with "capped
  at 80%" indicator when applicable, mass, power ratio, and speed %
- Add config-driven enchantment glint support to CustomItem (enchant-glint
  field); ship_engine gets glint by default
- Fix floor acceleration default mismatch (config says 0.015, code
  fallback said 0.005)
Previously only ship_wheel and ship kits were giveable. Now supports:
- captains_manual (written help book)
- any custom-items entry (ship_engine, balloon, etc.)

Also adds tab completion for all giveable items and extracts
item listing into shared helpers to avoid duplication.
New config keys added in plugin updates (like ship_engine, stats section)
were invisible to existing servers because Bukkit's saveDefaultConfig()
never overwrites an existing config.yml. Users had to manually delete
their config to get new entries.

migrateConfig() now runs at startup: loads the jar's bundled config,
walks all leaf keys, and adds any that are missing from the user's
config. Never overwrites existing values — customizations are preserved.
Controlled by `auto-migrate-config: true` (opt-out by setting false).
Speed % now color-coded: red (<50%), gold (50-74%), yellow (75-99%),
green (100-124%), aqua (125%+). Ships below 50% speed show a hint to
add sails. Density line now combines the numeric value with a colored
float status label (e.g., "1.33 (Floats well)") instead of separate
lines. Removed surface offset from hover (redundant).

Applied consistently across ship info hover, stats banner, and
detection chat messages.
…er ref

- Engine blocks now drop the custom ship engine item (with PDC + glint)
  instead of a vanilla blast furnace when broken
- Add totalPositiveWeight field to ShipModel so assembled ships report
  correct positive weight instead of passing clamped maxHealth
- Guard computeStat() against divide-by-zero when defaultRatio >= 1.0
- Use local plugin reference for engine PDC check in scanner instead of
  redundant global lookup
Rename totalPositiveWeight → mass (sum of max(0, weight) per block).
This is the correct denominator for the power-to-mass ratio: it
represents how much solid material sails need to push, excluding
negative-weight floatation blocks.

Fixes airships getting zero sail benefit — getSailRatio() previously
returned 0 for negative totalWeight. Now uses mass, so airships with
sails correctly get horizontal speed benefit.

Also fixes:
- engineBlockIndices changed from Set to List so iteration order
  matches engineLocalPositions (fixes smoke at wrong engine / IOOBE)
- Lazy resolveWheelData() for chunk-recovered ships — looks up via
  ShipWheelManager.getWheelByShipUUID() on first access so fuel state
  is correctly loaded instead of assuming all engines fueled
- Shift-click non-fuel into engine GUI now blocked
- Dried kelp burn time 4001 → 4000
- YAML key renamed to "mass" with backwards-compat read of old
  "total_positive_weight" key
…er ref

Engine PDC preserved on disassembly (is_engine flag in rawYaml, restored
in placeBlocks). Ship info uses countFueledEngines() for ratio and shows
fueled/unfueled engines separately. Smoke particles now spawn at engine
shulker position instead of manual coordinate transform. Placed (unassembled)
engine blocks open the custom fuel GUI instead of vanilla furnace UI.
Shift-click non-fuel into engine GUI blocked. Smoke changed to
CAMPFIRE_COSY_SMOKE for shorter duration.

Known issues still unfixed:
- countFueledEngines() doesn't know total engine set (uses computeIfAbsent
  side effect, phantom map entries); needs engine indices parameter
- getShipInfo() uses stale lastDetected* fields for assembled ships instead
  of live ShipInstance data — mass can be 0/stale, effective power = mass
- Engine fuel state and detection data are disconnected systems
- Chat detection messages don't match lore format (missing mass, ratio)
- Unassembled ships show engines at full potential points, not as unfueled
…back

Root cause: assembleShip() never called setLastHealth() or
setLastDetectedStats(), so the ship wheel menu fell into the
"unassembled" display branch (lastMaxHealth=0) even for assembled
ships. This showed all engines at full potential points instead of
the fueled/unfueled breakdown.

Fixes:
- Set lastHealth + detection stats during assembly so menu has
  correct data immediately
- getShipInfo() now reads live ShipInstance data (model + fuel state)
  for assembled ships instead of stale lastDetected* fields
- countFueledEngines() takes engine block indices list so it checks
  ALL engines, not just those with map entries
- getEngineFuelSlots() no longer uses computeIfAbsent (was creating
  phantom entries in the fuel map)
- Physics fallback changed from engineCount (all fueled) to 0
  when wheelData is null
- tickEngineFuel skips engines with no fuel map entry
- Engine display always shows fueled/unfueled breakdown regardless
  of assembled state
- Engine GUI status shows Running/Ready/Idle based on burn ticks
  AND fuel items in slots
@def9a2a4 def9a2a4 added the help wanted Extra attention is needed label May 26, 2026
@def9a2a4 def9a2a4 linked an issue May 26, 2026 that may be closed by this pull request
def9a2a4 added 13 commits June 2, 2026 12:16
…tion deceleration scaling

Previously wool power (3) and banner power (7) were hardcoded across
multiple files. Fuel burn duration had no multiplier. Rotation
deceleration used raw config values with no ratio-based scaling.

Add wool-power, banner-power, fuel-burn-multiplier,
floor-rotation-deceleration, and cap-rotation-deceleration to
config.yml and ShipConfig. Consumers updated in subsequent commits.
…tation decel

- Defer computeEffectiveStats() from constructor to after wheelData is
  linked. Previously the first stat computation always saw 0 fueled
  engines because wheelData wasn't set yet. Now recomputeStats() is
  called explicitly after assembly and lazily on recovery.
- Guard minMovementThreshold so it only zeros speed when W/S aren't
  held — prevents low-ratio ships from being unable to move.
- Return empty bucket when lava bucket fuel is consumed (vanilla parity).
- Burn fuel on any movement input (A/D/Space/Sprint), not just W.
- Apply fuel-burn-multiplier from config when consuming fuel items.
- Scale rotation deceleration by power-to-mass ratio so heavy ships
  retain rotation momentum longer.
Engine fuel:
- Remap GUI fuel slots {1,2,3} → {0,1,2} for direct 1:1 mapping with
  blast furnace container indices. Status slot moves from 5 → 4.
- Transfer pre-assembly fuel from blast furnace containers into
  wheelData on assembly (was silently lost).
- Write wheelData fuel back to containers on disassembly (reverse gap).
- Clear stale fuel/burn entries on disassembly.
- Stop clearing entire blast furnace on save — targeted slot writes only.
- Crash-safe fuel deserialization with per-item try-catch.
- Add click-to-refresh on engine status item.

Stats display:
- Use weighted block count for density (matches physics, was using total
  block count which included weightless blocks like trapdoors).
- Use config values instead of hardcoded 0.8 sail cap, 2 base power,
  3 wool power, 7 banner power across all display paths.
- Standardize chat terminology: "power" → "pts".

Detection chat:
- Add chat output for assembled ship detection (was completely silent).
- Show live fuel state (fueled/unfueled engine breakdown) for assembled.
… dead data

Engine GUI validation:
- Block double-click collect and number-key hotbar swaps with non-fuel
  items. Add InventoryDragEvent handler to prevent drag-placing non-fuel.
- Recompute ship stats on engine GUI close so fuel changes take effect
  immediately without requiring movement.

Engine explosions:
- Handle EntityExplodeEvent and BlockExplodeEvent for engine blocks —
  drop custom ship engine item instead of vanilla blast furnace.

Null safety:
- Guard null/invalid shulkers in camera distance update loop.

ShipModel cleanup:
- Remove dead engineLocalPositions field (populated but never read;
  smoke particles use collision shulker positions instead).
- Accept woolPower/bannerPower as constructor params so sail power
  calculation uses config values. Old YAML with engine_local_positions
  key is silently ignored on load.
computeEffectiveStats() previously called ship.resolveWheelData(),
which could trigger recomputeStats() → computeEffectiveStats() again
when wheelData was first lazily resolved. The null guard prevented
infinite recursion but the double computation was wasteful and fragile.

Read ship.wheelData directly instead — all callers of recomputeStats()
(assembly, recovery, GUI close) guarantee wheelData is set beforehand.
…orms (1/3)

Root cause: the entity tracker sends vehicle rotation at byte precision
(~1.4° quantization) every 3 ticks, conflicting with float-precision
position sync packets sent every tick — creating periodic jitter.

Fix: freeze the vehicle's yaw at spawnYaw and track rotation internally
via physics.currentYaw. All visual rotation is applied through display
entity transformation matrices, bypassing the entity tracker entirely.

Changes:
- ShipPhysics: add currentYaw field, rotation updates it instead of
  teleporting the vehicle, forward direction and snap methods use it
- ShipInstance: all vehicle.getYaw() → physics.currentYaw, removed
  version-specific display rotation branch (always apply delta rotation
  via transformation), added idle yaw sync and setInterpolationDelay(0)
- ShipCollision: collision forward direction uses physics.currentYaw

Known issues to fix in follow-up commits:
- idle yaw sync causes double rotation when ship stops (display
  transformation not updated on idle tick but vehicle yaw jumps)
- setInterpolationDelay(0) causes item display Y jitter (interpolation
  duration > update interval creates cascading positional lag)
- DisplayShip dismount velocity uses frozen vehicle yaw (wrong direction)
- ShipPersistence saves frozen vehicle yaw (loses rotation on chunk save)
- currentYaw can grow unbounded without normalization
Issues observed during in-game testing of the internal yaw tracking:

- idle yaw sync caused double rotation on stop: when the ship went idle,
  the vehicle yaw was teleported from spawnYaw to currentYaw, but the
  display transformation (still carrying deltaYaw) was not updated on
  idle ticks — resulting in visual = inheritedYaw + deltaYaw = 2x rotation.
  Fix: remove idle yaw sync entirely, vehicle yaw stays frozen forever.

- setInterpolationDelay(0) caused item display Y jitter: with
  interpolation_duration=2 but updates every 1 tick, each interpolation
  was interrupted before completion, creating cascading positional lag
  visible as massive downward jitter on item displays.
  Fix: remove setInterpolationDelay(0), let transforms snap at 20 Hz
  (still 3x smoother than the original 6.67 Hz byte-precision tracker).

- dismount velocity used frozen vehicle yaw: players ejected from a
  rotated ship would fly in the original spawn direction.
  Fix: use physics.currentYaw in DisplayShip dismount calculation.

- persistence saved frozen vehicle yaw: chunk unload would lose the
  ship's actual rotation.
  Fix: save physics.currentYaw in ShipPersistence.fromInstance().

- snapToFineGrid changed vehicle yaw: driver exit would unfreeze the
  vehicle yaw, re-introducing entity tracker byte-precision packets.
  Fix: only snap position, keep vehicle yaw frozen.

Remaining: currentYaw can grow unbounded without normalization.
Custom ships lost their rotation on chunk reload because recoverEntities()
set spawnYaw to model.initialRotation.x (assembly yaw), but the idle sync
had already reset spawnYaw to currentYaw. This mismatch created a non-zero
deltaYaw on recovery, causing double rotation (inherited vehicle yaw +
transformation delta both contributing rotation).

Fix: always set spawnYaw = vehicle.getYaw() on recovery (for all ship
types), matching the idle sync's invariant that spawnYaw == currentYaw.
deltaYaw is 0 on recovery, display shows R(initial), and the vehicle's
inherited yaw provides the actual rotation.

Also:
- Extract updateDisplayTransforms() method from tick() so the idle yaw
  sync can call it after resetting spawnYaw (ensures display matches
  the synced state immediately, preventing one-tick visual glitch)
- Add idle yaw sync: when ship stops, sync vehicle yaw to currentYaw
  for NBT persistence, reset spawnYaw to currentYaw, update displays
- Normalize currentYaw to [0,360) after each rotation update
- Use normalizeAngle() for position sync yaw delta to handle wrapping
- Normalize spawnYaw/currentYaw at all init sites
…oGrid (4/3)

The idle yaw sync teleported the vehicle on the first idle tick to update
its NBT yaw for chunk persistence. This caused a 1-frame visual pop:
the ENTITY_TELEPORT packet (changing vehicle yaw) and the display entity
metadata update (resetting transform delta to 0) arrive at the client as
separate packets, momentarily showing double-rotation.

Root fix: save physics.currentYaw in per-world YAML metadata
(ShipWorldData) so chunk recovery no longer depends on vehicle entity
NBT yaw. The idle sync teleport is then unnecessary and removed entirely.
Vehicle yaw now stays frozen at spawnYaw for the entity's entire lifetime.

Metadata uses config.contains() to distinguish legacy files (no
current_yaw key → Float.NaN sentinel → fall back to vehicle NBT) from
files where the ship genuinely faces yaw=0 (north).

Also fixes alignToGrid(): the vehicle teleport was passing snappedYaw,
unfreezing the vehicle yaw. Now preserves loc.getYaw() (frozen) and
resets spawnYaw + refreshes display transforms in the ShipInstance
wrapper. ShipWheelManager.disassembleShip() reads physics.currentYaw
instead of the frozen vehicle yaw for block placement rotation.
BACKGROUND

The rotation system (82bda96) freezes the vehicle entity's yaw at
spawnYaw for its entire lifetime. All visual rotation is applied through
display entity transformation matrices using:

  deltaYaw = physics.currentYaw - spawnYaw

On 1.21.9+, Minecraft clients inherit the parent vehicle's yaw when
rendering display entity passengers. This means the client renders:

  visual rotation = vehicle.getYaw() + deltaYaw + initialModelRotation
                  = spawnYaw + (currentYaw - spawnYaw) + initial
                  = currentYaw + initial  ✓

This is only correct when the invariant holds:

  spawnYaw == vehicle.getYaw()

If spawnYaw is reset to a value that differs from vehicle.getYaw(),
the client's inherited vehicle rotation no longer cancels out and the
ship appears at the wrong angle.

WHAT 1648bb5 BROKE

1648bb5 replaced the idle yaw sync with per-world metadata persistence
(saving physics.currentYaw to ShipWorldData YAML). The goal was correct —
the idle sync caused a 1-frame double-rotation pop because the
ENTITY_TELEPORT packet (updating vehicle yaw) and the display metadata
packet (resetting deltaYaw to 0) arrived at the client in separate frames.
Metadata persistence avoids this by never changing vehicle yaw at all.

However, 1648bb5 introduced two bugs that break the invariant:

BUG 1 — alignToGrid() froze vehicle yaw while resetting spawnYaw

  alignToGrid() in ShipPhysics was changed to pass loc.getYaw()
  (the frozen vehicle yaw) in the teleport Location instead of
  snappedYaw. The ShipInstance wrapper then does:

    spawnYaw = physics.currentYaw;  // = snappedYaw

  This left vehicle.getYaw() at the old frozen value while spawnYaw
  was updated to snappedYaw — breaking the invariant.

  After align (before fix):
    vehicle.getYaw() = oldFrozenYaw  (e.g. 47°)
    spawnYaw         = 90°  (reset in wrapper)
    deltaYaw         = 0°   (currentYaw - spawnYaw)
    client renders   = 47° + 0° = 47°  ← wrong

  In-game effect: after using the align command, the ship's display
  blocks would visually snap to their spawn orientation (the old frozen
  yaw), while the collision boxes remained at the correct snapped angle.
  Invisible walls next to visible blocks.

BUG 2 — chunk recovery set spawnYaw from metadata instead of vehicle NBT

  recoverEntities() was changed to set BOTH spawnYaw and currentYaw
  from the metadata yaw:

    spawnYaw         = metadataYaw  (e.g. 90°)
    currentYaw       = metadataYaw  (e.g. 90°)
    deltaYaw         = 0°

  But the vehicle entity loads from chunk NBT with its original frozen
  yaw (e.g. 0°), which the client sees. So:

    vehicle.getYaw() = 0°   (from NBT — was never updated)
    spawnYaw         = 90°  (from metadata)
    deltaYaw         = 0°
    client renders   = 0° + 0° = 0°  ← should be 90°

  In-game effect: every ship that had been rotated from its spawn angle
  would appear at the wrong rotation after chunk unload/reload. Walking
  away from a ship and returning would show it "unrotated" back to its
  spawn angle, while collisions remained at the correct rotated position.

FIXES

Fix 1 — revert alignToGrid() to update vehicle yaw (ShipPhysics.java)

  Restore snappedYaw in the teleport Location for alignToGrid(). The
  ship is stationary after align, so there is no active position-sync
  packet to conflict with the rotation packet — no byte-precision jitter.
  Cardinal angles (90° multiples) also map exactly to byte encoding
  (90/360 x 256 = 64), so there is zero quantization error.

  After align with fix:
    vehicle.getYaw() = 90°  (updated by teleport)
    spawnYaw         = 90°  (reset in wrapper)
    currentYaw       = 90°
    deltaYaw         = 0°
    client renders   = 90° + 0° = 90°  ✓

  Invariant maintained. The ShipWheelManager change reading
  ship.physics.currentYaw for block placement remains correct (same
  value as vehicle.getYaw() post-align, but more explicit).

Fix 2 — recovery sets spawnYaw from vehicle NBT, currentYaw from metadata
         (ShipInstance.java recoverEntities)

  spawnYaw must equal vehicle.getYaw() (what the client inherits from
  NBT). The metadata yaw goes into currentYaw, making deltaYaw provide
  the compensation:

    spawnYaw   = normalizeYaw(vehicle.getYaw())   // e.g. 0° (NBT)
    currentYaw = normalizeYaw(metadataYaw)         // e.g. 90°
    deltaYaw   = 90° - 0° = 90°
    client     = 0° + 90° = 90°  ✓

  Legacy metadata without current_yaw falls back to spawnYaw, giving
  deltaYaw = 0 — identical to pre-metadata behaviour.

Fix 3 — replace R_initial-only init display loop with updateDisplayTransforms()
         (ShipInstance.java recoverEntities)

  The previous init loop applied only R_initial (no delta). With fix 2
  making spawnYaw != currentYaw, this would cause a 1-tick flash at the
  wrong angle before the first tick applied the correct transform.
  Replacing the 18-line manual loop with updateDisplayTransforms() applies
  the full deltaYaw immediately on recovery, eliminating the flash. The
  call is safe at this point: vehicle, displays, spawnYaw, currentYaw,
  cachedR_initial, and all work matrices are fully initialized.

WHAT REMAINS CORRECT FROM 1648bb5

  - ShipWorldData saving physics.currentYaw and loading via
    config.contains() (correct sentinel, handles yaw=0 case)
  - Idle sync removal (safe — spawnYaw is never reset during normal
    ticking, so the invariant holds without it)
  - alignToGrid() wrapper (spawnYaw reset + updateDisplayTransforms)
  - ShipWheelManager reading physics.currentYaw for block placement
Adds `custom-ships.destruction-mode` to config.yml with two values:

- `disassemble` (default): current behavior — blocks placed back into the
  world, wheel block broken and dropped, explosions spawned.
- `destroy`: ship is permanently lost. Stored inventory contents and engine
  fuel drop as loose items at the ship's location; blocks and the wheel item
  are not recovered. Explosions still spawn.

Implementation:
- ShipConfig: adds `destroyOnDeath` boolean field loaded from
  `custom-ships.destruction-mode` (true iff value == "destroy").
- ShipInstance.destroyAndDropItem(): when destroyOnDeath is set, drops
  storages and fuel then calls destroyWithCleanup() instead of routing
  through disassembleShip(). Prefab ships are unaffected (branch is inside
  the "custom".equals(shipType) guard).
- ConfigValidator.migrateConfig() picks up the new key automatically via
  its full-key scan of the bundled config.

Three bugs are fixed in the following commit (found during review):
- destroyWithCleanup() called without null-guarding getDisplayShip()
- wheel block left in-world and placedWheels map not cleaned up on destroy,
  causing the wheel to reappear after server restart
- lead items silently lost when ship shulkers are removed; players lose
  leads with no feedback or item drop
def9a2a4 and others added 30 commits June 21, 2026 17:27
Master switch for the power-to-mass ratio system. When false, custom ships bypass ratio scaling and use their fixed default stats (speed/rotation/vertical), ignoring sails, engines, and mass — restoring pre-stats behavior. Engines also stop consuming fuel in this mode.

statsEnabled is plumbed through ShipConfig and defaults to true, so existing configs are unaffected. Both computeEffectiveStats() and the tickEngineFuel() call in ShipPhysics are gated on it.
The Ship Info panel and detection chat always reported 0 fueled engines
for a parked ship, even when its engine furnaces held fuel, understating
the projected speed/ratio. Pre-assembly the fuel lives in the blast
furnace block inventory rather than in wheelData, so countFueledEngines
correctly returned 0 and the unassembled UI branch hardcoded 0.

Detection now inspects each engine furnace's contents during the
structure scan (new hasFuel helper, counting items with a burn time),
stores the result as lastDetectedFueledEngineCount, and reads it in the
unassembled branch. The count is folded into setLastDetectedStats so the
whole detection snapshot stays atomic; the two assembled-path callers
pass the live countFueledEngines value.

The detection chat is updated to split fueled vs unfueled engines (as the
assembled path already did) and fold the engine bonus into the previewed
speed %, so the message no longer falsely claims "unfueled" when fuel is
present. Assembled ships were already correct and physics is unaffected.
Document the two changes that landed without their changelog entries:

- custom-ships.stats.enabled: note the off switch in the intro bullets
  and the Ship Stats System section, and add `enabled: true` to the
  config block. Covers what disabling does (fixed default stats, engines
  stop burning fuel), the safe default, and that it applies to newly
  assembled ships / after restart.
- Add a Bug Fixes entry for unassembled previews now counting fuel held
  in engine furnaces instead of always reporting 0 fueled engines.
Wooden shelves (*_shelf) had collider: true and display_rotation: true,
both copied from the chiseled_bookshelf entry in 63bc339.

- collider: false — shelves are thin wall-mounted blocks and should not
  have a solid collision box.
- drop display_rotation: true — unlike chiseled bookshelf (whose facing
  BlockDisplay ignores), the 1.21.9 shelf is a normal directional block
  whose facing BlockDisplay renders natively, like furnace. The manual
  display_yaw rotation was additive, double-applying facing and rendering
  shelves at the wrong orientation during flight.
Capture review/planning notes ahead of the v0.0.16 release. No code
changes — documentation and project-instruction updates only.

todo-v0.0.16-fixes.md (new): findings A–N from three review passes over
the 0.0.16 release (stats-toggle review, a five-agent deep bug review,
and a third pass over previously-unaudited subsystems). Each finding is
re-verified and adversarially critiqued, with severity, root cause, fix,
and end-to-end verification steps. Priority order: F (immobile after
every reload, prefab + sail-only) and E (destroy-mode fuel dup) are
CRITICAL; A/G/G2/K HIGH; the rest MEDIUM/LOW. Records C as an accepted
"won't fix" (live /blockships reload for flying ships) so the limitation
stays documented.

docs/todo/TODO-persistence.md (new): design for eliminating
plugin-written YAML by moving runtime ship/wheel state into
Minecraft-native storage (entity & block PersistentDataContainer +
scoreboard-tag scanning), with chunk-load discovery, a one-version
migration shim, risks/tradeoffs, and verification.

CLAUDE.md: convert to a bullet list and add two working conventions —
keep temporary files in .temp/ (not system /tmp/), and skip decorative
fluff around bash commands.
Two CRITICAL fixes from the v0.0.16 fix-audit re-review.

F: recoverEntities() never called recomputeStats(), so ships rebuilt from
persistence came back with effective stats at 0 -- immovable, can't turn,
airships unable to ascend/descend -- on every chunk reload and server restart.
Prefab and sail-only custom ships stayed frozen; only engine'd ships self-healed
via the per-tick fuel path. Fix: resolveWheelData() + physics.recomputeStats() at
the end of recoverEntities(), mirroring the spawn path (idempotent, null-guarded).

E: a destroy-mode death dropped engine fuel twice -- once from the stale
assembly-time storages snapshot (pre-burn amount), once from wheelData. Skip
engine block indices in the destroy-mode storages drop loop so wheelData is the
sole authoritative source. Also corrects ships saved before the fix.

Also document both in CHANGELOG-v0.0.16.md and record the re-review findings in
todo-v0.0.16-fixes.md (refute G/O1/M1/N1 as false positives, add O2/O3/P1-P3,
revise the G2/J1/K fix approaches).

WIP: hoppers, engines, and engine inventory/drops are still in flux on this branch.
Flip custom-ships.stats.enabled default from true to false, pending a
rework of the power-to-mass ratio system in a future update. Fresh
installs and configs missing the key now fall back to fixed default
stats (sails/engines cosmetic); existing configs that set the key are
unaffected.

Changed in all three source-of-truth spots: config.yml, the ShipConfig
config-read fallback, and the Builder field initializer. Updated the
v0.0.16 changelog to reflect the new default.
Engines only do anything while the power-to-mass stats system is on, and
that system now ships disabled by default. Skip registering the ship_engine
recipe when custom-ships.stats.enabled is false, so engines can't be crafted
in the default config. This goes through registerAllRecipes, so it re-applies
on /blockships reload.

Also correct the stale "default: true" comment on ShipConfig.statsEnabled,
and add a stats-relevance triage to the 0.0.16 fix-audit doc, sorting the
findings into three bins: not-stats-related (fix regardless of the toggle),
stats-related game-breaking, and stats-related minor - to scope the release
now that stats/engines ship off by default pending a refactor.
Assembling a custom ship containing a hopper threw IllegalArgumentException
because HOPPER's 5 slots isn't a multiple of 9, which the size-based
Bukkit.createInventory overload requires. Because scanStructure has already
serialized+cleared the ship's containers by then, the failed assembly also
permanently deleted their contents.

Add a nullable InventoryType to StorageType (HOPPER -> InventoryType.HOPPER)
and a createStorageInventory helper that uses the type-based createInventory
overload (no 9-multiple restriction) when invType is set. Route both storage
creation sites (recovery load + assembly) through it. Also cap the two
setContents restore calls to the inventory size so a token/slot drift can't
throw and fail a ship load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mbly can't destroy items

scanStructure serialized a ship's container contents and then immediately emptied the world containers. Because the ShipInstance constructor runs (and can throw) after that clear but before removeBlocks, any assembly failure permanently deleted the contents of every container on the ship. The hopper crash was the deterministic trigger, but any throw did it.

Keep the serialize in scan but remove the clear+update from both the Container and TileStateInventoryHolder branches. Empty the containers instead in a new guarded Pass 0 in removeBlocks, just before the setType passes. A throw before removeBlocks now leaves the world containers full, so the assembly is a clean, retryable no-op instead of item loss.

Pass 0 iterates solidBlocks only (no inventory-holding block is attachable) and guards each clear per-block so one failure can't leave a half-removed ship; a clear that fails at worst drops that container's items rather than deleting them. This relies on scan and removeBlocks staying synchronous within one tick (no hopper transfer between the deferred clear and removal) - documented on removeBlocks. Also removes a stale TODO whose setType(AIR, true) change is already in place.
…y ghost entities

Three stats-independent release blockers (findings G2, K, S).

G2 (+U): a plain furnace/smoker on a ship opened a 27-slot chest GUI, but
disassembly restored into the real 3-slot block and silently destroyed
anything in the extra slots. deserializeInventory now collects items whose
slot is beyond the real inventory, and the two restore callers drop them at
the block instead of discarding — covering both new and already-saved ships,
since overflow is measured against the runtime block size. Also read the slot
index via Number to tolerate a Long/Double from a hand-edited model (U).

K: the Captain's Manual shapeless recipe only matched on base material (any
PLAYER_HEAD), so a mob head / decorative head / anvil-renamed head / balloon
could craft the manual and be silently consumed. The shapeless branch now
re-validates the grid against the registered ingredient pool, matching custom
items by their custom_item_id PDC (rename-proof) rather than display name; a
wrong or extra ingredient sets the result to null. Refactored isShipWheel
onto a shared matchesCustomItemId helper.

S: if ship assembly threw partway (e.g. a bad block), the half-built
ShipInstance was discarded with no handle to its already-spawned entities,
leaking invisible ghosts that multiplied on each retry. Wrapped the
constructor spawn sequence in try/catch that tears everything down and
rethrows. destroy() now also removes the displays list directly (child
displays are parented a tick later, so on a mid-assembly throw they are not
yet passengers of parent) and null-guards the vehicle removal.
…ed (B)

P1 — runaway ghost ship: acceleration, drag, the stop threshold, and rotation keyed only off the input flags, which are cleared solely by freeSeat() <- VehicleExitEvent. When a driver leaves the seat without that event (death while seated, forced cross-world teleport) the flags stay stuck true and the ship flies/turns forever unmanned. Gate accel, drag, and stop on a new throttling = hasDriver && (fwd||back) so a driverless ship falls into the drag branch and coasts to a stop; gate only the active-turn rotation arms on hasDriver (decay else stays always-on so a spinning ghost winds down); and add hasDriver to the engine fuel-burn guard.

B — stats UI leaked while disabled: 0.0.16 ships the power-to-mass system off by default, but the wheel menu and detect chat still showed power ratio / speed % / points / 'add sails'. Thread statsEnabled into ShipInfo and, when off, show a 'Stats system disabled — fixed speed' note instead in createInfoItem/createStatsItem and both detect-chat blocks, keeping the composition-independent lines (blocks, weight, health, density, mass, seats).
…k copy

Ships built from newer blocks (e.g. spruce_shelf and other *_shelf variants)
were rejected on assembly for servers upgraded from an older version. The
plugin extracted blocks.yml to the data folder on first run via
saveResource(..., false) and then only ever read that disk copy, never merging
new defaults — so blocks added in later versions stayed missing from the
on-disk config. The same stale-copy pattern affected items.yml and prefab ship
models.

Stop keeping editable disk copies of bundled content. New ConfigResources.load
reads config/<path> if an admin override exists, else straight from the jar, so
every update ships current defaults automatically. Route blocks.yml
(BlockConfigManager), items.yml (ItemTextureManager), and prefab models
(ShipModel.fromFile) through it, and drop DisplayShip.extractDefaultModelFiles.

ConfigValidator now warns when a legacy copy still sits at the folder root
(no longer read — move to config/ or delete) and when a config/blocks.yml
override is missing entries the bundled default has.

config.yml and the ships/custom-items readers are unchanged; getConfig()
already layers jar defaults so it does not suffer this staleness. Splitting
config.yml is a deferred follow-up.
Document the stale-copy fix (662292f) in the v0.0.16 changelog: a summary
bullet noting new blocks work on upgraded servers without resetting configs,
and a Bug Fixes entry explaining the root cause (blocks.yml/items.yml/prefab
models extracted once and never updated), the jar-first loading with optional
config/ overrides, and the reworked ConfigValidator.
Batch of stats-independent fixes ahead of 0.0.16:

- U: the assembly-path container restore cast the slot index with (Integer),
  which throws on a migrated/hand-edited model whose slot is a Long/Double.
  Read it via Number.intValue() (matches the already-fixed deserializeInventory).

- T: placeBlocks restored each block's metadata (banner/sign color, sign text,
  container items, skull) unguarded, so one bad value threw out of the loop and
  left the ship half-placed but still registered. Wrap the per-block loop body
  in try/catch that logs and skips only that block's metadata; the block itself
  is already placed, so the ship still fully disassembles and deregisters.

- O3: recoverEntities threw IllegalStateException when a persisted collider
  index no longer fit the model, and neither the startup sweep nor the async
  chunk-load recovery loop caught it, so one stale ship aborted recovery of the
  rest of the batch. Skip the out-of-range collider with a warning instead of
  throwing, and add a per-ship catch to both recovery loops as a backstop.

- L: /blockships give cleanup. Dedupe the giveable list (ship_wheel and
  captains_manual were listed twice, once hardcoded and once from custom-items)
  via a LinkedHashSet; add a giveOrDrop helper so a full inventory drops the
  item at the player's feet instead of silently eating it; guard the
  Material.valueOf in ItemFactory.loadCustomItem so a typo'd base-material
  degrades to null instead of throwing (this path also runs during recipe
  registration at startup, so it previously broke plugin enable entirely).

- Furnace GUI: a plain furnace/smoker/blast-furnace now opens a real 3-slot
  furnace GUI in flight instead of a 27-slot chest, via a new
  StorageType.FURNACE(3, InventoryType.FURNACE). Exact size match to the real
  block means zero overflow on disassembly. The special engine (blast furnace
  with its own EngineMenuGUI) is unaffected — its storage type is inert at
  runtime. Applies to newly assembled ships; existing ships keep their
  persisted CHEST type, which the overflow-drop backstop keeps non-destructive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chunk_steering / chunk_steering_airship only checked total displacement
> 1.0 after recovery, which the dismount deck/seat offset satisfies even
when the recovered ship never moved — a false negative that let the F
"immobile after reload" regression pass green across CI. Assert >=2 blocks
westward (negative X, the steered direction) instead, mirroring the
fresh-spawn directional check in test-bot.js.
The previous strengthening (c23a7a0) asserted >=2 blocks westward, but the
recovered ship faces and drives north — verified in-game: the smallship moves
~18 blocks -Z and the airship ~18 north + ~20 up. The westward check only saw
the ~1-block dismount offset (dX=-1.0) and failed a working ship. Assert >=10
blocks north (negative Z) instead, and log all three axes so the heading stays
visible. (The main test's westward result comes from its A/D turn sequence; the
chunk test never steers left/right, so the ship travels straight north.)
countFueledEngines treated any non-AIR item in an engine's furnace slots as
"fueled", but tickEngineFuel only consumes items with getBurnTime > 0. A
non-burnable item (raw iron, cobblestone) sneaked into a ship engine's slots
(via a hopper/dispenser into the underlying blast furnace, or the offhand-swap
GUI gap) therefore counted as fuel forever and granted permanent free engine
thrust that never depleted.

Require EngineMenuGUI.getBurnTime > 0 in the two "is this engine fueled?"
scans: countFueledEngines and EngineMenuGUI.createStatusItem's status readout.
The item-moving scans (assembly copy, serialize/deserialize, drop, consume)
are left untouched, so no items are filtered or lost. VALID_FUELS is exactly
the getBurnTime>0 set, so no real fuel ever reads as unfueled. Inert when
custom-ships.stats.enabled is false (the default) since the count is only read
by the power calc, which returns early when stats are off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sassemble

A block's custom name (anvil rename) is tile-entity NBT that blockdata
can't carry, so a renamed chest/barrel/furnace/hopper/dropper/dispenser or
banner lost its name through a custom-ship assemble->disassemble cycle, and
while assembled its storage GUI showed a generic label ("Ship Chest").

Both Container (via LockableTileState) and Banner implement Nameable, so a
single generic path covers them:
- scanStructure serializes any Nameable block's customName() as a GSON
  component string into rawYaml["custom_name"] (null for un-renamed blocks,
  so nothing is stored and the generic label is preserved).
- createStorageInventory takes the name and uses it as the in-flight GUI
  title with full color/format fidelity, falling back to the generic label
  when absent or on malformed JSON. Both call sites (assembly, chunk
  recovery) pass it, guarding the cast so a wrong-type value degrades
  gracefully instead of throwing.
- placeBlocks reapplies the name to the placed Nameable block on
  disassembly, in a separate generic pass (fires even for a named block
  with no items/patterns) inside the existing per-block try/catch.

Rides the existing rawYaml persistence round-trip, so reload/recovery is
covered with no schema change. Signs aren't Nameable (no block name);
lectern books already round-trip via the inventory path (only the open
page number, cosmetic, is unaffected).
- Leash: transferLeadsFromShip placed the LeashHitch at shipLoc + rotatedPos via Location.getBlock(), which floors, while placeBlocks positions the restored fence with Math.round() of the same rotated offset (to cancel cos(90°)≈6e-17 error). On a rotated ship (90/180/270°) a coordinate that landed just under an integer floored one block low, so the hitch spawned off the fence, in air, and popped off — breaking the lead. Round the components to match placeBlocks so the hitch lands on the actual fence.

- Sign: the scan read signSide.getColor().name() unguarded, but SignSide.getColor() is @nullable, so a colorless sign would NPE and abort the assembly scan. Default to BLACK (the vanilla default; the restore side already tolerates a null/absent color).
…masked NPE

Assembly (ShipWheelManager.assembleShip) built the ShipInstance and transferred leads with no error handling, and the block removal happens only afterward. If construction threw (e.g. the root armor stand spawn is denied by another plugin, or bad block metadata), the exception propagated raw to the caller: the player got no feedback, and the ShipInstance constructor's own cleanup called ShipRegistry.unregister on an instance whose vehicle was never assigned, throwing an NPE that masked the real cause.

- ShipRegistry.unregister: null-guard the vehicle deref so destroy() is safe to call on a partially-built, never-registered instance.
- assembleShip: wrap construction + lead transfer in try/catch (removeBlocks stays outside, so a failure never removes the world blocks). On failure, destroy a built-but-unregistered ship (guarded so cleanup can't mask the cause), log the full stack trace, tell the player it failed with the exception detail, and point them to the issue tracker. Returns false.
Enforce ASCII for punctuation/text and improve diagnostics on
unexpected failures. Emoji (warning/health/airship) and section-sign
color codes are kept intentionally.

- Replace non-ASCII "funky" characters in comments and messages with
  ASCII: em-dash -> '-', arrow -> '->', degree -> ' deg', '*'/'~' for
  multiplication/approximation. Applies to code comments and a few
  player/log strings.
- Replace all bare printStackTrace() calls with contextual logging.
  Genuinely-unexpected sites (item/profile deserialization, armor
  stand/shulker/collider setup, steering packet handlers) now log the
  operation + identifier via getLogger().log(..., throwable) so the
  trace is preserved, and point users at the issue tracker. Config-parse
  failures keep a warning with the offending key.
- Add BlockShipsPlugin.ISSUES_URL constant and route the two inline
  issue-tracker URLs through it.
- Give clearer reasons in the spawndrowned failure messages.
…metadata round-trip)

Guards the v0.0.16 assembly/disassembly fixes the bot suite didn't cover: the hopper
5-slot inventory crash (#7), container item dup/loss (G2), and guarded metadata
restore (T).

The new weird_ship test builds a ship whose deck is packed with odd-sized containers
(hopper/dropper/dispenser/barrel/trapped_chest), furnace-family blocks, and metadata
blocks (banner, sign), preloads the hopper with coal, then assembles -> drives ->
disassembles and asserts the round-trip is clean:
- full entity spawn (>=12 shulkers; a crash destroys the ship -> ~0)
- hopper coal round-trips exactly, incl. the 5th slot, with no stray dropped items
- no [BlockShips] errors logged during the run

Disassembly uses the real player path (sneak + right-click a ship shulker opens the
wheel menu -> Disassemble), since assembly removes the wheel block. Adds the WEIRD_SHIP
config, blockCharWorldPos, disassembleViaWheelMenu, and server-log scan helpers. Widens
the test-server-ci server.log grep to catch WARNING-level metadata-skip and
printStackTrace signatures as a CI backstop.
… metadata

- onCommand: replace the silent fall-through (which showed the raw,
  hand-maintained plugin.yml usage) with an explicit "Unknown
  subcommand" message + sendHelp(), making the in-game help the single
  source of truth for the command list.
- Prefix all command guard/permission-denied/error messages with the
  red color code so failures read consistently; success messages
  unchanged.
- plugin.yml: set author to def9a2a4 (was the "You" placeholder) and add
  the missing highlightseats and spawndrowned entries so usage matches
  the actual 11 subcommands.
Follow-up cleanup to 662292f. Remove the now-unused YamlConfiguration and
java.io.File imports from BlockConfigManager (loadConfig switched to
ConfigResources.load, which returns a FileConfiguration), and hoist the
duplicated config.getMapList("blocks") in ShipModel.fromFile into a local so
the model block-list is parsed once instead of twice. No behavior change.
Mob heads (zombie/skeleton/creeper/piglin/dragon skulls) fell through to
the BlockDisplay branch, which renders only the baked block model and never
runs the skull block-entity renderer — so floor variants lost their rotation
and wall variants were wrong. Route every head (player and mob) through the
ItemDisplay + HEAD transform path that player heads already use:

- Scanner now captures skull_rotation/skull_facing for any Skull block, not
  just PLAYER_HEAD; skull_profile stays player-only.
- ShipInstance routes on a new hasHead flag (skull_profile | skull_rotation |
  skull_facing), resolves the head item material from the block name (mapping
  wall variants to their floor item form), and applies a skin profile only
  when present. Material.valueOf is guarded so assembly never aborts.

Also extract the floor/wall skull transform into a shared applySkullTransform
helper used by the spawn, per-tick, and chunk-recovery paths. The recovery
path previously applied floor-only math to wall heads, so even player wall
heads jumped on chunk reload; the three sites can no longer drift.

Wall heads (player + mob, except dragon's full block) now get a 0.5 shulker
collider, shifted toward the wall and up via a facing-derived per-block offset
so the hitbox lines up with the rendered head.
The weird_ship hopper preload never landed, so the round-trip assertion
always saw 0 coal and the test failed in CI. Two bugs:

- The /item replace block command omitted the required `with` keyword, so
  the server silently rejected it and the hopper stayed empty.
- buildCustomShipWithWheel leaves a ship wheel in the bot's hand (creative
  place doesn't consume it), so right-clicking the hopper hit the wheel
  interaction handler instead of opening the container.

Add `with`, clear the bot's inventory right after the wheel is placed, and
verify the preload actually landed (open the world hopper and count coal)
so a bad preload fails as "preload failed" rather than as an ambiguous
round-trip mismatch later. Verified passing locally (coal=5).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

help wanted Extra attention is needed

Projects

None yet

1 participant