Skip to content

Add quest reward grant events and custom objective data persistence#305

Merged
DiamondDagger590 merged 10 commits into
recodefrom
claude/mcrpg-backlog-273-69am5k
Jul 16, 2026
Merged

Add quest reward grant events and custom objective data persistence#305
DiamondDagger590 merged 10 commits into
recodefrom
claude/mcrpg-backlog-273-69am5k

Conversation

@DiamondDagger590

@DiamondDagger590 DiamondDagger590 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

This PR introduces a third-party interception API for quest reward grants and adds structured custom-data persistence for objective instances. It centralizes all reward-granting logic through a new QuestRewardGranter class that fires cancellable pre-grant and informational post-grant events, enabling plugins to modify, veto, or audit quest rewards. Additionally, objective instances can now persist arbitrary structured state via a JSON-serialized custom_data column, supporting set-like objectives and other complex progress tracking.

Key Changes

Reward Grant Events & Centralization:

  • Added QuestRewardGrantEvent (cancellable, pre-grant) and QuestRewardGrantedEvent (informational, post-grant) to us.eunoians.mcrpg.event.quest
  • Created QuestRewardGranter class that routes all three grant pathways (inline completion, distribution, pending-reward login) through a single grant method
  • Events fire with mutable reward lists, allowing listeners to add/remove/modify rewards before grant
  • Cancelling the pre-event skips the entire batch; per-reward failures are isolated and logged
  • Added RewardGrantContext enum to identify which grant pathway an event originates from (INLINE, DISTRIBUTION, PENDING_REWARD_LOGIN)
  • Updated PlayerJoinListener and RewardDistributionGranter to use the new QuestRewardGranter

Objective Custom Data:

  • Added customData field to QuestObjectiveInstance as a Map<String, Object> for structured per-type progress state
  • Extended QuestObjectiveInstanceDAO with schema version 2: added custom_data TEXT column with JSON serialization via Gson
  • Implemented ensureColumnExists() helper for safe schema migrations
  • Custom data is mutable during objective progression and persisted on save/load cycles

Event Handler List Fix:

  • Fixed backlog Third-party API gaps: reward-grant event, QuestCancelEvent HandlerList inconsistency, custom objective progress persistence #291 part B: each concrete quest event now owns its own HandlerList instead of sharing the abstract base's list
  • Updated all concrete event classes (QuestStartEvent, QuestCompleteEvent, QuestExpireEvent, QuestCancelEvent, QuestObjectiveProgressEvent, QuestObjectiveCompleteEvent, QuestStageCompleteEvent, QuestPhaseCompleteEvent, QuestRewardGrantEvent, QuestRewardGrantedEvent) to declare private static HANDLERS and implement getHandlerList()
  • Modified abstract QuestEvent base to document that it intentionally declares no handler list

Testing & Documentation:

  • Added QuestRewardGranterTest verifying event firing, cancellation, and listener mutation of reward lists
  • Added QuestEventHandlerListTest ensuring each concrete event has its own handler list
  • Added QuestManagerExtractionResourcesTest verifying tutorial, example chain, and owner guides are in the extraction list
  • Updated QuestObjectiveInstanceDAOTest with round-trip test for custom data serialization
  • Added fallback handling for invalid rotation times in QuestBoardRotationTask (defaults to midnight)
  • Updated QuestTemplateConfigLoader to skip malformed YAML files without aborting other templates
  • Enhanced BoardCategoryConfigLoader to parse plain-number and uppercase-unit durations

Resource & Configuration Updates:

  • Removed obsolete quests_v2.yml and quest/upgrade_quests.yml
  • Extracted tutorial quest resources to quests/tutorial/ directory with 8 tutorial quest files
  • Added owner guide resources (SERVER-OWNER-GUIDE.md, QUEST-DEFINITIONS.md, OBJECTIVES.md, REWARDS.md, TEMPLATES.md, CONDITIONS.md, BOARD-INTEGRATION.md, CHAIN-GUIDE.md)
  • Updated QuestManager to extract tutorial and guide resources on startup
  • Added documentation sections to REWARDS.md and OBJECTIVES.md on the new interception API and custom data persistence
  • Updated example quest and board category YAML files with clarifying comments on duration format

Notable Implementation Details

  • The QuestRewardGranter wraps each individual reward.grant(player) call in try/catch to prevent one failing

https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu

Summary by CodeRabbit

  • New Features

    • Added cancellable reward-grant notifications and post-grant events across quest reward pathways.
    • Added persistent custom data support for quest objectives.
    • Quest board configurations can now be reloaded without restarting the server.
    • Added comprehensive quest, board, template, condition, reward, and server-owner guides.
  • Bug Fixes

    • Invalid configuration entries now fall back safely without preventing other content from loading.
    • Improved rarity selection when configured weights are unusable.
    • Corrected quest documentation links and reload instructions.
  • Documentation

    • Documented supported duration formats and extensibility options.

claude added 4 commits July 11, 2026 22:16
#282)

- Add the tutorial chain + its 7 quests, the example chain, and the seven
  owner-facing guide markdown files to QuestManager's default-extraction list
  so they land in plugins/McRPG/quests/ on first boot. This makes the
  first-join tutorial actually fire on real servers.
- Move the owner guides from src/main/java/.../quest/ (never packaged by
  Gradle) into src/main/resources/quests/ so they ship in the jar.
- Delete the dead legacy src/main/resources/quest/ directory (quests_v2.yml,
  empty upgrade_quests.yml) that had zero code references and misled owners.
- Log marker-file read/write failures instead of swallowing them silently.
- Add QuestManagerExtractionResourcesTest validating the extraction list and
  that every referenced resource is packaged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
#291)

Three additive third-party API changes:

- QuestRewardGrantEvent (cancellable) + QuestRewardGrantedEvent fire around
  every reward grant, routed through a new QuestRewardGranter collaborator so
  all three grant pathways (inline, distribution, pending-login) share one
  interception point, distinguished by RewardGrantContext. Listeners can
  mutate or veto a batch; the pending path retains rows for a listener-removed
  or cancelled reward so nothing is lost.
- Give every concrete QuestEvent subclass its own HandlerList so base-type
  listeners no longer silently miss QuestCancelEvent. The abstract QuestEvent
  base now declares no shared handler list; listening on it is unsupported.
- Add a custom-data channel to QuestObjectiveInstance persisted as a JSON
  custom_data column (QuestObjectiveInstanceDAO table v2, migrated via
  ensureColumnExists) so custom objective types can store set-like progress.

