Skip to content

Kanturu Refinery Tower: refactor context, fix re-entry, persist tower window - #970

Merged
sven-n merged 14 commits into
MUnique:masterfrom
eduardosmaniotto:fix/kanturu-refinery-tower
Sep 29, 2026
Merged

sven-n merged 14 commits into
MUnique:masterfrom
eduardosmaniotto:fix/kanturu-refinery-tower

Conversation

@eduardosmaniotto

@eduardosmaniotto eduardosmaniotto commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Bugs fixed

  • Re-entry crash: entering Kanturu hit ArgumentOutOfRangeException
    (MiniGameType.Kanturu unhandled by the enter-result packet). Kanturu now
    maps through ToKanturuEnterResult() to its own 0xD1/0x01 packet.
  • No tower re-entry after victory, teleport-out, or server restart.
    Entry works while the window is open: live tower games are joined, otherwise
    a tower-only game is recreated without event phases (same map key, tower
    timers, no waves/rewards). Tower games start instantly (no lobby,
    countdown, or victory message) and spawn at the Nightmare entry.
  • Fresh tower maps were wrong: closed barrier, event-start spawn, MayaBattle
    flicker. They now replicate the victory state from creation (open barrier,
    Tower state from birth, Nightmare-zone spawn via the handshake-time
    GetEntrySpawnPosition hook) with the correct remaining-time dialog.
  • Stale games blocked entry: tearing-down (Ended) games no longer
    advertise entry at the gateway and no longer shadow tower recreation;
    tower entry also skips disposed/disposing instances.
  • GM restart dead-end: /startkanturu destroyed the tower and then got
    blocked by the window. It now always disposes first, clears the tower
    window, and resets the task cooldown, so the forced start succeeds
    first try; the regular schedule stays blocked.
  • Shared wave timers: each wave (monsters + boss) runs on one clock —
    15 min for waves 1–2, 20 min for wave 3 and Nightmare. Boss phases inherit
    the remainder; an already-expired remainder fails the wave at once.
  • Nightmare fight: summons fire from HP thresholds through configured
    summon waves (9–11, 7x Dread Fear); teleports drift toward the tower gate
    and no longer restore boss health (messages reworded accordingly);
    guardians open the fight with a 30s intro; the HUD counts minions only
    (0 while the boss is still up).
  • Maya pendant kill: the wide-area attack damages everyone and instantly
    kills players without the Moonstone Pendant via KillInstantlyAsync
    (dodge-proof, full death flow), Broken Shower pattern; pendant check runs
    on equipped items only.
  • Refills and access: Maya standbys accept re-entry up to 15 players
    (gateway shows the standby entry state); death warps out to Kanturu Relics
    via the engine respawn flow; joinability is now evaluated atomically under
    the entering lock; idle never-entered towers end after a grace period.
  • Scheduling: runs every 6 hours.

Refactoring

  • KanturuContext decomposed into KanturuKillTracker (atomic per-phase
    generations), KanturuMonsterComparer, KanturuNightmarePhaseSelector,
    KanturuBarrierAreaHelper, KanturuRequiredItemHelper,
    KanturuTowerWindow, KanturuTowerEntry, KanturuMayaWideAttacker
    (attack loop, damage, pendant kill), KanturuWaveTimer (shared countdown),
    KanturuWaveGroup, and IKanturuPhaseRunner (monster-wave / transition /
    nightmare runners, spawn-wave seam for summons).
  • OnMonsterDied is synchronous again (fire-and-forget broadcasts), same
    pattern as the invasion death broadcast — no async void suppression.
  • Generic mini-game layer stays free of Kanturu specifics (virtual hooks:
    joinable entry, map-requirement waiver, cancellable waits); the gateway
    dialog reuses MiniGameContext.IsJoinable plus small state helpers.
  • Extended Kanturu coverage: wave/group timers, summon thresholds and skips,
    attacker selection, entry matrix, minion-count exclusion.

Configuration / data

  • KanturuStartConfiguration defaults: 6-hour timetable, 6-hour task
    duration, TowerOpenDuration 23h (admin-editable) and persisted
    TowerOpenUntilUtc; CreateDefault tower default 23h.
  • Merged RefreshKanturuData update: safezone fix, 15 participants,
    summon waves, one-shot start-config reset; seeder guards against duplicates
    and fails loudly on missing monsters.
  • Map data: summon waves 9–11 around the teleport targets; teleport
    message rewordings (no recovery claims).
  • Scheduled Kanturu starts are skipped while the Tower of Refinement window is open — with the default 23h tower and 6h schedule, one victory covers roughly three starts; a GM start still proceeds and ends the window.

