Skip to main content

Events

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*EventActive flag 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 / isGoblinEventActive
  • QUEENS_REGARD / isQueenEventActive
  • SECOND_CITADEL_ATTACK / isSecondCitadelEventActive
  • CHAOS_GOBLINS_ATTACK / isChaosGoblinEventActive
  • CHAOS_FORCES_ATTACK / isChaosMarineEventActive
  • CHAOS_SPIDERS_INVADE / isChaosSpiderEventActive
  • FIREWALL_ATTACK / isFirewallEventActive
  • UNDEAD_ARMIES_ATTACK / isUndeadEventActive
  • GUILD_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:

  1. DOUBLE_SPECIAL_TOKENS active — world_events row with type=DOUBLE_SPECIAL_TOKENS spanning NOW; assert addSpecialMoney(100) increments the special_tokens ledger by 200.
  2. DOUBLE_MONEY active — world_events row with type=DOUBLE_MONEY spanning NOW; assert addMoney(100) increments gold by 200 (after fixer wires the consumer).
  3. No active row — empty world_events (or rows whose window is in the past/future); assert addSpecialMoney(100) increments by 100 and addMoney(100) increments by 100.
  4. Disabled row — enabled=0 with 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_at are 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.