Kanturu Refinery Tower: refactor context, fix re-entry, persist tower window - #970
Conversation
… 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
left a comment
There was a problem hiding this comment.
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
KanturuStartPlugIncan skip the next day's event entirely, with no retry. - Gateway:
canEntercan't becometrueduring the entrance window, becauseCurrentKanturuStateis stillNonethen. - Construction:
MiniGameContext's constructor reads the virtualMinimumEnterDurationbeforeTowerModeis assigned, andRunGameAsyncraces the derived constructor body. - Wave timing: a
TimeLimitofTimeSpan.Zerofails 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
# Conflicts: # src/GameServer/RemoteView/MiniGames/Extensions.cs # src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs # src/Persistence/Initialization/Updates/UpdateVersion.cs
|
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 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.
sven-n
left a comment
There was a problem hiding this comment.
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:
RefreshKanturuDataUpdatePlugIndeletes theKanturuStartPlugInconfiguration row, which deactivates the plug-in on a running server — Kanturu then stays dead until restart.RunRequiredItemWearAsyncnow 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
SpawnTimeoutnow includes the phaseStartDelay, leaving ~2s of margin. IsJoinableandIsReusableTowerdisagree aboutMiniGameState.Closed, which a zero-length tower lobby passes through.PrewarmTowerGameis 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
sven-n
left a comment
There was a problem hiding this comment.
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
CustomConfigurationand keeps the row, so the plug-in stays active; - the spawn timeout moved to the caller, after
_beginAsync; IsReusableTowerdelegates toIsJoinable,Closedis accepted whenAllowEnterWhilePlaying, andMiniGamePlayerRegistrymatches — one rule in one place, which is the nicer fix;PrewarmTowerGameis guarded by a running-instance check;RunRequiredItemWearAsyncmoved 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
TowerModebut 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
ObjectAddedsubscription 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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
Bugs fixed
ArgumentOutOfRangeException(
MiniGameType.Kanturuunhandled by the enter-result packet). Kanturu nowmaps through
ToKanturuEnterResult()to its own0xD1/0x01packet.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.
MayaBattleflicker. They now replicate the victory state from creation (open barrier,
Towerstate from birth, Nightmare-zone spawn via the handshake-timeGetEntrySpawnPositionhook) with the correct remaining-time dialog.Ended) games no longeradvertise entry at the gateway and no longer shadow tower recreation;
tower entry also skips disposed/disposing instances.
/startkanturudestroyed the tower and then gotblocked 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.
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.
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).
kills players without the Moonstone Pendant via
KillInstantlyAsync(dodge-proof, full death flow), Broken Shower pattern; pendant check runs
on equipped items only.
(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.
Refactoring
KanturuContextdecomposed intoKanturuKillTracker(atomic per-phasegenerations),
KanturuMonsterComparer,KanturuNightmarePhaseSelector,KanturuBarrierAreaHelper,KanturuRequiredItemHelper,KanturuTowerWindow,KanturuTowerEntry,KanturuMayaWideAttacker(attack loop, damage, pendant kill),
KanturuWaveTimer(shared countdown),KanturuWaveGroup, andIKanturuPhaseRunner(monster-wave / transition /nightmare runners, spawn-wave seam for summons).
OnMonsterDiedis synchronous again (fire-and-forget broadcasts), samepattern as the invasion death broadcast — no
async voidsuppression.joinable entry, map-requirement waiver, cancellable waits); the gateway
dialog reuses
MiniGameContext.IsJoinableplus small state helpers.attacker selection, entry matrix, minion-count exclusion.
Configuration / data
KanturuStartConfigurationdefaults: 6-hour timetable, 6-hour taskduration,
TowerOpenDuration23h (admin-editable) and persistedTowerOpenUntilUtc;CreateDefaulttower default 23h.RefreshKanturuDataupdate: safezone fix, 15 participants,summon waves, one-shot start-config reset; seeder guards against duplicates
and fails loudly on missing monsters.
message rewordings (no recovery claims).
Verification
with open barrier → restart → instant tower re-entry → correct "closes in
N hours" dialog → window expiry returns to the regular schedule.