Verification

  • Full test suite: 1109/1109 green; zero new warnings in touched files.
  • In-game verified: victory → teleport out → re-enter at Nightmare entry
    with open barrier → restart → instant tower re-entry → correct "closes in
    N hours" dialog → window expiry returns to the regular schedule.

… window

Decomposes the KanturuContext god object into focused collaborators with
unit test coverage, fixes event/tower re-entry, and makes the Tower of
Refinement replayable: it stays open for a configurable window that
survives server restarts, without replaying the boss phases.

Bugs fixed:
- Re-entering Kanturu crashed the enter-result packet with
  ArgumentOutOfRangeException (MiniGameType.Kanturu unhandled); Kanturu
  now maps through its own 0xD1/0x01 packet.
- The tower couldn't be re-entered after victory, teleport-out, or
  restart (button disabled); entry now works while the window is open.
- Fresh tower maps had a closed barrier and wrong spawn; they now
  replicate the victory state (open barrier, Nightmare-zone spawn) from
  creation, with correct open/close timers in the gateway dialog.

Refactoring:
- KanturuContext split into KanturuKillTracker, KanturuMonsterComparer,
  KanturuNightmarePhaseSelector, KanturuBarrierAreaHelper,
  KanturuRequiredItemHelper, KanturuTowerWindow, KanturuTowerEntry, and
  IKanturuPhaseRunner with wave/transition/nightmare runners.
- Monster death handling is synchronous again (no async void), following
  the invasion death-broadcast pattern.
- Generic mini-game layer stays free of Kanturu specifics; Kanturu
  behavior lives behind MiniGameContext virtuals and the Kanturu
  enter-request handler.
…port)

- KanturuKillTracker now swaps atomic per-phase generations, so a kill
  landing during a phase change can neither pollute nor complete the new
  phase; adds parallel stress and generation-isolation tests.
- Tower waits honor cancellation for prompt teardown instead of lingering
  on uncancelable multi-hour delays.
- Nightmare teleport rechecks IsAlive after the delay and after the move,
  so a mid-teleport kill can't resurrect the boss after victory.
- Tower reuse is state-defined (Open/Closed/Playing); the gateway dialog
  reuses MiniGameContext.IsJoinable instead of duplicating the rule.
- Tower window persist uses read-modify-write, so a concurrent admin
  schedule save can't clobber it; unknown phase kinds log a warning
  instead of silently falling back to the wave runner.
- Gateway dialog reuses MiniGameContext.IsJoinable instead of
  hand-rolling the entry rule, so both stay in agreement.
- Drop the no-op second live assignment when persisting the tower window.

@sven-n sven-n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Quick review of the Kanturu Refinery Tower changes — nice decomposition of KanturuContext, and the tower-window persistence follows the established BotFeaturePlugIn read-modify-write pattern.

Six inline findings, four of them worth a look before merge:

  • Scheduling: the new tower-window gate in KanturuStartPlugIn can skip the next day's event entirely, with no retry.
  • Gateway: canEnter can't become true during the entrance window, because CurrentKanturuState is still None then.
  • Construction: MiniGameContext's constructor reads the virtual MinimumEnterDuration before TowerMode is assigned, and RunGameAsync races the derived constructor body.
  • Wave timing: a TimeLimit of TimeSpan.Zero fails the phase instantly instead of meaning "no limit".

Plus two minor ones (an unobserved task in the WhenAny race, and a lost early return in OnMonsterDied that costs a full-map scan per kill).

Things I checked and found sound: MiniGameMapKey compatibility of the transient TowerMiniGameDefinition, the KanturuTowerWindow persistence pattern, spawn-capture arming order in KanturuNightmareRunner, Died/OnMonsterDied subscription for late joiners, CurrentMiniGame clearing on map removal, the coordinate-255 guard in KanturuBarrierAreaHelper, and the UpdateVersion/GUID uniqueness of the new update plug-in.

Note: no .NET SDK was available in my environment, so nothing here was compiled or run — these are review findings only.


Generated by Claude Code

Comment thread src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs
Comment thread src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs Outdated
Comment thread src/GameLogic/MiniGames/MiniGameContext.cs Outdated
Comment thread src/GameLogic/MiniGames/Kanturu/KanturuContext.cs Outdated
Comment thread src/GameLogic/MiniGames/Kanturu/KanturuContext.cs
Comment thread src/GameLogic/MiniGames/Kanturu/KanturuContext.cs Outdated
# Conflicts:
#	src/GameServer/RemoteView/MiniGames/Extensions.cs
#	src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
#	src/Persistence/Initialization/Updates/UpdateVersion.cs
@eduardosmaniotto
eduardosmaniotto marked this pull request as draft September 25, 2026 16:15
@eduardosmaniotto

eduardosmaniotto commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

I've managed to find the Kanturu event official information in the Wayback Machine: https://web.archive.org/web/20200802210156/http://muonline.webzen.com/guides/25/180/game-contents/kanturu

Kanturu Event.pdf

I noticed some differences from our version, and I'll address those in this PR now.

PS. The official article specifies a generic "Players must kill all 50 monsters" with no mention of which ones. So I left the normal waves as they currently are.

- Share one countdown per wave (monsters + boss) via KanturuWaveGroup,
  TimeLimitGroup and KanturuWaveTimer; followers inherit the remainder,
  expired remainders fail the wave.
- Trigger Nightmare summons from HP thresholds through configured summon
  waves (9-11, 7x Dread Fear); teleports drift toward the tower gate and
  no longer restore boss health; teleport messages updated.
- Run Maya's wide attack (broadcast, damage, pendant insta-kill) through
  KanturuMayaWideAttacker; Nightmare HUD counts minions only.
- Allow refills during Maya standbys up to 15 players and pendant-free
  tower re-entry; gateway dialog reflects live state with standby entry.
- Schedule runs every 6 hours with silent starts: no entrance
  announcements even with custom timetables, blank messages skipped.
- GM /startkanturu always disposes, clears the tower window and resets
  the cooldown before forcing; entry checks evaluate joinability atomically.
- Cover waves, timers, summons, attacker selection and entry with tests.
@eduardosmaniotto
eduardosmaniotto marked this pull request as ready for review September 26, 2026 03:47

@sven-n sven-n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Second pass, now against 5048c05 (diffed 5ea7d7a1..5048c051).

Five of the six findings from my first review are addressed — CurrentKanturuState defaulting to MayaBattle, the constructor no longer reading the virtual MinimumEnterDuration and the game loop starting outside it, TimeSpan.Zero time limits, both racers observed in WaitForPhaseEndAsync, and the early return in OnMonsterDied. Thanks for the Observe helper and the generation-swap in the kill tracker; those read well.

CI is red on this head: the MUnique.OpenMU build (#20260927.15) reports 1 error, and Codacy is action_required. The head was merged from master ~13 minutes after #977 (Raklion) landed, so an interaction with that is worth checking first. I couldn't read the Azure log, and I ruled out the obvious merge suspects — UpdateVersion is clean (118 Raklion / 119 RefreshKanturu), the three resx/Designer pairs are consistent, and Raklion adds no MiniGameType value the enter-result switches would miss.

Five new findings inline; the first two I'd treat as blocking:

  • RefreshKanturuDataUpdatePlugIn deletes the KanturuStartPlugIn configuration row, which deactivates the plug-in on a running server — Kanturu then stays dead until restart.
  • RunRequiredItemWearAsync now also runs in tower mode, so a visitor who kept their Moonstone Pendant loses it and is warped out after ~2h of a 23h window, while a visitor without one is fine.
  • The Nightmare SpawnTimeout now includes the phase StartDelay, leaving ~2s of margin.
  • IsJoinable and IsReusableTower disagree about MiniGameState.Closed, which a zero-length tower lobby passes through.
  • PrewarmTowerGame is unthrottled fire-and-forget from a client-driven packet.

One thing from the first round is unchanged and now has more bite: the tower-window gate in IsPreviousEventStillRunning. With the schedule moved to every 6 hours and TowerOpenDuration still 23h, a single victory silently skips roughly the next three scheduled starts, and SetOpenUntilUtcAsync(null) still only runs if the full 23h delay completes uninterrupted (teardown cancels it). If that's the intended cadence it deserves a note in the PR description; if not, capping the window at the next scheduled start would fix both.

Also worth flagging for reviewers: the PR has grown from 39 files/2.4k added lines to 56/3.7k since the first review, taking in the Nightmare summons and pendant work that were previously listed as out of scope, plus a default schedule change. That's a lot to land in one go.

As before: no .NET SDK in my environment, so nothing here was compiled or run.


Generated by Claude Code

Comment thread src/Persistence/Initialization/Updates/RefreshKanturuDataUpdatePlugIn.cs Outdated
Comment thread src/GameLogic/MiniGames/Kanturu/KanturuContext.cs Outdated
Comment thread src/GameLogic/MiniGames/Kanturu/KanturuNightmareRunner.cs Outdated
Comment thread src/GameLogic/MiniGames/MiniGameContext.cs
Comment thread src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs Outdated

@sven-n sven-n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Third pass, on 78b210dd. Setting the build failure aside for this round at the maintainer's request.

All five findings from the previous review are properly addressed:

  • the update now clears CustomConfiguration and keeps the row, so the plug-in stays active;
  • the spawn timeout moved to the caller, after _beginAsync;
  • IsReusableTower delegates to IsJoinable, Closed is accepted when AllowEnterWhilePlaying, and MiniGamePlayerRegistry matches — one rule in one place, which is the nicer fix;
  • PrewarmTowerGame is guarded by a running-instance check;
  • RunRequiredItemWearAsync moved below the tower-mode return.

I also checked that the Closed widening can't regress the other mini-games: AllowEnterWhilePlaying is overridden only by KanturuContext, so nothing else changes behaviour.

Two things left, both inline:

  • The pendant fix covers TowerMode but not the post-victory tower phase, which runs in the same context with the wear loop still going. That's the common path, and the item is non-repairable, so winners lose their pendant permanently ~2h into a 23h window while fresh tower visitors don't. I'd treat this one as blocking.
  • A minor leftover from the timeout move: the abandoned spawn task keeps its ObjectAdded subscription and ends in an unobserved cancellation.

As before, no .NET SDK in my environment — nothing here was compiled or run.


Generated by Claude Code

return;
}

_ = Task.Run(() => this.RunRequiredItemWearAsync(this.GameEndedToken), this.GameEndedToken);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Moving this below the tower-mode return fixes the TowerMode case, but not the path players will actually hit most.

A won event doesn't create a new context: RunKanturuGameLoopAsync falls through to RunTowerOfRefinementAsync in this game, with the wear loop started here still ticking against GameEndedToken for the whole tower window. So for the normal "beat the boss, stay in the tower" flow the pendant is still worn down, while a visitor entering a fresh tower-mode instance is exempt — the same inversion as before, just one path over.

Concretely, with the configured RequiredItemDurabilityLoss = 1 per RequiredItemDurabilityLossInterval = 1 min against the Moonstone Pendant's durability of 120, a winner who stays in the tower has it destroyed by RemovePlayersWithDestroyedItemsAsync and is warped out after ~2 hours of a 23-hour window. The pendant is Blocked.Repair, so that loss is permanent — the players who cleared the event are the ones who pay for it.

Since the placement can't express "stop when the state flips", a state check in the loop looks like the better fit — e.g. WearRequiredItemsAsync returning early while SkipMapEntryRequirements holds, which already covers both TowerMode and CurrentKanturuState == Tower.


Generated by Claude Code

await this._beginAsync(phase, cancellationToken).ConfigureAwait(false);
try
{
this._nightmareMonster = await spawnTask.WaitAsync(nightmare.SpawnTimeout, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor follow-up on the timeout move (which does fix the StartDelay problem): on timeout, WaitAsync stops this await, but spawnTask itself keeps running.

WaitForNightmareSpawnAsync is still sitting on nightmareFound.Task.WaitAsync(ct) inside its try, so its finally hasn't run and the Map.ObjectAdded handler stays subscribed for the rest of the game. When the game ends, that abandoned task is cancelled with nobody awaiting it, which surfaces as an unobserved OperationCanceledException — the same shape as the race Observe was added for in WaitForPhaseEndAsync.

Passing a linked token that the runner cancels on timeout would unwind the waiter properly and unsubscribe; an Observe(spawnTask) in the catch would at least silence the exception.


Generated by Claude Code

@sven-n
sven-n merged commit ed99b4c into MUnique:master Sep 29, 2026
0 of 2 checks passed
@eduardosmaniotto
eduardosmaniotto deleted the fix/kanturu-refinery-tower branch September 29, 2026 17:56
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