A new behavioural class of challenge that is only available to islands with a
team, and that can require a proportion of the team to be online to complete.
Team behaviour is implemented as optional modifiers on the existing Challenge
object — not a new challenge type or a new Requirements subclass — so all
existing challenges, GUIs, importers and handlers keep working unchanged.
| Question | Decision |
|---|---|
| Aggregation scope (whose items/stats count) | Online members only |
| Presence metric | Percentage of team (fraction 0.0–1.0), rounded with ceil, floor of 1 |
| Data model | Extend existing Challenge with optional fields, default off |
| Per-member ("everyone pays") primitive | Yes — Roll Call Feast requires each online member to contribute individually |
| Pooled removal split | Option A — equal absolute contribution ("water level") |
| Visibility to non-teams | Per-challenge flag, default show (greyed/disabled); admin can set hidden |
| Roll Call Feast per-member amount | requirement / requiredPresentCount (the configured amount is the team total) |
These are chosen to exercise three reusable primitives so the feature is general rather than one-off.
| # | Name | teamChallenge |
teamPresence |
Aggregation | Existing requirement reused | Primitive exercised |
|---|---|---|---|---|---|---|
| 1 | All Hands on Deck | ✓ | 1.0 |
— | none (presence only) | Presence gate |
| 2 | Pooled Tribute | ✓ | 0.5 |
aggregate | Inventory | Aggregate-across-online-members |
| 3 | Synchronized Build | ✓ | 0.5 |
— | Island | Composability (presence + existing requirement) |
| 4 | Combined Effort | ✓ | 0.0 |
aggregate | Statistic | Aggregate statistic across members |
| 5 | Roll Call Feast | ✓ | 1.0 |
per-member | Inventory | Per-member ("everyone pays") |
Three primitives total: presence gate, aggregate across online members (inventory + statistic), per-member contribution.
New optional @Expose fields, all defaulting to "off" so existing challenges
deserialize unchanged. Placed near the existing condition/environment block and
cloned in copy().
@Expose private boolean teamChallenge = false; // gate: only teams see / attempt it
@Expose private double teamPresence = 0.0; // 0.0–1.0 fraction of team online required
@Expose private boolean aggregateTeam = false; // sum inventory/stats across online members
@Expose private boolean perMember = false; // each online member must individually satisfy (Roll Call Feast)
@Expose private boolean hideIfNoTeam = false; // default false = show greyed/disabled to soloists; true = hide entirelyteamPresence is a fraction, not a whole percent, to avoid a units bug and
to serialize cleanly.
Required-present count:
int required = Math.max(1, (int) Math.ceil(teamSize * teamPresence));A positive presence never rounds down to 0. The requirement that a team exists
at all (at least two members) is enforced separately by the team-size check, not
by this presence gate - so 50% of a 2-member team is 1, not 2. A presence of
0.0 (Combined Effort) skips the gate entirely and only aggregates.
The pipeline already iterates team members for the Statistic requirement
(island.getMemberSet() + Bukkit.getPlayer() null-check for online status),
so "aggregate across online members" generalises existing code.
A new checkTeamPresence() slotted into checkIfCanCompleteChallenge,
alongside the environment check. Online members:
Island island = addon.getIslands().getIsland(world, user);
List<Player> online = island == null ? List.of() :
island.getMemberSet().stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.toList();
int required = Math.max(1, (int) Math.ceil(island.getMemberSet().size() * challenge.getTeamPresence()));
// fail if online.size() < requiredGeneralise the existing statistic loop into a helper that walks the online
members, sums each member's contribution (matched inventory quantity or
statistic value), and validates the total. Reused by both checkInventory()
and checkStatistic() when aggregateTeam is true.
A single member is allowed to hold the entire required amount. We deliberately do not require the amount to be spread across members — forcing a spread would push players into "stupid" actions (dividing a stack between members just to pass).
The configured requirement is the team total. Each present member must
individually hand in share = ceil(requirement / requiredPresentCount). Check
every online member; if any one falls short, the challenge fails. On completion,
remove share from each member. This is the one mechanic that cannot be
expressed as a normal challenge.
Why divide by present count (not a fixed per-head amount): a fixed per-head bill lets members log out to dodge their share while the remaining minimum still completes the challenge — the team's total cost shrinks as people leave, so dodging is rewarded. Dividing the fixed total by the present count makes the team's aggregate cost invariant to turnout: fewer present → each present member pays more, removing any incentive to thin the crowd. This must be made clear to the admin in the editor: "the amount you enter is split across the present team, not charged per person."
When an aggregate inventory challenge consumes items (cost, not just a
possession check), the required quantity R is removed across online members
as equitably as possible: everyone pays the same absolute amount, capped at
what they hold; anyone short gives all they have and the rest cover the
difference.
R = required quantity to remove
holdings = matched quantity each online member has
# Find the integer "water level" L: the largest L with sum_i min(holdings_i, L) <= R
base = sum_i min(holdings_i, L)
remainder = R - base # 0 <= remainder < (members above L)
# Each member pays min(holdings_i, L), plus 1 extra for each of the `remainder` LARGEST holders above L
take_i = min(holdings_i, L) + (member i among the `remainder` largest holders above L ? 1 : 0)
The integer rounding remainder is absorbed by the largest holders first — deterministic, order-independent, and mildly progressive (the richest pay the odd unit, since they are least hurt by it).
- Water level
L = 27:min(10,27)+min(15,27)+min(20,27)+min(40,27)+min(100,27) = 10+15+20+27+27 = 99 remainder = 100 − 99 = 1, given to the largest holder aboveL(the 100-holder)- Removed:
10, 15, 20, 27, 28→ the three small holders are wiped; the leftover55splits as evenly as possible (27/28) between the 40- and 100-holder, the richest absorbing the odd unit.
Properties: order-independent, integer-clean, one-line explainable — "Everyone chips in equally; if you don't have enough you give all you have and the rest cover the difference."
take items: true/false (consume vs. possession-check only) is the existing
cost/requirement distinction and is independent of this split.
- Visibility to soloists is a per-challenge flag (
hideIfNoTeam), defaultfalse→ the challenge is shown greyed/disabled to players without a team (a recruitment nudge). An admin can set ittrueto hide the challenge entirely until the player has a team. This replaces the earlier idea of a single globalSettingstoggle. - Filter in
ChallengesPanel.updateFreeChallengeList()/updateChallengeList():removeIf(c -> c.isTeamChallenge() && c.isHideIfNoTeam() && !playerHasTeam(...)); non-hidden team challenges are rendered disabled. generateChallengeDescription()gains a line such asTeam: 2/3 onlineso players understand the gate.
- Admin GUI (
panel/admin/) toggles for the four new fields. default.json/template.yml: document the new keys, ship the five reference challenges as examples.locales/en-US.yml: the new description line and aninsufficient-teamerror string.
- Member logs off mid-completion. Re-check presence inside
fullFillRequirements, not only at validation — otherwise a team could pass the gate, then have a member leave before costs are taken. teamPresence = 0.0. Skip the presence gate; aggregate only (Combined Effort).- Team smaller than the rounded requirement.
ceil+ floor-of-1 (plus the separate team-exists check) means a 2-person team always has an achievable target forteamPresence <= 1.0(50% -> 1 present).
Three facts about the current code create most of the exploit surface, so they are addressed once as cross-cutting mitigations and then referenced per challenge.
X1 — Rewards go only to the clicking player (TryToComplete.java:256-274,
all rewards to this.user). In a team challenge, many members pay or contribute
but a single member claims → a free-rider/leech problem in every team
challenge. The clicker can have teammates fund a cost they personally reap.
Mitigation (master lever): team-challenge rewards go to every qualifying
present member (each receives the reward), or to a team bank — never to the
clicker alone. This single change neutralises the leech angle across all five.
X2 — Completion & cooldown are per-player by default (ChallengesManager.java:1010),
though an existing isStoreAsIslandData() mode keys them by island UUID
(:1005). If left per-player, all N members can each "complete" the same team
effort in turn → N× rewards and N× cooldowns for one team action.
Mitigation: team challenges force island-keyed storage regardless of the
global setting, so one team effort = one completion + one shared cooldown.
X3 — Statistics are absolute lifetime values, no delta (TryToComplete.java:1494;
getStatistic() at ChallengesManager.java:1339). Aggregating absolute
lifetime stats across online members lets a team borrow a veteran: invite a
player with 1,000,000 lifetime mob kills, have them online at completion to
satisfy "team total 1,000 kills," then they leave. Nothing was earned together.
Mitigation: for team statistic challenges, snapshot each member's stat as a
baseline when the team unlocks the challenge and require the increase
(current − baseline) to reach the target; only members present at unlock count
toward the baseline, so a late-joining veteran's lifetime total is excluded.
(Stored alongside the island-keyed completion record from X2.)
1. All Hands on Deck (presence only, no cost). Pure reward for turnout.
- Exploit: zero-cost reward printing — relog the crew (or a set of idle alts) and claim; if repeatable, an infinite faucet.
- Mitigations: non-repeatable or long, island-keyed cooldown (X2); keep the reward modest (it is a social/tutorial milestone); optionally require members to be within island protection bounds, not merely connected, so parked alts in another world do not count. X1 (reward all present) removes the "one clicker profits" angle but raises the alt incentive — hence keep the reward small here specifically.
2. Pooled Tribute (aggregate inventory, optional consume).
- Exploit A — borrowing (possession-only): if
take items = false, the team only needs to hold N collectively for one instant; borrow from a non-member friend, complete, return. Inherent to any possession check. Mitigation: prefer consume for valuable rewards; treat possession-only as a soft "show your wealth" flex with cosmetic rewards. - Exploit B — leech: contributors lose items (water-level removal), clicker reaps reward. Mitigation: X1.
- Exploit C — dodge by logging out: the total N is fixed, so logging members
out does not reduce the team's bill — it shifts a larger share onto those
who stay (equitable removal caps how badly, but stayers still pay more). This
is "unfair to teammates," not a system cheat; a griefer could stay offline so
loyalists pay. Mitigation: require a higher
teamPresencefor consuming Pooled Tribute challenges so a quorum must share the load. - Note: a single member holding all N is allowed by design (avoids forcing silly stack-splitting) and is not treated as an exploit.
3. Synchronized Build (presence + island requirement).
- Exploit: the island state can be built by one player over time; presence only requires bodies online at the click, so "the team" may have done nothing collectively beyond showing up (possibly with alts).
- Mitigations: non-repeatable + island-keyed (X2); to make "synchronized" meaningful, require present members to be inside island protection range at completion, not merely online. Reward sizing kept in line with a one-time build milestone.
4. Combined Effort (aggregate statistic). The veteran-borrow case — see X3; the baseline-delta fix is essential for this challenge, not optional. Without it this challenge is trivially defeated by a single recruited veteran.
5. Roll Call Feast (per-member contribution).
- Exploit — log out to dodge the bill: defeated by
requirement / requiredPresentCount(the team total is invariant to turnout — see the primitive section). AtteamPresence = 1.0there is no dodging at all (everyone must be present). - Exploit — alt padding to lower each share: bringing alts online lowers every member's share, but each alt must also pay its own share or the per-member check fails, so empty alts cannot help; funding alts is a net loss.
- Residual: a single poor member who cannot cover their (turnout-reduced) share blocks the whole team — but this encourages turnout (more present → smaller shares), which is the desired incentive. Combined with X1 (only present payers are rewarded), the incentive is fully aligned: show up, pay your share, get rewarded; dodge, pay nothing, get nothing.
| Mitigation | Status |
|---|---|
| X1 reward all present members | Required (defeats leeching everywhere) |
| X2 island-keyed completion/cooldown for team challenges | Required (defeats N× claim) |
| X3 statistic baseline-delta | Required for Combined Effort; recommended for any team statistic |
| Higher presence for consuming challenges | Recommended |
| "Within island bounds," not just online | Recommended for presence-flex challenges (1, 3) |
| Modest reward + non-repeatable for no-cost challenges | Recommended (challenge 1) |
| Component | File | Notes |
|---|---|---|
| Data model | database/object/Challenge.java |
new fields + copy() |
| Validation / presence gate | tasks/TryToComplete.java checkIfCanCompleteChallenge |
new checkTeamPresence() |
| Aggregate inventory/stat | tasks/TryToComplete.java checkInventory() / checkStatistic() |
generalise existing member loop |
| Per-member + pooled removal | tasks/TryToComplete.java fullFillRequirements |
per-member check; water-level removal |
| Reward all present (X1) | tasks/TryToComplete.java reward block :256-274 |
loop rewards over present members instead of this.user |
| Island-keyed storage (X2) | managers/ChallengesManager.java getDataUniqueID :1005-1010 |
force island key when teamChallenge |
| Statistic baseline-delta (X3) | ChallengesPlayerData + checkStatistic() :1494 |
store per-team baseline at unlock; compare current − baseline |
| Team / online detection | island.getMemberSet() + Bukkit.getPlayer() |
pattern already in codebase |
| Visibility | panel/user/ChallengesPanel.java |
filter + description line |
| Admin editing | panel/admin/ |
toggles |
| Resources | default.json, template.yml, locales/en-US.yml |
examples + strings |
No new Requirements subclass and no new Gson adapter are required — team
behaviour is a cross-cutting modifier on the existing requirement types.
Foundation first. X1/X2/X3 are prerequisites, not polish: without them the five reference challenges are individually exploitable regardless of how the team-gate mechanics are built. Each phase is independently shippable, leaves the build green, and is verifiable in isolation.
Goal: new fields exist and round-trip; nothing behaves differently yet.
- Add the five
@Exposefields toChallenge.java(teamChallenge,teamPresence,aggregateTeam,perMember,hideIfNoTeam), all default-off. - Update
Challenge.copy()to clone them. - A
Settings/Constantshome for theinsufficient-teamlocale key (string added later in Phase 5). - Tests: serialize → deserialize a
Challengewith the fields set and assert equality; assert a legacydefault.jsonchallenge (no team fields) still loads with all-off defaults. Acceptance:mvn testgreen; no GUI/behaviour change.
Goal: a team challenge completes and cools down once per island, not once per player.
- In
ChallengesManager.getDataUniqueID(:1005-1010), force the island key when the challengeisTeamChallenge(), regardless of the globalisStoreAsIslandData()setting. - Tests: two members of one island; member A completes a
teamChallenge; assert member B sees it already completed and the cooldown is shared. Assert a non-team challenge is unaffected by this change. Acceptance: one team effort = one completion record.
Goal: rewards are distributed to every qualifying present member, not the clicker alone.
- Refactor the reward block (
TryToComplete.java:256-274) so that for ateamChallengeit iterates the present members and gives each the item/money/ exp/command reward; non-team challenges keep the single-userpath unchanged. - Decide command-reward semantics (run per member with their name substituted).
- Tests: mock a 3-present team, complete a team challenge, assert each present member received the reward and offline members received nothing; assert a normal challenge still rewards only the completer. Acceptance: no leech path.
Goal: team statistic challenges measure progress earned while on the team, not borrowed lifetime totals.
- Add per-team baseline storage (in the island-keyed
ChallengesPlayerData, alongside completion): snapshot each member's statistic when the challenge unlocks for the team. - In
checkStatistic()(:1494), for a team challenge comparecurrent − baselineagainst the requirement; exclude members with no baseline (i.e. joined after unlock). - Tests: veteran with high lifetime stat joins after unlock → contributes 0; members present at unlock contribute only their increase. Acceptance: veteran-borrow defeated.
Goal: checkTeamPresence() gates completion by ceil(teamSize · teamPresence)
(floor 2), re-checked at fulfilment.
- New
checkTeamPresence()incheckIfCanCompleteChallenge(near the environment check); re-check insidefullFillRequirementsto close the gate-then-leave window. - This phase alone makes All Hands on Deck and Synchronized Build functional (the latter just composes with the existing Island requirement).
- Tests: present-count below/at/above threshold; rounding at odd team sizes; member logs off between validation and fulfilment → fails cleanly.
Goal: the inventory/statistic aggregation and the per-member cost.
- Generalise the existing member-iteration loop into an
"aggregate across present members" helper; wire into
checkInventory()andcheckStatistic()whenaggregateTeamis set. Enables Pooled Tribute and Combined Effort (with Phase 3). - Per-member check +
requirement / requiredPresentCountshare; enables Roll Call Feast. - Water-level removal (Option A) for consuming Pooled Tribute challenges — a small standalone, unit-testable method.
- Add the
insufficient-teamandTeam: x/y onlinelocale strings (en-US.yml). - Tests: the worked water-level example (
10,15,20,40,100→10,15,20,27,28) as a direct unit test; aggregate pass with a single holder; per-member failure when one member is short; possession-only vs. consume paths.
Goal: soloists see team challenges per the hideIfNoTeam flag; tooltips
explain the gate.
- Filter/disable in
ChallengesPanel.updateFreeChallengeList()/updateChallengeList()perhideIfNoTeam. generateChallengeDescription()gains theTeam: x/y onlineline and a disabled-state hint.- Tests: solо player with
hideIfNoTeam=falsesees it disabled; withtruedoes not see it; team player sees it enabled with the presence line.
Goal: admins can author team challenges; the five examples ship.
- Toggles/inputs in
panel/admin/for the five fields, with the Roll Call Feast "amount is the team total, split across present members" note surfaced. - Add the five reference challenges to
default.json/template.yml. - Tests: admin-panel tests (JUnit 5 + Mockito per
PanelTestHelper) for the new toggles; import round-trip of the five examples.
- Phases 0–3 are the foundation and should land before any reference challenge is exposed to players, or early adopters will hit the exploits.
- Phases 4–5 deliver the mechanics; 6–7 deliver UX and content.
- Each phase keeps
mvn verifygreen and is independently reviewable.