Events
Status: SHIPPED (epic 19069) — event service, registry table, and isActiveEvent are live; 7 of the 14 flags have wired consumers, the other 7 are dormant (see table). Verified against code 2026-05-31.
Last updated: September 30, 2026
Dark Tower — Events Subsystem
Status: SHIPPED (epic 19069) — event service, registry table, and isActiveEvent are live; 7 of the 14 flags have wired consumers, the other 7 are dormant (see table). Verified against code 2026-05-31.
Subsystem: Time-windowed events — global promotions (double-XP, double-money, server-unlock-prizes) and the public
is*EventActiveflag surface. Primary sources: ,DarkTowerEventType,DarkTowerEvent, .
Origin
This doc resolves design call 19059 (queued from the 2026-05-11 darktower utils stub audit at temp/2c85d208-2f47-4ad2-a343-766d11420dad/dt-stub-audit.md item #10). The audit found returning false unconditionally, making every event-driven gameplay variant permanently inactive.
Decision
Events split into two scope buckets:
- WORLD-WIDE (13 flags, ACTIVE) — server-driven promotions everyone sees at once. Backed by a single DB-row event registry; wired through a Caffeine-cached .
- PER-CHARACTER INVASIONS (10 flags, RETIRED) — no infrastructure ever shipped for per-character event scheduling, no NLU surface for "your goblin invasion is active", no per-card invasion content. Retire the public flag surface entirely.
Per-character event support is NOT a deferred feature. It is dropped. If a future game pivot wants it, it returns as a new subsystem with a new design call, not by un-retiring these flag names.
Active subset — 13 flags, world-wide
| Flag | DarkTowerEventType | Consumer status (verified against code 2026-05-31) |
|---|---|---|
| isDoubleSpecialTokenWeekend | DOUBLE_SPECIAL_TOKENS | WIRED — .addSpecialMoney:108, populateDailySpecialCurrency:178 (2× multiplier) |
| isDoubleMoneyWeekend | DOUBLE_MONEY | WIRED — .addMoney:57 (2× multiplier) |
| isDoubleExperienceWeekend | DOUBLE_EXPERIENCE | WIRED — .addExperience:211 (2× multiplier) |
| isTripleExperienceWeekend | TRIPLE_EXPERIENCE | WIRED — .addExperience:209 (3× multiplier; triple takes precedence if both active) |
| isDoubleResourceWeekend | DOUBLE_RESOURCES | WIRED — .addResourceOne:33, addResourceTwo:64 |
| isDoubleRaidBoostsWeekend | DOUBLE_RAID_BOOSTS | WIRED — .addRaidScore:57, :123 |
| isExtraTimeBoostRewards | EXTRA_TIME_BOOST | DORMANT — service answers it honestly, but no consumer reads it yet |
| isExtraTokensPurchaseBoostRewards | EXTRA_SPECIAL_TOKENS | DORMANT — no consumer |
| isExtraHeroItemsPurchaseBoostRewards | EXTRA_HERO_ITEMS | DORMANT — no consumer |
| isExtraHeroMissionItemsPurchaseBoostRewards | EXTRA_HERO_MISSION_ITEMS | DORMANT — no consumer |
| isExtraVipPointsBoostRewards | EXTRA_VIP_VALUE | DORMANT — no consumer |
| isExtraCreatorPointsBoostRewards | EXTRA_CREATOR_POINTS | DORMANT — no consumer |
| isServerUnlockPrizesPurchaseActive | SERVER_UNLOCK_PRIZES_PURCHASE | DORMANT — no consumer (shop unlock-prizes gate is a separate design call) |
"DORMANT" means the public is* method stays, the enum value stays, and the service answers isActive honestly when a DB row says so — but no consumer reads the flag yet. The flag is dormant, not dead. A later content patch can wire it without a schema change.
Retired subset — 9 flags, dead surface
Public methods AND enum values deleted in epic 19069:
GOBLINS_ATTACK/isGoblinEventActiveQUEENS_REGARD/isQueenEventActiveSECOND_CITADEL_ATTACK/isSecondCitadelEventActiveCHAOS_GOBLINS_ATTACK/isChaosGoblinEventActiveCHAOS_FORCES_ATTACK/isChaosMarineEventActiveCHAOS_SPIDERS_INVADE/isChaosSpiderEventActiveFIREWALL_ATTACK/isFirewallEventActiveUNDEAD_ARMIES_ATTACK/isUndeadEventActiveGUILD_WAR/isGuildEventActive
Preserved combat flag — `ORCS_WAR_PATH` / `isOrcsWarpathActive`
The original design call (19059) earmarked this for retirement, but .java:550 consumes isOrcsWarpathActive as an in-combat crazed-orc bonus. Epic 19069's ide_find_references pass found the live caller and preserved both the flag method and the enum value. ORCS_WAR_PATH is now wired through like every other active flag — toggle it by inserting a world_events row.
Before deleting any other flag in the future, run ide_find_references on the public method. If any production caller exists, leave the method + enum value and document the live caller here — flag for a follow-up cleanup.
DarkTowerEvent.minutesBetweenAttacks field becomes meaningless after the retire (it was the per-character invasion cadence). Leave the field on the POJO for now; remove only if no other consumer exists.
Source of truth
New table dark_citadel.world_events:
CREATE TABLE dark_citadel.world_events (
event_id INT PRIMARY KEY AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL, -- must match an active DarkTowerEventType name
starts_at DATETIME NOT NULL,
ends_at DATETIME NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
note VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_active_window (event_type, enabled, starts_at, ends_at)
);
event_type is VARCHAR(64), not a MySQL ENUM. Reason: enum churn requires schema change every time a new event type lands; varchar + Java-side validation is the project pattern for this case. Validation: DarkTowerEventType.valueOf(row.event_type) on read; rows that fail validation are logged and ignored.
Service shape
Shipped at (the live shape; the mapper is ):
@Service
@AllArgsConstructor
@LogDurationAfter
public class {
private final worldEventsMapper; // MyBatis mapper, simple SELECT all enabled rows
private final Cache<Boolean, List<DarkTowerWorldEvent>> activeEventsCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(60))
.maximumSize(1)
.build();
public boolean isActive(final DarkTowerEventType type) {
final Instant now = Instant.now();
return activeRows().stream()
.anyMatch(row -> row.getEventType() == type
&& row.getStartsAt().isBefore(now)
&& row.getEndsAt().isAfter(now));
}
private List<DarkTowerWorldEvent> activeRows() {
return activeEventsCache.get(Boolean.TRUE, k -> worldEventsMapper.findEnabled());
}
}
Accessed from static via (.java:36-44).
60s TTL is intentional. World-wide promo flags don't need sub-second freshness; the 60s window is the upper bound on how stale a player's view of a promo can be after an admin flips it on. Trade-off vs hammering MySQL: each session checks isActiveEvent many times per request; without the cache, every flag check becomes a SELECT.
Cache invalidation on event toggle: optional. The 60s TTL is short enough that "flip flag, wait a minute, ramp the promo" is the documented ops procedure. No explicit MCP tool in the first cut.
Ops surface — first cut
Manual SQL via MCP context({ sql: "INSERT INTO dark_citadel.world_events ..." }). No dashboard UI in the first cut.
Operator playbook for a double-XP weekend:
INSERT INTO dark_citadel.world_events (event_type, starts_at, ends_at, note)
VALUES ('DOUBLE_EXPERIENCE', '2026-06-13 00:00:00', '2026-06-16 00:00:00', 'Last Light Vol-1 launch weekend');
Disable: UPDATE dark_citadel.world_events SET enabled = 0 WHERE event_id = N;
Audit: SELECT * FROM dark_citadel.world_events ORDER BY starts_at DESC LIMIT 20;
A follow-up design call can promote this to a ops:/game-admin dashboard page once the manual SQL ops surface proves insufficient (likely never, given the rate of promo events).
Regression tests
Authored in game_test.scenario_tests (IDs 590-594) plus (JUnit). Coverage:
- DOUBLE_SPECIAL_TOKENS active —
world_eventsrow with type=DOUBLE_SPECIAL_TOKENS spanning NOW; assertaddSpecialMoney(100)increments the special_tokens ledger by 200. - DOUBLE_MONEY active —
world_eventsrow with type=DOUBLE_MONEY spanning NOW; assertaddMoney(100)increments gold by 200 (after fixer wires the consumer). - No active row — empty
world_events(or rows whose window is in the past/future); assertaddSpecialMoney(100)increments by 100 andaddMoney(100)increments by 100. - Disabled row —
enabled=0with active window; assert flag returns false.
These were authored as failing tests before the fix (pre-fix isActiveEvent always returned false, so test #1 returned base 100 instead of 200); they pass post-fix. Some end-to-end multiplier exercises (DOUBLE_MONEY mine-gold, XP grant) are gated on setup fields that do not exist yet, so service-level coverage lives in — see the notes on rows 593/594.
Frontend impact
None for the first cut. Voice game; promo activity is announced via existing reward-readout paths (e.g. "You earned 200 special tokens. Double-token weekend is live."). A future banner / event indicator in the DT frontend is a stretch goal — out of scope for this design call.
Code refs
| Surface | File:Line |
|---|---|
| Public flag methods (13 promo + ORCS_WAR_PATH) | backend/src/main/java/llitd/utils/darktower/.java |
isActiveEvent (live — reads the service) |
.java:36-44 |
| Enum (NONE + 13 promo + ORCS_WAR_PATH = 15) | backend/src/main/java/llitd/model/darktower/enums/events/DarkTowerEventType.java |
| Event POJO | backend/src/main/java/llitd/model/darktower/event/DarkTowerEvent.java |
| Sole live caller today | .addSpecialMoney:100-108, populateDailySpecialCurrency:134-138 |
| Service | backend/src/main/java/llitd/service/darktower/.java |
| Wrapper accessor pattern | gameBasicProperties.getDarkTowerWrapperService().get*Service() — see , , etc. |
| Audit | temp/2c85d208-2f47-4ad2-a343-766d11420dad/dt-stub-audit.md item #10 (DT2/19051) |
Known limitations
- The 7 dormant flags (the 6 EXTRA_* purchase-perk flags + SERVER_UNLOCK_PRIZES_PURCHASE) have no consumer. Future content that wants to consume them must verify the wiring chain end-to-end in the same patch; do NOT assume "flag exists in the registry → consumer exists in the code."
- No timezone handling —
world_events.starts_at/ends_atare server-local. Adequate for current single-region deploy. Document as "server-local UTC" and revisit if multi-region. - No overlap protection — two DOUBLE_EXPERIENCE rows with overlapping windows both return active; multipliers do not stack (consumer code reads
isActive(type), which returns boolean). Operator responsibility to avoid overlap.