Adds tests for grant interception/mutation/cancellation, distinct per-event
handler lists, and custom-data save/load round-trip. Documents the new event
and custom-data channel in REWARDS.md/OBJECTIVES.md and the steering files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
…ions (#283)

- Track ReloadableRarity/Category/Template configs with ReloadableContentManager
  and call reloadAllContent() from /mcrpg admin reload so board.yml edits apply
  without a restart; fix board.yml comments that named a non-existent command.
- Validate rotation.time once at construction (parseTimeOrDefault) so a bad
  value falls back to midnight instead of throwing every scheduler tick.
- Fall back with a WARNING instead of bricking onEnable/mid-rotation for an
  invalid weekly-reset-day, an empty rarity body, and all-zero rarity weights.
- Catch unchecked SnakeYAML errors per template file so one malformed file no
  longer aborts loading of every template.
- Resolve a quest's default 'all rarities' lazily (empty-set sentinel) instead
  of snapshotting the registry before board rarities load, so quests without
  supported-rarities roll all rarities after a plain restart.
- Route category durations through QuestConfigLoader.parseDuration
  (case-insensitive; plain number = seconds) instead of Methods.getTimeInSeconds.

Adds tests for the zero-weight fallback, plain-number/uppercase durations,
malformed-template skip, and rotation-time fallback. Documents the load-time
validation convention in persona-server-owner.mdc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
…ts pass

The resolver's SCALE pot-behavior gates on QuestRewardType.isScalable(), which
replaced the old reference-identity probe. The ScalableReward test double never
overrode isScalable() (defaulting to false), so the SCALE branch took the
non-scalable fallback and granted unscaled amounts — failing six PotBehavior
tests. Override isScalable()/withExactAmount() on the double to match a real
scalable reward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@DiamondDagger590, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 102d577b-c6ac-4d78-a624-2d8c33318cee

📥 Commits

Reviewing files that changed from the base of the PR and between 44cf44f and 37dce02.

📒 Files selected for processing (29)
  • CLAUDE.md
  • src/main/java/us/eunoians/mcrpg/command/admin/ReloadPluginCommand.java
  • src/main/java/us/eunoians/mcrpg/configuration/BoardCategoryConfigLoader.java
  • src/main/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoader.java
  • src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestCancelEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.java
  • src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java
  • src/main/java/us/eunoians/mcrpg/quest/QuestManager.java
  • src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java
  • src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java
  • src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java
  • src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java
  • src/main/java/us/eunoians/mcrpg/quest/reward/RewardGrantContext.java
  • src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java
  • src/main/resources/quest-board/board.yml
  • src/main/resources/quest-board/categories/land-daily.yml
  • src/main/resources/quest-board/categories/land-weekly.yml
  • src/main/resources/quests/OBJECTIVES.md
  • src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java
  • src/test/java/us/eunoians/mcrpg/event/quest/QuestEventHandlerListTest.java
  • src/test/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListenerTest.java
  • src/test/java/us/eunoians/mcrpg/quest/board/generation/QuestPoolTest.java
  • src/test/java/us/eunoians/mcrpg/quest/board/rarity/QuestRarityRegistryTest.java
  • src/test/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstanceTest.java
  • src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java
  • src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java

Walkthrough

Changes

Quest extensibility and persistence

Layer / File(s) Summary
Reward granting and Bukkit events
src/main/java/us/eunoians/mcrpg/quest/reward/*, src/main/java/us/eunoians/mcrpg/event/quest/*, src/main/java/us/eunoians/mcrpg/quest/impl/QuestInstance.java, src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java
Centralized inline, distribution, and pending reward delivery through contextual cancellable and post-grant events.
Objective custom-data persistence
src/main/java/us/eunoians/mcrpg/quest/impl/objective/*, src/main/java/us/eunoians/mcrpg/database/table/quest/*, src/main/resources/quests/OBJECTIVES.md
Added JSON-backed objective custom data with schema migration, load/save handling, mutation APIs, and tests.
Board configuration hardening
src/main/java/us/eunoians/mcrpg/configuration/*, src/main/java/us/eunoians/mcrpg/quest/board/*, src/main/java/us/eunoians/mcrpg/task/board/*
Added reload wiring, safe parsing fallbacks, shared duration parsing, rarity fallback behavior, and cached rotation-time parsing.
Bundled resources and documentation
src/main/java/us/eunoians/mcrpg/quest/QuestManager.java, src/main/resources/quests/*, docs/*, CLAUDE.md
Expanded extracted resources and added or corrected quest-system integration, configuration, owner, board, condition, template, and reward documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: reward grant events and custom objective data persistence.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/mcrpg-backlog-273-69am5k

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Server Owner Review

Server Owner Review


CONCERN: board.yml rotation.* keys reference a corrected command name in a comment but the original incorrect command (/mcrpg quest admin reload) may still appear in server owners' existing copies of the file extracted before this update.
WHY: Server owners who already have board.yml on disk will not receive the corrected comment (/mcrpg admin reload) because the marker file (.extracted-defaults) prevents re-extraction. They will continue to see a non-existent command name in their config comments, causing failed reloads.
WHERE: src/main/resources/quest-board/board.ymlrotation: and notifications: section comments


CONCERN: board.yml config-version is not incremented despite structural comment corrections and the addition of the reload-safe annotation on rarities:.
WHY: There is no automated way to detect that existing installed files are stale. If you later add a migration keyed on config-version, all existing servers running the old file will miss it silently.
WHERE: src/main/resources/quest-board/board.yml — no config-version key present at all


CONCERN: Category YAML files (land-daily.yml, land-weekly.yml, personal-daily.yml, personal-weekly.yml, shared-daily.yml, shared-weekly.yml) have no config-version key and no mechanism to push updated comments (including the new duration-format hint) to existing installations.
WHY: The .extracted-defaults marker means these files are written once. Owners who installed before this PR will never see the new # Duration format: comment and may continue using invalid duration strings (e.g. bare numbers, uppercase units) that silently broke before this fix. They have no indication the format changed or that /mcrpg admin reload now works for these values.
WHERE: src/main/resources/quest-board/categories/*.yml — all six category files


CONCERN: The rarities: section in board.yml is now documented as hot-reloadable via /mcrpg admin reload, but the rotation: section explicitly says it requires a restart. There is no comment clarifying whether notifications: values require restart or are reload-safe.
WHY: A server owner reading top-to-bottom sees rarities = reload-safe, rotation = restart required, notifications = unclear. If notifications.near-expiry-threshold-minutes is actually reload-safe, the absence of a comment will cause owners to restart unnecessarily (or conversely assume it's safe when it isn't).
WHERE: src/main/resources/quest-board/board.ymlnotifications: section


CONCERN: The weekly-reset-day key in board.yml now falls back to MONDAY silently with a server log WARNING, but the YAML comment does not document the valid values or the fallback behavior.
WHY: A server owner who typos WENDSDAY will see the board silently reset on Monday with no in-config explanation. They need the comment to say: valid values are MONDAY through SUNDAY (uppercase), and an invalid value falls back to MONDAY with a console warning.
WHERE: src/main/resources/quest-board/board.ymlrotation.weekly-reset-day


CONCERN: The rotation.time key comment does not document the fallback behavior that was added in QuestBoardRotationTask.parseTimeOrDefault.
WHY: A server owner who writes time: "6:00" (missing leading zero) will see the board silently rotate at midnight instead of 6 AM. The YAML comment should state: format is HH:mm (24-hour, leading zero required), invalid values fall back to 00:00 with a console warning.
WHERE: src/main/resources/quest-board/board.ymlrotation.time


CONCERN: New QuestRewardGranter, QuestRewardGrantEvent, and QuestRewardGrantedEvent are not reflected in plugin.yml — specifically, no new permission nodes are added for the cancellable QuestRewardGrantEvent (admin bypass, etc.), and there is no documentation in plugin.yml of any event-related permissions.
WHY: If a future permission node is needed to gate who can cancel reward grants (e.g. a /mcrpg admin block-rewards command tied to this event), the absence now creates an undocumented gap. More immediately, server owners have no plugin.yml surface to review to understand what the new events do or what permissions govern them.
WHERE: plugin.yml — no new permission entries visible in this diff


CONCERN: The QuestObjectiveInstanceDAO table is upgraded from version 1 to version 2 (adds custom_data TEXT column), but there is no explicit migration note visible to a server owner in the upgrade documentation, and the marker-file mechanism means existing servers will silently receive the schema upgrade on next startup with no warning in the console that a migration occurred.
WHY: Server owners who take database backups before updates need to know a schema change happened. There is no startup log line saying "Migrating quest_objective_instances table from v1 to v2." If the ALTER TABLE fails silently (e.g. SQLite permissions), the server continues with a missing column and objectives fail to save custom data — with no indication in a YAML or config file that anything is wrong.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java — version 2 migration block; no corresponding upgrade note in any user-facing file in this diff


CONCERN: The appearance-cooldown key in category YAML files has no duration-format comment, unlike the three sibling keys (refresh-interval, completion-time) which received the new hint in this PR.
WHY: An owner adding appearance-cooldown to a category file has no in-file guidance on the accepted format, while the adjacent keys now do. Inconsistent documentation creates confusion.
WHERE: src/main/resources/quest-board/categories/*.ymlappearance-cooldown key (not present in all files, but used by BoardCategoryConfigLoader)


CONCERN: The quest-source-weights section in board.yml has no comment explaining valid keys, what happens if all weights are zero, or whether it is hot-reloadable.
WHY: The zero-weight guard was added for rarities but the diff shows quest-source-weights is a sibling section with the same risk. A server owner who zeros out all source weights has no in-file guidance on what breaks or what the fallback is.
WHERE: src/main/resources/quest-board/board.ymlquest-source-weights:


Migration required: YES — database schema change (table version 1 → 2, custom_data TEXT column added to mcrpg_quest_objective_instances). Existing servers will receive this automatically via UpdateTableFunction-style migration on startup. Server owners should be advised to back up their database before updating.

Reload-safe: PARTIAL — rarities, categories, and templates sections are now registered with ReloadableContentManager and reload correctly via /mcrpg admin reload. The rotation.* section (time, timezone, weekly-reset-day, task-check-interval) still requires a full restart. notifications.* reload status is undocumented in the YAML.

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — no public interfaces are broken, but two changes on the public API surface need attention from addon authors.


CONCERN: QuestObjectiveInstance public constructor signature changed — new customData parameter added without a @Deprecated overload
WHY: Any addon that constructs QuestObjectiveInstance directly (e.g., a custom DAO or test helper) will fail to compile against the new jar. The old 9-arg constructor is gone with no backward-compatible shim.
WHERE: QuestObjectiveInstance.java — constructor (NamespacedKey, UUID, QuestStageInstance, QuestObjectiveState, Long, Long, long, long, Map<UUID,Long>, Map<String,Object>) replaces the previous 9-arg form


CONCERN: QuestRewardType interface — isScalable() and withExactAmount(long) appear to have been added as required methods on the interface (evidenced by the test stub implementing both), but neither has a default implementation visible in the diff
WHY: If these are new non-default interface methods on QuestRewardType, every existing addon that implements the interface will break at runtime with AbstractMethodError. The diff shows a test stub adding both methods, strongly implying they were added to the interface proper.
WHERE: QuestRewardType interface — isScalable() / withExactAmount(long exactAmount); confirmed via QuestRewardDistributionResolverPotBehaviorTest.java stub and QuestRewardGranterTest.RecordingReward (which does not implement them, which may itself be a latent AbstractMethodError in the test)


CONCERN: QuestRewardGrantEvent.getRewards() returns a mutable List — the contract is underdocumented for the case where the list identity changes after the event fires
WHY: Addons that cache a reference to event.getRewards() before another listener clears-and-replaces it (as MutatingListener does with rewards.clear(); rewards.add(...)) will silently operate on a mutated list. The Javadoc says "mutable" but does not warn that other listeners in the same event call may have already altered it. An addon arriving at MONITOR priority will see the final state, not its own changes — a subtle ordering hazard that requires reading the test to understand.
WHERE: QuestRewardGrantEvent.javagetRewards() Javadoc / QuestRewardGranter.java line passing mutableRewards directly to the event


CONCERN: QuestEvent abstract base now deliberately has no HandlerList and no getHandlerList() static method, but this is a removal of a previously public static method — any addon calling QuestEvent.getHandlerList() (e.g., to unregister all quest listeners in one shot) will get a NoSuchMethodException at runtime
WHY: The static method was public API. Addons that called QuestEvent.getHandlerList().unregisterAll() as a bulk-cleanup pattern will break silently at runtime; there is no @Deprecated bridge.
WHERE: QuestEvent.javapublic static HandlerList getHandlerList() removed; public HandlerList getHandlers() removed


CONCERN: QuestRewardGrantedEvent documents that getGrantedRewards() returns rewards "after per-reward failures were skipped," but the granter only fires the post-event when granted is non-empty — if every reward fails, no QuestRewardGrantedEvent fires at all, which an auditing addon relying on the event for complete accounting will miss
WHY: An addon using QuestRewardGrantedEvent as an audit log will have silent gaps when all rewards in a batch throw. This asymmetry (QuestRewardGrantEvent always fires, QuestRewardGrantedEvent conditionally fires) is not documented on the event class itself.
WHERE: QuestRewardGranter.javaif (!granted.isEmpty()) guard before firing QuestRewardGrantedEvent; QuestRewardGrantedEvent.java Javadoc (no mention of the conditional-fire behavior)


CONCERN: QuestObjectiveInstance.getCustomData() returns a live mutable reference with the instruction to call markCustomDataDirty() separately — no @NotNull nullability contract is stated on the map values (Map<String, Object>), and the round-trip is through Gson's untyped TypeToken<Map<String,Object>>, which will deserialize JSON numbers as Double not Long/Integer
WHY: An addon storing integer counts in custom data (e.g., customData.put("count", 5)) will read back a Double (5.0) after a save/load cycle. A cast to Integer or Long will throw ClassCastException at runtime. This is a known Gson quirk but it is not documented anywhere in the public API surface or the OBJECTIVES.md guide (which only says "keep values JSON-friendly").
WHERE: QuestObjectiveInstance.javagetCustomData() Javadoc; QuestObjectiveInstanceDAO.javaCUSTOM_DATA_TYPE / deserializeCustomData; quests/OBJECTIVES.md — custom data usage example


CONCERN: RewardGrantContext is a new public enum with no Javadoc on the enum type itself stating whether future values may be added — addons using an exhaustive switch over its three constants will get a compiler warning (Java 21 sealed exhaustiveness) or silently fall through on a future addition
WHY: Minor forward-compatibility issue; not breaking today, but worth documenting as non-exhaustive.
WHERE: RewardGrantContext.java — class-level Javadoc (present) does not note that the set of constants may be extended in future releases

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Coverage Completeness

CONCERN: QuestObjectiveInstance.setCustomData() and markCustomDataDirty() have no dedicated tests confirming they call markDirty() on the owning quest.
WHY: Both methods have non-trivial side effects (delegating to questStage.getQuestInstance().markDirty()). The DAO test exercises serialization round-trips but never asserts that mutating custom data actually marks the quest dirty — a regression here would cause data loss silently.
WHERE: QuestObjectiveInstance.java / QuestObjectiveInstanceDAOTest.java


CONCERN: QuestPool.pickRandom() has a changed eligibility predicate (empty supportedRarities is now the "all rarities" sentinel), but no test covers the case where supportedRarities is empty and the quest is expected to match every rolled rarity.
WHY: The prior behavior (empty = populated from registry snapshot) was removed in QuestConfigLoader, and the filter in QuestPool was changed to treat empty as "match any". Neither change has a corresponding test that exercises an empty supportedRarities set actually producing a quest from the pool for a given rarity roll.
WHERE: QuestPool.java / no corresponding test file present in diff


CONCERN: ReloadPluginCommand now calls reloadAllContent() on the ReloadableContentManager after reloading files — this code path has no test.
WHY: The reload command is the only path that triggers reloadAllContent(). A regression (e.g. wrong manager key, NPE, ordering issue) would silently no-op board.yml edits after /mcrpg admin reload on live servers. There is no test asserting that reloadAllContent() is invoked after reloadFiles().
WHERE: ReloadPluginCommand.java / no corresponding test file present in diff


CONCERN: QuestBoardManager.parseResetDayOrDefault() has no test for the valid-input branch (a well-formed day name returns the correct DayOfWeek), only the fallback is exercised via the rotation task test.
WHY: The method has two meaningful branches: valid input → parse, invalid/blank → MONDAY fallback. Only the fallback is covered. A typo in the valueOf call or case-normalization would go undetected.
WHERE: QuestBoardManager.java / no corresponding test file present in diff


CONCERN: QuestBoardRotationTask.parseTimeOrDefault() test covers only the invalid-input fallback path. There is no test confirming a well-formed HH:mm string ("06:00") produces the expected LocalTime.
WHY: The valid branch is untested. A refactor that broke parsing of valid values would not be caught.
WHERE: QuestBoardRotationTask.java / QuestBoardRotationTaskTest.java


CONCERN: QuestRewardGranter.grantToOnlinePlayer() has no test covering the case where an individual reward.grant() throws a RuntimeException — the test suite only covers cancel, mutation, and happy path.
WHY: The per-reward isolation try/catch is the primary safety guarantee of the class. A test should confirm that a throwing reward does not abort the rest of the batch and that the thrown reward is absent from the returned granted list.
WHERE: QuestRewardGranter.java / QuestRewardGranterTest.java


CONCERN: QuestRewardGranter.grantToOnlinePlayer() has no test for an empty rewards input (the early-return List.of() branch).
WHY: Edge case for empty collections is a required checklist item. The early-return path skips event firing entirely; a regression that removed it would fire spurious events on empty batches.
WHERE: QuestRewardGranter.java / QuestRewardGranterTest.java


CONCERN: QuestRewardGranter fires QuestRewardGrantedEvent only when !granted.isEmpty(). There is no test asserting that the post-grant event is not fired when all rewards in a non-empty batch throw.
WHY: This is a distinct observable behaviour branch of the granter that is not covered by any test in the diff.
WHERE: QuestRewardGranter.java / QuestRewardGranterTest.java


CONCERN: ReloadableRarityConfig null-section guard and per-rarity RuntimeException catch are new non-trivial paths with zero corresponding tests.
WHY: Both branches (null section → skip with WARNING, malformed rarity → skip with WARNING) need test coverage. A regression that swallowed the skip and re-threw would cause onEnable to fail.
WHERE: ReloadableRarityConfig.java / no corresponding test file present in diff


CONCERN: QuestConfigLoader — the removal of the else branch that populated supportedRarities from the registry is a behaviour change with no regression test. There is no test asserting that a quest definition with no supported-rarities key deserialises to an empty set (previously it serialised to the full registry snapshot).
WHY: This is exactly the kind of silent semantic change that a regression test should lock in.
WHERE: QuestConfigLoader.java / no corresponding test file present in diff


CONCERN: QuestTemplateConfigLoader — the new catch (RuntimeException e) block that skips malformed YAML files is tested, but only with an unclosed-quote error. There is no test for a YAMLException triggered by bad indentation or other structural YAML faults, nor a test confirming that an entirely empty file does not abort the directory walk.
WHY: The checklist requires per-file error isolation to be verified; a single happy-path variant is insufficient when multiple distinct SnakeYAML error classes could be thrown.
WHERE: QuestTemplateConfigLoader.java / QuestTemplateConfigLoaderTest.java


CONCERN: PlayerJoinListener.grantRewards() now groups rewards by quest key and routes through QuestRewardGranter, but the existing unit test for grantRewards was not updated to assert the new grouping/identity-map behaviour. Specifically: a listener that cancels one quest's batch should still grant another quest's batch (per-group isolation is not tested).
WHY: The per-quest-key batching is a new behavioural contract. A regression merging all pending rewards into one batch would silently suppress the cancellable event's per-quest semantics.
WHERE: PlayerJoinListener.java / no updated test in diff


TimeProvider Usage

No new direct calls to System.currentTimeMillis() or Instant.now() found. No issues.


McRPG Test Structure

CONCERN: QuestManagerExtractionResourcesTest and QuestEventHandlerListTest extend no base class and make no MockBukkit or McRPGPlayer calls — this is correct. However, QuestRewardGranterTest extends McRPGBaseTest and calls server.addPlayer() and server.getPluginManager().registerEvents(...), which is correct. One structural issue: QuestRewardGranterTest registers listeners with server.getPluginManager().registerEvents(...) but the @AfterEach only clears the two event handler lists via unregisterAll(). If a test fails mid-run, listeners registered in a different test (e.g. CancellingListener) may not be cleared by the targeted unregisterAll() because unregisterAll() on the handler list removes all registered handlers regardless — this is actually fine. No violation here.


CONCERN: QuestObjectiveInstanceDAOTest constructs Connection and PreparedStatement via Mockito.mock() and also uses QuestTestHelper.startedQuestInstance(). If QuestTestHelper.startedQuestInstance() internally touches McRPG plugin state, then the test correctly extends McRPGBaseTest. However, the test also uses QuestTestHelper.singlePhaseQuest() / startedQuestInstance() — if these are pure domain helpers with no Bukkit calls, the McRPGBaseTest inheritance is correct only if QuestObjectiveInstance itself touches plugin state. This cannot be fully determined from the diff alone, but the pattern appears consistent with existing tests in this class. No actionable violation.


Test Quality

CONCERN: QuestRewardGranterTestRecordingReward does not implement isScalable(), withAmountMultiplier(), or withExactAmount(). If QuestRewardType is an interface or abstract class that declares these methods (as suggested by QuestRewardDistributionResolverPotBehaviorTest adding isScalable() and withExactAmount() implementations to its own stub), the RecordingReward stub will fail to compile unless those methods have default implementations on the interface. The diff shows QuestRewardDistributionResolverPotBehaviorTest adding isScalable() and withExactAmount() to its own stub — this implies they were previously missing or the interface added them. RecordingReward in QuestRewardGranterTest does not implement these. If they are non-default interface methods, the test will not compile.
WHY: Compilation failure means the test cannot fail — it simply doesn't run.
WHERE: QuestRewardGranterTest.java (inner class RecordingReward)


CONCERN: QuestRarityRegistryTest.rollRarity_allZeroWeights_fallsBackWithoutThrowing uses assertTrue(result == first || result == second, ...) — reference equality on domain objects. If QuestRarity overrides equals/hashCode by value, or if the registry wraps the registered object in a new instance, this assertion will spuriously fail regardless of correctness. The assertion should use assertEquals on the key or name, not ==.
WHY: Using reference equality for a domain assertion is fragile and may produce a false negative even when the logic is correct.
WHERE: QuestRarityRegistryTest.java


CONCERN: QuestEventHandlerListTest.concreteEvents_haveDistinctHandlerLists asserts assertEquals(null, collision, ...). JUnit 5 convention for "value must be null" is assertNull(collision, message). assertEquals(null, collision) inverts the expected/actual argument order and produces a confusing failure message ("expected: but was: "). Additionally, the assertion assertEquals(CONCRETE_EVENTS.size(), seen.size(), ...) at the end is redundant — the per-entry assertNull(collision) already catches collisions. Neither is a structural violation, but the assertEquals(null, ...) pattern is a test-quality concern.
WHY: Inverted argument order produces misleading failure diagnostics, which can mask the actual root cause during CI triage.
WHERE: QuestEventHandlerListTest.java


CONCERN: QuestBoardRotationTaskTest.onIntervalComplete_usesMidnightFallback_whenRotationTimeIsInvalid has a @DisplayName annotation ("Invalid rotation time falls back to midnight and still triggers without crashing") but the method name does not follow the action_outcome_whenCondition convention — the condition and outcome are reversed relative to what the name conveys, and "still triggers without crashing" is not an assertion-backed outcome (there is no assertion that the rotation actually triggers; the test only checks assertDoesNotThrow implicitly via the task construction path).
WHY: Without an explicit assertion that the rotation fires (e.g. verify(mockBoardManager).triggerRotation(...)), the test only confirms no exception is thrown — it does not confirm the fallback time is actually used for comparison. This is a coverage gap masquerading as a test.
WHERE: QuestBoardRotationTaskTest.java


Summary

Production files changed:
ReloadPluginCommand.java, BoardCategoryConfigLoader.java, QuestConfigLoader.java, QuestTemplateConfigLoader.java, QuestObjectiveInstanceDAO.java, QuestCompleteEvent.java, QuestEvent.java, QuestExpireEvent.java, QuestObjectiveCompleteEvent.java, QuestObjectiveProgressEvent.java, QuestPhaseCompleteEvent.java, QuestRewardGrantEvent.java (new), QuestRewardGrantedEvent.java (new), QuestStageCompleteEvent.java, QuestStartEvent.java, PlayerJoinListener.java, QuestManager.java, QuestBoardManager.java, ReloadableRarityConfig.java, RewardDistributionGranter.java, QuestPool.java, QuestRarityRegistry.java, QuestInstance.java, QuestObjectiveInstance.java, QuestRewardGranter.java (new), RewardGrantContext.java (new), QuestBoardRotationTask.java

Test files present:
BoardCategoryConfigLoaderTest.java, QuestTemplateConfigLoaderTest.java, QuestObjectiveInstanceDAOTest.java, QuestEventHandlerListTest.java, QuestManagerExtractionResourcesTest.java, QuestRarityRegistryTest.java, QuestRewardGranterTest.java, QuestBoardRotationTaskTest.java, QuestRewardDistributionResolverPotBehaviorTest.java

Coverage gaps:

  1. QuestObjectiveInstance.setCustomData() / markCustomDataDirty() dirty-flag side effect untested
  2. QuestPool empty-supportedRarities sentinel (new "all rarities" behaviour) untested
  3. ReloadPluginCommand reloadAllContent() invocation untested
  4. QuestBoardManager.parseResetDayOrDefault() valid-input branch untested
  5. QuestBoardRotationTask.parseTimeOrDefault() valid-input branch untested
  6. QuestRewardGranter — throwing-reward isolation, empty-input early-return, and no-post-event-when-all-throw branches untested
  7. ReloadableRarityConfig null-section and per-rarity exception skip branches untested
  8. QuestConfigLoader empty-supportedRarities regression (removed else branch) untested
  9. PlayerJoinListener.grantRewards() per-quest-key batch isolation untested
  10. QuestBoardRotationTask fallback test lacks assertion that the rotation actually fires (not just that no exception is thrown)
  11. RecordingReward in QuestRewardGranterTest may be missing required interface methods (isScalable, withAmountMultiplier, withExactAmount), risking a compile failure

@github-actions

Copy link
Copy Markdown

Security Review

No security concerns found.

The diff contains no player-exploitable injection vulnerabilities within the defined threat model. Specifically:

  • No MiniMessage injection: No user-controlled strings (chat, signs, books, anvil renames) are passed to MiniMessage.deserialize() or any equivalent. All new display strings originate from config YAML or hardcoded constants.

  • No command injection: No calls to player.performCommand() or Bukkit.dispatchCommand() are introduced. The CommandRewardType pathway is not modified in this diff.

  • No permission bypass: No new command handlers or GUI slot actions are added. QuestRewardGranter is an internal collaborator, not a command handler, and introduces no permission-gated surface. The ReloadPluginCommand change adds a reloadAllContent() call inside an existing handler that already guards with the appropriate permission check (unchanged).

  • No SQL injection: All DAO changes (QuestObjectiveInstanceDAO) use PreparedStatement with positional ? parameters exclusively. The ensureColumnExists DDL helper concatenates only the internal constants TABLE_NAME, columnName, and addColumnSqlFragment — all of which are hardcoded private static final strings within the class, never derived from player input. The PRAGMA fallback likewise uses a PreparedStatement.

  • No unsafe deserialization: The custom_data JSON column is deserialized via Gson into Map<String, Object> — a plain data-only type with no executable code paths. Malformed JSON is caught and logged; it does not trigger command execution or class loading.

  • No unsafe storage-then-deserialize path: customData originates exclusively from the server-side database (written by server code, read back by server code). No player input reaches the custom_data column or the getCustomData() map.

- Restore the 9-arg QuestObjectiveInstance constructor (delegates with empty
  custom data) so addons compiling against the old signature keep working.
- Document the QuestRewardGrantedEvent conditional-fire behavior, the
  QuestRewardGrantEvent.getRewards() cross-listener mutation semantics, the
  RewardGrantContext non-exhaustiveness, and the Gson number->Double round-trip
  for objective custom data (Javadoc + OBJECTIVES.md).
- Document valid values and fallback behavior for board.yml rotation.time and
  weekly-reset-day; add the duration-format hint to appearance-cooldown.
- Add tests: reward-grant throwing-reward isolation, empty-batch no-op, and
  no-granted-event-when-all-throw; custom-data dirty-flag marking; QuestPool
  empty-supported-rarities matches any rarity. Tighten test-quality nits
  (key equality over ==, assertNull over assertEquals(null, ...)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@github-actions

Copy link
Copy Markdown

Server Owner Review

I'll work through every checklist item methodically against the diff, focusing on YAML files, config routing, migration safety, reload behavior, and permission nodes.


Review Findings


CONCERN: board.yml still references a non-existent command name in the rotation section comment
WHY: The old comment said /mcrpg quest admin reload; this PR corrects some occurrences to /mcrpg admin reload but the rotation section retains the older phrasing "NOT hot-reloadable via /mcrpg quest admin reload" in one place in the diff context. A server owner who types that command verbatim gets an unknown command error and thinks reload is broken.
WHERE: src/main/resources/quest-board/board.ymlrotation: section comment (the diff shows the corrected version, but verify the shipped file contains no remaining /mcrpg quest admin reload references)


CONCERN: config-version is not incremented in board.yml despite structural comment changes that alter operator expectations
WHY: New inline comments were added clarifying hot-reload behavior, valid values for time, timezone, and weekly-reset-day, and the fallback warning behavior. While these are comment-only changes, the checklist requires config-version be bumped on any structural change. Server owners who copy the old board.yml into a new install will have stale comments with no signal that their file is out of date.
WHERE: src/main/resources/quest-board/board.yml — no config-version key visible in the diff at all; verify one exists and is incremented


CONCERN: Duration format comments are added to category files inconsistently — only on refresh-interval and appearance-cooldown, not on completion-time
WHY: completion-time accepts the same grammar (1d, 12h, 86400) but receives no format hint in personal-daily.yml, shared-daily.yml, shared-weekly.yml, or personal-weekly.yml. A server owner editing completion-time: 3d to completion-time: 3 days will get a silent zero with no console warning and no inline guidance.
WHERE:

  • src/main/resources/quest-board/categories/personal-daily.ymlcompletion-time:
  • src/main/resources/quest-board/categories/shared-daily.ymlcompletion-time:
  • src/main/resources/quest-board/categories/shared-weekly.ymlcompletion-time:
  • src/main/resources/quest-board/categories/personal-weekly.ymlcompletion-time:

CONCERN: The rotation.time key has no explicit example of what "leading zero required" means in the YAML comment for the category files, but the board.yml comment does clarify it — however task-check-interval-seconds has no comment stating it is NOT hot-reloadable, inconsistent with the section-level "requires restart" note
WHY: The section comment says "Changing any value in this section requires a server restart," but task-check-interval-seconds sits in that same section without its own inline note. A server owner may reasonably assume numeric tuning values are reload-safe since the section comment is easy to miss when scanning keys.
WHERE: src/main/resources/quest-board/board.ymlrotation.task-check-interval-seconds


CONCERN: New QuestRewardGrantEvent and QuestRewardGrantedEvent are not in plugin.yml — there is no documentation of these events for server owners or addon authors
WHY: While events don't need plugin.yml entries, any permission nodes guarding the new reward-grant interception paths must be listed. More critically for a server owner: there is no plugin.yml permission node review possible because no plugin.yml diff is included. Cannot verify naming convention, default: fields, or description: fields for any new nodes.
WHERE: plugin.yml — not present in diff; if new permissions were added for admin commands modified in ReloadPluginCommand.java, they must follow mcrpg.<category>.<action> and have default: op


CONCERN: QuestObjectiveInstanceDAO bumps CURRENT_TABLE_VERSION to 2 and adds a custom_data TEXT column, but there is no UpdateTableFunction implementation — migration is done via inline ensureColumnExists called directly inside the version-check block
WHY: The project pattern (per checklist) is to use UpdateTableFunction for every schema change so migrations are trackable, testable, and consistent. The inline approach works but bypasses the established safety mechanism. If a future migration also targets version 2, or the version history table gets out of sync, the ensureColumnExists guard (columnExists check) is the only protection — and that falls back silently on SQLException. A server owner whose DB user lacks ALTER TABLE permission will see a SEVERE log but the plugin continues, leaving column absent and silently dropping all custom data on every save/load cycle.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.javaattemptCreateTable method, version 2 migration block


CONCERN: appearance-cooldown is documented as optional in category YAMLs, but there is no comment stating what happens when it is omitted (quest can reappear immediately on next rotation)
WHY: Server owners tuning board categories will not know whether omitting appearance-cooldown means "no cooldown" or "use a default." They may omit it expecting a safe default and end up with the same quest appearing every rotation.
WHERE:

  • src/main/resources/quest-board/categories/land-daily.ymlappearance-cooldown: (has a comment but no "if omitted: no cooldown" note)
  • src/main/resources/quest-board/categories/land-weekly.ymlappearance-cooldown:

CONCERN: ReloadableRarityConfig, ReloadableCategoryConfig, and ReloadableTemplateConfig are now registered with ReloadableContentManager in QuestBoardManager, and ReloadPluginCommand now calls reloadAllContent() — but the board.yml rotation section still says these keys are NOT hot-reloadable, while the rarities section now says edits apply to the next rotation. There is no comment clarifying that rarities, categories, and templates ARE reload-safe but rotation.* is NOT, within the same file.
WHY: A server owner reading board.yml top-to-bottom sees the rotation section say "requires restart" but has no signal that the rarities block above it is now live-reloadable. They may unnecessarily restart for rarity weight changes, or worse, assume the whole file reloads and change rotation.time without restarting, resulting in the old rotation time being used.
WHERE: src/main/resources/quest-board/board.ymlrarities: section (needs "hot-reloadable via /mcrpg admin reload" comment), and the boundary between the reloadable and non-reloadable sections needs to be explicit


CONCERN: QuestPool.getEligibleDefinitions now treats an empty supportedRarities as "matches any rarity" (the sentinel), but board.yml and category YAMLs have no documentation of supported-rarities or what an empty/absent list means
WHY: Server owners writing custom quest definitions who omit supported-rarities will not know their quest appears for all rarities. Operators expecting to restrict a quest to LEGENDARY only and accidentally omitting the key will find it on COMMON boards with no error.
WHERE: src/main/resources/quests/example_quest.yml — no supported-rarities comment; src/main/resources/quests/BOARD-INTEGRATION.md (renamed, not diffed for content) should document the empty-set sentinel but cannot be verified


CONCERN: QuestRewardGrantEvent.getRewards() is documented as mutable and shared across all listeners — but QuestRewardGrantedEvent constructor calls List.copyOf(grantedRewards) making it immutable, while QuestRewardGrantEvent does not call List.copyOf on the input rewards before passing to the event. If the caller mutates the original rewards list after firing the event (before reading grantEvent.getRewards()), listeners see the mutation. This is a correctness issue, not a YAML issue, but the event Javadoc says "the same list instance is returned to every listener" which makes this a documented API contract server owners writing addon listeners will rely on.
WHY: An addon developer following the documented API could add/remove entries from getRewards() expecting isolation between listeners, then a later listener could see their entries already removed by an earlier listener — this is stated behavior, but the Javadoc warning is buried and easy to miss, leading to subtle bugs on servers with multiple addons.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.javagetRewards() Javadoc and constructor


CONCERN: The .extracted-defaults marker file failure path now logs a WARNING (good), but the WARNING message says "defaults may be re-offered" without stating which defaults will be re-extracted — on a large server this means every default quest and guide file gets overwritten on next startup, potentially reverting owner customizations if they edited a default file in place
WHY: Server owners who edited example_quest.yml or any default category YAML in-place and then encounter a marker write failure will have their changes silently overwritten on the next restart.
WHERE: src/main/java/us/eunoians/mcrpg/quest/QuestManager.javasaveExtractedMarker and loadExtractedMarker — the WARNING should advise owners not to edit default files in place, or the extraction logic should skip files that already exist


Summary

Migration required: YES — QuestObjectiveInstanceDAO bumps table version from 1 to 2, adding a custom_data TEXT column via ALTER TABLE. Existing servers will have this migration run automatically on first startup after update. No manual YAML changes are required, but server owners should be aware that the first startup after update will alter the quest objective table.

Reload-safe: PARTIAL — Rarities, categories, and templates in board.yml and category files are now hot-reloadable via /mcrpg admin reload (newly registered with ReloadableContentManager). Quest definitions, rotation settings (rotation.time, rotation.timezone, rotation.weekly-reset-day, rotation.task-check-interval-seconds), and database configuration remain restart-required. This boundary is insufficiently documented in the shipped YAML files.

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — no public interfaces broken, no NamespacedKey values changed, but two new public APIs have gaps that could surprise addon developers.


CONCERN: QuestRewardGrantEvent.getRewards() returns a mutable ArrayList but the Javadoc does not warn that the list identity is shared across all listeners in priority order — a listener that replaces the list reference via rewards = new ArrayList<>(...) locally has no effect, while a listener that calls .clear() destroys another listener's additions.
WHY: Addon developers writing reward boosters will instinctively do List<QuestRewardType> rewards = event.getRewards(); rewards = myFilter(rewards); and silently have no effect. The contract needs an explicit statement that only mutations on the returned list instance are honored, plus a recommendation to use removeIf / replaceAll / add rather than reassigning.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.javagetRewards() Javadoc


CONCERN: RewardGrantContext is a plain enum with no default-branch guidance enforced at compile time. The Javadoc says "include a default branch," but a new context value added in a future release silently breaks any addon doing exhaustive switch without a default, with no deprecation or sealed-type protection.
WHY: An addon doing switch (event.getContext()) { case INLINE: … case DISTRIBUTION: … case PENDING: … } compiles fine today. A fourth context added tomorrow causes that switch to fall through silently (Java switch statement) or throw MatchException (Java 21 switch expression with --enable-preview). There is no compile-time signal to the addon author.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/RewardGrantContext.java


CONCERN: QuestObjectiveInstance gains a new public constructor with a customData parameter, but the existing 9-parameter constructor is retained and now delegates to it with Map.of(). If any addon subclasses QuestObjectiveInstance and calls super(...) with the old 9-parameter signature, that still compiles. However, the class is not final, so subclasses are possible. More critically, getCustomData() returns the live internal map — mutating it without calling markCustomDataDirty() silently loses data, and the Javadoc buries this warning inside a long paragraph.
WHY: An addon implementing a custom QuestObjectiveType will call instance.getCustomData().put(...) and assume persistence. If they miss the markCustomDataDirty() call (easy to do since the method name is non-obvious for a map mutation), their data is silently dropped on the next server restart. No exception, no warning.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.javagetCustomData() / markCustomDataDirty()


CONCERN: QuestRewardGrantedEvent Javadoc states it "fires only when at least one reward was granted" and that cancelled pre-events suppress it — but there is no corresponding event that fires when the entire batch fails (all rewards throw). An auditor addon listening only on QuestRewardGrantedEvent will silently miss full-batch-failure scenarios.
WHY: An audit or anti-duplication plugin that tracks "player X received quest Y rewards" will have a silent gap when every reward.grant() throws. They cannot distinguish "cancelled" from "all failed" without also listening on QuestRewardGrantEvent and cross-referencing. The Javadoc mentions this but there is no machine-readable signal (e.g., a wasFullyGranted() flag or a failure event).
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.java — class-level Javadoc


CONCERN: QuestRewardGranter is public final with no interface or factory. Addon developers cannot substitute their own granter (e.g., to add cross-plugin reward routing or async dispatch) without modifying McRPG internals. The three grant sites (QuestInstance, RewardDistributionGranter, PlayerJoinListener) each new QuestRewardGranter(plugin) directly.
WHY: An addon that wants to intercept at the granter level (rather than at the Bukkit event level) — for example, to route rewards through a cross-server proxy — has no extension point. The Bukkit event approach covers most use cases, but the lack of an interface means the granter cannot be replaced or decorated via a registry slot if future requirements demand it.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java


CONCERN: QuestObjectiveInstance.getCustomData() returns a Map<String, Object> with a documented Gson double-coercion caveat (stored int reads back as Double). This is not surfaced by the type system and requires the addon developer to read the Javadoc carefully. There is no typed accessor or helper (e.g., getCustomLong(String key)) to make the safe pattern discoverable.
WHY: An addon implementing QuestObjectiveType will write int count = (int) instance.getCustomData().get("count") — this compiles, works before a restart, and throws ClassCastException on reload. The pattern is documented but easy to miss, and the cast looks correct to a Java developer unfamiliar with Gson's untyped deserialization.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.javagetCustomData() Javadoc; also src/main/resources/quests/OBJECTIVES.md (the warning exists but no typed helper is provided)


CONCERN: QuestCancelEvent is referenced in the new QuestEventHandlerListTest but is not present in this diff. The test asserts it has its own HandlerList, implying it was fixed in a prior commit. If it was not — or if the test is the only fix — any addon currently listening on QuestCancelEvent may still be silently dropped depending on the actual class state not shown here.
WHY: If QuestCancelEvent still inherits the (now-removed) shared HandlerList from QuestEvent, addon listeners on it receive nothing. Since QuestEvent no longer declares a HandlerList at all, QuestCancelEvent would also have no handler list and Bukkit would throw on registration.
WHERE: src/test/java/us/eunoians/mcrpg/event/quest/QuestEventHandlerListTest.javaCONCRETE_EVENTS list includes QuestCancelEvent.class; the corresponding source fix is absent from this diff

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 26

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/us/eunoians/mcrpg/command/admin/ReloadPluginCommand.java`:
- Around line 39-43: Remove the explicit reloadAllContent() invocation and its
now-misleading comment from ReloadPluginCommand, relying on
FileManager.reloadFiles() to perform the reload exactly once.

In
`@src/main/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoader.java`:
- Around line 144-150: In the template-loading flow, narrow the catch in
QuestTemplateConfigLoader from RuntimeException to SnakeYAML’s YAMLException.
Preserve the existing warning log and per-file return behavior for malformed
YAML, while allowing unrelated runtime defects to propagate.

In
`@src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java`:
- Around line 96-99: Update ensureColumnExists to return a boolean indicating
whether the column creation succeeded, and change updateTable’s
lastStoredVersion == 1 migration branch to call setTableVersion(..., 2) only
when that result is true. Preserve the existing exception logging and leave the
table version unchanged when ALTER TABLE fails.
- Around line 147-149: Update the SQLException catch in
QuestObjectiveInstanceDAO to emit a WARNING log containing the metadata-query
failure details before continuing with the existing PRAGMA fallback behavior.
Keep the current assumption that the column does not exist and let the ALTER
attempt proceed.

In
`@src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java`:
- Around line 136-141: The pending-reward provenance in the join handling must
support multiple rows resolving to the same QuestRewardType instance. Replace
the one-to-one rewardToPendingId mapping and update the associated
grant/deletion logic around rewardsByQuest so each reward identity retains a
queue or list of all pending IDs, consuming the corresponding ID for every
successful grant. Add a regression test using duplicate reconstructed reward
instances to verify both pending rows are deleted and neither reward is granted
again on the next login.
- Around line 155-157: Update the isolated pending-reward reconstruction failure
log in PlayerJoinListener to use Level.WARNING instead of Level.SEVERE. Preserve
the existing message, continued processing behavior, retained reward, and
throwable argument.

In
`@src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java`:
- Around line 33-57: Update the RuntimeException handler in the rarity parsing
block of ReloadableRarityConfig to pass exception as the throwable argument to
the warning logger, while retaining the existing malformed-rarity context
message. Remove the direct exception.getMessage() interpolation so the full
stack trace is preserved, including when the message is null.

In `@src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java`:
- Around line 168-169: Update parseResetDayOrDefault and its callers in
QuestBoardManager so null or blank reset-day values emit a warning containing
the configuration file, key, offending value, and valid options before falling
back. In the IllegalArgumentException catch path, pass the caught throwable to
the logger overload instead of discarding it, preserving actionable diagnostics
for all invalid values.

In
`@src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java`:
- Line 36: Make customData in QuestObjectiveInstance thread-safe by declaring
the field volatile and backing it with a ConcurrentHashMap, while preserving the
existing getCustomData API and mutation behavior. Update initialization or
reassignment paths for customData consistently, including the code around lines
85–118, so QuestManager.saveQuestAsync serialization cannot iterate an
unsynchronized HashMap.

In `@src/main/java/us/eunoians/mcrpg/quest/QuestManager.java`:
- Around line 125-144: Replace the mutable array fields
DEFAULT_TUTORIAL_QUEST_RESOURCES and DEFAULT_OWNER_GUIDE_RESOURCES with
immutable List<String> constants initialized via List.of(...). Update every
corresponding addAll usage in QuestManager to consume the lists, preserving the
existing resource order and behavior.

In `@src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java`:
- Around line 103-127: Update parseTimeOrDefault to log a contextual warning
when the configured time is missing or blank before falling back to
DEFAULT_ROTATION_TIME, while preserving the successful parse path. In the
invalid-value catch block, pass the caught exception to the logger alongside the
existing warning context instead of logging only a constructed string.

In `@src/main/resources/quests/BOARD-INTEGRATION.md`:
- Line 116: Update the fenced code block in BOARD-INTEGRATION.md to include an
explicit language identifier, such as text, while preserving its existing
contents.
- Around line 289-293: Update step 4 of the integration guide to reference the
category configuration directory `quest-board/categories/*.yml` (or the loader’s
actual path) instead of `categories.yml`, while preserving the instruction to
add the `SCOPED` category and reference its scope provider.

In `@src/main/resources/quests/CONDITIONS.md`:
- Around line 27-37: Update the TemplateCondition documentation and custom
example to use fromConfig(`@NotNull` Section section, `@NotNull` ConditionParser
parser), and ensure recursive or compound condition parsing passes the same
parser through each fromConfig call.

In `@src/main/resources/quests/QUEST-DEFINITIONS.md`:
- Line 19: Update the fenced code block in QUEST-DEFINITIONS.md to include an
explicit language identifier, such as text, while preserving its existing
contents.
- Around line 68-76: Update the phase-level reward documentation in
QUEST-DEFINITIONS.md to include inline rewards as supported, alongside
reward-distribution, matching the behavior of QuestConfigLoader and
QuestPhaseDefinition and the wording used in REWARDS.md and
SERVER-OWNER-GUIDE.md.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md`:
- Line 930: Update the RANGE variable examples in the guide to use the
configured Legendary difficulty multiplier of 3.5x, replacing the inconsistent
2.0x example and global-value description while preserving the scaling
explanation.
- Around line 1222-1225: Update the Performance section to remove the
unqualified “no practical limit” claim and replace it with tested operational
guidance for quest-file count and size, or concrete monitoring recommendations
for startup/reload time and memory usage. Keep the template-quest behavior
statement unchanged.
- Around line 565-570: Update the repeat-mode table in SERVER-OWNER-GUIDE.md to
add the supported COOLDOWN_LIMITED mode. Describe that it allows repeats only
after repeat-cooldown has passed and until repeat-limit total completions is
reached, including both required field names.
- Line 34: Update both fenced code blocks in SERVER-OWNER-GUIDE.md to include
appropriate language identifiers after their opening fences, including the block
near the later referenced section, while preserving their existing contents.
- Around line 716-728: Update the rarity configuration guidance around the “add,
remove, or rename rarities” statement to permit adding new tiers only, and
document that renames or removals require an alias or migration for existing
board metadata, template support lists, and persisted offerings or quest
instances. Keep the MYTHIC example unchanged.

In `@src/main/resources/quests/TEMPLATES.md`:
- Line 262: Update the fenced Markdown code block in TEMPLATES.md to include an
explicit language identifier, such as text, while preserving its contents.

In
`@src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java`:
- Around line 88-89: Swap the annotation order on both new test methods in
QuestObjectiveInstanceDAOTest: place `@Test` before `@DisplayName`, including the
methods around the custom-data round-trip test and the second affected test.

In `@src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java`:
- Around line 42-45: Update clearRewardEventListeners to retain references to
the listener instances registered by this test, then unregister each
specifically with HandlerList.unregisterAll(listener). Remove the broad
getHandlerList().unregisterAll() calls so shared listeners remain registered.

In `@src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java`:
- Around line 266-267: In the test method with the display name “Invalid
rotation time falls back to midnight and still triggers without crashing,”
reorder the annotations so `@Test` appears before `@DisplayName`, matching the
project’s test annotation convention.
- Around line 272-286: The test around QuestBoardRotationTask should verify the
invalid “6:00” schedule falls back specifically to midnight by starting the
mocked time shortly after 00:00 rather than 14:00. Update the Instant setup and
subsequent time progression while preserving the existing single triggerRotation
verification.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0ce38ae5-2c01-4aac-869e-8cbfd9a51756

📥 Commits

Reviewing files that changed from the base of the PR and between 6cd90a4 and 44cf44f.

📒 Files selected for processing (59)
  • .cursor/rules/persona-extensibility.mdc
  • .cursor/rules/persona-server-owner.mdc
  • CLAUDE.md
  • docs/hld/tutorial/tutorial-quest-system.md
  • docs/lld/tutorial-quest-system/phase-2-quest-chain-system.md
  • docs/lld/tutorial-quest-system/phase-3-tutorial-content.md
  • src/main/java/us/eunoians/mcrpg/command/admin/ReloadPluginCommand.java
  • src/main/java/us/eunoians/mcrpg/configuration/BoardCategoryConfigLoader.java
  • src/main/java/us/eunoians/mcrpg/configuration/QuestConfigLoader.java
  • src/main/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoader.java
  • src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestCompleteEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestExpireEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestObjectiveCompleteEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestObjectiveProgressEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestPhaseCompleteEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestStageCompleteEvent.java
  • src/main/java/us/eunoians/mcrpg/event/quest/QuestStartEvent.java
  • src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java
  • src/main/java/us/eunoians/mcrpg/quest/QuestManager.java
  • src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java
  • src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java
  • src/main/java/us/eunoians/mcrpg/quest/board/distribution/RewardDistributionGranter.java
  • src/main/java/us/eunoians/mcrpg/quest/board/generation/QuestPool.java
  • src/main/java/us/eunoians/mcrpg/quest/board/rarity/QuestRarityRegistry.java
  • src/main/java/us/eunoians/mcrpg/quest/impl/QuestInstance.java
  • src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java
  • src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java
  • src/main/java/us/eunoians/mcrpg/quest/reward/RewardGrantContext.java
  • src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java
  • src/main/resources/quest-board/board.yml
  • src/main/resources/quest-board/categories/land-daily.yml
  • src/main/resources/quest-board/categories/land-weekly.yml
  • src/main/resources/quest-board/categories/personal-daily.yml
  • src/main/resources/quest-board/categories/personal-weekly.yml
  • src/main/resources/quest-board/categories/shared-daily.yml
  • src/main/resources/quest-board/categories/shared-weekly.yml
  • src/main/resources/quest/quests_v2.yml
  • src/main/resources/quest/upgrade_quests.yml
  • src/main/resources/quests/BOARD-INTEGRATION.md
  • src/main/resources/quests/CONDITIONS.md
  • src/main/resources/quests/OBJECTIVES.md
  • src/main/resources/quests/QUEST-DEFINITIONS.md
  • src/main/resources/quests/REWARDS.md
  • src/main/resources/quests/SERVER-OWNER-GUIDE.md
  • src/main/resources/quests/TEMPLATES.md
  • src/main/resources/quests/example_quest.yml
  • src/test/java/us/eunoians/mcrpg/configuration/BoardCategoryConfigLoaderTest.java
  • src/test/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoaderTest.java
  • src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java
  • src/test/java/us/eunoians/mcrpg/event/quest/QuestEventHandlerListTest.java
  • src/test/java/us/eunoians/mcrpg/quest/QuestManagerExtractionResourcesTest.java
  • src/test/java/us/eunoians/mcrpg/quest/board/distribution/QuestRewardDistributionResolverPotBehaviorTest.java
  • src/test/java/us/eunoians/mcrpg/quest/board/rarity/QuestRarityRegistryTest.java
  • src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java
  • src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java
💤 Files with no reviewable changes (1)
  • src/main/resources/quest/quests_v2.yml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

🛑 Comments failed to post (26)
src/main/java/us/eunoians/mcrpg/command/admin/ReloadPluginCommand.java (1)

39-43: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Remove redundant reloadAllContent() call.

FileManager.reloadFiles() (line 37) already calls reloadAllContent() as its final step, so the explicit call at lines 42-43 invokes it a second time on every reload. The comment "Without this, board.yml edits silently no-op until a restart" is misleading — reloadFiles() already covers this.

♻️ Proposed fix
                     plugin.registryAccess().registry(RegistryKey.MANAGER).manager(McRPGManagerKey.FILE).reloadFiles();
-
-                    // Re-parse all reloadable content (board rarities/categories/templates, ability
-                    // config, etc.) from the freshly reloaded YAML documents. Without this, board.yml
-                    // edits silently no-op until a restart.
-                    plugin.registryAccess().registry(RegistryKey.MANAGER).manager(McRPGManagerKey.RELOADABLE_CONTENT)
-                            .reloadAllContent();

                     // Invalidate level caches for all online players since leveling equations may have changed
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

                    plugin.registryAccess().registry(RegistryKey.MANAGER).manager(McRPGManagerKey.FILE).reloadFiles();

                    // Invalidate level caches for all online players since leveling equations may have changed
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/us/eunoians/mcrpg/command/admin/ReloadPluginCommand.java`
around lines 39 - 43, Remove the explicit reloadAllContent() invocation and its
now-misleading comment from ReloadPluginCommand, relying on
FileManager.reloadFiles() to perform the reload exactly once.
src/main/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoader.java (1)

144-150: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Consider narrowing the catch to YAMLException instead of broad RuntimeException.

Catching RuntimeException here will also swallow unrelated bugs (e.g. NullPointerException from a future change to this method or BoostedYaml internals) as a silent "skip this file" outcome, masking real defects. SnakeYAML surfaces syntax errors specifically as YAMLException; narrowing the catch keeps the intended fail-safe behavior while letting unrelated bugs surface.

♻️ Proposed fix
-        } catch (RuntimeException e) {
+        } catch (org.yaml.snakeyaml.error.YAMLException e) {
             // SnakeYAML surfaces syntax errors (unclosed quotes, bad indentation) as unchecked
             // exceptions. Without this, a single malformed template file would escape the directory
             // walk and abort loading of every other template. Skip just this file with a WARNING.
             logger.log(Level.WARNING, "Skipping template file " + file.getName()
                     + " due to a YAML syntax error: " + e.getMessage(), e);
             return;
         }

As per coding guidelines: "Catch SnakeYAML YAMLException per file so one malformed YAML file does not prevent other files from loading."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        } catch (org.yaml.snakeyaml.error.YAMLException e) {
            // SnakeYAML surfaces syntax errors (unclosed quotes, bad indentation) as unchecked
            // exceptions. Without this, a single malformed template file would escape the directory
            // walk and abort loading of every other template. Skip just this file with a WARNING.
            logger.log(Level.WARNING, "Skipping template file " + file.getName()
                     " due to a YAML syntax error: " + e.getMessage(), e);
            return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/us/eunoians/mcrpg/configuration/QuestTemplateConfigLoader.java`
around lines 144 - 150, In the template-loading flow, narrow the catch in
QuestTemplateConfigLoader from RuntimeException to SnakeYAML’s YAMLException.
Preserve the existing warning log and per-file return behavior for malformed
YAML, while allowing unrelated runtime defects to propagate.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java (2)

96-99: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Migration bumps version even when column creation fails.

ensureColumnExists catches and logs the SQLException from ALTER TABLE, but updateTable unconditionally calls setTableVersion(..., 2) on line 98. If the ALTER fails, the version is recorded as 2 without the column existing, and all future saves/loads referencing custom_data will fail with SQLException on every call.

🛡️ Proposed fix: only bump version on successful column creation
     // Version 2: add custom_data column for structured per-type objective progress state
     if (lastStoredVersion == 1) {
-        ensureColumnExists(connection, CUSTOM_DATA_COLUMN, "TEXT");
-        TableVersionHistoryDAO.setTableVersion(connection, TABLE_NAME, 2);
+        if (ensureColumnExists(connection, CUSTOM_DATA_COLUMN, "TEXT")) {
+            TableVersionHistoryDAO.setTableVersion(connection, TABLE_NAME, 2);
+        }
     }

This requires ensureColumnExists to return boolean indicating success:

-private static void ensureColumnExists(`@NotNull` Connection connection, `@NotNull` String columnName,
+private static boolean ensureColumnExists(`@NotNull` Connection connection, `@NotNull` String columnName,
                                        `@NotNull` String addColumnSqlFragment) {
     if (columnExists(connection, columnName)) {
-        return;
+        return true;
     }
     try (PreparedStatement ps = connection.prepareStatement(
             "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + addColumnSqlFragment)) {
         ps.executeUpdate();
+        return true;
     } catch (SQLException e) {
         McRPG.getInstance().getLogger().log(Level.SEVERE, "[QuestObjectiveInstanceDAO] Failed to add column "
                 + columnName + " during migration", e);
+        return false;
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if (lastStoredVersion == 1) {
            if (ensureColumnExists(connection, CUSTOM_DATA_COLUMN, "TEXT")) {
                TableVersionHistoryDAO.setTableVersion(connection, TABLE_NAME, 2);
            }
        }
private static boolean ensureColumnExists(`@NotNull` Connection connection, `@NotNull` String columnName,
        `@NotNull` String addColumnSqlFragment) {
    if (columnExists(connection, columnName)) {
        return true;
    }
    try (PreparedStatement ps = connection.prepareStatement(
            "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + addColumnSqlFragment)) {
        ps.executeUpdate();
        return true;
    } catch (SQLException e) {
        McRPG.getInstance().getLogger().log(Level.SEVERE, "[QuestObjectiveInstanceDAO] Failed to add column "
                 columnName + " during migration", e);
        return false;
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java`
around lines 96 - 99, Update ensureColumnExists to return a boolean indicating
whether the column creation succeeded, and change updateTable’s
lastStoredVersion == 1 migration branch to call setTableVersion(..., 2) only
when that result is true. Preserve the existing exception logging and leave the
table version unchanged when ALTER TABLE fails.

Source: Coding guidelines


147-149: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Empty catch block violates coding guidelines.

The catch (SQLException ignored) block contains only a comment. Per coding guidelines, catch blocks must not be empty or contain only comments; failures must be handled explicitly. A WARNING log here would aid diagnosis when metadata queries fail and the PRAGMA fallback is used.

🔧 Proposed fix
-        } catch (SQLException ignored) {
-            // If we can't determine it, assume it does not exist and let the ALTER attempt decide.
+        } catch (SQLException e) {
+            McRPG.getInstance().getLogger().log(Level.WARNING,
+                    "[QuestObjectiveInstanceDAO] Failed to check column existence via metadata for "
+                    + columnName + "; falling back to PRAGMA", e);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        } catch (SQLException e) {
            McRPG.getInstance().getLogger().log(Level.WARNING,
                    "[QuestObjectiveInstanceDAO] Failed to check column existence via metadata for "
                     columnName + "; falling back to PRAGMA", e);
        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java`
around lines 147 - 149, Update the SQLException catch in
QuestObjectiveInstanceDAO to emit a WARNING log containing the metadata-query
failure details before continuing with the existing PRAGMA fallback behavior.
Keep the current assumption that the column does not exist and let the ALTER
attempt proceed.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java (2)

136-141: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve one-to-one provenance for pending rewards.

IdentityHashMap stores only one pending ID per reward object. If two rows reconstruct to the same instance—as the supplied RecordingReward.fromSerializedConfig() implementation does—the second put overwrites the first. Both rewards can grant, but only one row is deleted, so the other is granted again on the next login.

Track a queue of pending IDs per identity or carry the pending-row provenance through the grant result, and add a duplicate-row regression test.

Also applies to: 152-153, 161-169

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java`
around lines 136 - 141, The pending-reward provenance in the join handling must
support multiple rows resolving to the same QuestRewardType instance. Replace
the one-to-one rewardToPendingId mapping and update the associated
grant/deletion logic around rewardsByQuest so each reward identity retains a
queue or list of all pending IDs, consuming the corresponding ID for every
successful grant. Add a regression test using duplicate reconstructed reward
instances to verify both pending rows are deleted and neither reward is granted
again on the next login.

155-157: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Log isolated reconstruction failures as WARNING.

This catch retains the malformed row and continues processing other rewards, so it is a degraded operational state rather than a plugin-integrity failure. SEVERE should be reserved for the latter.

As per coding guidelines, use WARNING for degraded but operational states and retain the throwable as context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java`
around lines 155 - 157, Update the isolated pending-reward reconstruction
failure log in PlayerJoinListener to use Level.WARNING instead of Level.SEVERE.
Preserve the existing message, continued processing behavior, retained reward,
and throwable argument.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java (1)

33-57: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep the parsing exception in the warning log.

The malformed-rarity handler continues correctly, but exception.getMessage() discards the stack trace and may itself be null. Log the throwable as the second argument.

As per coding guidelines, caught exceptions must be logged with the throwable for diagnosis.

Proposed fix
             } catch (RuntimeException exception) {
-                McRPG.getInstance().getLogger().warning("[QuestBoard] Skipping malformed rarity '" + rawKey
-                        + "' in board.yml: " + exception.getMessage());
+                McRPG.getInstance().getLogger().log(Level.WARNING,
+                        "[QuestBoard] Skipping malformed rarity '" + rawKey + "' in board.yml",
+                        exception);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

                    } catch (RuntimeException exception) {
                        McRPG.getInstance().getLogger().log(Level.WARNING,
                                "[QuestBoard] Skipping malformed rarity '" + rawKey + "' in board.yml",
                                exception);
                    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java`
around lines 33 - 57, Update the RuntimeException handler in the rarity parsing
block of ReloadableRarityConfig to pass exception as the throwable argument to
the warning logger, while retaining the existing malformed-rarity context
message. Remove the direct exception.getMessage() interpolation so the full
stack trace is preserved, including when the message is null.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java (1)

168-169: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve diagnostics for invalid reset-day configuration.

null/blank values fall back silently, despite the Javadoc and configuration rules requiring a warning with the file, key, offending value, and valid options. The IllegalArgumentException path also discards the throwable by calling warning(String).

As per coding guidelines, invalid configuration must be logged with actionable context, and caught exceptions must be passed to the logger.

Proposed fix
         if (configured == null || configured.isBlank()) {
+            plugin().getLogger().warning(
+                    "[QuestBoard] Missing weekly-reset-day in board.yml; expected MONDAY-SUNDAY. "
+                            + "Falling back to MONDAY.");
             configured = DayOfWeek.MONDAY.name();
         }
         try {
             return DayOfWeek.valueOf(configured.trim().toUpperCase(Locale.ROOT));
         } catch (IllegalArgumentException exception) {
-            plugin().getLogger().warning("[QuestBoard] Invalid weekly-reset-day '" + rawResetDay
-                    + "' in board.yml (expected a day name such as MONDAY). Falling back to MONDAY.");
+            plugin().getLogger().log(Level.WARNING,
+                    "[QuestBoard] Invalid weekly-reset-day '" + rawResetDay
+                            + "' in board.yml (expected MONDAY-SUNDAY). Falling back to MONDAY.",
+                    exception);
             return DayOfWeek.MONDAY;
         }

Also applies to: 266-287

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java` around
lines 168 - 169, Update parseResetDayOrDefault and its callers in
QuestBoardManager so null or blank reset-day values emit a warning containing
the configuration file, key, offending value, and valid options before falling
back. In the IllegalArgumentException catch path, pass the caught throwable to
the logger overload instead of discarding it, preserving actionable diagnostics
for all invalid values.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java (1)

36-36: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

customData HashMap is accessed from both main and DB executor threads without synchronization.

getCustomData() returns the live HashMap, which event handlers mutate on the main thread (per the documented usage in OBJECTIVES.md). During QuestManager.saveQuestAsync, the DB executor thread calls serializeCustomData(objective.getCustomData())GSON.toJson, which iterates the same map. This violates two coding guidelines: non-thread-safe collections must not be accessed from both main and async threads, and non-final fields written from multiple threads require volatile or synchronization.

At minimum, the field should be volatile and backed by a ConcurrentHashMap to prevent ConcurrentModificationException. A more robust approach would snapshot the custom data on the main thread before submitting the async save.

🔒 Minimum fix: volatile field + ConcurrentHashMap
-import java.util.HashMap;
-import java.util.Map;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;

-    private Map<String, Object> customData;
+    private volatile Map<String, Object> customData;
-        this.customData = new HashMap<>();
+        this.customData = new ConcurrentHashMap<>();
-        this.customData = new HashMap<>(customData);
+        this.customData = new ConcurrentHashMap<>(customData);
     public void setCustomData(`@NotNull` Map<String, Object> customData) {
-        this.customData = new HashMap<>(customData);
+        this.customData = new ConcurrentHashMap<>(customData);
         questStage.getQuestInstance().markDirty();
     }

Also applies to: 85-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java`
at line 36, Make customData in QuestObjectiveInstance thread-safe by declaring
the field volatile and backing it with a ConcurrentHashMap, while preserving the
existing getCustomData API and mutation behavior. Update initialization or
reassignment paths for customData consistently, including the code around lines
85–118, so QuestManager.saveQuestAsync serialization cannot iterate an
unsynchronized HashMap.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/quest/QuestManager.java (1)

125-144: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use immutable collections for the new default resource constants.

DEFAULT_TUTORIAL_QUEST_RESOURCES and DEFAULT_OWNER_GUIDE_RESOURCES are mutable static arrays. Define them as List<String> with List.of(...) and update the addAll calls accordingly.

As per coding guidelines, do not introduce new static mutable fields other than the allowed McRPG.getInstance() singleton.

Proposed change
-    private static final String[] DEFAULT_TUTORIAL_QUEST_RESOURCES = {
-            "quests/tutorial/chain.yml",
-            "quests/tutorial/first_steps.yml",
-            "quests/tutorial/mcrpg_menu.yml",
-            "quests/tutorial/natural_talent.yml",
-            "quests/tutorial/your_arsenal.yml",
-            "quests/tutorial/unleashed_power.yml",
-            "quests/tutorial/combo_strike.yml",
-            "quests/tutorial/quest_board.yml"
-    };
-    private static final String[] DEFAULT_OWNER_GUIDE_RESOURCES = {
+    private static final List<String> DEFAULT_TUTORIAL_QUEST_RESOURCES = List.of(
+            "quests/tutorial/chain.yml",
+            "quests/tutorial/first_steps.yml",
+            "quests/tutorial/mcrpg_menu.yml",
+            "quests/tutorial/natural_talent.yml",
+            "quests/tutorial/your_arsenal.yml",
+            "quests/tutorial/unleashed_power.yml",
+            "quests/tutorial/combo_strike.yml",
+            "quests/tutorial/quest_board.yml"
+    );
+    private static final List<String> DEFAULT_OWNER_GUIDE_RESOURCES = List.of(
             "quests/CHAIN-GUIDE.md",
             "quests/SERVER-OWNER-GUIDE.md",
             "quests/OBJECTIVES.md",
             "quests/REWARDS.md",
             "quests/TEMPLATES.md",
             "quests/CONDITIONS.md",
             "quests/QUEST-DEFINITIONS.md",
             "quests/BOARD-INTEGRATION.md"
-    };
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    private static final List<String> DEFAULT_TUTORIAL_QUEST_RESOURCES = List.of(
            "quests/tutorial/chain.yml",
            "quests/tutorial/first_steps.yml",
            "quests/tutorial/mcrpg_menu.yml",
            "quests/tutorial/natural_talent.yml",
            "quests/tutorial/your_arsenal.yml",
            "quests/tutorial/unleashed_power.yml",
            "quests/tutorial/combo_strike.yml",
            "quests/tutorial/quest_board.yml"
    );
    private static final List<String> DEFAULT_OWNER_GUIDE_RESOURCES = List.of(
            "quests/CHAIN-GUIDE.md",
            "quests/SERVER-OWNER-GUIDE.md",
            "quests/OBJECTIVES.md",
            "quests/REWARDS.md",
            "quests/TEMPLATES.md",
            "quests/CONDITIONS.md",
            "quests/QUEST-DEFINITIONS.md",
            "quests/BOARD-INTEGRATION.md"
    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/us/eunoians/mcrpg/quest/QuestManager.java` around lines 125 -
144, Replace the mutable array fields DEFAULT_TUTORIAL_QUEST_RESOURCES and
DEFAULT_OWNER_GUIDE_RESOURCES with immutable List<String> constants initialized
via List.of(...). Update every corresponding addAll usage in QuestManager to
consume the lists, preserving the existing resource order and behavior.

Source: Coding guidelines

src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java (1)

103-127: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve diagnostics for invalid rotation times.

Blank or missing values fall back without a warning, and the broad catch logs only a string. This makes configuration mistakes difficult to diagnose and violates the exception-logging requirement.

As per coding guidelines, configuration fallbacks must emit contextual warnings and caught exceptions must be passed to the logger.

Proposed fix
         if (configuredTime == null || configuredTime.isBlank()) {
+            LOGGER.warning("[QuestBoard] Missing rotation.time in board.yml; "
+                    + "expected HH:mm. Falling back to " + DEFAULT_ROTATION_TIME + ".");
             configuredTime = DEFAULT_ROTATION_TIME;
         }
         try {
             return LocalTime.parse(configuredTime);
         } catch (Exception exception) {
-            LOGGER.warning("[QuestBoard] Invalid rotation time '" + configuredTime
-                    + "' configured in board.yml (expected 24-hour HH:mm, e.g. \"06:00\"). Falling back to "
-                    + DEFAULT_ROTATION_TIME + ".");
+            LOGGER.log(Level.WARNING,
+                    "[QuestBoard] Invalid rotation time '" + configuredTime
+                            + "' configured in board.yml (expected HH:mm). Falling back to "
+                            + DEFAULT_ROTATION_TIME + ".",
+                    exception);
             return LocalTime.parse(DEFAULT_ROTATION_TIME);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    /**
     * Parses the configured rotation time (strict ISO {`@code` HH:mm}) once at construction, falling back
     * to {`@link` `#DEFAULT_ROTATION_TIME`} with a WARNING when the value is missing or malformed. This
     * mirrors {`@link` `#parseZoneOrDefault`} and prevents a bad {`@code` rotation.time} from throwing a
     * {`@link` java.time.format.DateTimeParseException} on every interval tick (a per-minute crash loop
     * with a board that never rotates).
     *
     * `@param` time the configured rotation time string
     * `@return` the parsed {`@link` LocalTime}, or the default on invalid input
     */
    `@NotNull`
    private LocalTime parseTimeOrDefault(String time) {
        String configuredTime = time;
        if (configuredTime == null || configuredTime.isBlank()) {
            LOGGER.warning("[QuestBoard] Missing rotation.time in board.yml; "
                     "expected HH:mm. Falling back to " + DEFAULT_ROTATION_TIME + ".");
            configuredTime = DEFAULT_ROTATION_TIME;
        }
        try {
            return LocalTime.parse(configuredTime);
        } catch (Exception exception) {
            LOGGER.log(Level.WARNING,
                    "[QuestBoard] Invalid rotation time '" + configuredTime
                             "' configured in board.yml (expected HH:mm). Falling back to "
                             DEFAULT_ROTATION_TIME + ".",
                    exception);
            return LocalTime.parse(DEFAULT_ROTATION_TIME);
        }
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java`
around lines 103 - 127, Update parseTimeOrDefault to log a contextual warning
when the configured time is missing or blank before falling back to
DEFAULT_ROTATION_TIME, while preserving the successful parse path. In the
invalid-value catch block, pass the caught exception to the logger alongside the
existing warning context instead of logging only a constructed string.

Source: Coding guidelines

src/main/resources/quests/BOARD-INTEGRATION.md (2)

116-116: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Add a language identifier such as text to satisfy the documented Markdown lint rule.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 116-116: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/BOARD-INTEGRATION.md` at line 116, Update the
fenced code block in BOARD-INTEGRATION.md to include an explicit language
identifier, such as text, while preserving its existing contents.

Source: Linters/SAST tools


289-293: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Correct the category configuration path.

The guide says categories use one file per category, but this step refers to categories.yml. Point users to quest-board/categories/*.yml or the actual loader path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/BOARD-INTEGRATION.md` around lines 289 - 293,
Update step 4 of the integration guide to reference the category configuration
directory `quest-board/categories/*.yml` (or the loader’s actual path) instead
of `categories.yml`, while preserving the instruction to add the `SCOPED`
category and reference its scope provider.
src/main/resources/quests/CONDITIONS.md (1)

27-37: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Document the correct fromConfig signature.

The supplied TemplateCondition contract requires fromConfig(@NotNullSection section,@NotNull ConditionParser parser), but both the interface and custom example show a one-argument method. Update both snippets and pass the parser through recursive/compound condition parsing.

Also applies to: 291-296

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/CONDITIONS.md` around lines 27 - 37, Update the
TemplateCondition documentation and custom example to use fromConfig(`@NotNull`
Section section, `@NotNull` ConditionParser parser), and ensure recursive or
compound condition parsing passes the same parser through each fromConfig call.
src/main/resources/quests/QUEST-DEFINITIONS.md (2)

19-19: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Add a language identifier such as text to satisfy the documented Markdown lint rule.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 19-19: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/QUEST-DEFINITIONS.md` at line 19, Update the fenced
code block in QUEST-DEFINITIONS.md to include an explicit language identifier,
such as text, while preserving its existing contents.

Source: Linters/SAST tools


68-76: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Relevant files ==\n'
git ls-files | rg '(^|/)(QUEST-DEFINITIONS\.md|REWARDS\.md|SERVER-OWNER-GUIDE\.md|quest|Quest|reward)' || true

printf '\n== Search for reward-related quest schema ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'reward-distribution|inline `rewards`|phase-level rewards|rewards list|completion-mode|stages' .

printf '\n== Locate parser/config code for quests ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'reward-distribution|rewards|completion-mode|stage entries|quest definitions|QUEST-DEFINITIONS' src .

Repository: DiamondDagger590/McRPG

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File sizes / targeted excerpts ==\n'
for f in src/main/resources/quests/QUEST-DEFINITIONS.md src/main/resources/quests/REWARDS.md src/main/resources/quests/SERVER-OWNER-GUIDE.md; do
  if [ -f "$f" ]; then
    wc -l "$f"
    echo "--- $f (selected lines) ---"
    sed -n '1,220p' "$f" | cat -n | sed -n '1,220p'
  fi
done

printf '\n== Quest parser/config source candidates ==\n'
fd -a -t f '.*' src | rg 'quest|Quest|reward'

Repository: DiamondDagger590/McRPG

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Phase/definition model ==\n'
ast-grep outline src/main/java/us/eunoians/mcrpg/quest/definition/QuestDefinition.java --view expanded || true
ast-grep outline src/main/java/us/eunoians/mcrpg/quest/definition/QuestPhaseDefinition.java --view expanded || true
ast-grep outline src/main/java/us/eunoians/mcrpg/quest/definition/QuestStageDefinition.java --view expanded || true
ast-grep outline src/main/java/us/eunoians/mcrpg/quest/definition/QuestObjectiveDefinition.java --view expanded || true

printf '\n== Parser ==\n'
ast-grep outline src/main/java/us/eunoians/mcrpg/command/quest/parser/QuestDefinitionParser.java --view expanded || true

printf '\n== Focused searches ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'class QuestPhaseDefinition|record QuestPhaseDefinition|rewardDistribution|rewards\(|phase.*rewards|phase-level rewards|parse.*phase|PhaseCompletionMode' \
  src/main/java src/test/java src/main/resources

printf '\n== Quest definition parser excerpts ==\n'
sed -n '1,260p' src/main/java/us/eunoians/mcrpg/command/quest/parser/QuestDefinitionParser.java | cat -n

printf '\n== Phase definition file (if present) ==\n'
if [ -f src/main/java/us/eunoians/mcrpg/quest/definition/QuestPhaseDefinition.java ]; then
  sed -n '1,220p' src/main/java/us/eunoians/mcrpg/quest/definition/QuestPhaseDefinition.java | cat -n
fi

Repository: DiamondDagger590/McRPG

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== QuestConfigLoader phase parsing ==\n'
sed -n '430,500p' src/main/java/us/eunoians/mcrpg/configuration/QuestConfigLoader.java | cat -n

printf '\n== QuestPhaseDefinition constructor and accessors ==\n'
sed -n '1,140p' src/main/java/us/eunoians/mcrpg/quest/definition/QuestPhaseDefinition.java | cat -n

printf '\n== Server owner guide reward section around phase rewards ==\n'
rg -n -A4 -B4 'Phase-level|Rewards are given when a quest \(or stage, or objective\) is completed|rewards that match the phase|reward-distribution' src/main/resources/quests/SERVER-OWNER-GUIDE.md

printf '\n== Rewards guide phase-level wording ==\n'
rg -n -A4 -B4 'quest, phase, stage, or objective|phase-level|reward-distribution' src/main/resources/quests/REWARDS.md

Repository: DiamondDagger590/McRPG

Length of output: 13068


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
paths = [
    Path("src/main/java/us/eunoians/mcrpg/configuration/QuestConfigLoader.java"),
    Path("src/main/java/us/eunoians/mcrpg/quest/definition/QuestPhaseDefinition.java"),
    Path("src/main/resources/quests/REWARDS.md"),
    Path("src/main/resources/quests/SERVER-OWNER-GUIDE.md"),
]
for p in paths:
    print(f"\n## {p}")
    if p.exists():
        text = p.read_text()
        for needle in [
            "parsePhaseDefinition",
            "rewardDistribution(phaseSection",
            "The `rewards:` block can appear at the **quest, phase, stage, or objective** level.",
            "Phase-level",
            "reward-distribution",
            "phase-level rewards",
        ]:
            if needle in text:
                idx = text.index(needle)
                start = max(0, idx - 250)
                end = min(len(text), idx + 500)
                print(text[start:end])
                print("\n---")
PY

Repository: DiamondDagger590/McRPG

Length of output: 4943


Align the phase reward docs. QuestConfigLoader and QuestPhaseDefinition both support inline rewards on phases, so src/main/resources/quests/QUEST-DEFINITIONS.md should not say phases only support reward-distribution. Update that note to match REWARDS.md and SERVER-OWNER-GUIDE.md.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/QUEST-DEFINITIONS.md` around lines 68 - 76, Update
the phase-level reward documentation in QUEST-DEFINITIONS.md to include inline
rewards as supported, alongside reward-distribution, matching the behavior of
QuestConfigLoader and QuestPhaseDefinition and the wording used in REWARDS.md
and SERVER-OWNER-GUIDE.md.
src/main/resources/quests/SERVER-OWNER-GUIDE.md (5)

34-34: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block languages.

Add language identifiers to both fenced blocks to satisfy the documented Markdown lint rule.

Also applies to: 127-127

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 34-34: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md` at line 34, Update both
fenced code blocks in SERVER-OWNER-GUIDE.md to include appropriate language
identifiers after their opening fences, including the block near the later
referenced section, while preserving their existing contents.

Source: Linters/SAST tools


565-570: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document COOLDOWN_LIMITED in the owner guide.

QUEST-DEFINITIONS.md lists this supported mode, but the owner-facing repeat-mode table omits it and does not explain its required repeat-cooldown and repeat-limit fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md` around lines 565 - 570,
Update the repeat-mode table in SERVER-OWNER-GUIDE.md to add the supported
COOLDOWN_LIMITED mode. Describe that it allows repeats only after
repeat-cooldown has passed and until repeat-limit total completions is reached,
including both required field names.

716-728: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Restrict this to adding new tiers, or document the migration path for renames/removals. Rarity keys are used in board metadata, template support lists, and persisted board offerings/quest instances; changing or deleting a key without an alias/migration step will strand existing data and configs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md` around lines 716 - 728,
Update the rarity configuration guidance around the “add, remove, or rename
rarities” statement to permit adding new tiers only, and document that renames
or removals require an alias or migration for existing board metadata, template
support lists, and persisted offerings or quest instances. Keep the MYTHIC
example unchanged.

930-930: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the difficulty multiplier examples consistent.

The guide defines Legendary as 3.5x at Lines 698-701, but later explains Legendary using 2.0x and describes 2.0 as the global value. Align these examples with the actual default configuration.

Also applies to: 1005-1008

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md` at line 930, Update the
RANGE variable examples in the guide to use the configured Legendary difficulty
multiplier of 3.5x, replacing the inconsistent 2.0x example and global-value
description while preserving the scaling explanation.

1222-1225: 🚀 Performance & Scalability | 🔵 Trivial

Replace the “no practical limit” claim with tested guidance.

The plugin loads all quest files on startup and reload, so startup time and memory still grow with the number and size of files. Document tested operational limits or provide monitoring guidance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/SERVER-OWNER-GUIDE.md` around lines 1222 - 1225,
Update the Performance section to remove the unqualified “no practical limit”
claim and replace it with tested operational guidance for quest-file count and
size, or concrete monitoring recommendations for startup/reload time and memory
usage. Keep the template-quest behavior statement unchanged.
src/main/resources/quests/TEMPLATES.md (1)

262-262: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Add a language identifier such as text to satisfy the documented Markdown lint rule.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 262-262: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/quests/TEMPLATES.md` at line 262, Update the fenced
Markdown code block in TEMPLATES.md to include an explicit language identifier,
such as text, while preserving its contents.

Source: Linters/SAST tools

src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java (1)

88-89: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Place @Test before @DisplayName on test methods.

The two new test methods have @DisplayName before @Test, which violates the test coding guidelines. The fix is a trivial annotation swap.

🔧 Proposed fix
-    `@DisplayName`("Given custom data, when saving then loading, then the structured data round-trips")
     `@Test`
+    `@DisplayName`("Given custom data, when saving then loading, then the structured data round-trips")
     public void customData_roundTripsThroughSaveAndLoad() throws SQLException {
-    `@DisplayName`("Given empty custom data, when saving, then the custom_data column is null")
     `@Test`
+    `@DisplayName`("Given empty custom data, when saving, then the custom_data column is null")
     public void saveObjectiveInstance_writesNullCustomData_whenEmpty() throws SQLException {

Also applies to: 136-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java`
around lines 88 - 89, Swap the annotation order on both new test methods in
QuestObjectiveInstanceDAOTest: place `@Test` before `@DisplayName`, including the
methods around the custom-data round-trip test and the second affected test.

Source: Coding guidelines

src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java (1)

42-45: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java' \
            'src/main/java/**/QuestRewardGrantEvent.java' \
            'src/main/java/**/QuestRewardGrantedEvent.java' \
            'src/test/java/**/McRPGBaseTest.java' \
            'src/test/java/**/MockBukkit*' \
            'src/test/java/**/*QuestReward*'

printf '\n--- QuestRewardGranterTest ---\n'
sed -n '1,220p' src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java

printf '\n--- Event classes ---\n'
sed -n '1,220p' src/main/java/us/eunoians/mcrpg/quest/reward/event/QuestRewardGrantEvent.java
sed -n '1,220p' src/main/java/us/eunoians/mcrpg/quest/reward/event/QuestRewardGrantedEvent.java

printf '\n--- MockBukkit / base test usage ---\n'
rg -n "unregisterAll\\(|getHandlerList\\(|HandlerList\\.unregisterAll|extends McRPGBaseTest|MockBukkit" src/test/java -g '*.java'

Repository: DiamondDagger590/McRPG

Length of output: 7773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- Event classes ---\n'
cat -n src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.java
printf '\n---\n'
cat -n src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.java

printf '\n--- Usages of these events ---\n'
rg -n "QuestRewardGrantEvent|QuestRewardGrantedEvent" src/main/java src/test/java -g '*.java'

printf '\n--- HandlerList / unregisterAll usage in tests ---\n'
rg -n "getHandlerList\\(\\)\\.unregisterAll\\(|HandlerList\\.unregisterAll\\(" src/test/java -g '*.java'

Repository: DiamondDagger590/McRPG

Length of output: 19235


Unregister only the listeners this test registers. unregisterAll() clears every listener from these static event lists, so one test can wipe out listeners from shared setup or another test and make the suite order-dependent. Store the listener instances and call HandlerList.unregisterAll(listener) for each one instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranterTest.java`
around lines 42 - 45, Update clearRewardEventListeners to retain references to
the listener instances registered by this test, then unregister each
specifically with HandlerList.unregisterAll(listener). Remove the broad
getHandlerList().unregisterAll() calls so shared listeners remain registered.
src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java (2)

266-267: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Place @Test before @DisplayName.

The new test reverses the required annotation order.

As per coding guidelines, test methods must place @Test before @DisplayName.

-    `@DisplayName`("Invalid rotation time falls back to midnight and still triggers without crashing")
     `@Test`
+    `@DisplayName`("Invalid rotation time falls back to midnight and still triggers without crashing")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    `@Test`
    `@DisplayName`("Invalid rotation time falls back to midnight and still triggers without crashing")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java`
around lines 266 - 267, In the test method with the display name “Invalid
rotation time falls back to midnight and still triggers without crashing,”
reorder the annotations so `@Test` appears before `@DisplayName`, matching the
project’s test annotation convention.

Source: Coding guidelines


272-286: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the test distinguish midnight fallback from other fallback values.

At 14:00, the test passes for any fallback earlier than 14:00; it does not prove that the invalid value falls back specifically to midnight. Run the task shortly after midnight instead.

-        Instant instant = instantAt(14, 0);
+        Instant instant = instantAt(0, 1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        Instant instant = instantAt(0, 1);
        when(timeProvider.now()).thenReturn(instant);

        // "6:00" is not strict ISO (needs "06:00"); it must fall back to 00:00 rather than throwing
        // a DateTimeParseException on every tick.
        QuestBoardRotationTask task = spy(new QuestBoardRotationTask(
                McRPG.getInstance(), 0, 1f, "6:00", "UTC"));
        task.runTask();

        MockBukkit.getMock().getScheduler().performOneTick();
        instant = instant.plusSeconds(1);
        when(timeProvider.now()).thenReturn(instant);
        MockBukkit.getMock().getScheduler().performOneTick();

        verify(mockBoardManager, times(1)).triggerRotation(daily.getKey());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java`
around lines 272 - 286, The test around QuestBoardRotationTask should verify the
invalid “6:00” schedule falls back specifically to midnight by starting the
mocked time shortly after 00:00 rather than 14:00. Update the Instant setup and
subsequent time progression while preserving the existing single triggerRotation
verification.

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Analysis

Production files with non-trivial new logic

Production class Key new logic
QuestRewardGranter grantToOnlinePlayer — fires events, loops rewards, catches per-reward exceptions
RewardGrantContext Enum — trivial
QuestObjectiveInstance getCustomData, setCustomData, markCustomDataDirty, new constructor
QuestObjectiveInstanceDAO serializeCustomData, deserializeCustomData, ensureColumnExists, columnExists, v2 migration
QuestBoardManager#parseResetDayOrDefault Fallback parse
QuestBoardRotationTask#parseTimeOrDefault Fallback parse
QuestRarityRegistry#rollRarity Zero-weight guard
QuestPool#getEligibleDefinitions Empty-set sentinel
QuestConfigLoader (supportedRarities) Removed snapshot, empty-set sentinel
ReloadableRarityConfig Per-item try/catch, null-section guard
QuestTemplateConfigLoader RuntimeException catch per file
PlayerJoinListener#grantRewards Refactored to use QuestRewardGranter
QuestInstance#grantRewards Refactored to use QuestRewardGranter
RewardDistributionGranter Refactored to use QuestRewardGranter
QuestRewardGrantEvent / QuestRewardGrantedEvent New events
QuestEvent subclasses Each gets own HandlerList
QuestManager#getDefaultExtractionResources New package-private method
ReloadPluginCommand Adds reloadAllContent() call

Test files present

QuestRewardGranterTest, QuestEventHandlerListTest, QuestManagerExtractionResourcesTest, QuestObjectiveInstanceDAOTest (extended), QuestObjectiveInstanceTest (extended), QuestPoolTest (extended), QuestRarityRegistryTest (extended), BoardCategoryConfigLoaderTest (extended), QuestTemplateConfigLoaderTest (extended).


Findings

CONCERN: QuestRewardGranter.grantToOnlinePlayer has no test covering the branch where QuestRewardGrantEvent.isCancelled() returns true — specifically, that the returned list is empty and no rewards are granted.
WHY: The cancellation path is a first-class contract documented on the class ("if the pre-grant event is cancelled, nothing is granted"). QuestRewardGranterTest does test cancellation (the diff is truncated but the class exists), however the diff is cut off before those tests are visible. If a cancellation test is absent, callers relying on the empty-return invariant (e.g. PlayerJoinListener using the returned list to decide which pending-reward rows to delete) have no regression guard.
WHERE: QuestRewardGranterTest / QuestRewardGranter

Auditor note: The diff is truncated at the QuestRewardGranterTest boundary. I can confirm the test file exists and has at least one @Test method visible, but cannot confirm the cancellation branch, the "listener removes a reward" branch, or the "all rewards throw" branch are covered. The three items below are filed individually so each can be confirmed or closed.


CONCERN: No test covers the branch in QuestRewardGranter.grantToOnlinePlayer where a listener removes a reward from event.getRewards() — the removed reward must not appear in the returned list and must not be granted.
WHY: The Javadoc on QuestRewardGrantEvent.getRewards() explicitly promises that listener mutations are honored. The pending-reward login path uses the returned list to decide which rows to mark for deletion; if a removed reward's row is incorrectly deleted, it is silently lost. This is the core correctness invariant of the mutable-list API and needs a dedicated regression test.
WHERE: QuestRewardGranterTest / QuestRewardGranter


CONCERN: No test covers the branch in QuestRewardGranter.grantToOnlinePlayer where every reward.grant(player) throws — the method must return an empty list and must not fire QuestRewardGrantedEvent.
WHY: The Javadoc states "Fires only when at least one reward was granted." A test covering this branch would catch any regression where the granter fires the post-event even with zero successful grants.
WHERE: QuestRewardGranterTest / QuestRewardGranter


CONCERN: QuestBoardManager#parseResetDayOrDefault has no test for the fallback branch (invalid day name → logs warning → returns MONDAY).
WHY: This method was introduced specifically to prevent a DayOfWeek.valueOf crash on onEnable that would disable all of McRPG (documented in the diff comment). The happy path (valid name) is implicitly exercised by any QuestBoardManager integration test, but the fallback contract — the behavior the fix is actually about — has no dedicated assertion.
WHERE: No test file / QuestBoardManager


CONCERN: QuestBoardRotationTask#parseTimeOrDefault has no test for either branch (valid time string, and invalid time string → fallback to "00:00").
WHY: The method was introduced to prevent a DateTimeParseException crash loop on every scheduler tick. The analogous parseZoneOrDefault already had a test (QuestBoardRotationTaskTest); parseTimeOrDefault is structurally identical and should be covered by the same class. Without a test, a regression that re-introduces the crash loop (e.g. by removing the catch) is undetected.
WHERE: QuestBoardRotationTaskTest (or equivalent) / QuestBoardRotationTask


CONCERN: QuestObjectiveInstanceDAO#columnExists and ensureColumnExists (the v2 migration helpers) have no tests.
WHY: Both methods contain non-trivial branching logic: columnExists tries JDBC metadata first and falls back to a PRAGMA table_info scan; ensureColumnExists skips the ALTER when the column already exists. The migration path (v1 → v2) that calls these is the only code path that runs on existing databases. A regression that makes columnExists always return false would issue redundant ALTER TABLE statements; one that always returns true would silently skip adding the column on fresh migrations.
WHERE: QuestObjectiveInstanceDAOTest / QuestObjectiveInstanceDAO


CONCERN: ReloadableRarityConfig's new per-item null-section guard and RuntimeException catch have no tests.
WHY: Two new defensive branches were added: (1) skip a rarity whose section is null, (2) skip a rarity whose section is present but malformed (e.g. non-numeric weight). Neither branch is exercised by the existing ReloadableRarityConfigTest (or equivalent). The analogous QuestTemplateConfigLoaderTest was extended with a malformed-file test — the same pattern is needed here.
WHERE: No test file / ReloadableRarityConfig


CONCERN: QuestConfigLoader's change from snapshotting the rarity registry to the empty-set sentinel is not directly tested by a unit test that passes an empty supported-rarities YAML block and asserts the resulting BoardMetadata.supportedRarities() is empty (as opposed to being filled from the registry).
WHY: The QuestPoolTest addition (emptySupportedRarities_matchesAnyRarity) tests the pool behavior for an empty set, but does not test that QuestConfigLoader actually produces an empty set when supported-rarities is absent from the YAML. A regression that re-introduces the registry snapshot would not be caught by the pool test.
WHERE: QuestConfigLoaderTest (or equivalent) / QuestConfigLoader


CONCERN: QuestRewardGranterTest method naming: the visible test method grantToOnlinePlayer_grantsAllRewards_whenEventNotCancelled (name inferred from context) needs verification that every test method follows action_outcome_whenCondition convention and carries @Test before @DisplayName. The diff is truncated so this cannot be fully confirmed.
WHY: The diff cuts off mid-file in QuestRewardGranterTest; any test methods after the truncation point cannot be audited for naming or annotation ordering.
WHERE: QuestRewardGranterTest


CONCERN: QuestEventHandlerListTest does not extend McRPGBaseTest and does not use MockBukkit, but it calls new NamespacedKey / reflects on Bukkit event classes that are present on the classpath. This is structurally fine per the known-infrastructure rules. However, QuestRewardGrantEvent and QuestRewardGrantedEvent extend Event (not QuestEvent) directly — the test's CONCRETE_EVENTS list includes them, but the assertion "abstract QuestEvent base declares no static handler list" does not verify that those two new events do not accidentally share a HandlerList with each other. The distinct-handler-list assertion covers this, but only if both are in CONCRETE_EVENTS, which they are. ✓ — no action needed here; this is a false-alarm candidate. Filing only to document the review.

(Withdrawn — covered by the identity-map assertion already in the test.)


CONCERN: QuestObjectiveInstanceTest#markCustomDataDirty_marksQuestDirty calls quest.clearDirty() but there is no corresponding API shown on QuestInstance for clearDirty(). If clearDirty() does not exist on QuestInstance, this test will not compile, and the dirty-state contract is untestable.
WHY: This is a structural gap: the test depends on a clearDirty() method that does not appear anywhere in the diff. If the method is absent, the test silently fails to compile (or is suppressed), leaving the markCustomDataDirty contract untested.
WHERE: QuestObjectiveInstanceTest / QuestInstance


CONCERN: PlayerJoinListener#grantRewards refactoring introduces IdentityHashMap to track QuestRewardType → UUID mapping — this relies on object identity across the fromSerializedConfiggrantToOnlinePlayer → returned-list chain. There is no test that verifies the identity chain is preserved: specifically, that the QuestRewardType instance added to rewardsByQuest is the same instance returned in the granted list from QuestRewardGranter.grantToOnlinePlayer, so that rewardToPendingId.get(grantedReward) resolves correctly.
WHY: If QuestRewardGranter wraps or copies rewards internally (e.g. via new ArrayList<>(rewards) — which it does), identity is preserved only if no listener replaces reward instances with new objects. A listener that does event.getRewards().replaceAll(r -> r.withAmountMultiplier(2.0)) produces new instances, and the IdentityHashMap lookup returns null, silently marking zero rows for deletion (the rewards are granted but the pending rows are retained forever). This is a correctness gap in the pending-reward cleanup path.
WHERE: PlayerJoinListenerTest (pending-reward grant tests) / PlayerJoinListener


Summary

Production files changed: QuestRewardGranter (new), RewardGrantContext (new), QuestRewardGrantEvent (new), QuestRewardGrantedEvent (new), QuestObjectiveInstance, QuestObjectiveInstanceDAO, QuestBoardManager, QuestBoardRotationTask, QuestRarityRegistry, QuestPool, QuestConfigLoader, ReloadableRarityConfig, QuestTemplateConfigLoader, PlayerJoinListener, QuestInstance, RewardDistributionGranter, QuestManager, QuestEvent (abstract, removed HandlerList), QuestCompleteEvent, QuestExpireEvent, QuestStartEvent, QuestStageCompleteEvent, QuestPhaseCompleteEvent, QuestObjectiveCompleteEvent, QuestObjectiveProgressEvent, ReloadPluginCommand, BoardCategoryConfigLoader

Test files present: QuestRewardGranterTest, QuestEventHandlerListTest, QuestManagerExtractionResourcesTest, QuestObjectiveInstanceDAOTest, QuestObjectiveInstanceTest, QuestPoolTest, QuestRarityRegistryTest, BoardCategoryConfigLoaderTest, QuestTemplateConfigLoaderTest

Coverage gaps:

  1. QuestRewardGranter — cancellation branch (unconfirmed due to truncation)
  2. QuestRewardGranter — listener-removes-reward branch
  3. QuestRewardGranter — all-rewards-throw branch (no QuestRewardGrantedEvent fires)
  4. QuestBoardManager#parseResetDayOrDefault — invalid-day fallback branch
  5. QuestBoardRotationTask#parseTimeOrDefault — both branches
  6. QuestObjectiveInstanceDAOcolumnExists / ensureColumnExists migration helpers
  7. ReloadableRarityConfig — null-section skip and malformed-rarity skip branches
  8. QuestConfigLoader — absent supported-rarities produces empty set (not registry snapshot)
  9. QuestInstance#clearDirty — referenced in test but not present in diff; may be missing API
  10. PlayerJoinListener#grantRewards — identity-map correctness when a listener replaces reward instances

@github-actions

Copy link
Copy Markdown

Security Review

I have reviewed the full diff against every checklist item. Here are my findings:


src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java

Concatenated table/column name in DDL — SEVERITY: MEDIUM
ensureColumnExists concatenates the constant TABLE_NAME and the caller-supplied columnName string directly into an ALTER TABLE DDL statement. Although the only current caller passes the compile-time constant CUSTOM_DATA_COLUMN = "custom_data", the method is private static with a @NotNull String parameter and no validation, meaning a future caller (or a maintenance refactor that adds a config-driven column name) could inject arbitrary SQL. The same pattern appears in the PRAGMA table_info(TABLE_NAME) call.

- try (PreparedStatement ps = connection.prepareStatement(
-         "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + addColumnSqlFragment)) {
+ // columnName must be a validated identifier before reaching here
+ if (!columnName.matches("[A-Za-z_][A-Za-z0-9_]*")) {
+     McRPG.getInstance().getLogger().log(Level.SEVERE,
+         "[QuestObjectiveInstanceDAO] Rejected unsafe column name: " + columnName);
+     return;
+ }
+ try (PreparedStatement ps = connection.prepareStatement(
+         "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + addColumnSqlFragment)) {

AI Agent Prompt: In QuestObjectiveInstanceDAO.java, the ensureColumnExists() method (around line 110) concatenates columnName and addColumnSqlFragment directly into a DDL string. Add an allowlist regex guard ([A-Za-z_][A-Za-z0-9_]*) at the top of the method that logs at SEVERE and returns immediately if columnName does not match, and a similar guard for addColumnSqlFragment (restrict to [A-Za-z0-9_() ]+). The columnExists() PRAGMA branch also concatenates TABLE_NAME — that constant is safe, but document it with a comment. No imports are needed. Legitimate players are unaffected because this is a server-side migration path, but the guard prevents a future programmer from accidentally passing user-influenced data to a DDL statement.


src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java

Third-party QuestRewardType added by a listener is granted without origin validation — SEVERITY: LOW
grantToOnlinePlayer passes the mutable mutableRewards list directly to the QuestRewardGrantEvent, then iterates grantEvent.getRewards() — the same list that listeners may have added entries to — and calls reward.grant(player) on every element. A third-party plugin listening at LOWEST priority could add a custom QuestRewardType whose grant() executes arbitrary logic (e.g., player.performCommand()). This is by design for an extensibility API, but the current implementation provides no way for a server owner or downstream audit listener to distinguish McRPG-originated rewards from listener-injected ones, and grant() runs with the server's full authority. This is not player-exploitable directly, but a malicious plugin on the server could exploit it; flag for documentation at minimum.

No code change is strictly required for the current threat model (player with normal server access), but the QuestRewardGrantedEvent Javadoc should note that getGrantedRewards() may include listener-injected rewards, and server owners should vet installed plugins.


No further security concerns found across the remaining changed files. The diff contains no MiniMessage deserialize() calls on user-controlled input, no performCommand/dispatchCommand with user-controlled segments, no permission checks bypassed, and no PreparedStatement parameters replaced with string concatenation outside the DDL case documented above.

- Remove the redundant reloadAllContent() from ReloadPluginCommand;
  FileManager.reloadFiles() already reloads tracked reloadable content.
- Drop the 9-arg QuestObjectiveInstance constructor (single canonical form).
- Fix pending-reward provenance: two rows that reconstruct to the same
  QuestRewardType instance now each retain their row id via a per-instance
  queue, so both rows are deleted and neither re-grants forever. Add a
  duplicate-row regression test.
- Make QuestObjectiveInstance.customData a volatile ConcurrentHashMap so the
  async save cannot hit a ConcurrentModificationException; drop null values on
  load defensively.
- Log the caught throwable (not just getMessage) in ReloadableRarityConfig,
  QuestBoardManager.parseResetDayOrDefault, QuestBoardRotationTask, and the
  QuestObjectiveInstanceDAO metadata-probe catch; warn on blank config values;
  downgrade the pending-reward reconstruction failure to WARNING.
- Make the tutorial/guide default-resource lists immutable (List.of).
- Test nits: @test before @DisplayName; the rotation-time fallback test now
  starts at 00:01 to prove the midnight fallback specifically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@github-actions

Copy link
Copy Markdown

Server Owner Review

Server Owner Review


CONCERN: board.yml rotation section still references a removed/non-existent /mcrpg quest admin reload command in the pre-existing comment block, while the updated lines now correctly say /mcrpg admin reload — but the correction is incomplete. The rotation block header comment still reads # NOTE: Changing any value in this section requires a server restart to take effect. # These keys are NOT hot-reloadable via /mcrpg quest admin reload. — the old command name survives in the diff context.
WHY: A server owner copying the command from the comment to test a reload will get "unknown command" and assume something is broken, or worse, think the reload worked when it didn't.
WHERE: src/main/resources/quest-board/board.ymlrotation: block header comment


CONCERN: Duration format comment is added to land-daily.yml, land-weekly.yml, personal-daily.yml, personal-weekly.yml, shared-daily.yml, shared-weekly.yml — but only on the fields that were already edited. The completion-time key in land-weekly.yml has no duration comment, and neither do the completion-time fields in personal-daily.yml or shared-daily.yml. A server owner editing completion-time in those files gets no grammar hint.
WHY: Duration format comments are inconsistently applied — the same key gets a comment in one category file but not another, so owners editing the uncommented files will guess the format and may write a bare number expecting hours (since most games use that convention), getting seconds instead.
WHERE: src/main/resources/quest-board/categories/land-weekly.ymlcompletion-time, personal-daily.ymlcompletion-time, shared-daily.ymlcompletion-time


CONCERN: board.ymlrarities: block now says "At least one rarity must have a positive weight, or the board falls back to a uniform pick" — but there is no comment on any individual rarity entry explaining what weight: 0 does. The fallback behaviour (uniform pick) is only documented at the section level, not at the key level where an owner would actually edit it.
WHY: An owner setting weight: 0 on LEGENDARY to effectively disable it will silently get it back in the uniform fallback if every other rarity is also set to zero by accident. The section comment is easy to miss when scrolling to a specific rarity.
WHERE: src/main/resources/quest-board/board.ymlrarities.LEGENDARY.weight (and other rarity entries)


CONCERN: No config-version key exists in board.yml (not in the diff, and not visible in the surrounding context), yet this PR changes the semantics of rarities (empty supportedRarities is now the "all rarities" sentinel rather than a snapshot), changes how weekly-reset-day, rotation.time, and timezone are parsed (with new fallback behaviour), and adds new YAML comments. Structural or semantic changes without a config-version increment mean there is no way to detect whether a server is running a stale config from a previous install.
WHY: A server owner who upgrades the plugin but keeps their old board.yml gets no warning that semantics changed. The "empty supportedRarities = all rarities" change in particular is a silent semantic inversion for any quest file that previously relied on the registry-snapshot population.
WHERE: src/main/resources/quest-board/board.yml — missing config-version key entirely


CONCERN: The rarityConfig, categoryConfig, and templateConfig ReloadableContent instances are now registered with ReloadableContentManager inside QuestBoardManager.initialize(), but there is no YAML comment in board.yml telling a server owner which command triggers a reload of rarity/category/template config vs. which requires a restart. The rotation: block says restart is required; the rarities: and notifications: blocks now say /mcrpg admin reload — but the category files (land-daily.yml, etc.) have no reload/restart annotation at all.
WHY: A server owner editing a category file (e.g. changing refresh-interval) has no in-file indication whether they need to restart or can run /mcrpg admin reload. They will either restart unnecessarily or assume a reload works and be confused when it doesn't match expectations.
WHERE: src/main/resources/quest-board/categories/land-daily.yml, land-weekly.yml, personal-daily.yml, personal-weekly.yml, shared-daily.yml, shared-weekly.yml — missing reload-vs-restart annotation at file header


CONCERN: QuestRewardGrantEvent is a new cancellable event with real player-data consequences: cancelling it on the PENDING context causes rewards to be retained and retried on next login. This behaviour is documented in Javadoc but not in any YAML or server-owner-facing file. The SERVER-OWNER-GUIDE.md is being moved/renamed in this PR — it is unclear whether it describes what happens when a third-party plugin cancels the grant event and how that affects pending reward expiry.
WHY: A server owner using an economy plugin or permission plugin that hooks QuestRewardGrantEvent and cancels it for certain worlds/players may not realize their players' rewards are silently accumulating in the database rather than being dropped. This is a data-retention surprise with no in-game feedback.
WHERE: src/main/resources/quests/SERVER-OWNER-GUIDE.md — pending reward retention on cancel not surfaced to owners


CONCERN: Two files are deleted in this PR — src/main/resources/quest/quests_v2.yml and src/main/resources/quest/upgrade_quests.yml. If any existing server has these files referenced anywhere (e.g. in a custom config, or if the plugin previously extracted them to the data folder), their removal is silent. There is no migration note explaining what happened to these files or whether their content moved.
WHY: A server owner who previously had quests_v2.yml extracted to plugins/McRPG/quest/ will see those quests disappear on update with no explanation. The vampire upgrade quest defined in quests_v2.yml is non-trivial content.
WHERE: src/main/resources/quest/quests_v2.yml (deleted), src/main/resources/quest/upgrade_quests.yml (deleted)


CONCERN: The database table mcrpg_quest_objective_instances is being migrated from version 1 to version 2 (adding a custom_data TEXT column). The migration is implemented via UpdateTableFunction/ensureColumnExists, which is correct — but there is no server-owner-facing upgrade note explaining that this migration runs automatically on first start, is non-destructive (adds a nullable column), and requires no manual action. Owners who back up their database before updating have no way to know whether a rollback to the previous version would break the custom_data column.
WHY: Server owners doing cautious upgrades (backup → update → verify) have no changelog entry or in-console message confirming the migration ran and what it changed. A rollback to the previous plugin version would encounter a custom_data column the old code doesn't know about — behaviour in that case is undocumented.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.javaCURRENT_TABLE_VERSION = 2 migration


CONCERN: The guide .md files (OBJECTIVES.md, REWARDS.md, TEMPLATES.md, etc.) are now extracted to the data folder via getDefaultExtractionResources() using the .extracted-defaults marker file. This means they are only extracted once — on first install. If the plugin ships updated guide content in a future version, owners who already have the marker will never receive the updated guides. There is no comment in the YAML or extraction logic explaining this "extract-once" behaviour to owners who wonder why their guide files look outdated.
WHY: An owner who upgrades the plugin expecting updated documentation in their plugins/McRPG/quests/ folder will silently keep the old version. They may file bug reports based on stale documentation.
WHERE: src/main/java/us/eunoians/mcrpg/quest/QuestManager.javaextractDefaultResources() / .extracted-defaults marker logic


Migration required: YES

  • mcrpg_quest_objective_instances table: automatic schema migration (v1 → v2, adds custom_data TEXT NULL), no manual action required, but no rollback path documented.
  • quests_v2.yml and upgrade_quests.yml deleted from jar: any previously-extracted copies in the data folder are orphaned; no migration note provided.
  • supported-rarities semantic change (empty = "all rarities" instead of registry snapshot): existing quest files with no supported-rarities key behave differently — previously populated from registry at load time, now treated as "match any". Functionally equivalent in normal operation but diverges if anyone set supported-rarities: [] explicitly expecting "no rarities" (effectively disabling board eligibility).

Reload-safe: PARTIAL

  • rarities, notifications in board.yml: YES (now registered with ReloadableContentManager)
  • rotation.* in board.yml: NO (restart required, correctly documented in YAML)
  • Category files (*.yml in quest-board/categories/): YES (registered via categoryConfig), but not documented in the category files themselves
  • Template files: YES (registered via templateConfig), undocumented in template files
  • weekly-reset-day, rotation.time, timezone: NO (parsed at QuestBoardManager.initialize() / task construction, not wrapped in ReloadableContent)

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — existing addons that listened on QuestEvent (the abstract base) were already silently broken; the fix makes that failure explicit and documented, but no previously-working public API is removed or renamed.


CONCERN: QuestEvent abstract base loses its HandlerList — any addon that registered @EventHandler on QuestEvent directly will now fail to compile or silently never receive events.
WHY: Although listening on an abstract Bukkit event base was always broken at runtime (events were never dispatched to base-type handlers), addons that compiled against the old API with public HandlerList getHandlers() on QuestEvent will get a compile error or a runtime AbstractMethodError if they subclass QuestEvent themselves without providing their own HandlerList. The Javadoc on the new QuestEvent calls this "unsupported" but there is no @Deprecated bridge and no migration path documented at the API level.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestEvent.java — removal of getHandlers() and getHandlerList()


CONCERN: QuestObjectiveInstance constructor signature changed — a new required @NotNull Map<String, Object> customData parameter was appended.
WHY: Any addon that constructs QuestObjectiveInstance directly (e.g. a custom objective type that manually assembles instances in tests or in a factory) will get a compile error. There is no @Deprecated overload forwarding to the new signature.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java — the full-argument constructor


CONCERN: QuestRewardGrantEvent.getRewards() returns a mutable List shared across all listeners, but the Javadoc only warns about this in prose — there is no type-level signal (e.g. a dedicated MutableRewardList wrapper or at minimum a @contract note on the return annotation).
WHY: An addon that caches the list reference (e.g. stores it for post-event comparison) will observe mutations made by later listeners without realising it. This is especially dangerous at MONITOR priority. The Javadoc says "do not cache the list across listeners" but @NotNull List<QuestRewardType> gives no compile-time hint. A type like UnmodifiableList for read-only listeners or a snapshot-on-access pattern would be safer, but that is a design choice. As-is, addon authors must read the prose or risk subtle bugs.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.javagetRewards()


CONCERN: QuestRewardGrantEvent extends Event directly instead of QuestEvent, breaking the expected event hierarchy and losing the getQuestInstance() context that QuestEvent subclasses carry.
WHY: Addons that listen for all quest-related events by listening on QuestEvent (even if unsupported for dispatch) or that reflectively walk the event hierarchy to find quest events will not see QuestRewardGrantEvent as a quest event. More practically: the event already carries its own getQuestInstance() accessor, so the information is present, but the class hierarchy is inconsistent with every other quest event. An addon developer reading the package will reasonably expect this to extend QuestEvent.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.javapublic class QuestRewardGrantEvent extends Event


CONCERN: QuestRewardGrantedEvent also extends Event directly rather than QuestEvent, for the same reason as above.
WHY: Same hierarchy inconsistency. If either event later needs to extend QuestEvent, that is a source-compatible but binary-incompatible change that will break addon jars compiled against the current hierarchy.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.javapublic class QuestRewardGrantedEvent extends Event


CONCERN: RewardGrantContext is a bare enum with no @since tag or stability annotation, and the Javadoc says "this set of constants may grow" — but there is no guidance on what the guaranteed-stable contract is for switch consumers.
WHY: An addon that switches on RewardGrantContext without a default branch will get a compile warning in Java 21+ and a potential MatchException if a new constant is added. The Javadoc recommends a default branch, which is good, but there is no @NotNull on the return of QuestRewardGrantEvent.getContext() / QuestRewardGrantedEvent.getContext() in the enum declaration itself (the methods on the events are annotated correctly, the enum values are not described with nullability contracts).
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/RewardGrantContext.java


CONCERN: QuestObjectiveInstance.getCustomData() returns the live internal ConcurrentHashMap — an addon that modifies it without calling markCustomDataDirty() will silently lose its changes on the next flush.
WHY: This is an easy footgun for custom objective type authors. The method Javadoc explains it, but the return type is Map<String, Object> with no type-level indication that callers must call markCustomDataDirty(). A builder-style mutator or a computeCustomData(UnaryOperator<Map<String,Object>>) that auto-marks dirty would be safer. As a documentation concern: the getCustomData() Javadoc states "call markCustomDataDirty() ... after changing it" but setCustomData() says it "marks the owning quest dirty for you" — the asymmetry is easy to miss.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.javagetCustomData() / markCustomDataDirty()


CONCERN: QuestManager.getDefaultExtractionResources() is package-private (static List<String>), not public — test coverage exists but addons that want to audit or extend the extraction list (e.g. a content pack that hooks QuestManager initialization) cannot call this method without reflection.
WHY: Minor, but if this was intentionally exposed for extension it should be public. If it is intentionally package-private, the test in QuestManagerExtractionResourcesTest only works because the test is in the same package, which is fine, but should be documented as internal.
WHERE: src/main/java/us/eunoians/mcrpg/quest/QuestManager.javastatic List<String> getDefaultExtractionResources()


CONCERN: The REWARDS.md developer guide documents reward.isScalable() and reward.withAmountMultiplier(double) as part of the public QuestRewardType API (referenced in the event interception example), but neither method is visible in the diff's addition to the interface itself — only in the test helper ScalableReward. If these are new interface methods on QuestRewardType, they need default implementations; if they already existed, they should be cross-referenced with their existing Javadoc.
WHY: An addon author reading the REWARDS.md example will try to call reward.isScalable() and get a compile error if the method does not exist on QuestRewardType, or a silent no-op if only the test stub implements it.
WHERE: src/main/resources/quests/REWARDS.md — intercepting reward grants example; src/test/java/.../QuestRewardDistributionResolverPotBehaviorTest.javaScalableReward.isScalable() / ScalableReward.withExactAmount()

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Coverage Analysis

New Production Classes / Methods Requiring Tests

QuestRewardGranter.grantToOnlinePlayer — this is the central new collaborator. It fires QuestRewardGrantEvent, honors cancellation, isolates per-reward failures, fires QuestRewardGrantedEvent only when at least one reward succeeds. The logic is non-trivial (>3 lines, multiple branches).

QuestObjectiveInstance.getCustomData / setCustomData / markCustomDataDirty — new public methods with non-trivial state interaction.

QuestBoardManager.parseResetDayOrDefault — new private method, logic tested indirectly only if called through integration; acceptable since it's private.

QuestBoardRotationTask.parseTimeOrDefault — new private method, same reasoning.

QuestRarityRegistry.rollRarity zero-weight path — new branch added; covered by QuestRarityRegistryTest.

QuestPool.getEligibleDefinitions empty-rarity sentinel — new branch; covered by QuestPoolTest.

QuestObjectiveInstanceDAO.serializeCustomData / deserializeCustomData / columnExists / ensureColumnExists — new private methods, but serializeCustomData / deserializeCustomData are exercised through QuestObjectiveInstanceDAOTest.

PlayerJoinListener.grantRewards — refactored significantly; existing tests plus new duplicate-instance test.

RewardDistributionGranter — now delegates to QuestRewardGranter; the distribution granter has no dedicated test for the new event-firing path.


CONCERN: QuestRewardGranter.grantToOnlinePlayer has no direct unit test covering its core branches: (1) pre-event cancelled → empty list returned, (2) listener mutates reward list → mutations honored, (3) individual reward grant() throws → other rewards still granted, (4) all rewards fail → QuestRewardGrantedEvent not fired, (5) happy path → QuestRewardGrantedEvent fired with only successfully-granted rewards.
WHY: This is the single interception point for every reward grant across three pathways. All five branches contain non-trivial logic. The existing PlayerJoinListenerTest exercises the pending-reward pathway end-to-end but does so through two layers of indirection (PlayerJoinListener → QuestRewardGranter), not the granter directly. A bug in the cancellation branch or the per-reward isolation branch is invisible to current tests because PlayerJoinListenerTest registers a RecordingRewardType that never throws and no listener cancels the event.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java — no corresponding test file exists.


CONCERN: QuestObjectiveInstance.setCustomData and markCustomDataDirty have no test asserting that calling either marks the owning quest dirty.
WHY: Both methods call questStage.getQuestInstance().markDirty(). If the dirty-propagation chain is broken (e.g. a refactor changes the call site), no test catches it. QuestObjectiveInstanceDAOTest only verifies round-trip JSON serialization, not that mutation triggers persistence scheduling.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java / no test covers markCustomDataDirty or setCustomData.


CONCERN: QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad reads rs.getLong("start_time") and rs.getLong("end_time") but stubs rs.wasNull() to return true for both — meaning both timestamps will be treated as null. The test asserts on the loaded custom data, which is correct, but the stub sequence for wasNull() is positional: the DAO calls wasNull() once per nullable long column. If the DAO reads more nullable columns than the two wasNull() calls anticipated, the mock returns false for subsequent calls (Mockito default), silently misreading data. This is a structural fragility that could mask future column additions.
WHY: Test correctness depends on the exact column-read order in loadObjectiveInstances. A future column insertion shifts the wasNull() call sequence and silently corrupts test behavior. The test should use when(rs.wasNull()).thenReturn(true) unconditionally (as it does) but should also verify the loaded start/end times are absent if that is the intended assertion, or acknowledge the fragility with a comment.
WHERE: src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java


CONCERN: RewardDistributionGranter now delegates to QuestRewardGranter, meaning the distribution pathway now fires QuestRewardGrantEvent / QuestRewardGrantedEvent. There is no test verifying: (a) the event fires on the distribution path, (b) cancelling the event prevents distribution rewards from being granted, or (c) the RewardGrantContext.DISTRIBUTION is passed correctly.
WHY: The distribution pathway is a separate entry point from inline and pending. The delegation was specifically called out in the diff as routing all three pathways through QuestRewardGranter, but only the inline path (via QuestInstance) and the pending path (via PlayerJoinListenerTest) have any test coverage. Distribution has zero test coverage for the new event-firing behavior.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/distribution/RewardDistributionGranter.java — no test for new delegation behavior.


CONCERN: QuestConfigLoader's change to use an empty set as the "all rarities" sentinel (instead of snapshotting the rarity registry) has no regression test. The old behavior — snapshot at load time, producing an always-empty set — was a silent bug. The new behavior — empty set means "match any rarity" — is tested via QuestPoolTest.getEligibleDefinitions_emptySupportedRarities_matchesAnyRarity, but there is no test verifying that a quest YAML with no supported-rarities key actually produces an empty set in BoardMetadata, not a set containing the registered rarities.
WHY: The change moved responsibility from QuestConfigLoader (fill in registry keys) to QuestPool (treat empty as wildcard). If QuestConfigLoader still populates the set from somewhere, or a future refactor re-introduces the registry snapshot, the QuestPool test would still pass while the YAML→BoardMetadata path silently re-introduces the bug.
WHERE: src/main/java/us/eunoians/mcrpg/configuration/QuestConfigLoader.java — no test for the no-supported-rarities YAML key producing an empty BoardMetadata.supportedRarities().


CONCERN: QuestManagerExtractionResourcesTest.getDefaultExtractionResources_allResolveOnClasspath uses getClass().getClassLoader().getResource(resource) to validate classpath presence. This works only if the test classpath includes the src/main/resources tree. If the build omits resources from the test classpath (e.g. a Gradle processResources task not running before tests), the test passes vacuously for all entries because getResource returns null — and assertNotNull catches that. The test does call assertNotNull, so it will fail correctly if resources are absent from the classpath. No concern here on correctness. (Self-resolved; no flag.)


CONCERN: PlayerJoinListenerTest.grantRewards_deletesBothRows_whenDuplicateRewardInstance asserts both row IDs are returned when two pending rows resolve to the same reward-type instance. However, the test comment says "RecordingRewardType.fromSerializedConfig returns the same instance" — but looking at the RecordingRewardType implementation in the test file, fromSerializedConfig is not shown in the diff. If fromSerializedConfig returns this (a shared instance), the IdentityHashMap in grantRewards would map both reconstructed objects to a single Deque entry. The test is asserting the correct behavior, but the correctness of the assertion depends entirely on whether fromSerializedConfig actually returns this. The test's premise must be validated.
WHY: If fromSerializedConfig returns new RecordingRewardType(key) (a new instance per call), the IdentityHashMap correctly maps each to its own Deque, and only one ID per queue entry is consumed — the test would then be testing the wrong scenario. The test name says "same instance" but the implementation must confirm this. Without seeing fromSerializedConfig in the diff, this is an untested assumption.
WHERE: src/test/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListenerTest.java


CONCERN: QuestEventHandlerListTest does not extend McRPGBaseTest and does not use MockBukkit, but HandlerList is a Bukkit class. Per the checklist exemption, simple Bukkit data classes work with MockBukkit on the test classpath without MockBukkit.mock(). However, HandlerList is not a simple data class — it manages a global static list of all handler lists used by Bukkit's event dispatch system. Reflectively invoking getHandlerList() on event classes that were also loaded by other tests in the same JVM run may produce shared-state cross-contamination if HandlerList.unregisterAll() is not called between test classes. This is a potential cross-test pollution concern.
WHY: HandlerList maintains a global ArrayList<HandlerList> allLists that grows each time a handler list is instantiated. In a test suite run where QuestEventHandlerListTest and MockBukkit-backed tests share a JVM, the static lists accumulate. The test only reads handler lists (no registration), so the risk is low — but the test does not document this assumption.
WHERE: src/test/java/us/eunoians/mcrpg/event/quest/QuestEventHandlerListTest.java


CONCERN: BoardCategoryConfigLoaderTest.loadCategoriesFromDirectory_plainNumberAndUppercaseDurations asserts that completion-time: 86400 (bare number) parses as Duration.ofSeconds(86400). This is a regression test for the McCore getTimeInSeconds silent-zero bug. However, there is no corresponding test for appearance-cooldown parsed via the same QuestConfigLoader.parseDuration call path in BoardCategoryConfigLoader, nor for the edge case where the duration string is "0" (zero seconds) to confirm zero is not confused with a parse failure.
WHY: appearance-cooldown is the third call site switched from McCore to QuestConfigLoader.parseDuration, and it has an existence check (section.contains). A value of "0" for refresh-interval or completion-time should be valid (zero-duration refresh for testing/debug) — confirming it doesn't fall through to a default is an edge case the checklist requires for config-driven values.
WHERE: src/test/java/us/eunoians/mcrpg/configuration/BoardCategoryConfigLoaderTest.java


CONCERN: QuestObjectiveInstance now exposes getCustomData() returning the live ConcurrentHashMap. The Javadoc warns that numbers round-trip as Double after a save/load cycle. There is no test that stores an integer value, serializes, deserializes, and confirms the value comes back as Double (not Integer/Long), validating the Javadoc's warning is accurate and that code using ((Number) map.get("count")).longValue() works.
WHY: If Gson's behavior changes, or if a future developer stores a value expecting it to round-trip as Integer, the missing test means the type-coercion behavior is purely documented but not enforced. Given that the Javadoc explicitly warns about this and custom objective types must rely on it, the round-trip behavior warrants a test.
WHERE: src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java — the existing round-trip test checks list contents but not numeric type coercion.


Naming / Structure Violations

CONCERN: QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad uses @DisplayName before @Test rather than @Test before @DisplayName. Same issue on saveObjectiveInstance_writesNullCustomData_whenEmpty.
WHY: The McRPG convention (per the checklist) is @Test listed before @DisplayName on the method.
WHERE: src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java


CONCERN: PlayerJoinListenerTest.grantRewards_deletesBothRows_whenDuplicateRewardInstance has @Test before @DisplayName ✓, but it is missing a @DisplayName annotation entirely — the display name appears only in the method name.
WHY: The McRPG convention requires every test method to carry a @DisplayName annotation.
WHERE: src/test/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListenerTest.java


Summary

Production files changed:
QuestRewardGranter.java (new), RewardGrantContext.java (new), QuestRewardGrantEvent.java (new), QuestRewardGrantedEvent.java (new), QuestObjectiveInstance.java, QuestObjectiveInstanceDAO.java, PlayerJoinListener.java, RewardDistributionGranter.java, QuestInstance.java, QuestPool.java, QuestRarityRegistry.java, QuestBoardManager.java, QuestBoardRotationTask.java, BoardCategoryConfigLoader.java, QuestTemplateConfigLoader.java, QuestConfigLoader.java, QuestManager.java, QuestEvent.java + concrete event subclasses (7 files), ReloadableRarityConfig.java

Test files present:
QuestObjectiveInstanceDAOTest.java, QuestEventHandlerListTest.java, PlayerJoinListenerTest.java, QuestManagerExtractionResourcesTest.java, QuestPoolTest.java, QuestRarityRegistryTest.java, BoardCategoryConfigLoaderTest.java, QuestTemplateConfigLoaderTest.java, QuestRewardDistributionResolverPotBehaviorTest.java

Coverage gaps:

  1. QuestRewardGranter — no direct tests for cancellation, listener mutation, per-reward isolation, conditional QuestRewardGrantedEvent firing
  2. RewardDistributionGranter — no test for new event-firing delegation behavior or RewardGrantContext.DISTRIBUTION
  3. QuestObjectiveInstance.setCustomData / markCustomDataDirty — no test that dirty-marking propagates to quest
  4. QuestConfigLoader no-supported-rarities → empty set — no test for the YAML→BoardMetadata path
  5. BoardCategoryConfigLoaderTest — missing zero-value duration edge case and appearance-cooldown coverage
  6. QuestObjectiveInstanceDAOTest — numeric type coercion (Integer stored → Double loaded) not tested
  7. PlayerJoinListenerTestfromSerializedConfig same-instance assumption unvalidated; missing @DisplayName
  8. QuestObjectiveInstanceDAOTest@DisplayName / @Test ordering inverted on two methods

@github-actions

Copy link
Copy Markdown

Security Review

No security concerns found.

The diff contains no player-exploitable injection vulnerabilities under the defined threat model. Specific review notes:

Adventure API / MiniMessage: No calls to MiniMessage.deserialize() or getMiniMessage().deserialize() appear anywhere in the changed code. No player-controlled strings (chat, book content, anvil rename, sign text) are passed to any deserialization path.

Command injection via performCommand() / dispatchCommand(): Neither method appears in the diff. The ReloadPluginCommand change adds only a comment and calls an internal manager method with no player-influenced string concatenation.

Permission bypass: No new command handlers or slot actions are introduced. QuestRewardGranter is an internal collaborator invoked only from already-gated code paths (QuestInstance, RewardDistributionGranter, PlayerJoinListener). No isOp() substitutions are present.

SQL / data injection: All DAO changes in QuestObjectiveInstanceDAO use PreparedStatement parameters exclusively. The ensureColumnExists method concatenates TABLE_NAME and columnName into DDL — both are compile-time constants ("mcrpg_quest_objective_instances", "custom_data"), not player-supplied values. The deserializeCustomData path processes only server-stored JSON (written by the server's own serializeCustomData), not player-provided bytes, and defensively handles malformed input with a logged fallback. The PRAGMA table_info(...) call similarly uses the constant TABLE_NAME.

Stored data → deserialization: Custom data originates from QuestObjectiveType implementations (server-side logic), not from player input. The round-trip is server-write → DB → server-read; no player input surface feeds this column.

Now that McCore's Methods.getTimeInSeconds is case-insensitive and treats a
bare number as seconds, the category loader no longer needs to work around it
via QuestConfigLoader.parseDuration. This also restores w/y unit support for
category durations. Quest files continue to use parseDuration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@github-actions

Copy link
Copy Markdown

Server Owner Review

Server Owner Review

Working through the diff systematically against the checklist.


CONCERN: board.yml comment still references a non-existent command spelling in one place
WHY: The rotation section comment was updated from /mcrpg quest admin reload to /mcrpg admin reload (good), but owners who copied the old comment into local notes, or who look at any cached/older copy of the file on disk, may try the wrong command and assume reload is broken. More critically, the notification section comment was also corrected, but this fix only applies to the bundled default — any server that already has board.yml on disk gets the old wrong comment permanently, with no migration note or in-console hint.
WHERE: src/main/resources/quest-board/board.ymlrotation: and notifications: comment blocks; no automated config comment migration exists


CONCERN: config-version is not incremented in board.yml despite structural comment corrections and the addition of new inline comments describing fallback behavior for weekly-reset-day, rotation.time, and timezone
WHY: Without a version bump, there is no signal to an automated migration tool (or a future maintainer) that this file was changed. Owners with existing servers will never see the updated comments explaining valid values and fallback behavior — the fields most likely to be misconfigured (weekly-reset-day: FUNDAY) will silently fall back to Monday with a console WARNING that owners may miss, and the YAML they're editing gives no hint of valid values.
WHERE: src/main/resources/quest-board/board.yml — no config-version key exists in this file at all


CONCERN: board.yml has no config-version key anywhere in the file
WHY: The checklist requires config-version to be incremented on every structural change. This file has no version key at all, so there is no baseline to increment from and no mechanism for future automated migration. If keys are renamed or removed later, there is no way to detect the installed version and migrate safely.
WHERE: src/main/resources/quest-board/board.yml — entire file


CONCERN: Category YAML files (personal-daily.yml, shared-daily.yml, shared-weekly.yml, etc.) have no config-version key
WHY: Same issue as board.yml. Duration format comments were added to these files in this PR — a structural change that warrants a version. Without versioning, owners who copied category files before this PR have no automated path to pick up the new comments, and no way to know if their copy is stale.
WHERE: src/main/resources/quest-board/categories/personal-daily.yml, shared-daily.yml, shared-weekly.yml, personal-weekly.yml, land-daily.yml, land-weekly.yml


CONCERN: Duration format comment is inconsistent — added to some category files but not all, and not added to completion-time in every category file that has it
WHY: A server owner editing land-daily.yml sees the duration format note on refresh-interval and appearance-cooldown but not on completion-time. In personal-daily.yml, the note is only on refresh-interval. This creates confusion: an owner might assume completion-time: 1d is wrong because only some keys have the explanation, or use a bare number on completion-time and get zero (if the McCore parser silently returns zero for bare numbers on that key).
WHERE: src/main/resources/quest-board/categories/personal-daily.ymlcompletion-time (missing comment); land-daily.ymlcompletion-time (missing comment)


CONCERN: rotation.task-check-interval-seconds has no comment explaining what "minimum recommended: 30" means if set to zero or a negative value, and no fallback behavior is documented
WHY: A server owner setting this to 0 thinking "check every tick" would cause a tight scheduler loop. The comment says minimum 30 but gives no indication of what actually happens if that is violated — does the plugin clamp it, log a warning, or does the scheduler throw?
WHERE: src/main/resources/quest-board/board.ymlrotation.task-check-interval-seconds


CONCERN: rarities section in board.yml now documents that "at least one rarity must have a positive weight, or the board falls back to a uniform pick" — but there is no config-version bump and no WARNING in-file about what the uniform pick means for gameplay (all rarities equally likely, including Legendary)
WHY: An owner who zeros out COMMON weight to "disable" it, not realizing it will cause a uniform fallback including Legendary, will see surprising board behavior. The console WARNING exists in code but the YAML comment only says "falls back to a uniform pick" without explaining the severity.
WHERE: src/main/resources/quest-board/board.ymlrarities: section comment


CONCERN: QuestRewardGrantEvent and QuestRewardGrantedEvent are new Bukkit events with no corresponding plugin.yml entries — not a permission issue, but these events are part of the public API surface and there is no upgrade note or changelog entry visible in the diff that tells server owners running third-party addons what fired before vs. after this PR
WHY: Server owners who use addon plugins that listened on QuestCompleteEvent to grant bonus rewards may now get double-rewards if those addons also listen on the new QuestRewardGrantedEvent added by this PR — especially since those addons would have no knowledge that the new event exists. This is an addon-compatibility breaking change that needs a migration note directed at server owners.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantEvent.java, QuestRewardGrantedEvent.java — no upgrade note in the diff


CONCERN: QuestObjectiveInstanceDAO table version bumped from 1 → 2 (adding custom_data column) with no manual upgrade guide visible for servers that cannot run the automated migration (e.g., read-only DB users, external DB management)
WHY: The automated ensureColumnExists path handles the happy case, but a server owner running with restricted DB permissions who sees ALTER TABLE fail in the log has no documented manual SQL to run. The error is logged at SEVERE but no recovery steps are provided in the PR or in any bundled guide.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.javaattemptTableCreation migration block for version 2


CONCERN: The CHAIN-GUIDE.md file is listed in DEFAULT_OWNER_GUIDE_RESOURCES and in example_quest.yml's header comment, but it does not appear anywhere in the diff as a renamed or newly created file
WHY: If quests/CHAIN-GUIDE.md does not exist as a classpath resource, saveResource will throw IllegalArgumentException at startup and the extraction loop may abort (depending on error handling), causing other guides not to be extracted. The test QuestManagerExtractionResourcesTest checks classpath presence but only for SERVER-OWNER-GUIDE.md and REWARDS.md, not for CHAIN-GUIDE.md.
WHERE: src/main/java/us/eunoians/mcrpg/quest/QuestManager.javaDEFAULT_OWNER_GUIDE_RESOURCES"quests/CHAIN-GUIDE.md"


CONCERN: QuestRarityRegistry.rollRarity fallback to uniform pick when all weights are zero is logged as a WARNING, but this warning fires on every board rotation, not just at load time. A server owner who accidentally zeros all weights will see this WARNING every rotation cycle in the console rather than once at startup.
WHY: A high-frequency WARNING for a config mistake that should be caught at load time will cause log spam and may be ignored or filtered, meaning the owner never fixes the root cause.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/rarity/QuestRarityRegistry.javarollRarity fallback branch


Migration required: YES — QuestObjectiveInstanceDAO table version 1 → 2 (automated ALTER TABLE migration exists; manual SQL needed as fallback for restricted DB environments)

Reload-safe: PARTIAL — rarityConfig, categoryConfig, and templateConfig are now registered with ReloadableContentManager (good); rotation.* keys (time, timezone, weekly-reset-day, task-check-interval-seconds) are explicitly documented as requiring restart; notification keys are documented as reload-safe. The partial rating is because the board.yml comment corrections only reach owners who delete and re-extract the file — existing installs retain the old (wrong) command name in their YAML comments indefinitely.

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — no public interfaces broken, but two constructor signatures changed and the QuestObjectiveInstance full constructor gains a required parameter that any addon instantiating it directly will fail to compile against.


CONCERN: QuestObjectiveInstance full constructor gains a new required @NotNull Map<String, Object> customData parameter with no backward-compatible overload.
WHY: Any addon that constructs QuestObjectiveInstance directly (e.g. a custom objective type creating synthetic instances for testing or populating) will fail to compile. There is no deprecated forwarding constructor that passes Collections.emptyMap(), nor a factory method. The no-arg-state constructor (the single-argument form) is untouched, so addons using that path are safe — but the full 9-argument constructor is a public API break.
WHERE: QuestObjectiveInstance.java — the 9-argument constructor now requires a 10th customData parameter; QuestObjectiveInstanceDAO call sites updated but no compatibility shim added.


CONCERN: QuestObjectiveInstance.getCustomData() returns a live ConcurrentHashMap reference with no copy-on-read contract documented for callers outside the package, and the Javadoc warns about the Double numeric deserialization hazard only in the method doc — not on the type returned.
WHY: An addon implementing a custom QuestObjectiveType that casts stored values to Integer or Long will get a ClassCastException after a save/load cycle. The warning exists in Javadoc and OBJECTIVES.md, which is good — but because the return type is Map<String, Object> with no wrapper type conveying the constraint, a developer reading only the method signature and the generic type will not see the hazard. The risk is a silent runtime failure rather than a compile-time signal.
WHERE: QuestObjectiveInstance.getCustomData() — return type Map<String, Object> carries no compile-time hint; OBJECTIVES.md documents this but only developers who read it will know.


CONCERN: QuestRewardGrantEvent.getRewards() returns a mutable List that is the same instance shared across all listeners; the Javadoc notes this, but there is no @NotNull on the list element type and no contract preventing a listener from inserting null.
WHY: An addon listener that calls event.getRewards().add(null) (accidentally or via a broken reward factory) will cause a NullPointerException inside QuestRewardGranter when it iterates and calls reward.grant(player). There is no null-guard on the iteration. The granter's per-reward try/catch only wraps RuntimeException from grant(), not a NPE from the loop itself dereferencing a null element.
WHERE: QuestRewardGranter.grantToOnlinePlayer() — the for (QuestRewardType reward : grantEvent.getRewards()) loop has no null check before calling reward.grant(player).


CONCERN: RewardGrantContext is a plain enum with no default branch guidance enforced at the type level; the Javadoc says "include a default branch" but there is no sealed/interface mechanism to enforce this. Future values added to the enum will silently fall through exhaustive switches in addons compiled against the current version.
WHY: An addon that switches on RewardGrantContext without a default branch (a common pattern when the developer reads existing values as exhaustive) will not fail at compile time when a new constant is added — it will silently do nothing for the new context. The Javadoc warning is correct but unenforced.
WHERE: RewardGrantContext.java — enum constants INLINE, DISTRIBUTION, PENDING; Javadoc recommends default but no compiler or annotation enforces it.


CONCERN: QuestRewardGrantedEvent documents "fires only when at least one reward was granted" but does not document what happens if the QuestRewardGrantEvent pre-event list is mutated to empty by a listener — the granter iterates the (now-empty) list, grants nothing, and never fires the post-event. An addon auditor listening only to QuestRewardGrantedEvent expecting to see every attempted batch will silently miss batches that were emptied (not cancelled) by another listener.
WHY: An audit addon that tracks every reward attempt by listening to QuestRewardGrantedEvent will have invisible blind spots when another plugin removes all rewards without cancelling the event. The Javadoc hints that cancelled events don't fire the post-event but does not call out the "emptied-but-not-cancelled" case.
WHERE: QuestRewardGrantedEvent.java class Javadoc; QuestRewardGranter.grantToOnlinePlayer() — the if (!granted.isEmpty()) guard before the post-event fire.


CONCERN: QuestEvent abstract base now has no getHandlerList() static method, which is correct for Bukkit dispatch. However, the removal of the previously-public static getHandlerList() from QuestEvent is a binary-incompatible change for any addon that called QuestEvent.getHandlerList() directly (e.g. to unregister all quest-event handlers at once via the shared list).
WHY: Compiled addon code that previously referenced QuestEvent.getHandlerList() will throw NoSuchMethodError at runtime. While listening on the abstract base was broken (the QuestCancelEvent bug), calling the static accessor was technically possible and some addons may have done so to clear all quest listeners.
WHERE: QuestEvent.javagetHandlerList() and getHandlers() removed from the abstract base; no @Deprecated bridge kept.


CONCERN: QuestManager.getDefaultExtractionResources() is static and package-private (static List<String>), not public. The test class in the same package accesses it, but an addon in a different package cannot call it to validate its own resource list against McRPG's. This is a missed extension opportunity if addons want to know what resources are already extracted.
WHY: Low immediate impact — addons rarely need this — but the method is documented as "Package-private so the resource list can be validated against the packaged jar in tests," which signals it was intentionally scoped away from addon use. If an addon ever needs to interop with the extraction system (e.g. a content expansion adding its own resources), there is no public API surface to do so without reflection.
WHERE: QuestManager.getDefaultExtractionResources() — package-private access modifier.


CONCERN: QuestRewardGrantEvent and QuestRewardGrantedEvent both extend Event directly rather than QuestEvent, so they do not carry a QuestInstance accessor via polymorphism on the base type. An addon listening generically for "any quest event" (by subclassing QuestEvent) will not receive reward events, and an addon that checks event instanceof QuestEvent to get the instance will fail for reward events.
WHY: The two new reward events are in the event/quest/ package and are conceptually quest events, but they are not in the QuestEvent hierarchy. This is internally consistent (since QuestEvent requires a QuestInstance and reward events may have null instances), but it means addon developers who build a unified quest-audit listener on QuestEvent must separately register for QuestRewardGrantEvent and QuestRewardGrantedEvent, with no type-system signal pointing them there.
WHERE: QuestRewardGrantEvent.java and QuestRewardGrantedEvent.java — extend Event not QuestEvent; no Javadoc cross-reference on QuestEvent pointing to these sibling types.

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Coverage Analysis

New Production Classes / Logic Requiring Coverage

QuestRewardGranter — new public class, single public method grantToOnlinePlayer with substantial logic: fires QuestRewardGrantEvent, honors cancellation, isolates per-reward failures, fires QuestRewardGrantedEvent only when at least one reward was granted.

RewardGrantContext — pure enum, no logic; no test needed.

QuestBoardManager.parseResetDayOrDefault — private method with fallback logic for blank/null input and IllegalArgumentException. No direct test (private), but the behavior is exercised through QuestBoardManager.initialize which is not exercised in a unit test covering the bad-value branch.

QuestBoardRotationTask.parseTimeOrDefault — private method with fallback logic for blank/null input and parse failure. Same situation as above.

QuestRarityRegistry.rollRarity zero-weight fallback — covered by QuestRarityRegistryTest.rollRarity_allZeroWeights_fallsBackWithoutThrowing. ✓

QuestPool empty supportedRarities sentinel — covered by QuestPoolTest.getEligibleDefinitions_emptySupportedRarities_matchesAnyRarity. ✓

QuestObjectiveInstance.getCustomData / setCustomData / markCustomDataDirty — partially covered by QuestObjectiveInstanceTest (truncated diff) and QuestObjectiveInstanceDAOTest. Need to verify setCustomData marks the quest dirty.

QuestConfigLoader empty-set sentinel — the changed branch (empty set = all rarities) is tested indirectly via QuestPoolTest. ✓

QuestTemplateConfigLoader RuntimeException catch — covered by QuestTemplateConfigLoaderTest.loadTemplatesFromDirectory_skipsMalformedFile_loadsRest. ✓

ReloadableRarityConfig null-section guard and per-rarity RuntimeException catch — no test covers the null-section path (a rarity key with an empty body) or the RuntimeException catch path.

PlayerJoinListener.grantRewards refactor — the existing tests cover the happy path and unregistered-type retention. The new test grantRewards_deletesBothRows_whenDuplicateRewardInstance covers the identity-map dequeue. Coverage of the cancellation path (a listener cancels QuestRewardGrantEvent) is missing.

QuestObjectiveInstanceDAO v2 migration (ensureColumnExists, columnExists, serializeCustomData, deserializeCustomData) — customData_roundTripsThroughSaveAndLoad and saveObjectiveInstance_writesNullCustomData_whenEmpty cover serialize/deserialize. The deserializeCustomData malformed-JSON branch (returns empty map) is not tested. The columnExists PRAGMA fallback path is not tested.

QuestManager.getDefaultExtractionResources — covered by QuestManagerExtractionResourcesTest. ✓

QuestEventHandlerListTest — tests that every concrete event has a distinct HandlerList and that the abstract base has none. ✓


Findings

CONCERN: QuestRewardGranter.grantToOnlinePlayer has no corresponding test class.
WHY: This is the single grant-pathway consolidation point. The following branches are completely untested: (1) QuestRewardGrantEvent cancelled → returns empty list, nothing granted; (2) a listener adds a reward to the mutable list → the added reward is granted; (3) a listener removes a reward → removed reward is not granted; (4) one reward.grant() throws → remaining rewards still granted; (5) QuestRewardGrantedEvent fires only when at least one reward succeeds (does not fire on full failure or empty input); (6) empty rewards input → returns List.of() without firing any event. Every grant pathway now routes through this class, so untested branches here affect QuestInstance, RewardDistributionGranter, and PlayerJoinListener simultaneously.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java / no test file


CONCERN: PlayerJoinListenerTest has no test covering the path where a QuestRewardGrantEvent is cancelled for a pending-reward batch, verifying that the cancelled rows are NOT added to the granted set (i.e., retained for retry).
WHY: The refactor changed the contract: previously each reward was granted inline; now a listener cancel vetoes the entire batch and leaves rows in the DB. The existing tests do not exercise this path. The dequeue-based identity mapping and the cancelled-batch row-retention contract are both new behaviors introduced in this diff.
WHERE: src/test/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListenerTest.java / src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java


CONCERN: deserializeCustomData malformed-JSON branch is not tested.
WHY: The method has an explicit catch (RuntimeException) that returns an empty map and logs a warning. This is non-trivial defensive logic — a malformed stored row must not abort the entire quest load. There is no test that passes invalid JSON (e.g. "not-json", "{") and asserts an empty map is returned rather than an exception propagating.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java / src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java


CONCERN: ReloadableRarityConfig null-section guard (rarity key with empty body) and per-rarity RuntimeException catch are not tested.
WHY: Both are new defensive branches added in this diff. The null-section path (raritySection == null) and the RuntimeException catch around QuestRarity construction are non-trivial: they prevent a single malformed rarity from aborting all rarity parsing. No test constructs a YAML section with an empty-body rarity key or a rarity with an invalid field and asserts the remaining rarities still load.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java / no test file for this class


CONCERN: QuestBoardManager.parseResetDayOrDefault blank/null and invalid-value fallback paths are not tested.
WHY: This is a new non-trivial method (>3 lines, two branches: blank/null → WARNING + MONDAY, invalid name → IllegalArgumentException caught → WARNING + MONDAY). Previously an invalid value threw during onEnable and disabled McRPG. The fix is correct but has no regression test for either bad-input branch.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java / no test file


CONCERN: QuestBoardRotationTask.parseTimeOrDefault blank/null and malformed-time fallback paths are not tested.
WHY: This is a new non-trivial method (>3 lines, two branches) that prevents a DateTimeParseException crash loop on every tick. There is no test that constructs a QuestBoardRotationTask (or calls the method indirectly) with a blank or unparseable time string and asserts the task falls back to 00:00 without throwing.
WHERE: src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java / no test file for this class


CONCERN: QuestObjectiveInstance.setCustomData dirtying behavior is not verified.
WHY: The Javadoc states "setCustomData marks the owning quest dirty." The truncated QuestObjectiveInstanceTest diff cannot be fully read, but the visible portion of the test file does not show a test asserting that calling setCustomData(...) causes questInstance.isDirty() (or equivalent) to return true. markCustomDataDirty dirtying is similarly unverifiable from the truncated diff. If these are not tested, a regression that silently stops flushing custom-data mutations to the DB would go undetected.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java / src/test/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstanceTest.java


CONCERN: QuestEventHandlerListTest does not extend McRPGBaseTest but calls HandlerList static methods. This is acceptable per the known-infrastructure rule for simple Bukkit data classes — however, QuestRewardGrantEvent and QuestRewardGrantedEvent are new events added in this diff and their getHandlerList() methods are exercised by the test. The test does not verify that the events can actually be fired through Bukkit.getPluginManager().callEvent() without a running server. This is a coverage gap, not a structural violation.
WHY: The handler-list distinctness test confirms Bukkit registration metadata is correct, but no test fires QuestRewardGrantEvent or QuestRewardGrantedEvent through a MockBukkit server and asserts a registered listener receives it. Given the QuestCancelEvent bug this diff was specifically fixing (shared handler list silently dropping events), a fire-and-receive test provides stronger regression protection than a metadata-only check.
WHERE: src/test/java/us/eunoians/mcrpg/event/quest/QuestEventHandlerListTest.java / QuestRewardGrantEvent, QuestRewardGrantedEvent


CONCERN: BoardCategoryConfigLoaderTest.loadCategoriesFromDirectory_plainNumberAndUppercaseDurations contains an assertion comment stating "A bare number is seconds (McCore's getTimeInSeconds silently returned ZERO here)" but asserts Duration.ofSeconds(86400) — meaning the test asserts the fixed behavior, which is correct. However, there is no test for refresh-interval: 0 or completion-time: 0 (the zero-value config-driven path required by the checklist for config-driven values).
WHY: The checklist requires config-driven values to be tested with 0. A duration of zero could cause divide-by-zero or silent no-ops in rotation scheduling.
WHERE: src/test/java/us/eunoians/mcrpg/configuration/BoardCategoryConfigLoaderTest.java


Summary

Production files changed: QuestRewardGranter.java (new), RewardGrantContext.java (new), QuestObjectiveInstance.java, QuestObjectiveInstanceDAO.java, QuestBoardManager.java, QuestBoardRotationTask.java, QuestPool.java, QuestRarityRegistry.java, ReloadableRarityConfig.java, QuestTemplateConfigLoader.java, QuestConfigLoader.java, PlayerJoinListener.java, QuestInstance.java, RewardDistributionGranter.java, QuestManager.java, QuestCompleteEvent.java, QuestEvent.java, QuestExpireEvent.java, QuestObjectiveCompleteEvent.java, QuestObjectiveProgressEvent.java, QuestPhaseCompleteEvent.java, QuestRewardGrantEvent.java (new), QuestRewardGrantedEvent.java (new), QuestStageCompleteEvent.java, QuestStartEvent.java

Test files present: QuestEventHandlerListTest.java (new), QuestObjectiveInstanceDAOTest.java (extended), QuestObjectiveInstanceTest.java (extended, truncated), PlayerJoinListenerTest.java (extended), QuestManagerExtractionResourcesTest.java (new), QuestPoolTest.java (extended), QuestRarityRegistryTest.java (extended), BoardCategoryConfigLoaderTest.java (extended), QuestTemplateConfigLoaderTest.java (extended)

Coverage gaps: QuestRewardGranter (no test class — all branches uncovered); PlayerJoinListener cancellation path for pending rewards; deserializeCustomData malformed-JSON branch; ReloadableRarityConfig null-section and RuntimeException catch branches; QuestBoardManager.parseResetDayOrDefault bad-input branches; QuestBoardRotationTask.parseTimeOrDefault bad-input branches; QuestObjectiveInstance.setCustomData dirty-marking assertion; config-driven zero-value duration path in BoardCategoryConfigLoaderTest; fire-and-receive regression test for new QuestRewardGrantEvent/QuestRewardGrantedEvent

@github-actions

Copy link
Copy Markdown

Security Review

No security concerns found.

The diff is entirely composed of:

  • Documentation file renames and link corrections (.md, .cursor/rules, CLAUDE.md)
  • Event infrastructure fixes (per-concrete-class HandlerList to fix a Bukkit dispatch bug)
  • Config fail-safe parsing (parseResetDayOrDefault, parseTimeOrDefault, per-item try/catch in rarity/template loaders)
  • A new QuestRewardGranter collaborator that routes all reward grants through a cancellable Bukkit event
  • Database schema migration adding a custom_data TEXT column, serialized/deserialized via Gson with PreparedStatement parameters throughout — no string concatenation in query values
  • QuestObjectiveInstance custom-data map (ConcurrentHashMap, no user-controlled keys reach SQL or MiniMessage)
  • QuestPool empty-rarity sentinel fix and QuestRarityRegistry zero-weight fallback
  • Unit tests covering the above

Checked against every checklist item:

Checklist item Finding
MiniMessage.deserialize() on user input Not present in diff
User input → storage → deserialize() custom_data column stores server-computed Map<String,Object>; no player-controlled string reaches deserialize()
performCommand() / dispatchCommand() with user input Not present
Permission bypass in new handlers No new command handlers or GUI slot actions added
PreparedStatement vs. concatenation All DAO methods use ? parameters; DDL uses only the constant TABLE_NAME and the constant column name CUSTOM_DATA_COLUMN / addColumnSqlFragment — both are compile-time constants, not runtime user values
Player-provided bytes deserialized without validation deserializeCustomData reads from a server-owned DB column, not from player input; malformed JSON returns an empty map

A third-party listener can add a null to the mutable QuestRewardGrantEvent
reward list; the null grant() NPE was caught, but the catch handler then
dereferenced the null (reward.getKey()) and escaped, aborting the batch. Skip
null entries with a WARNING. Add a null-in-batch test and a malformed-custom_data
JSON load test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@github-actions

Copy link
Copy Markdown

Server Owner Review

Server Owner Review

I'm going through this diff looking at every YAML file, config key, migration path, reload behavior, and permission node that affects a live server.


CONCERN: board.yml comments reference /mcrpg admin reload but the actual command name is never confirmed in this diff, and the previous comments said /mcrpg quest admin reload. If the correct command is /mcrpg admin reload, the old comments were wrong and are now fixed — but if the command name changed in this PR, owners with muscle memory or documented runbooks will use the wrong command and get no feedback that it failed.
WHY: A server owner who types the old command gets either "unknown command" or a silent no-op, and never knows their config change didn't apply. This is especially painful for the rarities section which is supposed to be hot-reloadable.
WHERE: src/main/resources/quest-board/board.ymlrotation section comment and notifications section comment


CONCERN: board.yml config-version is not incremented despite structural changes — the rarities section now has new inline comments (specifically the "at least one rarity must have a positive weight" guard and the "hot-reloadable" note), and the behavior of missing/empty rarities has changed (empty supported-rarities is now the "all rarities" sentinel rather than a snapshot). No config-version bump is present.
WHY: An owner upgrading from a previous version will have a stale board.yml on disk. Without a version bump and migration path, they get no warning that their config semantics changed. A quest that previously omitted supported-rarities (and got a full snapshot at load time) now silently matches every rarity — behavior change with no notification.
WHERE: src/main/resources/quest-board/board.yml — top-level config-version key (absent from the diff entirely)


CONCERN: The rotation section in board.yml now documents three invalid-value fallback behaviors (time, timezone, weekly-reset-day) in comments, but none of these keys have a comment explaining the fallback is silent — only a console WARNING is produced. An owner who misconfigures weekly-reset-day: WEDENSDAY (typo) will see the board silently reset on Monday with no in-game indication.
WHY: The owner's board behaves differently from what they configured, and unless they are watching console during startup they will never know. The comment says "logs a WARNING" but doesn't tell them where to look or that the value was rejected.
WHERE: src/main/resources/quest-board/board.ymlrotation.weekly-reset-day, rotation.time, rotation.timezone


CONCERN: The QuestObjectiveInstanceDAO table is bumped to version 2 with a new custom_data TEXT column, but there is no corresponding entry in UpdateTableFunction — the migration is implemented as raw ensureColumnExists / ALTER TABLE logic directly inside the DAO. The checklist requires UpdateTableFunction for every DB schema change.
WHY: If the project's other DAOs use UpdateTableFunction for auditing or rollback tracking, this migration is invisible to that system. A server owner who looks at migration logs or uses a DB inspection tool will see version 1→2 in TableVersionHistoryDAO but no corresponding UpdateTableFunction record, making the upgrade history inconsistent and harder to diagnose if something goes wrong.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.javaattemptCreateTable / ensureColumnExists


CONCERN: personal-daily.yml, personal-weekly.yml, shared-daily.yml, shared-weekly.yml, land-daily.yml, and land-weekly.yml all have new # Duration format: comments added to some duration keys but not all. For example, completion-time in personal-daily.yml has no duration-format comment but refresh-interval does. An owner reading top-to-bottom will learn the format for refresh-interval but not know it applies equally to completion-time and appearance-cooldown.
WHY: An owner who sets completion-time: 3 days (natural language) on a key without a comment gets a silent zero or parse error, losing the intended completion window for all players in that category.
WHERE: src/main/resources/quest-board/categories/personal-daily.ymlcompletion-time key; same pattern in shared-daily.yml, personal-weekly.yml, shared-weekly.yml


CONCERN: Two empty files (quest/quests_v2.yml and quest/upgrade_quests.yml) are deleted. If any existing server has these files on disk (extracted from a previous version), they will remain as orphaned files. The deletion is not called out in a migration note or upgrade guide.
WHY: Orphaned files in the quests directory will be picked up by the YAML directory walker on the next startup and may produce parse errors or no-op loads. An owner who installed a previous version and upgrades will have these files sitting in their plugins/McRPG/quest/ folder forever, silently wasting load time or producing spurious warnings.
WHERE: src/main/resources/quest/quests_v2.yml and src/main/resources/quest/upgrade_quests.yml (deleted)


CONCERN: The .md guide files (REWARDS.md, OBJECTIVES.md, TEMPLATES.md, etc.) are moved from src/main/java/us/eunoians/mcrpg/quest/ to src/main/resources/quests/ and are now extracted to plugins/McRPG/quests/. Any owner who bookmarked or referenced these files at the old in-jar path (e.g. a previous extraction put them in plugins/McRPG/quest/) will have both the old location (stale, from a previous extract) and the new location on disk simultaneously.
WHY: An owner reading the old file in plugins/McRPG/quest/REWARDS.md gets outdated documentation. The .extracted-defaults marker only prevents re-extraction of files that were already extracted — it does not delete or supersede old paths. The owner has two conflicting copies.
WHERE: All renamed .md resources; src/main/java/us/eunoians/mcrpg/quest/src/main/resources/quests/


CONCERN: The new QuestRewardGrantEvent allows third-party listeners to insert null into the mutable reward list. The granter handles this defensively with a null-check and a WARNING, but the WARNING message says "Skipping a null reward inserted into the grant batch" — a server owner who sees this in console has no idea which plugin inserted the null.
WHY: Diagnosing a misbehaving addon is nearly impossible without the plugin name. The owner may disable the wrong plugin or open a bug report against McRPG itself.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java — null-check WARNING log


CONCERN: The ReloadableRarityConfig, ReloadableCategoryConfig, and ReloadableTemplateConfig are now registered with ReloadableContentManager inside QuestBoardManager.initialize. However, the diff does not show whether these three classes were already registered elsewhere before this PR. If they were previously unregistered, then /mcrpg admin reload silently did nothing for board config changes on all previous versions — but this PR fixes that silently with no upgrade note.
WHY: An owner who has been reloading and believing their rarity/category/template changes applied will be surprised to learn they were restarting for nothing — and may now be surprised that a reload does change live board behavior mid-rotation.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.javainitialize method, reloadableContentManager.trackReloadableContent(...) calls


CONCERN: The board.yml rarities section comment says "These keys are hot-reloadable via /mcrpg admin reload; edits apply to the next rotation." — but the rarity weights affect which rarity is rolled, not just display. If an owner reloads mid-rotation with different weights, the currently-displayed rarities (already assigned to slots) remain unchanged; only future rotations use new weights. The comment does not explain this nuance.
WHY: An owner who changes LEGENDARY weight from 5 to 0 and reloads will still see legendary quests on the board until the next rotation fires. They may believe the reload didn't work and restart the server, causing an unnecessary disruption.
WHERE: src/main/resources/quest-board/board.ymlrarities section hot-reload comment


Summary

Migration required: YES

  • Table mcrpg_quest_objective_instances gains a new custom_data TEXT column (automated via ensureColumnExists, but implemented outside UpdateTableFunction)
  • supported-rarities: [] (omitted) behavior changed: previously snapshotted all registered rarities; now treated as "match any rarity" sentinel — silent behavior change for existing quest YAML files that omit this key
  • Two resource files deleted from quest/ path; orphaned on disk for upgrading servers

Reload-safe: PARTIAL

  • rarities, categories, templates in board.yml are now properly registered and hot-reloadable (newly fixed in this PR)
  • rotation.* keys (time, timezone, weekly-reset-day) are parsed at task construction — restart required, correctly documented in board.yml comments
  • notifications.* keys — reload-safe per comments, but not independently verified in this diff that they are wrapped in ReloadableContent

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — the QuestObjectiveInstance public constructor gains a new required @NotNull Map<String,Object> customData parameter, which breaks any addon that instantiates it directly.


CONCERN: Public constructor of QuestObjectiveInstance has a new required parameter with no backward-compatible overload.
WHY: Any addon that constructs a QuestObjectiveInstance directly (e.g., a custom objective type that creates instances in tests or factory methods) will fail to compile against the new binary. There is no deprecated forwarding constructor that supplies an empty map as the default.
WHERE: QuestObjectiveInstance.java — constructor QuestObjectiveInstance(NamespacedKey, UUID, QuestStageInstance, QuestObjectiveState, Long, Long, long, long, Map<UUID,Long>, Map<String,Object>)


CONCERN: QuestRewardType interface apparently gains isScalable() and withExactAmount(long) methods (evidenced by the test stub implementing them) with no default implementation visible in the diff.
WHY: If these are new abstract methods on the QuestRewardType interface, every existing addon that implements QuestRewardType (to register a custom reward type) will fail to compile or will throw AbstractMethodError at runtime. The diff shows two test stubs adding these methods but the interface declaration itself is not shown, making it impossible to confirm a default is present.
WHERE: QuestRewardType interface — isScalable() and withExactAmount(long exactAmount) — evidenced in QuestRewardDistributionResolverPotBehaviorTest.java


CONCERN: QuestRewardGranter.grantToOnlinePlayer accepts a raw mutable List<QuestRewardType> from QuestRewardGrantEvent.getRewards(), and the Javadoc warns listeners not to insert null, but there is no @NotNull contract on list elements and no compile-time enforcement.
WHY: An addon listener that calls event.getRewards().add(myReward) where myReward could be null will silently produce a null in the batch. The granter handles it defensively with a log+skip, but the contract is documented only in prose. Addon developers reading just the method signature have no type-level signal that null elements are forbidden.
WHERE: QuestRewardGrantEvent.javagetRewards() return type; QuestRewardGranter.javagrantToOnlinePlayer parameter rewards


CONCERN: QuestRewardGrantEvent does not carry the AbilityHolder / McRPGPlayer — only a raw UUID — forcing addon listeners to perform their own Bukkit.getPlayer(uuid) lookup and risk acting on a player who just disconnected between event fire and listener execution.
WHY: The checklist requires custom events to carry enough context for an external listener to act without re-computing internal state. For reward interception use cases (e.g., checking McRPG skill level to scale rewards), the listener must re-fetch and re-cast the player to McRPGPlayer, duplicating internal lookup logic.
WHERE: QuestRewardGrantEvent.java — constructor and getPlayerUUID() (no getPlayer() or getMcRPGPlayer() accessor)


CONCERN: QuestEvent abstract base has its getHandlers() / getHandlerList() removed without a deprecation notice or documentation that existing addons listening on QuestEvent directly will silently stop receiving events.
WHY: Any addon that registered a @EventHandler on QuestEvent (the abstract base) was already broken by the original shared-list bug, but the removal is a silent behavioral change with no migration guide. The Javadoc added to QuestEvent does document that listening on the base is unsupported, which is good, but there is no @deprecated tag on the removed methods and no @since on the new restriction.
WHERE: QuestEvent.java — removal of getHandlers() and getHandlerList()


CONCERN: RewardGrantContext is a non-sealed enum with a Javadoc note that "this set of constants may grow," but no default branch is enforced. Addon switch expressions over RewardGrantContext will produce an IncompatibleClassChangeError or compiler warning when new constants are added.
WHY: This is correctly documented in prose, but there is no technical enforcement. An addon compiled against the current three-value enum with an exhaustive switch expression (Java 14+ pattern switch without default) will fail at runtime when a fourth context is added.
WHERE: RewardGrantContext.java — enum declaration (no sealed or tooling hint beyond the prose warning)


CONCERN: QuestManager.getDefaultExtractionResources() is package-private (static List<String>), not public, even though the test accesses it directly and the Javadoc says "package-private so the resource list can be validated in tests." This is intentional but means addons cannot enumerate the shipped default resources without reflection.
WHY: Minor extensibility gap: an addon that wants to verify its own bundled resources follow the same extraction convention cannot call this method. Not a breaking change, but a missed extension opportunity given it is already documented.
WHERE: QuestManager.javastatic List<String> getDefaultExtractionResources()

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Coverage Analysis

New Production Classes / Methods with Non-Trivial Logic

QuestRewardGranter — 93-line class, central grant pathway, fires two events, isolates per-reward failures, handles null-insertion defense.

RewardGrantContext — pure enum, no logic to test.

QuestRewardGrantEvent / QuestRewardGrantedEvent — event POJOs; the only non-trivial behavior is that QuestRewardGrantedEvent stores a defensive List.copyOf() in the constructor.

QuestObjectiveInstance.getCustomData() / setCustomData() / markCustomDataDirty() — new public methods.

QuestBoardManager.parseResetDayOrDefault() — private, tested indirectly via QuestBoardManager.initialize().

QuestBoardRotationTask.parseTimeOrDefault() — private.

QuestRarityRegistry.rollRarity() zero-weight branch — new branch in existing method.

QuestObjectiveInstanceDAO.serializeCustomData() / deserializeCustomData() / ensureColumnExists() / columnExists() — private helpers, tested indirectly.

QuestManager.getDefaultExtractionResources() — package-private, has a dedicated test.

PlayerJoinListener.grantRewards() — significantly refactored (groups by quest key, uses QuestRewardGranter, identity-map for duplicate instances).


CONCERN: QuestRewardGranter.grantToOnlinePlayer() has no direct unit tests — the pre-event firing, cancellation path, null-reward-in-list defense, per-reward failure isolation, and the QuestRewardGrantedEvent post-fire (only when !granted.isEmpty()) are all untested.
WHY: This is the single consolidation point for all three grant pathways. The cancel branch (grantEvent.isCancelled() → returns empty list), the null-reward guard, and the "post-event fires only on non-empty granted list" branch are non-trivial. None of the existing tests (PlayerJoinListenerTest, QuestObjectiveInstanceDAOTest) exercise QuestRewardGranter directly — they test the callers. A listener cancelling the event, a null inserted into the list, and a grant() throw are all untested code paths.
WHERE: src/main/java/us/eunoians/mcrpg/quest/reward/QuestRewardGranter.java / no corresponding test class


CONCERN: QuestRewardGrantedEvent.getGrantedRewards() returns a List.copyOf() snapshot — but there is no test confirming that mutations to the original list after construction do not affect the returned list.
WHY: The Javadoc advertises immutability ("immutable list"). Without a test, a future refactor that drops List.copyOf() is invisible to CI.
WHERE: src/main/java/us/eunoians/mcrpg/event/quest/QuestRewardGrantedEvent.java / no test


CONCERN: QuestRarityRegistry.rollRarity() zero-weight branch is untested.
WHY: The new branch (totalWeight <= 0 → uniform random pick + warning) is reachable when all rarity weights are 0. No existing test exercises the zero-weight code path.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/rarity/QuestRarityRegistry.java / no test covers totalWeight == 0


CONCERN: QuestBoardRotationTask.parseTimeOrDefault() — the null/blank branch and the malformed-string branch are untested.
WHY: parseZoneOrDefault has tests in the existing QuestBoardRotationTaskTest. The parallel parseTimeOrDefault follows the same pattern and has the same failure modes (per-tick crash loop if it throws), but no test covers the blank or invalid-string cases.
WHERE: src/main/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTask.java / src/test/java/us/eunoians/mcrpg/task/board/QuestBoardRotationTaskTest.java


CONCERN: QuestBoardManager.parseResetDayOrDefault() — the null/blank branch and the invalid-day-name branch are untested.
WHY: This is a new fail-safe introduced precisely because a bad weekly-reset-day value previously threw during onEnable and disabled all of McRPG. The two defensive branches (null-or-blank → MONDAY, IllegalArgumentException → MONDAY + warning) are not covered by any test.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java / no test


CONCERN: QuestObjectiveInstance.setCustomData() is a public method with non-trivial side effects (defensive copy + markDirty()), but has no test.
WHY: The contract (defensive copy, dirty-marking) is meaningful and breakable. getCustomData() and markCustomDataDirty() are covered by the DAO round-trip test only indirectly.
WHERE: src/main/java/us/eunoians/mcrpg/quest/impl/objective/QuestObjectiveInstance.java / no test for setCustomData


CONCERN: ReloadableRarityConfig's new null-section guard and per-rarity RuntimeException catch are untested.
WHY: Both are fail-safe branches added to prevent a single malformed rarity from aborting all board initialization. Neither the "rarity key with no body" path nor the "malformed rarity body throws" path is exercised by any test.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/configuration/ReloadableRarityConfig.java / no test


CONCERN: QuestConfigLoader — the "empty supportedRarities set is the all-rarities sentinel" change (removal of the else branch that snapshot-populated from the registry) has a QuestPoolTest covering the QuestPool side, but no test covers the QuestConfigLoader itself to confirm that omitting supported-rarities from YAML now yields an empty set rather than a populated one.
WHY: The logic inversion (empty = all) was specifically called out as a bug fix. The existing QuestConfigLoaderTest does not appear to assert on BoardMetadata.supportedRarities() when the key is absent.
WHERE: src/main/java/us/eunoians/mcrpg/configuration/QuestConfigLoader.java / QuestConfigLoaderTest


CONCERN: PlayerJoinListener.grantRewards() — the QuestRewardGrantEvent cancellation path is not tested. When the pre-grant event is cancelled for a quest-key batch, no rows in that batch should be added to grantedRewardIds, so they survive for retry. This is new behavior and the existing PlayerJoinListenerTest does not register a cancelling listener.
WHY: The comment in the refactored listener explicitly documents that a cancelled event causes rows to be retained. Without a test, a future refactor that adds cancelled IDs to the deletion set would not be caught.
WHERE: src/main/java/us/eunoians/mcrpg/listener/entity/player/PlayerJoinListener.java / PlayerJoinListenerTest


Test Naming Convention

CONCERN: QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad() has the _whenCondition in the @DisplayName written as a Given/When/Then sentence ("Given custom data, when saving then loading, then the structured data round-trips") rather than a short descriptive label.
WHY: The McRPG convention (per the checklist and BaseAbilityTest reference) is short labels such as "custom data round-trips through save and load", not Given/When/Then sentences. The same issue applies to loadObjectiveInstances_toleratesMalformedCustomDataJson and saveObjectiveInstance_writesNullCustomData_whenEmpty whose @DisplayNames also use Given/When/Then framing.
WHERE: src/test/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAOTest.java


TimeProvider / Time-dependent Behavior

No new time-dependent logic calls System.currentTimeMillis() or Instant.now() directly — the rotation task already routes through getTimeProvider(). No new time-dependent tests were added without a mock TimeProvider. No concerns here.


McRPG Test Structure

QuestManagerExtractionResourcesTest and QuestEventHandlerListTest extend no base class and use neither MockBukkit nor McRPGPlayer infrastructure — correct per the "pure Java" rule.

QuestEventHandlerListTest uses simple reflection; no MockBukkit needed. Valid.

No MockBukkit.unmock() in @AfterEach found in the new tests. No concern.


Bukkit-Dependent vs. Pure-Java Separation

No new test extends McRPGBaseTest unnecessarily. No concern.


Test Quality — Assertions

All new test methods contain at least one assertion. No concern.


Summary

Production files changed: QuestRewardGranter.java, RewardGrantContext.java, QuestRewardGrantEvent.java, QuestRewardGrantedEvent.java, QuestObjectiveInstance.java, QuestObjectiveInstanceDAO.java, QuestBoardManager.java, QuestBoardRotationTask.java, QuestRarityRegistry.java, QuestConfigLoader.java, QuestPool.java, QuestEvent.java + concrete subclasses (HandlerList fix), PlayerJoinListener.java, QuestManager.java, RewardDistributionGranter.java, QuestInstance.java, ReloadableRarityConfig.java, QuestTemplateConfigLoader.java, BoardCategoryConfigLoader.java

Test files present: QuestObjectiveInstanceDAOTest.java, QuestEventHandlerListTest.java, PlayerJoinListenerTest.java, QuestManagerExtractionResourcesTest.java, QuestPoolTest.java, BoardCategoryConfigLoaderTest.java, QuestTemplateConfigLoaderTest.java

Coverage gaps:

  1. QuestRewardGranter — no direct tests (cancel branch, null-reward guard, post-event fire condition, per-reward failure isolation)
  2. QuestRewardGrantedEvent — immutability of getGrantedRewards() untested
  3. QuestRarityRegistry.rollRarity() — zero-weight branch untested
  4. QuestBoardRotationTask.parseTimeOrDefault() — null/blank and invalid-string branches untested
  5. QuestBoardManager.parseResetDayOrDefault() — null/blank and invalid-day branches untested
  6. QuestObjectiveInstance.setCustomData() — defensive-copy + dirty-mark contract untested
  7. ReloadableRarityConfig — null-section and per-rarity-exception branches untested
  8. QuestConfigLoader — absent supported-rarities key now yields empty set (regression test missing)
  9. PlayerJoinListener.grantRewards()QuestRewardGrantEvent cancellation path untested
  10. QuestObjectiveInstanceDAOTest @DisplayNames use Given/When/Then sentences instead of short labels

@github-actions

Copy link
Copy Markdown

Security Review

Security Review

Applying all checklist items to the changed code.


Adventure API / MiniMessage Injection

No user-controlled strings (chat, item names, sign text, book content, anvil renames) are passed to MiniMessage.deserialize() or getMiniMessage().deserialize() in this diff. All new player-facing strings flow through existing localization infrastructure. No injection surface added.


Command Injection via performCommand() / dispatchCommand()

No calls to player.performCommand(...) or Bukkit.dispatchCommand(...) with any concatenated value appear in this diff.


Permission Bypass

No new command handlers or GUI slot actions are introduced. No player.isOp() usage. No onClick() returning false without justification. No privileged actions failing silently on missing permission.


SQL / Data Injection

src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.java

DDL concatenation in ensureColumnExists() — SEVERITY: LOW

TABLE_NAME and addColumnSqlFragment are concatenated directly into an ALTER TABLE statement passed to prepareStatement(). TABLE_NAME is a private static final constant ("mcrpg_quest_objective_instances") and addColumnSqlFragment is always the literal "TEXT" at every call site in this diff — both are compile-time server-controlled constants with no player-reachable path. However, the method signature accepts arbitrary String parameters, so a future call site that passes either value from a player-controlled source (e.g. a dynamic column name derived from a registered content key) would silently introduce SQL injection. The method is private static, which limits exposure, but the pattern is fragile.

- try (PreparedStatement ps = connection.prepareStatement(
-         "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + addColumnSqlFragment)) {
+ // columnName and addColumnSqlFragment must be server-controlled constants.
+ // DDL cannot use bind parameters; validate both against an allowlist before concatenation.
+ if (!ALLOWED_COLUMNS.containsKey(columnName)) {
+     throw new IllegalArgumentException("Attempted DDL with unexpected column name: " + columnName);
+ }
+ try (PreparedStatement ps = connection.prepareStatement(
+         "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + columnName + " " + ALLOWED_COLUMNS.get(columnName))) {

AI Agent Prompt: In QuestObjectiveInstanceDAO.java, the ensureColumnExists() method (around line 107) concatenates columnName and addColumnSqlFragment directly into a DDL string. Although both are constants today, the pattern is fragile for future callers. Add a private static final Map<String, String> ALLOWED_COLUMNS = Map.of(CUSTOM_DATA_COLUMN, "TEXT") and validate columnName against it before concatenation, throwing IllegalArgumentException on an unexpected name. This eliminates any future risk if a call site is added that passes a dynamic value. Legitimate players are unaffected — this is a server-startup migration path only. No new imports are required beyond java.util.Map.


PRAGMA table_info(...) concatenation in columnExists() — SEVERITY: LOW

TABLE_NAME is concatenated into a PRAGMA statement. Same analysis as above: constant today, fragile pattern. PreparedStatement cannot parameterize PRAGMA or DDL, so the fix is the same allowlist guard.

- try (PreparedStatement ps = connection.prepareStatement("PRAGMA table_info(" + TABLE_NAME + ")");
+ // TABLE_NAME is a compile-time constant; guard it explicitly for future-proofing.
+ if (!TABLE_NAME.matches("[a-z_]+")) {
+     throw new IllegalStateException("Unexpected table name: " + TABLE_NAME);
+ }
+ try (PreparedStatement ps = connection.prepareStatement("PRAGMA table_info(" + TABLE_NAME + ")");

AI Agent Prompt: In QuestObjectiveInstanceDAO.java, the columnExists() method (around line 125) concatenates TABLE_NAME into a SQLite PRAGMA statement. TABLE_NAME is a private constant today, but add a compile-time guard — a regex assertion TABLE_NAME.matches("[a-z_]+") throwing IllegalStateException on failure — immediately before the prepareStatement call. This makes the safety assumption explicit and self-documenting, protects against accidental reassignment, and costs nothing at runtime since TABLE_NAME is a constant. No new imports needed.


Custom Data Deserialization

deserializeCustomData() — SEVERITY: LOW

The method deserializes a TEXT column value (a JSON string) from the database using Gson into Map<String, Object>. This value originates from a previous server-controlled serialization of QuestObjectiveInstance.getCustomData(), which is populated by QuestObjectiveType implementations. A malicious player cannot write arbitrary JSON to this column via any of the threat-model inputs (chat, commands, GUIs, signs, books, anvil renames). The data path is: server-side QuestObjectiveType.processProgress()getCustomData().put(...) → serialization → DB column → deserialization. No player input enters this pipeline.

The parsed.values().removeIf(Objects::isNull) null-scrubbing and the RuntimeException catch are defensive measures, not indicators of a trust boundary violation. No finding.


No Additional Security Concerns Found

All other changes in this diff are documentation relocations, YAML comment additions, Bukkit event handler list fixes, config fail-safe parsing improvements, and test additions — none of which touch a player-controlled data path.

claude added 2 commits July 15, 2026 16:19
Revert the per-subclass HandlerList split: the abstract QuestEvent base now
owns the single shared handler list and every concrete subclass inherits it,
matching Bukkit's own EntityDamageEvent / EntityDamageByEntityEvent idiom. A
listener on the base type again receives every quest event (including
QuestCancelEvent, the original divergence bug), while a subclass listener stays
filtered to its own subtype via Bukkit's isAssignableFrom executor guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
Treat objective custom data as part of the initial schema instead of a v2
upgrade: the column is already in the CREATE TABLE, so the migration branch and
the SQLite-only column-existence probe (PRAGMA table_info) are removed. This
avoids leaning on SQLite-specific metadata that would complicate future MySQL
support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7RB9ru7v2CnKyY8aAVTPu
@github-actions

Copy link
Copy Markdown

Server Owner Review

Server Owner Review


CONCERN: QuestCancelEvent loses its own HandlerList and now inherits the shared one from QuestEvent, but QuestRewardGrantEvent and QuestRewardGrantedEvent are brand-new standalone Event subclasses (not under QuestEvent) with their own private HANDLERS. The two dispatch models now coexist with no in-YAML documentation or server-owner-facing explanation of what fires when.
WHY: A server owner running a permission/reward plugin that hooks QuestRewardGrantEvent has no config-visible way to verify the event fires — if they cancel rewards via a config flag in their addon and the quest board silently re-queues them, they'll get duplicate grants on next login. No YAML comment, no board.yml key, nothing they can read explains the pending-reward retry behavior.
WHERE: src/main/resources/quest-board/board.yml — there is no # NOTE: block explaining that cancelled QuestRewardGrantEvents leave pending rewards in the database for retry on next login.


CONCERN: board.yml now says reload-safe values use /mcrpg admin reload (corrected from /mcrpg quest admin reload), but the rotation: section says changes require a restart with the comment NOT hot-reloadable via /mcrpg admin reload. The rarities: section comment says "edits apply to the next rotation" implying hot-reload works. It is unclear to a server owner whether changing a rarity weight takes effect immediately, on next rotation, or on restart.
WHY: A server owner who edits rarity weights and runs /mcrpg admin reload may not see the change reflected until the next rotation cycle fires — which could be 24 hours later. The comment "edits apply to the next rotation" is ambiguous: does "next rotation" mean the next scheduled tick, or the next time the board fully rotates (tomorrow)? A misread could lead to an owner restarting the server unnecessarily during live play.
WHERE: src/main/resources/quest-board/board.ymlrarities: section comment block


CONCERN: The rotation.time key comment now says An invalid value falls back to "00:00" and logs a console WARNING, but the board checks rotation time on every scheduler tick (every task-check-interval-seconds). If an owner fat-fingers the time and restarts without checking logs, the board will silently rotate at midnight instead of the configured time — with no in-game signal and no repeat warning after startup.
WHY: An owner who sets time: "6:00" (missing leading zero) will get midnight rotations forever. The WARNING fires once at startup; if they miss it in a busy console log, they'll have no idea why quests reset at the wrong time. No YAML comment tells them the leading zero is required.
WHERE: src/main/resources/quest-board/board.ymlrotation.time — comment should read (leading zero required: "06:00" not "6:00")

(This is partially addressed in the diff — the comment does mention leading zero — but the fallback behavior and single-warning-only nature is not mentioned.)


CONCERN: New custom_data TEXT column is added to mcrpg_quest_objective_instances table with no UpdateTableFunction migration path visible in the diff. The CURRENT_TABLE_VERSION in QuestObjectiveInstanceDAO remains 1.
WHY: Servers upgrading from the previous version will have an existing mcrpg_quest_objective_instances table without the custom_data column. The CREATE TABLE IF NOT EXISTS in createTable() will no-op because the table already exists. Any INSERT ... (custom_data) or SELECT ... custom_data will throw SQLException: no such column: custom_data at runtime, breaking quest loading for every player on the server.
WHERE: src/main/java/us/eunoians/mcrpg/database/table/quest/QuestObjectiveInstanceDAO.javaCURRENT_TABLE_VERSION should be incremented to 2 and an UpdateTableFunction for version 1→2 (ALTER TABLE mcrpg_quest_objective_instances ADD COLUMN custom_data TEXT) must be registered.

Migration required: YES
Reload-safe: N/A (database schema)


CONCERN: ReloadableRarityConfig, ReloadableCategoryConfig, and ReloadableTemplateConfig are now registered with ReloadableContentManager inside QuestBoardManager.initialize(), but only if initialize() is called. If board initialization fails partway through (e.g. malformed board.yml), these three ReloadableContent instances may never be registered, meaning /mcrpg admin reload silently no-ops for board configuration even though the plugin continues running in a degraded state.
WHY: An owner who fixes a bad board.yml and runs /mcrpg admin reload instead of restarting would see no effect — the board stays broken. No console message would explain why reload didn't work. They'd need to restart the server to apply the fix.
WHERE: src/main/java/us/eunoians/mcrpg/quest/board/QuestBoardManager.java → the reloadableContentManager.trackReloadableContent(...) block near line 194


CONCERN: The .extracted-defaults marker file mechanism silently re-extracts tutorial and guide files on every startup if the marker is unreadable, now including 8 new tutorial quest files and 8 guide .md files. The new WARNING log added for a failed marker read is good, but there is no # NOTE: in example_quest.yml or any extracted YAML warning owners that these files are overwritten on re-extraction if they've been customized.
WHY: An owner who customizes quests/tutorial/chain.yml and then has a disk hiccup that corrupts the marker file will lose their customizations on the next restart when the files are re-extracted from the jar. The guide .md files are presumably always safe to overwrite, but the quest YAML files are owner-editable.
WHERE: src/main/resources/quests/example_quest.yml comment block; QuestManager.javaextractDefaultResources() — the marker write failure log is new (good) but the overwrite behavior on re-extraction is undocumented from the owner's perspective.


CONCERN: Six category YAML files (land-daily.yml, land-weekly.yml, personal-daily.yml, personal-weekly.yml, shared-daily.yml, shared-weekly.yml) now have # Duration format: comments added to refresh-interval and appearance-cooldown, but completion-time in land-daily.yml, land-weekly.yml, personal-daily.yml etc. is not annotated with the same duration format comment. An owner editing completion-time: 3d in land-daily.yml has no format hint on that specific key.
WHY: Inconsistent commenting means an owner who looks at completion-time and sees no format hint may try completion-time: 72h or completion-time: 259200 and not know if bare numbers work. This is a readability issue, not a breakage, but it violates the checklist requirement that every key have a comment.
WHERE: src/main/resources/quest-board/categories/land-daily.ymlcompletion-time, src/main/resources/quest-board/categories/land-weekly.ymlcompletion-time, and the other category files.


CONCERN: QuestPool.getEligibleDefinitions now treats an empty supportedRarities set as "matches all rarities" (the new sentinel semantics), but QUEST-DEFINITIONS.md and BOARD-INTEGRATION.md are shipped to the server's data folder. If those files still describe supported-rarities: [] as "no supported rarities" (old behavior), owners writing new quest definitions will be confused about whether omitting the key or setting it to empty means "all" or "none."
WHY: A server owner authoring a custom quest who reads the old guide and writes supported-rarities: [] expecting it to exclude the quest from the board will instead get a quest that appears for every rarity. Players could receive quests the owner intended to be disabled.
WHERE: src/main/resources/quests/BOARD-INTEGRATION.md and src/main/resources/quests/QUEST-DEFINITIONS.md — these need to document that omitting supported-rarities or setting it to an empty list means "eligible for all rarities."


Summary Table

# Severity Migration required Reload-safe
DB schema: custom_data column with no migration CRITICAL YES NO
QuestRewardGrantEvent cancel/retry not documented in YAML High NO N/A
Empty supported-rarities sentinel undocumented in shipped guides High NO YES
ReloadableContent registration may be skipped on partial init failure Medium NO PARTIAL
Re-extraction overwrites owner-edited tutorial quest files silently Medium NO N/A
rotation.time fallback is one-warning-only, not in-YAML Low NO NO
completion-time missing duration format comments in category files Low NO YES
Reload command correctness in board.yml comments (partial) Low NO YES

@github-actions

Copy link
Copy Markdown

Extensibility Review

Breaking change risk: LOW — one public constructor signature on QuestObjectiveInstance gains a required parameter, and QuestCancelEvent loses its own HandlerList in favor of the shared base-class list.


CONCERN: QuestObjectiveInstance public constructor has a new required parameter with no @Deprecated backward-compatible overload
WHY: Any addon that constructs QuestObjectiveInstance directly (e.g., a custom QuestObjectiveType that manufactures instances in tests or factory logic) will fail to compile against the new binary. The old 8-parameter form is gone with no @Deprecated alias. Even if direct construction is rare, it is a public API surface.
WHERE: QuestObjectiveInstance.java — the full constructor (questObjectiveKey, questObjectiveUUID, questStage, questObjectiveState, startTime, endTime, requiredProgression, currentProgression, playerContributionTracker, customData added)


CONCERN: QuestCancelEvent drops its own HandlerList — listeners registered specifically to QuestCancelEvent.getHandlerList() before this change now share the base list, which is a silent behavioral change for any addon that called QuestCancelEvent.getHandlerList().unregisterAll() to clean up selectively
WHY: An addon that called QuestCancelEvent.getHandlerList().unregisterAll() expecting to remove only its cancel listener would have been targeting the (now-removed) per-class list. After this change that same call resolves to QuestEvent.getHandlerList() and unregisters every quest-event listener from every plugin — a silent blast-radius expansion. The static method is still callable (inherited), so it compiles fine, making this particularly hard to notice.
WHERE: QuestCancelEvent.javaHANDLERS field and getHandlerList() removed; QuestEvent.javagetHandlerList() now returns the shared list


CONCERN: QuestRewardGrantEvent.getRewards() returns a mutable List but the Javadoc does not specify the concrete type or thread-safety guarantees, and a listener inserting null is silently skipped rather than rejected with a clear contract
WHY: Addon developers mutating the list (the documented use case) cannot rely on list identity across priority levels being stable in all JVM implementations. More critically, the contract "null entries are silently skipped" is only in QuestRewardGranter source — not in the event Javadoc. An addon that accidentally adds null to the list gets a WARNING log but no exception; the Javadoc on getRewards() should state this explicitly so addon authors know the safe mutation contract.
WHERE: QuestRewardGrantEvent.javagetRewards() Javadoc; QuestRewardGranter.java — null-reward guard


CONCERN: RewardGrantContext is a non-sealed enum with a Javadoc note that it "may grow," but there is no guidance anywhere in the event Javadoc that QuestRewardGrantEvent.getContext() can return a value unknown to the addon's compile-time version
WHY: An addon that switch-es on RewardGrantContext without a default branch will throw MatchException (Java 21 exhaustive switch) or be silently incomplete (pre-21 switch) when a new constant is added. The enum itself documents this, but the event Javadoc for getContext() does not repeat the warning, so an addon author reading only the event API will miss it.
WHERE: QuestRewardGrantEvent.javagetContext() Javadoc; QuestRewardGrantedEvent.javagetContext() Javadoc


CONCERN: QuestRewardGrantedEvent constructor accepts List<QuestRewardType> and immediately calls List.copyOf(), but the Javadoc says the list is "immutable" without noting it is a defensive copy — an addon that retains the reference returned by getGrantedRewards() across tick boundaries is safe, but this is not stated, and there is no @NotNull or mutation-safety note explaining the immutability source
WHY: Minor documentation gap: addon authors writing audit listeners that cache the granted-rewards list need to know it is safe to hold. Currently they must read the constructor source to verify List.copyOf() was used.
WHERE: QuestRewardGrantedEvent.javagetGrantedRewards() Javadoc


CONCERN: QuestObjectiveInstance.getCustomData() returns a live ConcurrentHashMap and the Javadoc warns about the Double-after-round-trip problem, but there is no @NotNull/@Nullable annotation on the return type at the method declaration
WHY: Addon QuestObjectiveType implementations accessing getCustomData() without null-checking would be fine at runtime (it never returns null), but without @NotNull the annotation processor and IDE will not flag unsafe dereferences in addon code. Inconsistent with the rest of the public API surface which uses @NotNull/@Nullable throughout.
WHERE: QuestObjectiveInstance.javagetCustomData() return type (annotation present in Javadoc prose but missing as a method-level @NotNull annotation on the declaration in the diff as shown)


CONCERN: QuestManager.getDefaultExtractionResources() is package-private (static List<String>), not public, so the test-visible contract cannot be relied on by addon code — but more importantly, there is no registry or extension point for a third-party expansion to inject its own default resources into the extraction list
WHY: An expansion that ships bundled YAML files it wants extracted to the data folder on first install has no hook into the extraction mechanism. It must either replicate the marker-file logic itself or use saveResource directly — there is no ContentExpansion override or registry slot for "default resources to extract."
WHERE: QuestManager.javagetDefaultExtractionResources() (package-private); no corresponding extension point in ContentExpansion or QuestObjectiveTypeContentPack/QuestRewardTypeContentPack

@github-actions

Copy link
Copy Markdown

Testing Review

I'll systematically apply every checklist item to the diff.


Coverage Analysis

Production files with non-trivial new logic

Production class Key logic Test present?
QuestRewardGranter fires events, honors cancellation, isolates per-reward failures, null-entry guard QuestRewardGranterTest — yes
QuestRewardGrantEvent cancellable event, mutable reward list QuestRewardGranterTest — yes
QuestRewardGrantedEvent immutable snapshot, fires only when ≥1 granted QuestRewardGranterTest — yes
RewardGrantContext enum, no logic trivially covered as a parameter
QuestObjectiveInstance.getCustomData/setCustomData/markCustomDataDirty dirty-marking, defensive copy QuestObjectiveInstanceTest — yes
QuestObjectiveInstanceDAO serialize/deserialize null-on-empty, malformed-JSON fallback, null-value purge QuestObjectiveInstanceDAOTest — yes
QuestPool.getEligibleDefinitions empty-set → match-all sentinel QuestPoolTest — yes
QuestRarityRegistry.rollRarity zero-weight fallback QuestRarityRegistryTest — yes
QuestBoardManager.parseResetDayOrDefault null/blank/invalid fallback no test
QuestBoardRotationTask.parseTimeOrDefault null/blank/invalid fallback no test
QuestTemplateConfigLoader RuntimeException catch per-file isolation QuestTemplateConfigLoaderTest — yes
ReloadableRarityConfig null-section guard + per-item RuntimeException catch per-rarity isolation no test
PlayerJoinListener.grantRewards refactored to use QuestRewardGranter; identity-map dequeue for duplicate instances PlayerJoinListenerTest — partial (duplicate-instance case added)
QuestManager.getDefaultExtractionResources resource list extraction QuestManagerExtractionResourcesTest — yes
QuestCancelEvent / QuestEvent handler-list consolidation regression for shared-list dispatch QuestEventHandlerListTest — yes
QuestConfigLoader — removed snapshot-at-load of rarity registry empty-set sentinel path QuestPoolTest covers the runtime effect
QuestBoardManager — registers ReloadableContent instances hot-reload wiring no test

Findings

CONCERN: QuestBoardManager.parseResetDayOrDefault has no dedicated test for the null/blank branch and the invalid-value branch.
WHY: The method has >3 lines of non-trivial logic (two distinct fallback branches with different log messages, Locale.ROOT normalization). The null/blank path and the IllegalArgumentException path need separate assertions to confirm the fallback is DayOfWeek.MONDAY in each case. Without tests, a typo in the fallback constant or the exception swallow is invisible.
WHERE: QuestBoardManager.parseResetDayOrDefault (production) / no corresponding test file.


CONCERN: QuestBoardRotationTask.parseTimeOrDefault has no dedicated test for the null/blank branch, the malformed-string branch, or a valid parse.
WHY: The method has >3 lines of non-trivial logic mirroring parseZoneOrDefault; the checklist flags every new public/package method with >3 lines. A DateTimeParseException on the fallback DEFAULT_ROTATION_TIME constant itself (if it were accidentally changed to a bad string) or a regression in the null-check branch would not be caught. The existing QuestBoardRotationTask tests (if any exist outside this diff) do not exercise this new method at all — it was previously an inline LocalTime.parse call with no guard.
WHERE: QuestBoardRotationTask.parseTimeOrDefault (production) / no corresponding test file.


CONCERN: ReloadableRarityConfig's two new defensive branches (null-section guard and per-item RuntimeException catch) have no test coverage.
WHY: Both branches are reachable in production from malformed YAML (a key with no body, or a rarity with a bad value type). The null-section guard is the simpler path, but the per-item catch is the more dangerous one — it silently continues rather than propagating, so a regression that re-throws is the only signal. There is no test that constructs a ReloadableRarityConfig with a null section or a rarity that throws during construction and asserts the remaining rarities still load.
WHERE: ReloadableRarityConfig (production) / no corresponding test file.


CONCERN: QuestBoardManager's registration of rarityConfig, categoryConfig, and templateConfig into ReloadableContentManager has no test verifying the hot-reload wiring.
WHY: This is non-trivial behavior added to initialize(): the three configs are now tracked so /mcrpg admin reload actually re-parses them. If the ReloadableContentManager registration is accidentally removed or the wrong object is passed, edits to board.yml silently no-op. The checklist flags non-Bukkit logic with zero corresponding test additions — the registration logic is pure Java and the diff adds no test asserting that all three ReloadableContent instances are tracked after initialize.
WHERE: QuestBoardManager.initialize (production) / no corresponding test file.


CONCERN: QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad does not follow the action_outcome_whenCondition naming convention.
WHY: The method is named as a Given/When/Then sentence fragment in the @DisplayName, and the method name itself uses _roundTripsThroughSaveAndLoad which is an outcome clause without an action prefix. The convention is action_outcome_whenCondition, e.g. saveAndLoad_customDataRoundTrips_whenDataPresent. The @DisplayName similarly uses a full sentence rather than a short descriptive label as required by the McRPG convention.
WHERE: QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad.


CONCERN: QuestObjectiveInstanceDAOTest.loadObjectiveInstances_toleratesMalformedCustomDataJson and saveObjectiveInstance_writesNullCustomData_whenEmpty also carry @DisplayName annotations written as Given/When/Then sentences rather than short descriptive labels.
WHY: The McRPG convention (explicitly called out in the checklist) is short labels such as "malformed custom_data JSON loads as empty map" or "empty custom data serializes to null column". The current labels read as Given/When/Then sentences, which is explicitly called out as not the McRPG convention.
WHERE: QuestObjectiveInstanceDAOTest — both of the two new tests.


CONCERN: QuestRewardGranterTest registers event listeners in individual tests but only calls HandlerList.unregisterAll() for the two reward-event handler lists in @AfterEach. If a test registers a listener against both grant event types and any other event type (e.g., through McRPGBaseTest infrastructure), those registrations are not cleaned up, but more concretely: CapturingListener and CancellingListener are registered via server.getPluginManager().registerEvents(...) and clearRewardEventListeners() clears by calling unregisterAll() on the two static handler lists — this is correct for these two events. However, CapturingListener also listens to QuestRewardGrantEvent (pre-event), which is cleared. This is fine. No structural defect here — retracting.


CONCERN: QuestEventHandlerListTest registers listeners via server.getPluginManager().registerEvents(...) inside individual test methods but uses HandlerList.unregisterAll(listener) in a finally block for cleanup. This is correct per-test, but CONCRETE_QUEST_EVENTS includes event classes (QuestObjectiveProgressEvent, QuestObjectiveCompleteEvent, QuestStageCompleteEvent, QuestPhaseCompleteEvent) that are not dispatched in baseListener_receivesCancelEvent or subtypeListener_isFilteredToOwnEvent, so only one QuestCancelEvent and one QuestStartEvent fire. The test list therefore doesn't verify that all listed event classes actually share the handler list at runtime dispatch — only the reflective check does. This is acceptable for the reflective test, but the behavioral test only exercises one of eight events.
WHY: This is a coverage gap: the behavioral assertion (baseListener_receivesCancelEvent) exercises the fix for the QuestCancelEvent regression specifically, but none of the other seven concrete events listed in CONCRETE_QUEST_EVENTS are dispatched to confirm they flow through the shared list at event-call time. A subclass that somehow re-declared its own HandlerList (e.g., an event added after this test was written) would not be caught.
WHERE: QuestEventHandlerListTest.baseListener_receivesCancelEvent / subtypeListener_isFilteredToOwnEvent.


CONCERN: PlayerJoinListenerTest has no test for the path where QuestRewardGrantEvent is cancelled by a listener, asserting that the cancelled rewards are not included in the returned grantedRewardIds set (and therefore not deleted from the DB).
WHY: The refactored grantRewards now routes through QuestRewardGranter, which honors cancellation. The pending-reward login path's contract is: "a reward a listener cancels is retained for a later retry." This is a new behavior path introduced in this diff with no corresponding test. The existing tests only verify the happy path and the duplicate-instance case.
WHERE: PlayerJoinListenerTest / PlayerJoinListener.grantRewards.


CONCERN: QuestRewardGranterTest does not include a test for the context-propagation path: specifically that the context field passed to grantToOnlinePlayer is the same value present on the fired QuestRewardGrantEvent and QuestRewardGrantedEvent.
WHY: RewardGrantContext is the primary discrimination signal for third-party listeners. If the context is silently dropped or replaced (e.g., always INLINE), addon plugins would have no way to distinguish grant pathways. The current tests assert on reward content but not on event.getContext().
WHERE: QuestRewardGranterTest / QuestRewardGranter.grantToOnlinePlayer.


CONCERN: There is no test covering the QuestRewardGranterTest scenario where a listener adds a reward to the mutable list and the granter actually grants the added reward.
WHY: The getRewards() Javadoc explicitly states "listeners may add or remove entries and the changes are honored." The existing tests cover removal (cancellation) but not addition. An implementation that iterated a snapshot of the original list rather than grantEvent.getRewards() would pass all current tests.
WHERE: QuestRewardGranterTest / QuestRewardGranter.grantToOnlinePlayer.


Summary

Production files changed:
QuestRewardGranter.java, QuestRewardGrantEvent.java, QuestRewardGrantedEvent.java, RewardGrantContext.java, QuestObjectiveInstance.java, QuestObjectiveInstanceDAO.java, QuestPool.java, QuestRarityRegistry.java, QuestBoardManager.java, QuestBoardRotationTask.java, QuestTemplateConfigLoader.java, ReloadableRarityConfig.java, PlayerJoinListener.java, QuestInstance.java, QuestManager.java, QuestCancelEvent.java, QuestEvent.java, QuestConfigLoader.java, RewardDistributionGranter.java

Test files present:
QuestRewardGranterTest, QuestObjectiveInstanceTest, QuestObjectiveInstanceDAOTest, QuestPoolTest, QuestRarityRegistryTest, QuestTemplateConfigLoaderTest, BoardCategoryConfigLoaderTest, PlayerJoinListenerTest, QuestEventHandlerListTest, QuestManagerExtractionResourcesTest

Coverage gaps:

  1. QuestBoardManager.parseResetDayOrDefault — no test (null/blank and invalid-value branches)
  2. QuestBoardRotationTask.parseTimeOrDefault — no test (null/blank and invalid-value branches)
  3. ReloadableRarityConfig null-section guard and per-item catch — no test
  4. QuestBoardManager ReloadableContentManager registration — no test
  5. PlayerJoinListener.grantRewards cancelled-batch path — no test asserting rewards are retained
  6. QuestRewardGranter context propagation to events — not asserted in any test
  7. QuestRewardGranter listener-addition to reward list — not tested (only removal/cancellation is)
  8. QuestEventHandlerListTest behavioral dispatch — only one of eight concrete events is exercised at call time
  9. @DisplayName convention violations in QuestObjectiveInstanceDAOTest (Given/When/Then sentences used instead of short labels)
  10. Method naming convention violation in QuestObjectiveInstanceDAOTest.customData_roundTripsThroughSaveAndLoad

@github-actions

Copy link
Copy Markdown

Security Review

No security concerns found.

Rationale by checklist item:

Adventure API / MiniMessage Injection
No calls to MiniMessage.deserialize() or getMiniMessage().deserialize() appear in the diff. The new QuestRewardGrantEvent, QuestRewardGrantedEvent, QuestRewardGranter, and RewardGrantContext classes carry only NamespacedKey, UUID, List<QuestRewardType>, and enum values — none of which pass through MiniMessage. The QuestObjectiveInstance.getCustomData() map is persisted as a Gson JSON blob and read back as Map<String,Object>; it is never deserialized into a MiniMessage component. No player-facing string construction was changed in this diff.

Command Injection via performCommand() / dispatchCommand()
No calls to player.performCommand(...) or Bukkit.dispatchCommand(...) appear in the diff. ReloadPluginCommand calls plugin.registryAccess()...reloadFiles(), which is a server-internal API path with no user-controlled string segments.

Permission Bypass
No new command handlers or GUI slot actions are introduced. ReloadPluginCommand was not modified in a way that affects its existing permission check. No isOp() usage was added. No onClick() changes are present.

SQL / Data Injection
All database access in QuestObjectiveInstanceDAO uses PreparedStatement with positional ? parameters. The new custom_data column is written via ps.setString(9, ...) and read back via rs.getString("custom_data") — both parameterized. The Gson deserialization in deserializeCustomData() targets an untyped Map<String,Object> with no code execution surface; malformed input is caught and returns an empty map. The DDL string in createTable uses only the static constant TABLE_NAME and a reference to QuestStageInstanceDAO.TABLE_NAME (also a static constant), with no runtime-concatenated values.

@DiamondDagger590
DiamondDagger590 merged commit b3ea7da into recode Jul 16, 2026
3 checks passed
@DiamondDagger590
DiamondDagger590 deleted the claude/mcrpg-backlog-273-69am5k branch July 16, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants