45 Commits

Author SHA1 Message Date
Acentech Dev
3d5d40b753 Add Warrior Wings & Cape (12/242-247)
Some checks failed
.NET Core / build (push) Has been cancelled
Six high-tier imported wings, one per class line: Wizard (242, Soul Master),
Magic (243, Duel Master), Elf (244, High Elf), Summoner (245, Dimension Master),
Warrior Cape (246, Lord Emperor + Fist Master) and Knight (247, Blade Master).
Each: base defense + defense-per-level, 3rd Wing Options, damage increase and
fast flight. Applied via update v99 (AddWarriorWingsUpdateSeason6), reusing the
shared "3rd Wing Options" option set to avoid Guid collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:27:22 +03:00
Acentech Dev
1cadaa08ab fix(items): reuse shared 3rd Wing Options + level tables for Poison wings (avoid Guid collision) 2026-07-19 18:36:38 +03:00
Acentech Dev
3e8cd8fdae feat(items): Poison Wing/Cape as high-tier wings (defense + options + level bonuses)
Switch from the basic CreateWing overload to the full one: base
defense 65/60, defense-per-level, wing options (HP recover / phys
damage / defense), damage increase, fast flight. Level req 100.
2026-07-19 18:14:46 +03:00
Acentech Dev
affa4bd73b feat(items): add Poison Wing (12/240) and Poison Cape (12/241)
Basic wings via the simple CreateWing overload (base defense, class
restriction, CanFly; no options). Poison Wing=Dark Knight, Poison
Cape=Dark Lord+Rage Fighter. Update-plugin v98 + fresh-seed via
Wings.Initialize().
2026-07-19 16:54:33 +03:00
Acentech Dev
fc9ba0e594 docs: design spec for Poison Wing & Poison Cape (wings 12/240, 12/241) 2026-07-19 16:44:35 +03:00
Acentech Dev
d84b29eac3 feat(maps): add Acheron, Debenter, Uruk, Ferea (maps 83-86)
Same pattern as Arkania: terrain resources Terrain84-87.att, map
initializer classes, registration, and update-plugin (v97) that
creates each map + spawn gate + /move warp AND assigns it to the
game server configuration so it is hosted. Also fix the Arkania
update-plugin to assign the game server config (was missing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:47:05 +03:00
Acentech Dev
80ec466ab1 fix(maps): Arkania is map number 82 (client World = number+1 off-by-one)
Client tried to load World84 for map 83; MU client world index is
server map number + 1 (Lorencia 0 -> World1). Arkania client assets
are World83, so the server map number is 82 and the terrain resource
is Terrain83.att (number+1).
2026-07-19 14:08:01 +03:00
Acentech Dev
e3d37467d5 feat(maps): add Arkania DB update-plugin with spawn gate and /move warp 2026-07-19 13:45:37 +03:00
Acentech Dev
7e21fc1fb3 feat(maps): add Arkania map initializer (number 83) and register it 2026-07-19 13:43:26 +03:00
Acentech Dev
4628902f11 feat(maps): add Arkania (map 83) terrain resource Terrain84.att 2026-07-19 13:42:46 +03:00
Acentech Dev
a61890ef6e docs: implementation plan for Arkania map pilot
7 tasks: terrain resource (Terrain84.att, off-by-one), map class +
registration, update-plugin with spawn gate + /move warp, docker
build/verify, deploy + panel verify, client assets, client verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:31:34 +03:00
Acentech Dev
3b2e26b27c docs: design spec for new maps import (pilot: Arkania/World83)
End-to-end import of one pilot map across server (OpenMU C# map
initializer + Terrain83.att resource + DB update-plugin), PC client
(World83/Object83 assets), and web (no changes; MapEditor renders
seeded terrain, user adds spawns/NPCs). Other 4 maps + mobile are
follow-up from the proven template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:21:40 +03:00
Acentech Dev
1de06d83ab fix(reset): warp the player home instead of moving him silently
Some checks failed
.NET Core / build (push) Has been cancelled
MoveHomeAsync wrote PositionX/PositionY/CurrentMap/Rotation on the character
record directly, which is a partial copy of Player.PlaceAtGateAsync: it placed
the player but skipped removing him from the map and telling the client.

Player.Position is backed by those very fields, so the coordinates jumped on the
server while the client never got a map change. The client then interpolated a
walk to the new spot and the character visibly slid across the map after a reset.

WarpToAsync does the same placement plus the map removal and the map change
notification, and it handles respawning on the same map. It is the path every
other caller uses (duel room, gate NPCs, mini games, castle siege portal).

The existing tests all ran with MoveHome = false, which is why this path was
never covered. The new test pins the notification: it fails on the old code
because MapChangeAsync is never invoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:26:04 +03:00
Acentech Dev
2927be1ba9 chore(docs): regenerate QuestProgressExtended packet doc as C2
Some checks failed
.NET Core / build (push) Has been cancelled
The packet definition declares C2HeaderWithSubCode, but the committed
doc was generated from an older C1 header and never refreshed. This is
the doc generator's output for the current XML, so the tree stays clean
after a build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:04:13 +03:00
Acentech Dev
16752f3649 chore(deploy): default RESOLVE_IP to the emulator host alias
Without RESOLVE_IP the ConnectServer advertises the public IP, which the
emulator cannot reach, so server select hangs. Default to 10.0.2.2 and
allow an override for real devices on the LAN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:04:13 +03:00
Acentech Dev
ef3bffe39c feat(items): add Imperial Sword (0/50) and Imperial Staff (5/50)
Two new weapons placed at free item slots so they never collide with
standard Season 6 items. The client name DB (Item_eng.bmd) is the source
of tooltip name/stats, so only free slots are usable for custom items.

Both seed paths are covered:
- Fresh DB: Weapons.Initialize() calls the new CreateImperialWeapons050().
- Existing DB: AddImperialWeapons050UpdateSeason6 (UpdateVersion 95),
  applied from the AdminPanel Updates page, with an idempotency guard.

Imperial Sword is a Magic Gladiator weapon (copy of 0/28), Imperial Staff
is a Wizard weapon (copy of 5/31).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:03:52 +03:00
Acentech Dev
fbe75d55be feat(CS): run the siege on ONE designated server (CastleSiegeServerId)
Some checks failed
.NET Core / build (push) Has been cancelled
With multiple game servers each has its own map instances, so the siege ran
independently on all of them - a guild could win uncontested on an empty server's
Valley of Loren. Now the siege (tick/spawn/battle/registration) runs only on the
server whose Id == config.CastleSiegeServerId (AdminPanel-editable). Other servers
skip the siege and just mirror the shared castle owner from config so the hunting-map
gate + castle flag rewards still work everywhere. The Guardsman on non-siege servers
tells players which server to switch to.
2026-07-15 19:10:48 +03:00
Acentech Dev
3e465c016e feat(CS): AdminPanel-friendly config (day checkboxes, minute/second durations)
Some checks failed
.NET Core / build (push) Has been cancelled
The generic plugin-config editor couldn't edit IList<DayOfWeek> and showed durations
in milliseconds + the runtime Persisted* state. Add [JsonIgnore] proxy properties that
the AutoFields editor CAN render: OpenDays ([Flags] enum -> checkboxes), Registration/
Preparation/Siege minutes + CrownHold seconds (int inputs). Hide the underlying TimeSpan/
list/Persisted* fields with [Browsable(false)]. Runtime JSON shape is unchanged (proxies
are JsonIgnore) so no persisted-config break.
2026-07-15 17:41:09 +03:00
Acentech Dev
f135e90899 fix(CS): stop crown re-capture loop + show seal panel only to the master
Some checks failed
.NET Core / build (push) Has been cancelled
- The occupier can no longer re-register its own throne (canCapture = eligible != occupier),
  which caused a repeating 60s panel loop; only a DIFFERENT guild can contest.
- The 60s registration panel (0xB2/0x15) is now sent ONLY to the master on the crown,
  not broadcast to switch-holders/other players. Shield(0x16) + capture(0x18) still broadcast.
+1 test (15 total).
2026-07-15 16:22:13 +03:00
Acentech Dev
e5dfa24585 fix(CS): send crown shield packet (0xB2/0x16) only on change, not every tick
Some checks failed
.NET Core / build (push) Has been cancelled
The shield packet pops a modal message box on the client; sending it every tick
re-opened the modal continuously and froze player input at siege start. Track the
shield state in the context and only push the packet when it actually flips.
2026-07-15 15:36:51 +03:00
Acentech Dev
3dd4881406 feat(CS): authentic Crown-hold throne capture (S6 protocol) replacing Sinior-talk
Some checks failed
.NET Core / build (push) Has been cancelled
Break all gates + hold both switches (defenses down) -> the Crown shield drops
(C1 B2 16=0). The guild master then stands on the Crown (176,212) and holds for
CrownHoldDuration (default 60s, client shows a 60s countdown via C1 B2 15) to
capture; capture broadcasts C1 B2 18 + golden text and sets the occupier. Losing a
switch or leaving the crown resets the hold (contestable until the siege timer ends).
Sinior (223) is now informational guidance. +2 tests (14 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:04:10 +03:00
Acentech Dev
4651ef232c refactor(CS): schedule + periods are config-owned, AdminPanel-editable
Some checks failed
.NET Core / build (push) Has been cancelled
Move the auto-schedule from context-owned state to the plugin config (single source
of truth): RegistrationOpenDays + RegistrationOpenTimes + Registration/Preparation/
SiegeDuration are all editable in the AdminPanel plugin config and take effect live
(context refreshes its config reference each tick via UpdateConfiguration). /csschedule
now writes the config. Removed the duplicate Persisted schedule fields. 12 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:52:07 +03:00
Acentech Dev
f586321123 feat(CS): castle flag owner logo (C1 B9 02) + catapults for war atmosphere
Some checks failed
.NET Core / build (push) Has been cancelled
#1 Castle flags now show the owner guild's logo: resolve owner name -> guild logo
(cached), broadcast C1 B9 02 (32-byte mark) to players on the castle map every 15s.
#3 War atmosphere: spawn catapult NPCs 221/222 at siege start (client renders them
as siege weapons); the 0xB2/0x17 start flag already flips the map to warzone mode.
All raw S6 packets, no client changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:19:07 +03:00
Acentech Dev
7cf790c2a8 feat(CS): on-map siege countdown timer (S6 client protocol C1 B2 17/1E)
Some checks failed
.NET Core / build (push) Has been cancelled
Client already renders the siege-map countdown but never received the packets.
New ICastleSiegeStatusViewPlugIn + RemoteView impl send the raw S6 packets:
C1 B2 17 (battle start/stop flag, arms the countdown) and C1 B2 1E (remaining
hour/minute). CastleSiegeEventPlugIn broadcasts them to players on the battle map
every 10s during Siege and sends stop at Settlement. No client changes. +1 test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:45:01 +03:00
Acentech Dev
ac6700a356 feat(CS): weekly auto-schedule (day-of-week + UTC time), persisted, /csschedule GM cmd
Some checks failed
.NET Core / build (push) Has been cancelled
The siege can now auto-open registration on scheduled days/time instead of only
manual /csphase. Schedule is context-owned state (like owner), persisted across
restarts via the same config-JSON path. New GM command /csschedule sets/views/clears
it (e.g. /csschedule Sunday 20:00, UTC). +2 unit tests (11 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:47:29 +03:00
Acentech Dev
05a10d495d fix(CS): persist config via non-caching typed context (caching context saved nothing)
Some checks failed
.NET Core / build (push) Has been cancelled
CreateNewContext(config) returns a caching context whose returned config objects
aren't tracked by that context, so SetConfiguration+SaveChanges was a silent no-op
(DB row never updated). Load a fresh change-tracked PlugInConfiguration by id in a
non-caching typed context, rewrite the JSON, save. Adds a confirmation log line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:30:18 +03:00
Acentech Dev
9bdd57703f feat(CS): persist owner/phase/registrations across restarts via plugin config JSON
Some checks failed
.NET Core / build (push) Has been cancelled
Castle Siege state was in-memory, so the castle owner (and thus the P4 hunting-map
reward) reset on every server restart/redeploy. Now the context marks itself dirty
on any persistable change (phase transition, registration, owner set); the plugin's
periodic tick writes a snapshot into its own PlugInConfiguration CustomConfiguration
(a JSON blob already stored in PostgreSQL - no schema migration) and restores it on
startup. Battle state (defenses/switches/occupier) stays transient. +2 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:17:34 +03:00
Acentech Dev
f03f6aa7ad fix(CS-P4): Guard portal uses Land of Trials entry ExitGate (no spawn gate exists)
Some checks failed
.NET Core / build (push) Has been cancelled
Land of Trials (31) has no IsSpawnGate, so SafeZoneSpawnGate was null -> portal
said 'not available'. Fall back to the map's single entry ExitGate (60-69,10-19,
the real map entrance) so owner-guild members actually warp in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:55:54 +03:00
Acentech Dev
d5097752af feat(CS-P4): castle Guard (220) as Land of Trials hunting portal
Some checks failed
.NET Core / build (push) Has been cancelled
The Valley of Loren Guard NPC now warps castle-owning guild members into the
hunting ground (map 31); non-owners get the sealed/owner-only reason. Provides
the in-world entry point that was missing (talking to Guard was unimplemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:45:01 +03:00
Acentech Dev
dfa884b86c feat(CS-P4): castle-exclusive hunting map (Land of Trials 31) gated to owner guild
Some checks failed
.NET Core / build (push) Has been cancelled
Ownership reward: only members of the current castle-owning guild may warp into
Land of Trials (map 31); sealed while unowned; GMs bypass. Enforced in both
WarpAction and WarpGateAction (// ADAMU-CUSTOM). Tax system deferred per user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:35:34 +03:00
Acentech Dev
b890e82a88 refactor(CS-P3): siege defenses = 6 castle gates only (removed 4 statues per user - kept clean gate mechanic)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 11:18:28 +03:00
Acentech Dev
8114930855 feat(CS-P3): 4 real guardian statues (283) at castle positions 82/107/94,129/131/126 - all defenses required for throne 2026-07-15 10:53:57 +03:00
Acentech Dev
af9554bf91 feat(CS-P3): REAL castle gates (NPC 277) at 6 client gate positions -> client closes+blocks them; breakable via HP template; inner guardian statues (283)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 03:18:00 +03:00
Acentech Dev
72ebc96e28 feat(CS-P3): throne registration via Sinior/Crown talk (223/216) after defenses down + both switches held; reachable defense positions
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 02:49:27 +03:00
Acentech Dev
db8bdd394f feat(CS-P3): PROPER siege - position-based Crown Switch hold (stand on them) + gates & statues destructibles + auto throne capture; remove non-working talk handlers
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 02:28:14 +03:00
Acentech Dev
c50af5e5c2 feat(CS-P3): full siege objective chain - guardian statues (Destructible spawn) + dual Crown Switch hold + throne capture gating 2026-07-15 01:52:19 +03:00
Acentech Dev
5549c2bec7 feat(CS-P3): siege PvP on Valley of Loren during siege (ADAMU-CUSTOM) + golden phase announcements
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 01:23:57 +03:00
Acentech Dev
c7147f7a63 feat(CS-P3): warp registered members to Valley of Loren on siege start + Crown Switch throne capture (NPC 217/218)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 00:57:28 +03:00
Acentech Dev
9f2078d724 feat(CS-P3): throne capture + settlement winner in state machine + tests
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 00:48:09 +03:00
Acentech Dev
396527a62a docs: CS P3 siege battle plan (minimal playable increment first) 2026-07-15 00:42:05 +03:00
Acentech Dev
41588bd237 feat(CS-P2): registration fee (zen) + already-registered guard on Guardsman 2026-07-15 00:36:20 +03:00
Acentech Dev
44dcb2fd58 feat(CS-P2): Guardsman (NPC 224) talk handler -> register guild during registration phase
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-14 23:11:35 +03:00
Acentech Dev
268d40acd1 feat(CS-P1): GM chat commands (/csstatus /csstart /csphase /cssetowner /csreset)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-14 22:44:46 +03:00
Acentech Dev
6999a780f3 feat(CS-P1): periodic Castle Siege plugin (per-context tick + custom config) 2026-07-14 22:41:46 +03:00
Acentech Dev
73de807bef feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests 2026-07-14 22:40:24 +03:00
51 changed files with 3825 additions and 14 deletions

View File

@@ -17,6 +17,10 @@ services:
environment:
DB_HOST: database
ASPNETCORE_URLS: http://+:8080
# ConnectServer'in GameServer'i client'a hangi IP ile bildirecegi.
# Default: emulator host alias 10.0.2.2 (normal ConnectServer 44405 akisi calissin).
# Gercek cihaz icin: RESOLVE_IP=<PC LAN IP> ile override et (orn. 192.168.0.4).
RESOLVE_IP: ${RESOLVE_IP:-10.0.2.2}
working_dir: /app/
depends_on:
- database

View File

@@ -1,4 +1,4 @@
# C1 F6 0C - QuestProgressExtended (by server)
# C2 F6 0C - QuestProgressExtended (by server)
## Is sent when
@@ -12,10 +12,10 @@ The client shows the quest progress accordingly.
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 272 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xF6 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x0C | Packet header - sub packet type identifier |
| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) |
| 1 | 2 | Short | 272 | Packet header - length of the packet |
| 3 | 1 | Byte | 0xF6 | Packet header - packet type identifier |
| 4 | 1 | Byte | 0x0C | Packet header - sub packet type identifier |
| 5 | 1 | Byte | | ConditionCount |
| 6 | 1 | Byte | | RewardCount |
| 7 | 1 | Byte | | RandomRewardCount |

View File

@@ -234,7 +234,7 @@
* [C1 F6 0A - AvailableQuests (by server)](C1-F6-0A-AvailableQuests_by-server.md)
* [C1 F6 0B - QuestStepInfo (by server)](C1-F6-0B-QuestStepInfo_by-server.md)
* [C1 F6 0C - QuestProgress (by server)](C1-F6-0C-QuestProgress_by-server.md)
* [C1 F6 0C - QuestProgressExtended (by server)](C1-F6-0C-QuestProgressExtended_by-server.md)
* [C2 F6 0C - QuestProgressExtended (by server)](C2-F6-0C-QuestProgressExtended_by-server.md)
* [C1 F6 0D - QuestCompletionResponse (by server)](C1-F6-0D-QuestCompletionResponse_by-server.md)
* [C1 F6 0F - QuestCancelled (by server)](C1-F6-0F-QuestCancelled_by-server.md)
* [C1 F6 1A - QuestStateList (by server)](C1-F6-1A-QuestStateList_by-server.md)

View File

@@ -0,0 +1,110 @@
# CS P3 — Kuşatma Savaşı — Uygulama Planı
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) tracking.
**Goal:** Castle Siege'i oynanabilir yapmak. İlk increment: Siege fazı başlayınca kayıtlı guild üyelerini CS haritasına (Valley of Loren) al, orada PvP aç, bir **Taht** ele geçirme noktası koy; kuşatma bitince tahtı tutan guild kazanır ve kale sahibi olur. Kapılar/kristaller/tam S6 protokolü sonraki increment'ler.
**Architecture:** P1 state machine'in Siege fazına giriş/çıkışına kanca takılır (`CastleSiegeContext` faz-değişim event'i zaten var: `PhaseChanged`). Siege başlayınca `CastleSiegeEventPlugIn` (server-side, GameContext erişimli) kayıtlı oyuncuları warp'lar + PvP açar; Taht bir NPC (talk → guild'i "occupier" yapar); Settlement'ta occupier = kazanan → `context.SetOwner`. Reuse: `player.WarpToAsync(ExitGate)`, `Map.Definition.BattleZone` (PvP), `IPlayerTalkToNpcPlugIn` (Taht), P1 `CastleSiegeContext`.
**Tech Stack:** .NET 10, OpenMU GameLogic/PlugIns, NUnit.
## Global Constraints
- Depo: `d:/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw` (bash tırnak içinde).
- Additive-first; core dokunuşu `// ADAMU-CUSTOM`.
- Lokal derleme `-p:ci=true`. Commit yazarı `Acentech Dev <acentech_dev@affinitybox.com>`.
- CS map = **Valley of Loren (WorldIndex 30)** — implementation'da `Persistence/Initialization`'dan tam numarayı + entrance gate'i doğrula.
- Zaman/rastgele state machine mantığında enjekte (P1 deseni).
## Investigation notları (implementation başında doğrula)
- Warp: `player.WarpToAsync(ExitGate gate)` (Player.cs:1088). CS map entrance gate'i gerekir — Initialization'da Valley of Loren spawn/exit gate'ini bul veya oluştur.
- PvP: kill izni `GameContext.PvpEnabled || Map.Definition.BattleZone != null || CurrentMiniGame.AllowPlayerKilling` (Player.cs:725). CS map'e Siege sırasında PvP açmak için: ya map Definition'a `BattleZone` ekle, ya da CS'ye özel bir `AllowPlayerKilling` context (MiniGame benzeri). **Karar (impl):** BattleZone en temiz — Valley of Loren Definition'a BattleZone tanımı (Initialization).
- Taht NPC: yeni bir NPC number (S6 CS'de "Crown/Throne switch"). `IPlayerTalkToNpcPlugIn` ile ele geçirme (remote-NPC/Guardsman deseni).
- CastleSiegeContext'e occupier alanı + kazanan mantığı eklenir (P1'de OwnerGuildName string; occupier de string guild adı).
---
### Task 1: State machine — occupier + settlement kazananı
**Files:**
- Modify: `src/GameLogic/CastleSiege/CastleSiegeContext.cs`
- Test: `tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs`
**Interfaces:**
- Produces: `CastleSiegeContext.CaptureThrone(string guildName)` (sadece Siege fazında occupier'ı set eder), `OccupierGuildName` (get), Settlement'ta occupier varsa `OwnerGuildName = occupier`.
- [ ] **Step 1: Test yaz** — Siege fazında `CaptureThrone("A")``OccupierGuildName=="A"`; Settlement tick sonrası `OwnerGuildName=="A"`. Ownership fazında CaptureThrone no-op.
```csharp
[Test]
public async Task ThroneCaptureDuringSiegeBecomesOwnerOnSettlementAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.CaptureThrone("Attackers");
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership (owner atanir)
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
}
```
- [ ] **Step 2: Çalıştır → fail (CaptureThrone/OccupierGuildName yok).**
- [ ] **Step 3: Implement**`CastleSiegeContext`'e:
- `private string? _occupier;` + `public string? OccupierGuildName => this._occupier;`
- `public void CaptureThrone(string g){ if(this.Phase==CastleSiegePhase.Siege) this._occupier = g; }`
- `ForceStartRegistrationAsync`/`ResetAsync`'te `_occupier=null`.
- Settlement→Ownership geçişinde (TickAsync Settlement case): `if(this._occupier is {} occ){ this.SetOwner(occ); } this._occupier=null;` (TransitionAsync'ten önce).
- [ ] **Step 4: Test geçsin.** **Step 5: Commit.**
---
### Task 2: Siege başlarken warp + PvP; bitince güvenli bölgeye al
**Files:**
- Modify: `src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs`
- (Belki) Create: `src/GameLogic/CastleSiege/CastleSiegeMapController.cs` (warp/PvP yardımcıları)
**Interfaces:**
- Consumes: `CastleSiegeContext.PhaseChanged` (P1), `player.WarpToAsync`, CS map (Valley of Loren).
- Produces: Siege fazına girince kayıtlı guild üyeleri CS map'e warp; Ownership'e dönünce map'ten güvenli bölgeye.
- [ ] **Step 1:** `CastleSiegeEventPlugIn.ExecuteTaskAsync` içinde faz değişimini yakala (context.Phase önceki-değerle karşılaştır ya da `PhaseChanged` event'ine abone ol). Siege'e GİRİŞTE: `gameContext`'teki tüm oyunculardan `GuildStatus.GuildId`'si kayıtlı guild'lerden biri olanları CS map entrance gate'ine `WarpToAsync`.
- [ ] **Step 2:** PvP: CS map Definition'a `BattleZone` (Initialization'da) — Siege boyunca kill serbest. (Alternatif: CS context'i `AllowPlayerKilling=true` mini-game gibi kaydet.) Impl'de en az riskli yolu seç, doğrula.
- [ ] **Step 3:** Siege bitince (Ownership'e dönüş): CS map'teki oyuncuları `WarpToSafezoneAsync`.
- [ ] **Step 4:** Derle + smoke. **Step 5: Commit.**
> Not: kayıtlı-guild üye eşleştirmesi için context'te guild ADI tutuyoruz; warp'ta oyuncunun guild adını `GuildServer.GetGuildAsync(guildId).Name` ile çözüp kayıtlı listeyle karşılaştır (Guardsman handler'daki desen).
---
### Task 3: Taht NPC — ele geçirme
**Files:**
- Create: `src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs`
- (Initialization) Taht NPC'sini CS map'e yerleştir (number ata).
**Interfaces:**
- Consumes: `IPlayerTalkToNpcPlugIn`, `CastleSiegeEventPlugIn.TryGetContext`, `context.CaptureThrone`.
- [ ] **Step 1:** `CastleSiegeThroneTalkPlugIn : IPlayerTalkToNpcPlugIn` (Guardsman deseni). Taht NPC number kontrolü → `eventArgs.HasBeenHandled=true` → Siege fazındaysa + oyuncu kayıtlı guild master/üyesiyse → `context.CaptureThrone(guildName)` + "Your guild captured the throne!" mesajı.
- [ ] **Step 2:** Taht NPC'sini CS map'e ekle (Initialization'da MonsterSpawn; number ata, örn. 277 "Castle Gate switch" S6 referansına bak).
- [ ] **Step 3:** Derle. **Step 4: Commit.**
---
### Task 4: Uçtan uca test + server image + admin akışı
- [ ] **Step 1:** Tüm CS testleri geçsin (`--filter CastleSiege`).
- [ ] **Step 2:** Image rebuild + container restart.
- [ ] **Step 3 (manuel/emülatör):** GM ile `/csstart` → Guardsman'de kayıt → `/csphase Siege` → oyuncu CS map'e warp olmalı, PvP açık, Taht'a konuş → "captured throne" → `/csphase Settlement``/csstatus` owner = guild. (Not: Taht ele geçirme akışı client↔server canlı doğrulanır.)
- [ ] **Step 4:** Gitea push.
---
## Sonraki increment'ler (P3 devamı)
- **Kapılar** (Destructible, HP, kırılınca terrain açılır — MiniGame terrain-change deseni).
- **Kristaller / Guardian Statue'lar** (Destructible; taht'a erişim için yıkılmalı).
- **Kontenjan/giriş kısıtı** (Siege'de sadece kayıtlı guild üyeleri map'e girebilir).
- **Tam S6 CS protokolü** (server→client CS state/time paketleri → client'ın native kuşatma UI'ı + **geri sayım** çalışır; client UI'ı hazır).
- **Contention** (tek ele geçirme yerine tutma süresi/skor ile kazanan).

View File

@@ -0,0 +1,446 @@
# New Map Import — Arkania Pilot Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Import one pilot map (Arkania, map number 83) end-to-end into OpenMU server, the PC MUnique client, and make it visible/editable in the web admin panel.
**Architecture:** OpenMU-native map: a C# `BaseMapInitializer` subclass registers map number 83 into `GameConfiguration.Maps`; its terrain comes from an embedded `.att` resource; an update-plugin applies the same to an existing DB. The map is seeded EMPTY (no monsters/NPCs) plus one spawn gate and a `/move` warp for reachability. The web admin panel needs no code changes — the map appears automatically once in the DB. PC client gets the `World83`/`Object83` asset folders.
**Tech Stack:** C# (.NET), OpenMU `Persistence.Initialization`, Docker build, MUnique MuMain PC client (Data folder assets).
## Global Constraints
- Server repo: `AdamuSw` at `D:\OpenMU\MU Client_Mobile 1.04d - Season 6E3\AdamuSw`, work on branch `feature/new-maps-import` (already created).
- Pilot map: **Arkania**, **map number 83** (client `World83`). Confirm the name against the pack screenshots before finalizing; if it turns out World83 is a different map, change only the `Name`/class name.
- **Terrain resource naming is OFF-BY-ONE:** OpenMU map number `N` loads embedded resource `Resources/Terrain{N+1}.att`. For Arkania (83) that is **`Terrain84.att`**, whose CONTENT is the pack's `Terrain83.att` (world-83 attributes). Verified against Aida (33→Terrain34.att), Vulcanus (63→Terrain64.att).
- Terrain resources are NOT globbed by the csproj — each is listed explicitly (`<None Remove>` + `<EmbeddedResource Include>`). New `.att` MUST be added to both lists.
- Update-plugins are applied to existing DBs ONLY via AdminPanel → Updates (`DataUpdateService`); never call `SaveChanges` inside a plugin. Fresh/zero DB auto-seeds via the map registration.
- Server build is via Docker: `docker build -f Startup/Dockerfile -t adamu-openmu:dev .` from the `AdamuSw` root. Local `dotnet build` is expected to fail at the source-generator pre-step (known, per the Imperial-items work) — use the Docker build as the compile gate.
- Pack source: `C:\Users\efpa\Downloads\Arkania Acheron Debenter Uruk Ferea\Arkania Acheron Debenter Uruk Ferea`.
- PC client Data root: `D:\AdaMu\AdaMu\Data`.
---
### Task 1: Add the Arkania terrain resource
**Files:**
- Create: `AdamuSw/src/Persistence/Initialization/Resources/Terrain84.att` (copied from pack `Server Side/Data/Terrain/Terrain83.att`)
- Modify: `AdamuSw/src/Persistence/Initialization/MUnique.OpenMU.Persistence.Initialization.csproj` (two lines: a `<None Remove>` and an `<EmbeddedResource Include>`)
**Interfaces:**
- Produces: embedded manifest resource `MUnique.OpenMU.Persistence.Initialization.Resources.Terrain84.att`, consumed by `TerrainUpdateHelper.UpdateTerrainFromResources` for map number 83.
- [ ] **Step 1: Copy the terrain file (pack Terrain83.att → resource Terrain84.att)**
Run (Git Bash):
```bash
SRC="/c/Users/efpa/Downloads/Arkania Acheron Debenter Uruk Ferea/Arkania Acheron Debenter Uruk Ferea/Server Side/Data/Terrain/Terrain83.att"
DST="/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Resources/Terrain84.att"
cp -f "$SRC" "$DST"
stat -c '%s %n' "$DST"
```
Expected: prints `65539 .../Terrain84.att` (matches OpenMU's other `.att` sizes).
- [ ] **Step 2: Verify the header matches OpenMU's terrain format**
Run:
```bash
xxd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Resources/Terrain84.att" | head -1
xxd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Resources/Terrain1.att" | head -1
```
Expected: both start with `00ff ff04` (same 3-byte header family). If Terrain84.att differs structurally, STOP — the pack file may be a different format.
- [ ] **Step 3: Register the resource in the csproj**
In `MUnique.OpenMU.Persistence.Initialization.csproj`, add next to the existing `Terrain*.att` entries:
In the `<None Remove=...>` group (near `<None Remove="Resources\Terrain1.att" />`):
```xml
<None Remove="Resources\Terrain84.att" />
```
In the `<EmbeddedResource Include=...>` group (near `<EmbeddedResource Include="Resources\Terrain1.att" />`):
```xml
<EmbeddedResource Include="Resources\Terrain84.att" />
```
- [ ] **Step 4: Verify both csproj lines are present**
Run:
```bash
grep -n "Terrain84.att" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/MUnique.OpenMU.Persistence.Initialization.csproj"
```
Expected: exactly two lines — one `<None Remove ...Terrain84.att />` and one `<EmbeddedResource Include ...Terrain84.att />`.
- [ ] **Step 5: Commit**
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
git add src/Persistence/Initialization/Resources/Terrain84.att src/Persistence/Initialization/MUnique.OpenMU.Persistence.Initialization.csproj
git commit -m "feat(maps): add Arkania (map 83) terrain resource Terrain84.att"
```
---
### Task 2: Create the Arkania map initializer and register it
**Files:**
- Create: `AdamuSw/src/Persistence/Initialization/VersionSeasonSix/Maps/Arkania.cs`
- Modify: `AdamuSw/src/Persistence/Initialization/VersionSeasonSix/GameMapsInitializer.cs` (add one `yield return`)
**Interfaces:**
- Produces: `internal class Arkania : BaseMapInitializer` with `internal const byte Number = 83;` and `internal const string Name = "Arkania";`. Consumed by `GameMapsInitializer` (fresh seed) and the update-plugin (Task 3).
- [ ] **Step 1: Create the map class**
Create `Arkania.cs` (mirrors the `Aida.cs` pattern, but with no monster/NPC spawns — those are added later via the web panel):
```csharp
// <copyright file="Arkania.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Arkania map (imported map, world 83).
/// Seeded empty; monster/NPC spawns and gates are added via the admin panel.
/// </summary>
internal class Arkania : BaseMapInitializer
{
/// <summary>
/// The Number of the Map.
/// </summary>
internal const byte Number = 83;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Arkania";
/// <summary>
/// Initializes a new instance of the <see cref="Arkania"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Arkania(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}
```
- [ ] **Step 2: Register the map in GameMapsInitializer**
In `VersionSeasonSix/GameMapsInitializer.cs`, in `MapInitializerTypes`, add after the last existing `yield return typeof(...);` line:
```csharp
yield return typeof(Arkania);
```
- [ ] **Step 3: Verify class + registration compile-consistency (textual)**
Run:
```bash
grep -n "class Arkania : BaseMapInitializer" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/VersionSeasonSix/Maps/Arkania.cs"
grep -n "typeof(Arkania)" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/VersionSeasonSix/GameMapsInitializer.cs"
```
Expected: one match each.
- [ ] **Step 4: Commit**
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
git add src/Persistence/Initialization/VersionSeasonSix/Maps/Arkania.cs src/Persistence/Initialization/VersionSeasonSix/GameMapsInitializer.cs
git commit -m "feat(maps): add Arkania map initializer (number 83) and register it"
```
---
### Task 3: Add spawn gate + `/move` warp, and the DB update-plugin
**Files:**
- Modify: `AdamuSw/src/Persistence/Initialization/Updates/UpdateVersion.cs` (add enum value)
- Create: `AdamuSw/src/Persistence/Initialization/Updates/AddArkaniaMapUpdateSeason6.cs`
**Interfaces:**
- Consumes: `Arkania.Number` (83) and `Arkania.Name` ("Arkania") from Task 2; the `Terrain84.att` resource from Task 1.
- Produces: update-plugin `AddArkaniaMapUpdateSeason6` with `Version => UpdateVersion.AddArkaniaMapSeason6`.
- [ ] **Step 1: Compute a walkable spawn coordinate from the terrain**
The spawn gate must sit on a walkable tile. Scan the pack `Terrain83.att` (3-byte header + 256×256 attribute bytes; a tile is walkable when its attribute byte has none of the blocking bits `0x04` NOMOVE / `0x08` NOGROUND set — i.e. value `0x00` or `0x01` safezone). Run:
```bash
python - <<'PY'
p="/c/Users/efpa/Downloads/Arkania Acheron Debenter Uruk Ferea/Arkania Acheron Debenter Uruk Ferea/Server Side/Data/Terrain/Terrain83.att"
d=open(p,'rb').read()[3:] # drop 3-byte header
# attribute[y*256 + x]; find a walkable tile nearest the center
best=None
for y in range(256):
for x in range(256):
a=d[y*256+x]
if a & 0x04 or a & 0x08: # NOMOVE or NOGROUND -> blocked
continue
dist=abs(x-128)+abs(y-128)
if best is None or dist<best[0]:
best=(dist,x,y,a)
print("spawn X=%d Y=%d (attr=0x%02x, dist-from-center=%d)"%(best[1],best[2],best[3],best[0]))
PY
```
Expected: prints a concrete `X`/`Y` (e.g. `spawn X=128 Y=130 ...`). Record these as `SPAWN_X` / `SPAWN_Y` for Step 3.
- [ ] **Step 2: Add the UpdateVersion enum value**
In `Updates/UpdateVersion.cs`, after `AddImperialWeapons050Season6 = 95,` add:
```csharp
/// <summary>
/// Adds the imported Arkania map (number 83).
/// </summary>
AddArkaniaMapSeason6 = 96,
```
- [ ] **Step 3: Create the update-plugin**
Create `Updates/AddArkaniaMapUpdateSeason6.cs` (mirrors `AddImperialWeapons050UpdateSeason6.cs` for the boilerplate and `AddLorenMarketJuliaWarpPlugIn.cs` for the inline gate/warp). Replace `SPAWN_X`/`SPAWN_Y` with the values from Step 1:
```csharp
// <copyright file="AddArkaniaMapUpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds the imported Arkania map (number 83): the map definition with its terrain,
/// a spawn gate on a walkable tile, and a /move warp entry so players can reach it.
/// Monsters and NPCs are added afterwards via the admin panel.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("2C8B0A61-6B2E-4D7A-9E3C-7F1A4B2D9E01")]
public class AddArkaniaMapUpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add Arkania map (83)";
internal const string PlugInDescription = "Adds the imported Arkania map (number 83) with terrain, a spawn gate, and a /move warp. Monsters/NPCs are added via the admin panel.";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddArkaniaMapSeason6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 19, 12, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
// Idempotency guard: skip if Arkania (83) already exists.
if (gameConfiguration.Maps.Any(m => m.Number == Arkania.Number))
{
return default;
}
var initializer = new Arkania(context, gameConfiguration);
initializer.Initialize(); // creates map 83 + loads terrain from Terrain84.att
initializer.SetSafezoneMap();
var arkania = gameConfiguration.Maps.First(m => m.Number == Arkania.Number);
// Spawn gate on a walkable tile (coordinates from the terrain scan).
var spawnGate = context.CreateNew<ExitGate>();
arkania.ExitGates.Add(spawnGate);
spawnGate.Map = arkania;
spawnGate.X1 = SPAWN_X;
spawnGate.Y1 = SPAWN_Y;
spawnGate.X2 = SPAWN_X;
spawnGate.Y2 = SPAWN_Y;
spawnGate.IsSpawnGate = true;
// /move warp entry.
if (gameConfiguration.WarpList.All(w => w.Name != Arkania.Name))
{
var warp = context.CreateNew<WarpInfo>();
warp.Index = 83;
warp.Name = Arkania.Name;
warp.Costs = 5000;
warp.LevelRequirement = 10;
warp.Gate = spawnGate;
}
return default;
}
}
```
- [ ] **Step 4: Verify enum + plugin references are consistent (textual)**
Run:
```bash
grep -n "AddArkaniaMapSeason6 = 96" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Updates/UpdateVersion.cs"
grep -n "UpdateVersion.AddArkaniaMapSeason6" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Updates/AddArkaniaMapUpdateSeason6.cs"
grep -cn "SPAWN_X\|SPAWN_Y" "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src/Persistence/Initialization/Updates/AddArkaniaMapUpdateSeason6.cs"
```
Expected: first two match; the third prints `0` (all `SPAWN_X`/`SPAWN_Y` placeholders were replaced with numbers). If it prints non-zero, replace the remaining placeholders.
- [ ] **Step 5: Commit**
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
git add src/Persistence/Initialization/Updates/UpdateVersion.cs src/Persistence/Initialization/Updates/AddArkaniaMapUpdateSeason6.cs
git commit -m "feat(maps): add Arkania DB update-plugin with spawn gate and /move warp"
```
---
### Task 4: Build the server image and verify map 83 is in the configuration
**Files:** none (build + verification only).
- [ ] **Step 1: Docker build**
Run (from AdamuSw root):
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
docker build -f Startup/Dockerfile -t adamu-openmu:dev . 2>&1 | tail -20
```
Expected: `Successfully tagged adamu-openmu:dev` (or the buildkit equivalent). If compilation fails, fix the reported C# error and rebuild.
- [ ] **Step 2: Verify the map symbol is compiled into the initialization DLL**
Run:
```bash
docker run --rm --entrypoint sh adamu-openmu:dev -c "grep -a -c Arkania /app/MUnique.OpenMU.Persistence.Initialization.dll || true"
```
Expected: a non-zero count (the `Arkania`/`AddArkaniaMap` strings are present in the built DLL). If `0`, the class was not compiled in — recheck Task 2/3.
- [ ] **Step 3: Commit (no code; checkpoint only if any fixes were made)**
Only if Steps 1-2 required code fixes:
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
git commit -am "fix(maps): resolve Arkania build errors"
```
---
### Task 5: Deploy the server and apply the update on the existing DB
**Files:** none (deploy + admin action).
- [ ] **Step 1: Bring the server up with the new image**
Run:
```bash
cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw"
docker compose -f deploy-adamu/docker-compose.local.yml up -d
```
Expected: containers start (game server + admin panel). Note: if the server points at the remote `65.109.224.204` DB instead of local, deploy the image to that host per the established deploy flow instead.
- [ ] **Step 2: Apply the update via AdminPanel → Updates**
Open the AdminPanel (`http://localhost:8081` locally, or the server's admin URL), go to the **Updates** page, and apply **"Add Arkania map (83)"**. (Fresh/zero DBs already have it from the seed and will show nothing to apply.)
Expected: the update applies without error and disappears from the pending list.
- [ ] **Step 3: Verify the map exists in the panel**
In the AdminPanel, open `/edit-config-grid/GameMapDefinition` (Config → Game Maps).
Expected: **Arkania** appears in the list with number 83.
- [ ] **Step 4: Verify terrain renders in the visual editor**
Open `/map-editor`, select **Arkania**.
Expected: the terrain image renders (not blank). Blank terrain ⇒ the `Terrain84.att` resource didn't load — recheck Task 1 (csproj embedding + off-by-one name).
---
### Task 6: Copy the client assets to the PC client
**Files:**
- Copy into `D:\AdaMu\AdaMu\Data`: `World83\`, `Object83\`, and the `Local\Eng` additions.
**Interfaces:**
- Consumes: pack `Client Side/Data/{World83,Object83,Local/Eng}`.
- Produces: renderable client-side assets for world index 83.
- [ ] **Step 1: Copy World83 and Object83**
Run:
```bash
PACK="/c/Users/efpa/Downloads/Arkania Acheron Debenter Uruk Ferea/Arkania Acheron Debenter Uruk Ferea/Client Side/Data"
DST="/d/AdaMu/AdaMu/Data"
cp -rf "$PACK/World83" "$DST/World83"
cp -rf "$PACK/Object83" "$DST/Object83"
ls "$DST/World83/EncTerrain83.att" "$DST/Object83" >/dev/null && echo "World83+Object83 copied"
```
Expected: prints `World83+Object83 copied`.
- [ ] **Step 2: Copy the map-name image and minimap for world 83**
Run:
```bash
PACK="/c/Users/efpa/Downloads/Arkania Acheron Debenter Uruk Ferea/Arkania Acheron Debenter Uruk Ferea/Client Side/Data/Local/Eng"
DST="/d/AdaMu/AdaMu/Data/Local/Eng"
[ -f "$PACK/ImgsMapName/Arkania.OZT" ] && cp -f "$PACK/ImgsMapName/Arkania.OZT" "$DST/ImgsMapName/" && echo "map-name image copied"
ls "$PACK/Minimap/" | grep -i "World83" | while read f; do cp -f "$PACK/Minimap/$f" "$DST/Minimap/"; echo "minimap $f copied"; done
```
Expected: the Arkania name image copies; a `Minimap_World83_eng.bmd` copies if present (absence is non-fatal — minimap only).
- [ ] **Step 3: Verify no case-mismatch on referenced object textures**
The MUnique PC client on Windows is case-insensitive, so a spot check suffices. Run:
```bash
ls "/d/AdaMu/AdaMu/Data/World83" | head; echo "---"; ls "/d/AdaMu/AdaMu/Data/Object83" | wc -l
```
Expected: World83 lists `EncTerrain83.*` + tiles; Object83 has a non-zero file count.
_(No commit — `D:\AdaMu\AdaMu` is the deployed client folder, not a git repo.)_
---
### Task 7: Verify in the PC client
**Files:** none (runtime verification). May require a `vendor/MuMain` change + rebuild if the engine caps the world index.
- [ ] **Step 1: Launch the client and reach Arkania**
Start `D:\AdaMu\AdaMu\main.exe`, log in to a character (server `65.109.224.204`), and issue `/move Arkania` in chat.
Expected: the character warps to Arkania; terrain and objects render; no `OpenTexture Failed` / crash; the character can walk.
- [ ] **Step 2: If the map does not load (world-index unsupported by the engine)**
If the client crashes or shows a blank/again-Lorencia world on entering 83, the MUnique engine may not handle world index 83. Check the terrain/world-loading path in `vendor/MuMain/src/source` for a hard world-index cap or a per-index table, add support for 83, and rebuild `Main.exe` (see the `pc-munique-client-build` memory: `vcvarsall x86` + vswhere on PATH + `cmake --build out/build/windows-x86-fresh --config Release`), then redeploy `main.exe` to `D:\AdaMu\AdaMu`. Re-run Step 1.
- [ ] **Step 3: Final confirmation**
Confirm all three surfaces:
- Server/panel: Arkania (83) listed, terrain renders in `/map-editor`.
- Client: `/move Arkania` enters a walkable, correctly-rendered map.
- Then the user adds monster/NPC spawns via the panel and confirms they appear live.
Pilot complete. Follow-up (separate plan): replicate Tasks 1-7 for maps 84-87 (Acheron/Debenter/Uruk/Ferea) — each terrain resource is `Terrain{N+1}.att` from the pack's `Terrain{N}.att` — plus mobile-client asset import.
## Self-Review notes
- **Spec coverage:** terrain resource (Task 1), map class + registration (Task 2), update-plugin + warp/gate (Task 3), build/verify (Task 4), deploy/apply/panel-verify (Task 5), client assets (Task 6), client verify (Task 7). All spec sections covered.
- **Off-by-one terrain naming** is captured as a Global Constraint and applied in Task 1 (resource `Terrain84.att` ← pack `Terrain83.att`).
- **No monster spawns in the seed** — intentional (user adds via panel), matching the spec.
- **Type consistency:** `Arkania.Number` (83) and `Arkania.Name` ("Arkania") defined in Task 2 are the exact symbols consumed in Task 3; `UpdateVersion.AddArkaniaMapSeason6` (=96) defined and consumed consistently.

View File

@@ -166,7 +166,9 @@ docker push <hub>/adamu-openmu:<tag>
| `GameLogic/PlayerActions/TalkNpcAction.cs` | 0b | remote-NPC: `TalkToNpcByNumberAsync` (additive metod) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli |
| `GameLogic/GameMap.cs` | 0b | remote-NPC: `GetNpcByNumber` helper (additive metod) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli; test: `GameMapTest.GetNpcByNumberFindsSpawnedNpcAsync` |
| `GameServer/MessageHandler/TalkNpcHandlerPlugInBase.cs` | 0b | remote-NPC: `0x8000` marker dalı (tek gerçek core-logic dokunuşu) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli |
| DataModel + Initialization (CS entity'leri) | P1 | CS persistence | ⏳ Planlanacak (EF migration gerekir) |
| `GameLogic/Player.cs` | P3 | CS PvP: Siege fazında Valley of Loren'de (map 30) kill izni (`IsCastleSiegeBattleActive`) | ✅ Uygulandı, `// ADAMU-CUSTOM` |
| `GameLogic/GameContext.cs` | P3 | `MapInitializer` property expose (guardian heykel Destructible runtime spawn) | ✅ Uygulandı, `// ADAMU-CUSTOM` |
| DataModel + Initialization (CS entity'leri) | P4 | CS persistence (sahiplik/vergi) | ⏳ Planlanacak (EF migration gerekir) |
| *(fazlar ilerledikçe eklenecek)* | | | |
## 10. Test stratejisi

View File

@@ -0,0 +1,75 @@
# New Maps Import — Design (Pilot: Arkania / World 83)
**Date:** 2026-07-19
**Status:** Approved (design), pending implementation plan
**Scope of this spec:** Import ONE pilot map (Arkania, world index 83) end-to-end across server (OpenMU), PC client, and web admin panel. The other four maps (Acheron 84, Debenter 85, Uruk 86, Ferea 87) and the mobile client are explicitly out of scope for the pilot and will be replicated from the proven pilot template in follow-up work.
## Background
A third-party map pack provides 5 maps with both client and server assets:
- **Source:** `C:\Users\efpa\Downloads\Arkania Acheron Debenter Uruk Ferea\Arkania Acheron Debenter Uruk Ferea`
- **Client Side/Data:** `World83-87` (terrain: `EncTerrain8N.att/.map/.obj`, `TerrainHeight.OZB`, `TerrainLight.OZJ`, tile textures, `minimap.map`), `Object83-87` (object BMD models + textures), `Local/Eng` (`ImgsMapName/*.OZT` map-name images, `Minimap/*.bmd`, `movereq_eng.bmd`).
- **Server Side/Data:** `MapManager.txt` (classic C++ MuServer map config — reference only), `Move/Gate.txt`, `Move.txt`, `MoveSummon.txt` (warp gates — reference only), `Terrain/Terrain83-87.att` (walkability/safezone attributes).
The server-side files are **classic C++ MuServer format** and are NOT consumed by OpenMU — except the `.att` terrain files, which match OpenMU's terrain resource format exactly.
### Key technical findings (verified)
1. **Terrain is a drop-in for OpenMU.** OpenMU loads map terrain via `GameMapDefinition.TerrainData`, populated by `_mapDefinition.UpdateTerrainFromResources(TerrainVersionPrefix)` in `BaseMapInitializer`, from an embedded resource `Resources/Terrain{Number}.att`. The pack's server `Terrain83.att` is **65539 bytes, header `00 ff ff 04`** — byte-for-byte the same format as OpenMU's existing `Resources/Terrain1.att` (65539, `00 ff ff 04`). The client's `EncTerrain83.att` (65540, encrypted) is NOT what the server wants; the server-side `.att` is.
2. **Indices 82-87 are free** in the current OpenMU (AdamuSw) map set — no collision at 83.
3. **Web admin panel needs no code changes.** Once a `GameMapDefinition` (number 83) exists in the DB, it appears automatically in:
- `/edit-config-grid/GameMapDefinition` (GameMaps CRUD)
- `/map-editor` → visual `MapEditor` (renders terrain from `TerrainData`; `CreateNewSpawnArea`, `CreateNewEnterGate`, `CreateNewExitGate`, duplicate/remove/undo/save)
- `/edit-config-grid/WarpInfo` (`/move` warp list), `/edit-config-grid/MonsterDefinition` (monsters)
4. **Monster spawns / NPCs are NOT in the pack.** The seeded map is intentionally EMPTY (scenery + walkable + safezone only). Spawns, NPCs and gates are added by the user via the web MapEditor.
5. **Index→name ambiguity.** Pack folders are World83-87 (no World82). `MapManager.txt` labels 82=Arkania,83=Acheron,…,87=TeamvsTeam (off-by-one / stock reference). The pack's own title order ("Arkania Acheron Debenter Uruk Ferea") + available folders (83-87) imply **83=Arkania, 84=Acheron, 85=Debenter, 86=Uruk, 87=Ferea**. Pilot uses **World83 = Arkania**, to be confirmed against the pack screenshots during implementation.
## Chosen approach
**Approach A — OpenMU-native C# map initializer + terrain resource + DB update-plugin.** This mirrors every existing OpenMU map and the proven Imperial-items dual-path pattern (see `android-add-new-weapon-item`). Rejected alternatives: (B) pure-panel creation — not viable, the 65KB `TerrainData` byte[] cannot be entered via a web form; (C) build a C++→OpenMU map converter to use the MapEditor Import — unnecessary work for a pilot.
## Components / changes
Server repo: `AdamuSw` (Gitea, branch off `main`).
1. **Terrain resource** — copy pack `Server Side/Data/Terrain/Terrain83.att``src/Persistence/Initialization/Resources/Terrain83.att`. Verify it is included as an embedded resource by the `.csproj` (the `Resources/*.att` glob); add explicitly if not globbed.
2. **Map initializer**`src/Persistence/Initialization/VersionSeasonSix/Maps/Arkania.cs`:
- `Number => 83`
- `Name => "Arkania"`
- `TerrainVersionPrefix => string.Empty`
- Safezone / spawn gate as required by `BaseMapInitializer` (minimal; a single safe spawn gate so players don't spawn on a blocked tile).
- Empty `CreateMonsterSpawns` / NPC init (spawns added later via panel).
- Follow the existing map-class pattern (many inherit a `Version075.Maps.*` base; Arkania is new so it derives from `BaseMapInitializer` directly, like other SeasonSix-only maps).
3. **Registration (fresh seed)** — add `Arkania` to the map initializer list so a zero DB seeds it (alongside `Weapons.Initialize()`-style wiring in the SeasonSix data-initialization).
4. **DB update-plugin (existing DB)**`src/Persistence/Initialization/Updates/AddArkaniaMapUpdateSeason6.cs`:
- `UpdateVersion` = next free number (after the Imperial `95`).
- Idempotency guard: if a map with `Number == 83` already exists, skip.
- Creates the map by invoking the same logic as the initializer.
- Applied at runtime only via **AdminPanel → Updates** (per the Imperial finding; `DataUpdateService` is only triggered there). Do NOT call `SaveChanges` inside the plugin.
5. **Access / warp (for testing)** — a `WarpInfo` entry ("Arkania", target map 83, coordinates on a safe tile, level/zen cost) so the map is reachable via `/move Arkania`, plus an entrance `ExitGate`. May be seeded or added via panel; seeding it keeps the pilot self-contained for verification.
6. **PC client** — copy `World83`, `Object83`, and the `Local/Eng` additions (`ImgsMapName/Arkania.OZT`, `Minimap/Minimap_World83_eng.bmd` if present, merge `movereq_eng.bmd`) into `D:\AdaMu\AdaMu\Data`. Verify the MUnique engine supports world index 83 (terrain/object loading is generic; if a world-index cap or per-index table blocks it, add support in `vendor/MuMain` source and rebuild `Main.exe` per `pc-munique-client-build`).
## Data flow
Server loads map 83 from `GameConfiguration``UpdateTerrainFromResources` decodes `Terrain83.att` into walkable/safezone grid → player issues `/move Arkania` (or uses the entrance gate) → server places them on the safe spawn tile of map 83 → PC client renders `Data/World83` (terrain + objects) → empty but walkable map. In the web panel, `MapEditor` renders the same terrain from `TerrainData`; the user places `MonsterSpawnArea`/gates/NPCs and saves → they take effect on the live server.
## Verification
1. **Server build:** `docker build -f Startup/Dockerfile -t adamu-openmu:dev .` succeeds; map 83 present in built config (symbol/log check). Local `dotnet build` is expected to fail at the source-gen pre-step (normal, per Imperial notes).
2. **Web:** map "Arkania" appears in `/edit-config-grid/GameMapDefinition`; `/map-editor` renders its terrain (not blank).
3. **PC client:** `/move Arkania` enters the map; terrain and objects render; no crash; safezone correct; player can walk.
4. Only after all three pass is the pilot considered done.
## Out of scope (follow-up, after pilot proven)
- Maps 84-87 (Acheron/Debenter/Uruk/Ferea) — same template, replicated.
- Mobile client asset import + APK considerations.
- Authoring actual monster spawns / NPCs / drops (done by the user via panel; not part of the seed).
- Any C++→OpenMU map converter.
## Risks
- **Index/name mismatch** → wrong map name shown. Mitigation: confirm World83 identity against pack screenshots before finalizing `Name`.
- **Engine world-index support** on the MUnique client for index 83 → if unsupported, requires a `vendor/MuMain` change + rebuild. Low likelihood (loading is data-driven) but flagged.
- **Terrain resource not globbed** by the csproj → map loads with empty terrain. Mitigation: verify embedded-resource inclusion during step 1.

View File

@@ -0,0 +1,34 @@
# Add Poison Wing & Poison Cape — Design
**Date:** 2026-07-19
**Status:** Approved
**Scope:** Add TWO wing items (basic: render + equippable + base defense). Source pack: `C:\Users\efpa\Downloads\asa devilzmu\asa devilzmu\6` (Wing241, Wing242).
## Items
| Item | Slot (group/number) | Model | Name | Classes |
|------|---------------------|-------|------|---------|
| Wing241 | 12 / 240 | MODEL_WING+240 | Poison Wing | Dark Knight |
| Wing242 | 12 / 241 | MODEL_WING+241 | Poison Cape | Dark Lord + Rage Fighter |
Wings are item group 12; `MODEL_WING = MODEL_ITEM + ITEM_WING (12*512)`, so item (12, N) auto-maps to model `MODEL_WING + N`. Numbers 240/241 are high/free in standard S6. Model files `Wing241.bmd`/`Wing242.bmd` load at `MODEL_WING+240`/`+241` (filename `Wing{N}` where N=number+1).
## Client (vendor/MuMain → D:\AdaMu\AdaMu)
1. `ZzzOpenData.cpp OpenItems()`: `AccessModel(MODEL_WING+240, L"Data\\Item\\", L"Wing", 241)` and `+241 → "Wing242"`. `OpenItemTextures()`: `OpenTexture(MODEL_WING+240/241, L"Item\\")`.
2. Copy `Wing241.bmd` + its texture, `Wing242.bmd` + its texture → `Data\Item\`. Extract the BMD's referenced texture names (decrypt) and copy the matching files (the pack ships `.ozt`/`.tga`; the engine loads the encrypted `.OZT`/`.OZJ`, so provide those; missing effect textures now log instead of blocking).
3. `Item_eng.bmd`: add name/stat records at slots 12/240 ("Poison Wing", DK) and 12/241 ("Poison Cape", DL+RF), via the 84-byte-record binary patcher (see `android-add-new-weapon-item`). Slot field = wing slot.
4. Rebuild `main.exe` + deploy.
## Server (AdamuSw)
5. `VersionSeasonSix/Items/Wings.cs`: new method `CreatePoisonWings()` with two `CreateWing` calls — `CreateWing(240, W, H, "Poison Wing", ...DK flag...)` and `CreateWing(241, W, H, "Poison Cape", ...DL+RF flags...)`, basic defense + level table, minimal options. W/H from the item icon size (default 12x12 or per pack).
6. Register in `Wings.Initialize()` (fresh seed) + update-plugin `AddPoisonWingsUpdateSeason6.cs` (UpdateVersion=98, idempotency guard on 12/240).
7. `docker build` + apply via AdminPanel → Updates (existing DB) / auto on fresh seed.
## Verify
Give the wings to a character (AdminPanel/inventory edit), equip in the wing slot, confirm: correct name/stats in tooltip (from Item_eng.bmd), 3D model renders on the character's back, class restriction enforced.
## Out of scope
Wing options (excellent), flight, per-level ancient/socket bonuses, balance. Just render + equip + base defense.

View File

@@ -0,0 +1,193 @@
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
/// <summary>
/// Configuration for the Castle Siege cycle. Rides on the plugin custom-configuration system (no dedicated
/// database table). A cycle runs: Ownership -> Registration -> Preparation -> Siege(war) -> Settlement, and
/// auto-starts when the current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute/second durations) are proxies over the
/// runtime fields, which are hidden from the editor to keep the form clean.
/// </summary>
public class CastleSiegeConfiguration
{
/// <summary>
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a
/// time). Editable in the AdminPanel as check boxes; proxies the runtime <see cref="RegistrationOpenDays"/>.
/// </summary>
[JsonIgnore]
public CastleSiegeScheduleDays OpenDays
{
get
{
var days = CastleSiegeScheduleDays.None;
foreach (var day in this.RegistrationOpenDays)
{
days |= (CastleSiegeScheduleDays)(1 << (int)day);
}
return days;
}
set => this.RegistrationOpenDays = Enum.GetValues<DayOfWeek>()
.Where(day => value.HasFlag((CastleSiegeScheduleDays)(1 << (int)day)))
.ToList();
}
/// <summary>
/// Gets or sets the times of day (UTC) at which a new cycle opens registration.
/// Empty = no auto-start (admins start cycles manually via the chat command).
/// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
/// <summary>Gets or sets how long the registration period lasts, in minutes (guilds may register).</summary>
[JsonIgnore]
public int RegistrationMinutes
{
get => (int)this.RegistrationDuration.TotalMinutes;
set => this.RegistrationDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
/// <summary>Gets or sets how long the preparation period lasts, in minutes (between registration and war).</summary>
[JsonIgnore]
public int PreparationMinutes
{
get => (int)this.PreparationDuration.TotalMinutes;
set => this.PreparationDuration = TimeSpan.FromMinutes(Math.Max(0, value));
}
/// <summary>Gets or sets how long the war (siege) period lasts, in minutes.</summary>
[JsonIgnore]
public int SiegeMinutes
{
get => (int)this.SiegeDuration.TotalMinutes;
set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
/// <summary>
/// Gets or sets how long the guild master must hold the Crown to capture the throne, in seconds.
/// The client shows a 60-second countdown, so 60 matches the on-screen timer.
/// </summary>
[JsonIgnore]
public int CrownHoldSeconds
{
get => (int)this.CrownHoldDuration.TotalSeconds;
set => this.CrownHoldDuration = TimeSpan.FromSeconds(Math.Max(1, value));
}
/// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.
/// </summary>
public int RegistrationFee { get; set; } = 100000;
/// <summary>
/// Gets or sets the game server (SW) id on which the Castle Siege runs. With multiple game servers each
/// has its own map instances, so the siege must happen on ONE designated server; the others skip it (they
/// only honor the shared castle owner for the rewards). Players must be on this server to take part.
/// </summary>
public byte CastleSiegeServerId { get; set; }
// --- Runtime fields (hidden from the AdminPanel; proxied by the friendly properties above) ---
/// <summary>Gets or sets the days of week registration auto-opens (UTC). Empty = every day.</summary>
[Browsable(false)]
public IList<DayOfWeek> RegistrationOpenDays { get; set; } = new List<DayOfWeek>();
/// <summary>Gets or sets how long the registration period lasts.</summary>
[Browsable(false)]
public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Gets or sets how long the preparation period lasts.</summary>
[Browsable(false)]
public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>Gets or sets how long the war (siege) period lasts.</summary>
[Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Gets or sets how long the guild master must hold the Crown to capture the throne.</summary>
[Browsable(false)]
public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
// --- Persisted runtime state (hidden from the AdminPanel) ---
// These ride on the plugin's custom-configuration JSON (stored in PostgreSQL), so the castle owner and the
// current cycle survive server restarts. Written by CastleSiegeEventPlugIn; restored on startup.
/// <summary>Gets or sets the persisted castle owner guild name (null = unowned).</summary>
[Browsable(false)]
public string? PersistedOwnerGuildName { get; set; }
/// <summary>Gets or sets the persisted current phase, so the cycle resumes after a restart.</summary>
[Browsable(false)]
public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership;
/// <summary>Gets or sets when the persisted phase started (UTC), or null if never persisted.</summary>
[Browsable(false)]
public DateTime? PersistedPhaseStartedUtc { get; set; }
/// <summary>Gets or sets the persisted registered guild names for the current cycle.</summary>
[Browsable(false)]
public IList<string> PersistedRegisteredGuilds { get; set; } = new List<string>();
/// <summary>
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls
/// within a 5-second window of a configured open time. When <see cref="RegistrationOpenDays"/> is empty,
/// the day is not restricted (every day). When no times are configured, auto-start is disabled.
/// </summary>
/// <param name="now">The current UTC time.</param>
public bool IsRegistrationOpenTime(DateTime now)
{
if (this.RegistrationOpenTimes.Count == 0)
{
return false;
}
if (this.RegistrationOpenDays.Count > 0 && !this.RegistrationOpenDays.Contains(now.DayOfWeek))
{
return false;
}
var nowTime = TimeOnly.FromDateTime(now);
var earlier = nowTime.Add(TimeSpan.FromSeconds(-5));
return this.RegistrationOpenTimes.Any(p => p.IsBetween(earlier, nowTime));
}
}
/// <summary>
/// The days of week on which the Castle Siege registration auto-opens. A [Flags] enum so the AdminPanel
/// renders it as a set of check boxes.
/// </summary>
[Flags]
public enum CastleSiegeScheduleDays
{
/// <summary>No day (auto-start day-unrestricted; still needs a time).</summary>
None = 0,
/// <summary>Sunday.</summary>
Sunday = 1 << 0,
/// <summary>Monday.</summary>
Monday = 1 << 1,
/// <summary>Tuesday.</summary>
Tuesday = 1 << 2,
/// <summary>Wednesday.</summary>
Wednesday = 1 << 3,
/// <summary>Thursday.</summary>
Thursday = 1 << 4,
/// <summary>Friday.</summary>
Friday = 1 << 5,
/// <summary>Saturday.</summary>
Saturday = 1 << 6,
}

View File

@@ -0,0 +1,418 @@
// <copyright file="CastleSiegeContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// In-memory Castle Siege phase state machine and battle contention (P3).
/// Time is injected via method parameters so it can be tested deterministically.
/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne.
/// The throne holder when the siege ends becomes the castle owner.
/// </summary>
public class CastleSiegeContext
{
/// <summary>The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.</summary>
public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly List<string> _registeredGuilds = new();
private readonly Dictionary<short, string?> _switchHolders = new() { { 217, null }, { 218, null } };
private DateTime _phaseStartedUtc;
private string? _occupier;
private int _defensesRemaining;
private bool _dirty;
private string? _crownHoldGuild;
private DateTime? _crownHoldStartUtc;
private bool _lastShieldDown;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param>
public CastleSiegeContext(CastleSiegeConfiguration configuration)
{
this.Configuration = configuration;
this.Phase = CastleSiegePhase.Ownership;
}
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
public event Action<CastleSiegePhase>? PhaseChanged;
/// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
/// so AdminPanel edits take effect without a restart.</summary>
public CastleSiegeConfiguration Configuration { get; private set; }
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
/// <param name="configuration">The live configuration.</param>
public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
/// <summary>Gets the current phase.</summary>
public CastleSiegePhase Phase { get; private set; }
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary>
public DateTime PhaseStartedUtc => this._phaseStartedUtc;
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
public string? OwnerGuildName { get; private set; }
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
public string? OccupierGuildName => this._occupier;
/// <summary>Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.</summary>
public int DefensesRemaining => this._defensesRemaining;
/// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now)
{
switch (this.Phase)
{
case CastleSiegePhase.Ownership:
if (this.Configuration.IsRegistrationOpenTime(now))
{
return this.ForceStartRegistrationAsync(now);
}
break;
case CastleSiegePhase.Registration:
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
}
break;
case CastleSiegePhase.Preparation:
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Siege, now);
}
break;
case CastleSiegePhase.Siege:
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
{
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
}
break;
case CastleSiegePhase.Settlement:
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier);
}
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
default:
break;
}
return ValueTask.CompletedTask;
}
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Registration, now);
}
/// <summary>Admin: forces a specific phase now.</summary>
/// <param name="phase">The target phase.</param>
/// <param name="now">The current UTC time.</param>
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
=> this.TransitionAsync(phase, now);
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
}
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
/// <param name="guildName">The guild name.</param>
public void RegisterGuild(string guildName)
{
if (this.Phase == CastleSiegePhase.Registration
&& !this._registeredGuilds.Contains(guildName))
{
this._registeredGuilds.Add(guildName);
this._dirty = true;
}
}
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
/// <param name="guildName">The owner guild name, or null to clear.</param>
public void SetOwner(string? guildName)
{
this.OwnerGuildName = guildName;
this._dirty = true;
}
/// <summary>
/// Mirrors the shared castle owner from the configuration. Used on game servers that do NOT host the
/// siege, so their hunting-map gate and castle flag still reflect the current owner. Does not mark the
/// state dirty (these servers never persist).
/// </summary>
public void SyncOwnerFromConfig()
{
this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
}
/// <summary>
/// Sets the weekly auto-schedule (days of week + UTC time) into the configuration and marks the state
/// dirty for persistence. Empty days disables auto-start (manual only). The configuration is the single
/// source of truth, so this is equivalent to editing the plugin config in the AdminPanel.
/// </summary>
/// <param name="days">The days of week registration should auto-open.</param>
/// <param name="time">The UTC time of day registration should auto-open, or null to disable.</param>
public void SetSchedule(IEnumerable<DayOfWeek> days, TimeOnly? time)
{
var orderedDays = days.Distinct().OrderBy(d => d).ToList();
this.Configuration.RegistrationOpenDays = orderedDays;
this.Configuration.RegistrationOpenTimes = orderedDays.Count > 0 && time is { } t
? new List<TimeOnly> { t }
: new List<TimeOnly>();
this._dirty = true;
}
/// <summary>
/// Returns how much time is left in the running siege battle (the on-map countdown value), or
/// <see cref="TimeSpan.Zero"/> when the siege is not currently running.
/// </summary>
/// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
{
if (this.Phase != CastleSiegePhase.Siege)
{
return TimeSpan.Zero;
}
var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
/// crown's shield is dropped for them), or null. Only meaningful during the siege.
/// </summary>
public string? GetShieldEligibleGuild()
{
if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
{
return null;
}
var holder = this._switchHolders[217];
return holder is not null && holder == this._switchHolders[218] ? holder : null;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>.
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
/// </summary>
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
/// <param name="masterHolding">Whether that guild's master is on the crown.</param>
/// <param name="now">The current UTC time.</param>
/// <param name="holdDuration">How long the master must hold to capture.</param>
public CrownTickResult TickCrownHold(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration)
{
var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null;
var shieldChanged = shieldDown != this._lastShieldDown;
this._lastShieldDown = shieldDown;
if (this.Phase != CastleSiegePhase.Siege)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null);
}
var wasHolding = this._crownHoldGuild is not null;
// The guild that already occupies the throne just holds it — no re-registration (avoids a capture loop).
// Only a DIFFERENT guild taking both switches can register/capture (contest).
var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier;
if (!canCapture || !masterHolding)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
}
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
{
this._crownHoldGuild = eligibleGuild;
this._crownHoldStartUtc = now;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild);
}
if (now - this._crownHoldStartUtc.Value >= holdDuration)
{
this._occupier = eligibleGuild;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._dirty = true;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild);
}
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild);
}
/// <summary>
/// Returns whether the persistable state changed since the last call, resetting the flag.
/// Called each tick by the plugin to decide whether to write state to the database.
/// </summary>
public bool ConsumeDirty()
{
var dirty = this._dirty;
this._dirty = false;
return dirty;
}
/// <summary>
/// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without
/// firing <see cref="PhaseChanged"/> or marking the state dirty. Battle state stays cleared.
/// </summary>
/// <param name="owner">The persisted owner guild name, or null.</param>
/// <param name="phase">The persisted phase.</param>
/// <param name="phaseStartedUtc">When the persisted phase started (UTC), or null to keep the default.</param>
/// <param name="registeredGuilds">The persisted registered guild names, or null.</param>
public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable<string>? registeredGuilds)
{
this.OwnerGuildName = owner;
this.Phase = phase;
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc;
this._registeredGuilds.Clear();
if (registeredGuilds is not null)
{
this._registeredGuilds.AddRange(registeredGuilds);
}
this._dirty = false;
}
/// <summary>Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned).</summary>
/// <param name="count">The defense count.</param>
public void SetDefenseCount(int count) => this._defensesRemaining = count > 0 ? count : 0;
/// <summary>Notifies that a castle defense (gate/statue) was destroyed. Lowers the remaining count (min 0).</summary>
public void NotifyDefenseDestroyed()
{
if (this._defensesRemaining > 0)
{
this._defensesRemaining--;
}
}
/// <summary>
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege.
/// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The holding guild's name, or null.</param>
public void SetSwitchHolder(short switchNumber, string? guildName)
{
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
{
this._switchHolders[switchNumber] = guildName;
}
}
/// <summary>
/// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC).
/// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the
/// guild is currently holding BOTH Crown Switches (a member standing on each).
/// </summary>
/// <param name="guildName">The capturing guild's name.</param>
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns>
public (bool Success, string Reason) TryCaptureThrone(string guildName)
{
if (this.Phase != CastleSiegePhase.Siege)
{
return (false, "The siege is not running.");
}
if (this._occupier is not null)
{
return (false, this._occupier == guildName
? "Your guild already holds the throne."
: $"The throne is already held by '{this._occupier}'.");
}
if (this._defensesRemaining > 0)
{
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
}
if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName)
{
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
}
this._occupier = guildName;
return (true, "throne captured");
}
/// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, "
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}";
private void ClearBattleState()
{
this._switchHolders[217] = null;
this._switchHolders[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._lastShieldDown = false;
}
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
{
this.Phase = phase;
this._phaseStartedUtc = now;
this._dirty = true;
this.PhaseChanged?.Invoke(phase);
return ValueTask.CompletedTask;
}
}
/// <summary>The event produced by a single <see cref="CastleSiegeContext.TickCrownHold"/> call.</summary>
public enum CrownEvent
{
/// <summary>Nothing changed this tick.</summary>
None,
/// <summary>A guild master just started holding the crown (start the client countdown).</summary>
HoldStarted,
/// <summary>The crown was held long enough — the guild captured the throne.</summary>
Captured,
/// <summary>An in-progress hold was interrupted (switch lost or master left the crown).</summary>
HoldReset,
}
/// <summary>The result of a crown-hold tick: the shield state and any event that occurred.</summary>
/// <param name="ShieldDown">Whether the crown shield is currently down (both switches held, defenses cleared).</param>
/// <param name="ShieldChanged">Whether the shield state changed this tick (only then should the client be told).</param>
/// <param name="Event">The event that occurred this tick.</param>
/// <param name="Guild">The guild the event refers to, if any.</param>
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild);

View File

@@ -0,0 +1,95 @@
// <copyright file="CastleSiegeGuardsmanTalkPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles talking to the Castle Siege 'Guardsman' NPC (number 224): registers the talking
/// player's guild for the current siege while the state machine is in the registration phase.
/// P2 first increment — guild marks, entrance fee and minimum-guild requirements come later.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000224")]
[PlugIn]
[Display(Name = "Castle Siege Guardsman", Description = "Registers a guild for the Castle Siege when talking to the Guardsman (NPC 224).")]
public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
{
/// <summary>Gets the NPC number of the Castle Siege 'Guardsman'.</summary>
public static short GuardsmanNpcNumber => 224;
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (npc.Definition.Number != GuardsmanNpcNumber)
{
return;
}
// We handle the dialog ourselves (no client-side window), so suppress the default "not implemented" message.
eventArgs.HasBeenHandled = true;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
return;
}
if (!CastleSiegeEventPlugIn.IsCastleSiegeServer(player.GameContext))
{
await ShowAsync(player, $"The Castle Siege runs on server {context.Configuration.CastleSiegeServerId}. Switch to that server to register and fight.").ConfigureAwait(false);
return;
}
if (context.Phase != CastleSiegePhase.Registration)
{
await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false);
return;
}
if (player.GuildStatus is not { } guildStatus || guildStatus.Position != GuildPosition.GuildMaster)
{
await ShowAsync(player, "Only a guild master can register for the Castle Siege.").ConfigureAwait(false);
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
}
if (context.RegisteredGuilds.Contains(guildName))
{
await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
return;
}
var fee = context.Configuration.RegistrationFee;
if (fee > 0 && !player.TryRemoveMoney(fee))
{
await ShowAsync(player, $"You need {fee} zen to register your guild for the Castle Siege.").ConfigureAwait(false);
return;
}
context.RegisterGuild(guildName);
await ShowAsync(player, fee > 0
? $"Your guild '{guildName}' is registered for the Castle Siege. ({fee} zen paid)"
: $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false);
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -0,0 +1,64 @@
// <copyright file="CastleSiegeHuntingPortalTalkPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// ADAMU-CUSTOM (Castle Siege P4 — ownership reward): the castle Guard (NPC 220) on Valley of Loren is the
/// portal into the castle-exclusive hunting ground (Land of Trials, map 31). It warps a player there only
/// when their guild owns the castle (or they are a game master); otherwise it explains why they can't enter.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000220")]
[PlugIn]
[Display(Name = "Castle Siege Hunting Portal (Guard)", Description = "Guard NPC 220 on Valley of Loren warps the castle-owning guild into Land of Trials (map 31).")]
public class CastleSiegeHuntingPortalTalkPlugIn : IPlayerTalkToNpcPlugIn
{
private const short GuardNpcNumber = 220;
private const short ValleyOfLorenMapNumber = 30;
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
// Only the castle Guard on Valley of Loren acts as the hunting-ground portal.
if (npc.Definition.Number != GuardNpcNumber
|| player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber)
{
return;
}
eventArgs.HasBeenHandled = true;
var (allowed, error) = await CastleSiegeMapAccess
.CanEnterMapAsync(player, CastleSiegeMapAccess.CastleHuntingMapNumber).ConfigureAwait(false);
if (!allowed)
{
await ShowAsync(player, error!).ConfigureAwait(false);
return;
}
var map = await player.GameContext.GetMapAsync((ushort)CastleSiegeMapAccess.CastleHuntingMapNumber).ConfigureAwait(false);
// Land of Trials has no spawn gate defined; fall back to its single entry ExitGate (the designated
// map entrance at ~60-69,10-19) so the player lands on walkable ground.
var gate = map?.SafeZoneSpawnGate ?? map?.Definition.ExitGates?.FirstOrDefault();
if (gate is null)
{
await ShowAsync(player, "The castle hunting ground is not available right now.").ConfigureAwait(false);
return;
}
await player.WarpToAsync(gate).ConfigureAwait(false);
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -0,0 +1,75 @@
// <copyright file="CastleSiegeMapAccess.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// ADAMU-CUSTOM (Castle Siege P4 — ownership reward): gates entry to the castle-exclusive hunting map
/// (Land of Trials, map 31). Only members of the current castle-owning guild may enter; while the castle
/// has no owner the map stays sealed. Game masters always pass (admin/testing).
/// </summary>
public static class CastleSiegeMapAccess
{
/// <summary>The castle-exclusive hunting map number (Land of Trials).</summary>
public const short CastleHuntingMapNumber = 31;
/// <summary>
/// Checks whether the player may enter the given map. Only the castle hunting map (31) is restricted;
/// every other map is always allowed.
/// </summary>
/// <param name="player">The player attempting to enter.</param>
/// <param name="mapNumber">The target map number.</param>
/// <returns>Whether entry is allowed and, when not, a human-readable reason to show the player.</returns>
public static async ValueTask<(bool Allowed, string? Error)> CanEnterMapAsync(Player player, short mapNumber)
{
if (mapNumber != CastleHuntingMapNumber)
{
return (true, null);
}
// Game masters may always enter (admin/testing).
if (player.SelectedCharacter?.CharacterStatus >= CharacterStatus.GameMaster)
{
return (true, null);
}
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
var owner = context?.OwnerGuildName;
if (string.IsNullOrEmpty(owner))
{
return (false, "The castle hunting ground is sealed until a guild owns the castle.");
}
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
if (guildName is not null && string.Equals(guildName, owner, StringComparison.Ordinal))
{
return (true, null);
}
return (false, $"Only members of the castle-owning guild '{owner}' may enter this hunting ground.");
}
private static async ValueTask<string?> GetGuildNameAsync(Player player)
{
if (player.GuildStatus is not { } guildStatus)
{
return null;
}
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
return name;
}
}
return guildStatus.GuildId.ToString();
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="CastleSiegePhase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// The phases of a Castle Siege cycle.
/// </summary>
public enum CastleSiegePhase
{
/// <summary>Resting phase: castle is (un)owned, waiting for the next registration window.</summary>
Ownership,
/// <summary>Guilds can register to attack.</summary>
Registration,
/// <summary>Registration closed; defenders prepare before the siege starts.</summary>
Preparation,
/// <summary>The siege battle is running.</summary>
Siege,
/// <summary>Siege ended; determining the new owner.</summary>
Settlement,
}

View File

@@ -0,0 +1,100 @@
// <copyright file="CastleSiegeThroneCaptureTalkPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles registering on the throne at the Sinior (223) / Crown (216) NPCs on Valley of Loren.
/// A registered guild takes the throne only when it has destroyed all castle defenses (gates + statues)
/// AND is holding both Crown Switches (a member standing on each). The guild on the throne when the siege
/// ends becomes the castle owner. Capturing broadcasts a server-wide golden message.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000223")]
[PlugIn]
[Display(Name = "Castle Siege Throne (Sinior/Crown)", Description = "Registers a guild on the throne (Sinior 223 / Crown 216) once defenses are down and both switches held.")]
public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
{
private static readonly short[] ThroneNpcNumbers = { 223, 216 };
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (!ThroneNpcNumbers.Contains(npc.Definition.Number))
{
return;
}
eventArgs.HasBeenHandled = true;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
return;
}
if (player.GuildStatus is not { } guildStatus)
{
await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false);
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
}
if (!context.RegisteredGuilds.Contains(guildName))
{
await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false);
return;
}
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state.
await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
{
if (context.Phase != CastleSiegePhase.Siege)
{
return "The siege is not running yet.";
}
if (context.DefensesRemaining > 0)
{
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
}
var eligible = context.GetShieldEligibleGuild();
if (eligible is null)
{
return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
}
if (eligible == guildName)
{
return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!";
}
return $"Guild '{eligible}' is holding both switches. Take a switch back to raise their shield.";
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -116,6 +116,10 @@ public class GameContext : AsyncDisposable, IGameContext
/// <inheritdoc />
public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate;
// ADAMU-CUSTOM: expose the map initializer so the Castle Siege can spawn guardian statues (Destructibles) at runtime.
/// <summary>Gets the map initializer (used to spawn NPCs/destructibles at runtime, e.g. Castle Siege statues).</summary>
public IMapInitializer MapInitializer => this._mapInitializer;
/// <inheritdoc />
public virtual bool PvpEnabled { get; }

View File

@@ -709,6 +709,11 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
return this.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.BlueNormal));
}
// ADAMU-CUSTOM: Castle Siege PvP gate — true while the siege phase runs on Valley of Loren (map 30).
private bool IsCastleSiegeBattleActive()
=> this.CurrentMap?.Definition.Number == 30
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.Phase == CastleSiege.CastleSiegePhase.Siege;
/// <inheritdoc/>
public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
{
@@ -723,7 +728,10 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
}
if (!this.GameContext.PvpEnabled && this.CurrentMap?.Definition.BattleZone == null &&
this.CurrentMiniGame?.AllowPlayerKilling is false)
this.CurrentMiniGame?.AllowPlayerKilling is false
// ADAMU-CUSTOM: Castle Siege — enable PvP on Valley of Loren (30) while the siege phase runs.
&& !this.IsCastleSiegeBattleActive())
{
return null;
}

View File

@@ -21,6 +21,15 @@ public class WarpAction
/// <param name="warpInfo">The warp information.</param>
public async ValueTask WarpToAsync(Player player, WarpInfo warpInfo)
{
// ADAMU-CUSTOM: Castle Siege hunting-map access (Land of Trials, owner guild only).
var (allowed, csError) = await CastleSiege.CastleSiegeMapAccess
.CanEnterMapAsync(player, warpInfo.Gate?.Map?.Number ?? 0).ConfigureAwait(false);
if (!allowed)
{
await player.ShowBlueMessageAsync(csError!).ConfigureAwait(false);
return;
}
if (this.CheckRequirements(player, warpInfo, out var errorMessage))
{
await player.WarpToAsync(warpInfo.Gate!).ConfigureAwait(false);

View File

@@ -55,6 +55,15 @@ public class WarpGateAction
return false;
}
// ADAMU-CUSTOM: Castle Siege hunting-map access (Land of Trials, owner guild only).
var (csAllowed, csError) = await CastleSiege.CastleSiegeMapAccess
.CanEnterMapAsync(player, enterGate.TargetGate.Map.Number).ConfigureAwait(false);
if (!csAllowed)
{
await player.ShowBlueMessageAsync(csError!).ConfigureAwait(false);
return false;
}
var currentPosition = player.IsWalking ? player.WalkTarget : player.Position;
var inaccuracy = player.GameContext.Configuration.InfoRange;
if (player.CurrentMap!.Definition.EnterGates.Contains(enterGate)

View File

@@ -0,0 +1,49 @@
// <copyright file="CastleSiegePhaseChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
[PlugIn]
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Ownership|Registration|Preparation|Siege|Settlement>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csphase";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !Enum.TryParse<CastleSiegePhase>(parts[1], true, out var phase))
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Ownership|Registration|Preparation|Siege|Settlement>", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="CastleSiegeResetChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Resets Castle Siege to the ownership phase and clears registrations. GM only.</summary>
[Guid("A1B2C3D4-0005-4E5F-9A0B-CA5710000005")]
[PlugIn]
[Display(Name = "Castle Siege Reset", Description = "GM command: /csreset")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeResetChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csreset";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ResetAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege reset to ownership phase.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,104 @@
// <copyright file="CastleSiegeScheduleChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Sets the Castle Siege weekly auto-schedule. GM only. Times are UTC.
/// Usage: <c>/csschedule</c> (show) | <c>/csschedule Sunday 20:00</c> | <c>/csschedule Sunday,Wednesday 20:00</c> | <c>/csschedule clear</c>.
/// </summary>
[Guid("A1B2C3D4-0006-4E5F-9A0B-CA5710000006")]
[PlugIn]
[Display(Name = "Castle Siege Schedule", Description = "GM command: /csschedule [<days> <HH:mm UTC> | clear]")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeScheduleChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csschedule";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege plugin not active.").ConfigureAwait(false);
return;
}
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// "/csschedule" -> show current schedule.
if (parts.Length == 1)
{
await ShowAsync(player, Describe(context)).ConfigureAwait(false);
return;
}
// "/csschedule clear" -> disable auto-start.
if (parts.Length == 2 && string.Equals(parts[1], "clear", StringComparison.OrdinalIgnoreCase))
{
context.SetSchedule(Array.Empty<DayOfWeek>(), null);
await ShowAsync(player, "Castle Siege auto-schedule cleared (manual start only).").ConfigureAwait(false);
return;
}
if (parts.Length < 3)
{
await ShowAsync(player, "Usage: /csschedule <day[,day...]> <HH:mm UTC> | /csschedule clear").ConfigureAwait(false);
return;
}
var days = new List<DayOfWeek>();
foreach (var token in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (!Enum.TryParse<DayOfWeek>(token, true, out var day))
{
await ShowAsync(player, $"Unknown day '{token}'. Use e.g. Sunday, Monday, Tuesday...").ConfigureAwait(false);
return;
}
days.Add(day);
}
if (!TimeOnly.TryParse(parts[2], CultureInfo.InvariantCulture, out var time))
{
await ShowAsync(player, $"Invalid time '{parts[2]}'. Use 24h HH:mm (UTC).").ConfigureAwait(false);
return;
}
context.SetSchedule(days, time);
await ShowAsync(player, $"{Describe(context)} Server UTC now: {DateTime.UtcNow:ddd HH:mm}.").ConfigureAwait(false);
}
private static string Describe(CastleSiegeContext context)
{
var config = context.Configuration;
if (config.RegistrationOpenTimes.Count == 0)
{
return "Castle Siege auto-schedule: (none). Set with /csschedule <days> <HH:mm UTC>.";
}
var days = config.RegistrationOpenDays.Count > 0 ? string.Join(",", config.RegistrationOpenDays) : "every day";
var times = string.Join(",", config.RegistrationOpenTimes.Select(t => t.ToString("HH\\:mm")));
return $"Castle Siege auto-schedule: {days} at {times} UTC (reg {config.RegistrationDuration:g} -> prep {config.PreparationDuration:g} -> war {config.SiegeDuration:g}).";
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CastleSiegeSetOwnerChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Sets the Castle Siege owner guild by name. GM only. Usage: /cssetowner GuildName (empty clears).</summary>
[Guid("A1B2C3D4-0004-4E5F-9A0B-CA5710000004")]
[PlugIn]
[Display(Name = "Castle Siege Set Owner", Description = "GM command: /cssetowner <guildName>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/cssetowner";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var parts = command.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var owner = parts.Length >= 2 ? parts[1].Trim() : null;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="CastleSiegeStartChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Forces the Castle Siege into the registration phase. GM only.</summary>
[Guid("A1B2C3D4-0002-4E5F-9A0B-CA5710000002")]
[PlugIn]
[Display(Name = "Castle Siege Start", Description = "GM command: /csstart")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeStartChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csstart";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForceStartRegistrationAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege: registration started.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,35 @@
// <copyright file="CastleSiegeStatusChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Shows the current Castle Siege status. GM only.</summary>
[Guid("A1B2C3D4-0001-4E5F-9A0B-CA5710000001")]
[PlugIn]
[Display(Name = "Castle Siege Status", Description = "GM command: /csstatus")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeStatusChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csstatus";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
var text = context?.GetStatusText() ?? "Castle Siege plugin not active.";
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,576 @@
// <copyright file="CastleSiegeEventPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using System.Collections.Concurrent;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration.
/// State is per-<see cref="IGameContext"/> and kept in memory (P1: no persistence).
/// When the siege phase starts (P3), warps registered guild members to the Valley of Loren battle map.
/// </summary>
[PlugIn]
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (phase state machine, scheduling, siege warp).")]
[Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")]
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
{
private const ushort ValleyOfLorenMapNumber = 30;
private const short CastleGateNumber = 277; // client MONSTER_CASTLE_GATE1 -> renders a real gate + blocks terrain until broken
private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for 277
private const int SwitchHoldRange = 3;
// Castle defenses spawned at siege start — the 6 REAL castle gates (client g_byGateLocation).
// The client renders them closed and blocks the terrain until each is broken. All must fall before the throne.
private static readonly (short Number, byte X, byte Y)[] DefenseSpawns =
{
(CastleGateNumber, 67, 114),
(CastleGateNumber, 93, 114),
(CastleGateNumber, 119, 114),
(CastleGateNumber, 81, 161),
(CastleGateNumber, 107, 161),
(CastleGateNumber, 93, 204),
};
// Siege weapons spawned at siege start purely for war atmosphere. The client renders monster 221/222 as
// catapults (attacker/defender). They are NOT counted as defenses (destroying them doesn't open the throne).
private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK
private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE
private static readonly (short Number, byte X, byte Y)[] CatapultSpawns =
{
(CatapultDefenseNumber, 80, 140),
(CatapultDefenseNumber, 110, 140),
(CatapultDefenseNumber, 93, 178),
(CatapultAttackNumber, 74, 100),
(CatapultAttackNumber, 112, 100),
};
// Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y).
private static readonly (short SwitchNumber, byte X, byte Y)[] SwitchPositions =
{
(217, 167, 194),
(218, 184, 195),
};
// The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne.
private static readonly Point CrownPosition = new(176, 212);
private const int CrownHoldRange = 4;
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
private string? _cachedFlagOwner;
private byte[]? _cachedFlagLogo;
/// <inheritdoc />
public CastleSiegeConfiguration? Configuration { get; set; }
/// <summary>
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The context, or <c>null</c> if not yet initialized.</returns>
public static CastleSiegeContext? TryGetContext(IGameContext gameContext)
=> Contexts.TryGetValue(gameContext, out var context) ? context : null;
/// <summary>
/// Returns whether the given game context is the designated Castle Siege server (the siege and registration
/// run only there). Non-server contexts (e.g. tests) or an uninitialized context count as the siege server.
/// </summary>
/// <param name="gameContext">The game context.</param>
public static bool IsCastleSiegeServer(IGameContext gameContext)
{
if (gameContext is not IGameServerContext serverContext
|| TryGetContext(gameContext) is not { } context)
{
return true;
}
return serverContext.Id == context.Configuration.CastleSiegeServerId;
}
/// <inheritdoc />
public object CreateDefaultConfig() => new CastleSiegeConfiguration();
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var context = Contexts.GetOrAdd(gameContext, gc =>
{
var config = this.Configuration ?? new CastleSiegeConfiguration();
var created = new CastleSiegeContext(config);
// Restore state persisted before the last restart (owner/phase/registrations) BEFORE subscribing,
// so restoring doesn't announce phases or re-spawn defenses.
created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds);
// Announce phase changes to the whole server and, when the siege begins, warp registered members.
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
return created;
});
// Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live.
if (this.Configuration is { } liveConfig)
{
context.UpdateConfiguration(liveConfig);
}
// With multiple game servers each has its own map instances, so the siege must run on ONE designated
// server (CastleSiegeServerId). Other servers skip the siege entirely — they only mirror the shared
// castle owner from the config so the hunting-map gate + castle flag rewards still work everywhere.
if (!IsCastleSiegeServer(gameContext))
{
context.SyncOwnerFromConfig();
if (DateTime.UtcNow.Second % 15 == 0)
{
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
return;
}
await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false);
// During the siege, evaluate the Crown Switches (held by standing on them) and the throne capture every tick.
if (context.Phase == CastleSiegePhase.Siege)
{
await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false);
}
// Persist owner/phase/registrations to the database whenever they changed, so they survive a restart.
if (context.ConsumeDirty())
{
await this.PersistStateAsync(gameContext, context).ConfigureAwait(false);
}
// Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any phase).
if (DateTime.UtcNow.Second % 15 == 0)
{
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
}
/// <inheritdoc />
public void ForceStart()
{
// Force-start applies to every active game context's siege.
foreach (var context in Contexts.Values)
{
_ = context.ForceStartRegistrationAsync(DateTime.UtcNow);
}
}
private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase)
{
try
{
switch (phase)
{
case CastleSiegePhase.Registration:
await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false);
break;
case CastleSiegePhase.Siege:
await AnnounceAsync(gameContext, "The Castle Siege has begun! Break the castle gates and guardian statues, then hold BOTH Crown Switches to take the throne!").ConfigureAwait(false);
await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false);
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
break;
case CastleSiegePhase.Settlement:
// Stop the on-map countdown for everyone still on the battle map.
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
break;
case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner:
await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false);
break;
default:
break;
}
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error handling phase change to {phase}.", phase);
}
}
private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context)
{
try
{
if (this.Configuration is not { } config)
{
return;
}
// Snapshot the live context into the persisted config fields.
config.PersistedOwnerGuildName = context.OwnerGuildName;
config.PersistedPhase = context.Phase;
config.PersistedPhaseStartedUtc = context.PhaseStartedUtc;
config.PersistedRegisteredGuilds = context.RegisteredGuilds.ToList();
// Find our plugin-configuration row via the in-memory config graph to get its id.
var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID;
var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId);
if (inMemory is null)
{
return;
}
// Load a fresh, change-tracked copy of that row in its own (non-caching) context, rewrite its
// custom-configuration JSON, and save — this is what actually persists to PostgreSQL.
using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration);
var row = await ctx.GetByIdAsync<PlugInConfiguration>(inMemory.GetId()).ConfigureAwait(false);
if (row is null)
{
return;
}
row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler);
await ctx.SaveChangesAsync().ConfigureAwait(false);
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogInformation("Castle Siege: persisted state (owner={owner}, phase={phase}).", config.PersistedOwnerGuildName ?? "(none)", config.PersistedPhase);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while persisting state to the database.");
}
}
private static ValueTask AnnounceAsync(IGameContext gameContext, string message)
=> gameContext.ForEachPlayerAsync(player =>
player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask());
private static void EnsureDestructible(MonsterDefinition target, MonsterDefinition? template)
{
if (target.ObjectKind == NpcObjectKind.Destructible)
{
return;
}
target.ObjectKind = NpcObjectKind.Destructible;
if (template is not null && target.Attributes.Count == 0)
{
foreach (var attribute in template.Attributes)
{
target.Attributes.Add(attribute);
}
}
}
private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
if (gameContext is not GameContext concrete)
{
context.SetDefenseCount(0);
return;
}
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map is null)
{
context.SetDefenseCount(0);
return;
}
var spawned = 0;
for (var i = 0; i < DefenseSpawns.Length; i++)
{
var spawn = DefenseSpawns[i];
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number);
if (definition is null)
{
continue;
}
// The stock CS gate (277) definition has no HP and isn't destructible; make it breakable
// by borrowing the HP/attributes of a working destructible (BloodCastle gate 131).
var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
EnsureDestructible(definition, template);
var spawnArea = new MonsterSpawnArea
{
MonsterDefinition = definition,
Quantity = 1,
X1 = spawn.X,
X2 = spawn.X,
Y1 = spawn.Y,
Y2 = spawn.Y,
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
var npc = await concrete.MapInitializer.InitializeSpawnAsync(1000 + i, map, spawnArea).ConfigureAwait(false);
if (npc is AttackableNpcBase attackable)
{
attackable.Died += (_, _) => context.NotifyDefenseDestroyed();
spawned++;
}
}
context.SetDefenseCount(spawned);
// Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses.
for (var i = 0; i < CatapultSpawns.Length; i++)
{
var spawn = CatapultSpawns[i];
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number);
if (definition is null)
{
continue;
}
var spawnArea = new MonsterSpawnArea
{
MonsterDefinition = definition,
Quantity = 1,
X1 = spawn.X,
X2 = spawn.X,
Y1 = spawn.Y,
Y2 = spawn.Y,
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
await concrete.MapInitializer.InitializeSpawnAsync(2000 + i, map, spawnArea).ConfigureAwait(false);
}
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while spawning castle defenses.");
context.SetDefenseCount(0);
}
}
private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map is null)
{
return;
}
// Each Crown Switch is held by whichever registered guild currently has a member standing on it.
foreach (var (switchNumber, x, y) in SwitchPositions)
{
string? holder = null;
var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType<Player>();
foreach (var player in nearby)
{
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
{
holder = guildName;
break;
}
}
context.SetSwitchHolder(switchNumber, holder);
}
var now = DateTime.UtcNow;
// Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops;
// that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable).
var eligible = context.GetShieldEligibleGuild();
Player? masterPlayer = null;
if (eligible is not null)
{
foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType<Player>())
{
if (player.GuildStatus?.Position == GuildPosition.GuildMaster
&& await GetGuildNameAsync(player).ConfigureAwait(false) == eligible)
{
masterPlayer = player;
break;
}
}
}
var crown = context.TickCrownHold(eligible, masterPlayer is not null, now, context.Configuration.CrownHoldDuration);
// Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal).
if (crown.ShieldChanged)
{
await ForEachOnBattleMapAsync(gameContext, p => p.SetCrownShieldAsync(crown.ShieldDown)).ConfigureAwait(false);
}
switch (crown.Event)
{
case CrownEvent.HoldStarted when masterPlayer is not null:
// The 60-second registration panel is shown ONLY to the master taking the crown.
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
break;
case CrownEvent.HoldReset when masterPlayer is not null:
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
break;
case CrownEvent.Captured when crown.Guild is { } captured:
await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false);
await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false);
break;
default:
break;
}
// Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just
// loaded the battle map pick it up, without visibly resetting the second-counter too often.
if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0)
{
var remaining = context.GetRemainingSiegeTime(now);
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
await BroadcastSiegeStateAsync(gameContext, true, (byte)(totalMinutes / 60), (byte)(totalMinutes % 60)).ConfigureAwait(false);
}
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error during siege tick processing.");
}
}
/// <summary>
/// Sends the Castle Siege countdown state to every player currently on the battle map (Valley of Loren).
/// When <paramref name="started"/> is true it also (re)seeds the remaining hour/minute.
/// </summary>
private static ValueTask BroadcastSiegeStateAsync(IGameContext gameContext, bool started, byte hour, byte minute)
=> gameContext.ForEachPlayerAsync(async player =>
{
if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber)
{
return;
}
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetBattleStateAsync(started)).ConfigureAwait(false);
if (started)
{
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetTimerAsync(hour, minute)).ConfigureAwait(false);
}
});
/// <summary>Invokes the Castle Siege status view for every player currently on the battle map.</summary>
private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func<ICastleSiegeStatusViewPlugIn, ValueTask> action)
=> gameContext.ForEachPlayerAsync(player =>
player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber
? player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(action).AsTask()
: Task.CompletedTask);
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
if (context.OwnerGuildName is not { Length: > 0 } owner)
{
return;
}
var logo = await this.GetOwnerLogoAsync(gameContext, owner).ConfigureAwait(false);
if (logo is null)
{
return;
}
await gameContext.ForEachPlayerAsync(async player =>
{
if (player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber)
{
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCastleFlagAsync(logo)).ConfigureAwait(false);
}
}).ConfigureAwait(false);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while broadcasting the castle flag.");
}
}
private async ValueTask<byte[]?> GetOwnerLogoAsync(IGameContext gameContext, string ownerName)
{
if (this._cachedFlagOwner == ownerName && this._cachedFlagLogo is not null)
{
return this._cachedFlagLogo;
}
if (gameContext is not IGameServerContext serverContext)
{
return null;
}
var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
if (guildId == 0)
{
return null;
}
var guild = await serverContext.GuildServer.GetGuildAsync(guildId).ConfigureAwait(false);
if (guild?.Logo is not { Length: > 0 } logo)
{
return null;
}
this._cachedFlagOwner = ownerName;
this._cachedFlagLogo = logo;
return logo;
}
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map?.SafeZoneSpawnGate is not { } gate)
{
return;
}
await gameContext.ForEachPlayerAsync(async player =>
{
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
{
await player.WarpToAsync(gate).ConfigureAwait(false);
}
}).ConfigureAwait(false);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
}
}
private static async ValueTask<string?> GetGuildNameAsync(Player player)
{
if (player.GuildStatus is not { } guildStatus)
{
return null;
}
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
return name;
}
}
return guildStatus.GuildId.ToString();
}
}

View File

@@ -202,10 +202,12 @@ public class ResetCharacterAction
if (homeMapDef is { }
&& await this._player.GameContext.GetMapAsync((ushort)homeMapDef.Number).ConfigureAwait(false) is { SafeZoneSpawnGate: { } spawnGate })
{
this._player.SelectedCharacter.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
this._player.SelectedCharacter.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
this._player.SelectedCharacter.CurrentMap = spawnGate.Map;
this._player.Rotation = spawnGate.Direction;
// ADAMU-CUSTOM: warp instead of writing the position fields directly.
// The direct writes moved the player without removing him from the map or sending a
// map change, so the client was never told and animated a walk to the new spot: the
// character visibly slid across the map. WarpToAsync does the same placement plus the
// map removal and the client notification.
await this._player.WarpToAsync(spawnGate).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="ICastleSiegeStatusViewPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
/// <summary>
/// Interface of a view which drives the client's on-map Castle Siege countdown (Season 6 protocol
/// C1 B2 17 = battle start/stop flag, C1 B2 1E = remaining hour/minute).
/// </summary>
public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
{
/// <summary>
/// Sends the siege battle start/stop flag (C1 B2 17). The client only ticks its countdown while this is set.
/// </summary>
/// <param name="started">Whether the siege battle is running.</param>
ValueTask SetBattleStateAsync(bool started);
/// <summary>
/// Sends the remaining siege time as hour/minute (C1 B2 1E) to (re)seed the countdown.
/// </summary>
/// <param name="hour">The remaining hours.</param>
/// <param name="minute">The remaining minutes.</param>
ValueTask SetTimerAsync(byte hour, byte minute);
/// <summary>
/// Sends the castle owner guild's 32-byte logo (C1 B9 02) so the client paints it onto the castle flags.
/// </summary>
/// <param name="guildLogo">The owner guild's 32-byte logo/emblem.</param>
ValueTask SetCastleFlagAsync(ReadOnlyMemory<byte> guildLogo);
/// <summary>
/// Drops (down = true) or raises (down = false) the shield/barrier over the crown (C1 B2 16).
/// The crown is only clickable/capturable while the shield is down.
/// </summary>
/// <param name="down">Whether the shield should be down.</param>
ValueTask SetCrownShieldAsync(bool down);
/// <summary>
/// Sends the crown-hold registration state (C1 B2 15): state 0 = started/in-progress (starts the client's
/// 60-second countdown), 1 = success, 2 = failed/reset.
/// </summary>
/// <param name="state">The registration state (0 progress, 1 success, 2 fail).</param>
/// <param name="accessMilliseconds">Accumulated hold time in milliseconds (client shows 60000 - this).</param>
ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds);
/// <summary>
/// Broadcasts that a guild registered the official seal / took the crown (C1 B2 18, state 1).
/// </summary>
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
ValueTask AnnounceSealCapturedAsync(string guildName);
}

View File

@@ -0,0 +1,182 @@
// <copyright file="CastleSiegeStatusViewPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The default implementation of <see cref="ICastleSiegeStatusViewPlugIn"/> which sends the Season 6
/// Castle Siege countdown packets straight to the game client as raw bytes (no packet-struct exists yet).
/// </summary>
[PlugIn]
[Display(Name = "Castle Siege Status View", Description = "Sends the Castle Siege on-map countdown/state packets (C1 B2 17 / C1 B2 1E).")]
[Guid("CA5710A0-7A1B-4C2D-8E3F-0000000017E0")]
public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
{
private readonly RemotePlayer _player;
/// <summary>
/// Initializes a new instance of the <see cref="CastleSiegeStatusViewPlugIn"/> class.
/// </summary>
/// <param name="player">The player.</param>
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
/// <inheritdoc />
public async ValueTask SetBattleStateAsync(bool started)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 09 B2 17 <started> <crownAccessTime:4 bytes = 0>
var span = connection.Output.GetSpan(9)[..9];
span.Clear();
span[0] = 0xC1;
span[1] = 0x09;
span[2] = 0xB2;
span[3] = 0x17;
span[4] = (byte)(started ? 1 : 0);
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetTimerAsync(byte hour, byte minute)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 06 B2 1E <hour> <minute>
var span = connection.Output.GetSpan(6)[..6];
span[0] = 0xC1;
span[1] = 0x06;
span[2] = 0xB2;
span[3] = 0x1E;
span[4] = hour;
span[5] = minute;
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCastleFlagAsync(ReadOnlyMemory<byte> guildLogo)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 24 B9 02 <32-byte guild logo>
var span = connection.Output.GetSpan(36)[..36];
span.Clear();
span[0] = 0xC1;
span[1] = 0x24;
span[2] = 0xB9;
span[3] = 0x02;
var logo = guildLogo.Span;
logo[..Math.Min(32, logo.Length)].CopyTo(span.Slice(4, 32));
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownShieldAsync(bool down)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0C B2 16 <state> <pad> <dword=0> ; state 0 = shield down, 1 = shield up
var span = connection.Output.GetSpan(12)[..12];
span.Clear();
span[0] = 0xC1;
span[1] = 0x0C;
span[2] = 0xB2;
span[3] = 0x16;
span[4] = (byte)(down ? 0 : 1);
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0C B2 15 <state> <pad3> <accessMs:4 LE @offset 8>
var span = connection.Output.GetSpan(12)[..12];
span.Clear();
span[0] = 0xC1;
span[1] = 0x0C;
span[2] = 0xB2;
span[3] = 0x15;
span[4] = state;
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8, 4), accessMilliseconds);
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AnnounceSealCapturedAsync(string guildName)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0D B2 18 <state=1> <guildName[8] utf8>
var span = connection.Output.GetSpan(13)[..13];
span.Clear();
span[0] = 0xC1;
span[1] = 0x0D;
span[2] = 0xB2;
span[3] = 0x18;
span[4] = 1;
var bytes = System.Text.Encoding.UTF8.GetBytes(guildName);
bytes.AsSpan(0, Math.Min(8, bytes.Length)).CopyTo(span.Slice(5, 8));
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
}

View File

@@ -24,6 +24,11 @@
<None Remove="Resources\075_Terrain5.att" />
<None Remove="Resources\075_Terrain8.att" />
<None Remove="Resources\Terrain1.att" />
<None Remove="Resources\Terrain83.att" />
<None Remove="Resources\Terrain84.att" />
<None Remove="Resources\Terrain85.att" />
<None Remove="Resources\Terrain86.att" />
<None Remove="Resources\Terrain87.att" />
<None Remove="Resources\Terrain10_1.att" />
<None Remove="Resources\Terrain10_2.att" />
<None Remove="Resources\Terrain10_3.att" />
@@ -103,6 +108,11 @@
<EmbeddedResource Include="Resources\075_Terrain5.att" />
<EmbeddedResource Include="Resources\075_Terrain8.att" />
<EmbeddedResource Include="Resources\Terrain1.att" />
<EmbeddedResource Include="Resources\Terrain83.att" />
<EmbeddedResource Include="Resources\Terrain84.att" />
<EmbeddedResource Include="Resources\Terrain85.att" />
<EmbeddedResource Include="Resources\Terrain86.att" />
<EmbeddedResource Include="Resources\Terrain87.att" />
<EmbeddedResource Include="Resources\Terrain10_4.att" />
<EmbeddedResource Include="Resources\Terrain10_3.att" />
<EmbeddedResource Include="Resources\Terrain10_2.att" />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,91 @@
// <copyright file="AddArkaniaMapUpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds the imported Arkania map (number 83): the map definition with its terrain,
/// a spawn gate on a walkable tile, and a /move warp entry so players can reach it.
/// Monsters and NPCs are added afterwards via the admin panel.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("2C8B0A61-6B2E-4D7A-9E3C-7F1A4B2D9E01")]
public class AddArkaniaMapUpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add Arkania map (83)";
internal const string PlugInDescription = "Adds the imported Arkania map (number 83) with terrain, a spawn gate, and a /move warp. Monsters/NPCs are added via the admin panel.";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddArkaniaMapSeason6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 19, 12, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
// Idempotency guard: skip if Arkania (82) already exists.
if (gameConfiguration.Maps.Any(m => m.Number == Arkania.Number))
{
return;
}
var initializer = new Arkania(context, gameConfiguration);
initializer.Initialize(); // creates map 82 + loads terrain from Terrain83.att
initializer.SetSafezoneMap();
var arkania = gameConfiguration.Maps.First(m => m.Number == Arkania.Number);
// Host the map on every game server configuration (fresh seeds do this
// automatically, but existing databases need it added explicitly).
var serverConfigs = await context.GetAsync<GameServerConfiguration>().ConfigureAwait(false);
foreach (var serverConfig in serverConfigs)
{
if (serverConfig.Maps.All(m => m.Number != Arkania.Number))
{
serverConfig.Maps.Add(arkania);
}
}
// Spawn gate on a walkable tile (center of the map; attr 0x00, verified walkable).
var spawnGate = context.CreateNew<ExitGate>();
arkania.ExitGates.Add(spawnGate);
spawnGate.Map = arkania;
spawnGate.X1 = 128;
spawnGate.Y1 = 128;
spawnGate.X2 = 128;
spawnGate.Y2 = 128;
spawnGate.IsSpawnGate = true;
// /move warp entry.
if (gameConfiguration.WarpList.All(w => w.Name != Arkania.Name))
{
var warp = context.CreateNew<WarpInfo>();
gameConfiguration.WarpList.Add(warp);
warp.Index = 83;
warp.Name = Arkania.Name;
warp.Costs = 5000;
warp.LevelRequirement = 10;
warp.Gate = spawnGate;
}
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="AddImperialWeapons050UpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds two new weapons at item number 50: Imperial Sword (group 0, Magic Gladiator) and
/// Imperial Staff (group 5, Wizard), as full working weapons (damage, attack speed, requirements,
/// options, equip flags). Placed at free slots so they never collide with standard items.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("BE9DD7D1-F352-4C56-A646-D78C36978982")]
public class AddImperialWeapons050UpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add Imperial Weapons (0/50, 5/50)";
internal const string PlugInDescription = "Adds Imperial Sword (0/50) and Imperial Staff (5/50) as fully working weapons.";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddImperialWeapons050Season6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 16, 12, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
#pragma warning disable CS1998
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
#pragma warning restore CS1998
{
// Idempotency guard: skip if the Imperial Sword 0/50 already exists.
if (gameConfiguration.Items.Any(i => i.Group == 0 && i.Number == 50))
{
return;
}
new Weapons(context, gameConfiguration).CreateImperialWeapons050();
}
}

View File

@@ -0,0 +1,100 @@
// <copyright file="AddImportedMapsUpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds the four imported maps Acheron (83), Debenter (84), Uruk (85) and Ferea (86):
/// the map definitions with their terrain, a spawn gate and a /move warp each, and
/// assigns each to the game server configuration so it is hosted and its spawns run.
/// Monsters and NPCs are added afterwards via the admin panel.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("3D9C1B72-7C3F-4E8B-AF14-E29D47A8B012")]
public class AddImportedMapsUpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add imported maps (Acheron, Debenter, Uruk, Ferea)";
internal const string PlugInDescription = "Adds maps 83-86 (Acheron, Debenter, Uruk, Ferea) with terrain, spawn gate and /move warp, hosted by the game server. Monsters/NPCs are added via the admin panel.";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddImportedMapsSeason6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 19, 15, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
var serverConfigs = (await context.GetAsync<GameServerConfiguration>().ConfigureAwait(false)).ToList();
AddMap(context, gameConfiguration, serverConfigs, new Acheron(context, gameConfiguration), Acheron.Number, Acheron.Name, 84, 127, 126);
AddMap(context, gameConfiguration, serverConfigs, new Debenter(context, gameConfiguration), Debenter.Number, Debenter.Name, 85, 132, 105);
AddMap(context, gameConfiguration, serverConfigs, new Uruk(context, gameConfiguration), Uruk.Number, Uruk.Name, 86, 128, 128);
AddMap(context, gameConfiguration, serverConfigs, new Ferea(context, gameConfiguration), Ferea.Number, Ferea.Name, 87, 128, 128);
}
private static void AddMap(IContext context, GameConfiguration gameConfiguration, IList<GameServerConfiguration> serverConfigs, IMapInitializer initializer, byte number, string name, short warpIndex, byte x, byte y)
{
// Idempotency guard: skip if the map already exists.
if (gameConfiguration.Maps.Any(m => m.Number == number))
{
return;
}
initializer.Initialize(); // creates the map + loads terrain from Terrain{number+1}.att
initializer.SetSafezoneMap();
var map = gameConfiguration.Maps.First(m => m.Number == number);
// Spawn gate on a walkable tile.
var spawnGate = context.CreateNew<ExitGate>();
map.ExitGates.Add(spawnGate);
spawnGate.Map = map;
spawnGate.X1 = x;
spawnGate.Y1 = y;
spawnGate.X2 = x;
spawnGate.Y2 = y;
spawnGate.IsSpawnGate = true;
// /move warp entry.
if (gameConfiguration.WarpList.All(w => w.Name != name))
{
var warp = context.CreateNew<WarpInfo>();
gameConfiguration.WarpList.Add(warp);
warp.Index = warpIndex;
warp.Name = name;
warp.Costs = 5000;
warp.LevelRequirement = 10;
warp.Gate = spawnGate;
}
// Host the map on every game server configuration (fresh seeds do this
// automatically, but existing databases need it added explicitly).
foreach (var serverConfig in serverConfigs)
{
if (serverConfig.Maps.All(m => m.Number != number))
{
serverConfig.Maps.Add(map);
}
}
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="AddPoisonWingsUpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds two imported wings at free item slots: Poison Wing (12/240, Dark Knight) and
/// Poison Cape (12/241, Dark Lord + Rage Fighter). Basic wings: base defense, class
/// restriction, CanFly; no wing options.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("7E4A2C63-9B1D-4F8A-A5C6-1D3E8F2B4A07")]
public class AddPoisonWingsUpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add Poison Wing & Poison Cape (12/240, 12/241)";
internal const string PlugInDescription = "Adds Poison Wing (12/240, Dark Knight) and Poison Cape (12/241, Dark Lord + Rage Fighter) as high-tier wings (defense, 3rd Wing Options, damage increase, fast flight).";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddPoisonWingsSeason6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 19, 17, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
#pragma warning disable CS1998
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
#pragma warning restore CS1998
{
// Idempotency guard: skip if Poison Wing (12/240) already exists.
if (gameConfiguration.Items.Any(i => i.Group == 12 && i.Number == 240))
{
return;
}
new Wings(context, gameConfiguration).CreatePoisonWings();
}
}

View File

@@ -0,0 +1,57 @@
// <copyright file="AddWarriorWingsUpdateSeason6.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Updates;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds six imported high-tier "Warrior" wings at free item slots (12/242-247), one per class line:
/// Wizard (Soul Master), Magic (Duel Master), Elf (High Elf), Summoner (Dimension Master),
/// Warrior Cape (Lord Emperor + Fist Master) and Knight (Blade Master). Each carries base defense,
/// class restriction, 3rd Wing Options, damage increase and fast flight.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("8F5B3D74-AC2E-4F9B-B6D7-2E4F9A3C5B18")]
public class AddWarriorWingsUpdateSeason6 : UpdatePlugInBase
{
internal const string PlugInName = "Add Warrior Wings & Cape (12/242-247)";
internal const string PlugInDescription = "Adds six high-tier Warrior wings (12/242-247): Wizard, Magic, Elf, Summoner, Warrior Cape (DL+RF) and Knight, each with defense, 3rd Wing Options, damage increase and fast flight.";
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddWarriorWingsSeason6;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 19, 20, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
#pragma warning disable CS1998
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
#pragma warning restore CS1998
{
// Idempotency guard: skip if the first Warrior wing (12/242) already exists.
if (gameConfiguration.Items.Any(i => i.Group == 12 && i.Number == 242))
{
return;
}
new Wings(context, gameConfiguration).CreateWarriorWings();
}
}

View File

@@ -479,4 +479,29 @@ public enum UpdateVersion
/// The version of the <see cref="AddIsQuestItemFlagPlugIn"/>.
/// </summary>
AddIsQuestItemFlag = 94,
/// <summary>
/// The version of the <see cref="AddImperialWeapons050UpdateSeason6"/>.
/// </summary>
AddImperialWeapons050Season6 = 95,
/// <summary>
/// The version of the <see cref="AddArkaniaMapUpdateSeason6"/>.
/// </summary>
AddArkaniaMapSeason6 = 96,
/// <summary>
/// The version of the <see cref="AddImportedMapsUpdateSeason6"/> (Acheron, Debenter, Uruk, Ferea).
/// </summary>
AddImportedMapsSeason6 = 97,
/// <summary>
/// The version of the <see cref="AddPoisonWingsUpdateSeason6"/> (Poison Wing 12/240, Poison Cape 12/241).
/// </summary>
AddPoisonWingsSeason6 = 98,
/// <summary>
/// The version of the <see cref="AddWarriorWingsUpdateSeason6"/> (Warrior Wings/Cape 12/242-247).
/// </summary>
AddWarriorWingsSeason6 = 99,
}

View File

@@ -100,6 +100,11 @@ public class GameMapsInitializer : GameMapsInitializerBase
yield return typeof(FortressOfImperialGuardian2);
yield return typeof(FortressOfImperialGuardian3);
yield return typeof(FortressOfImperialGuardian4);
yield return typeof(Arkania);
yield return typeof(Acheron);
yield return typeof(Debenter);
yield return typeof(Uruk);
yield return typeof(Ferea);
}
}
}

View File

@@ -269,6 +269,9 @@ internal class Weapons : InitializerBase
this.CreateWeapon(5, 34, 0, 0, 1, 4, false, "Raven Stick", 147, 70, 78, 30, 98, 130, 0, 50, 14, 0, 0, 0, 0, 0, 0, 0, 2, 0);
this.CreateWeapon(5, 36, 0, 0, 1, 4, false, "Divine Stick of Archangel", 104, 153, 165, 30, 182, 146, 0, 55, 13, 0, 0, 0, 0, 0, 0, 0, 1, 0);
// ADAMU-CUSTOM: new Imperial weapons at free slots (fresh-DB seed path; existing DBs get them via AddImperialWeapons050UpdateSeason6).
this.CreateImperialWeapons050();
this.AddGuardianOptions();
}
@@ -522,6 +525,25 @@ internal class Weapons : InitializerBase
}
}
/// <summary>
/// Creates only the two new "Imperial" weapons at item number 50 (Imperial Sword 0/50 and
/// Imperial Staff 5/50), reusing <see cref="CreateWeapon"/> and the already-persisted
/// item level bonus tables (looked up by name). Intended for a configuration update
/// plug-in running against an existing database, so it does NOT recreate the tables.
/// </summary>
public void CreateImperialWeapons050()
{
this._weaponDamageIncreaseTable = this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Damage Increase (Weapons)");
this._staffRiseTableEven = this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Staff Rise (even)");
this._staffRiseTableOdd = this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Staff Rise (odd)");
// Imperial Sword (copy of 0/28, Magic Gladiator) at number 50.
this.CreateWeapon(0, 50, 0, 56, 1, 4, true, "Imperial Sword", 139, 98, 122, 45, 90, 109, 380, 91, 73, 17, 0, 0, 0, 0, 1, 0, 0, 0);
// Imperial Staff (copy of 5/31, Wizard) at number 50.
this.CreateWeapon(5, 50, 0, 0, 1, 4, true, "Imperial Staff", 137, 57, 61, 30, 182, 124, 380, 48, 14, 0, 0, 2, 0, 0, 0, 0, 0, 0);
}
private void AddGuardianOptions()
{
var weaponOption = this.GameConfiguration.ItemOptions.First(io => io.PossibleOptions.Any(po => po.OptionType == ItemOptionTypes.GuardianOption && po.Number == (int)ItemGroups.Weapon));

View File

@@ -102,6 +102,64 @@ public class Wings : WingsInitializerBase
this.CreateWing(40, 2, 3, "Cape of Emperor", 150, 45, 220, 400, 0, 0, 0, 0, 3, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 39, 24, this._damageIncreaseByLevelTable, thirdWingOptions);
this.CreateWing(43, 4, 3, "Wing of Dimension", 150, 45, 220, 400, 0, 0, 0, 0, 0, 3, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.WizDamage), (0b10, OptionType.CurseDamage)), 39, 39, this._damageIncreaseByLevelTable, thirdWingOptions);
this.CreateWing(50, 2, 3, "Cape of Overrule", 150, 45, 220, 400, 0, 0, 0, 0, 0, 0, 3, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 39, 39, this._damageIncreaseByLevelTable, thirdWingOptions);
this.CreatePoisonWings();
this.CreateWarriorWings();
}
/// <summary>
/// ADAMU-CUSTOM: two imported wings (basic: base defense + class restriction, no wing options).
/// Poison Wing (12/240, Dark Knight), Poison Cape (12/241, Dark Lord + Rage Fighter).
/// </summary>
public void CreatePoisonWings()
{
// High-tier imported wings. Reuse the EXISTING shared "3rd Wing Options" option set and
// level-bonus tables (they already exist in the database) by fetching them from the
// configuration instead of re-creating them - re-creating uses fixed Guids and would
// collide when this runs as an update on an existing database.
var wingOptions = this.GameConfiguration.ItemOptions.First(o => o.Name == "3rd Wing Options");
var damageTable = this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Damage Increase (1st and 3rd Wings)");
this._defenseBonusByLevelTable ??= this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Defense Bonus (1st and 2nd Wings)");
// Poison Wing (12/240, Dark Knight): defense 65 + defense-per-level, 3rd Wing Options,
// damage increase, HP-recover / phys-damage / defense wing options, fast flight.
this.CreateWing(240, 5, 3, "Poison Wing", 150, 65, 220, 100, 0, 1, 0, 0, 0, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Poison Cape (12/241, Dark Lord + Rage Fighter): defense 60, same rich options.
this.CreateWing(241, 5, 3, "Poison Cape", 150, 60, 220, 100, 0, 0, 0, 0, 1, 0, 1, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
}
/// <summary>
/// ADAMU-CUSTOM: six imported high-tier "Warrior" wings (base defense + 3rd Wing Options + damage
/// increase, fast flight). One per class line:
/// 242 Wizard (Soul Master), 243 Magic (Duel Master), 244 Elf (High Elf), 245 Summoner (Dimension
/// Master), 246 Warrior Cape (Lord Emperor + Fist Master), 247 Knight (Blade Master).
/// </summary>
public void CreateWarriorWings()
{
// Reuse the EXISTING shared "3rd Wing Options" set and level-bonus tables (fixed Guids already in
// the database) by fetching them, to avoid Guid collisions when this runs as an update.
var wingOptions = this.GameConfiguration.ItemOptions.First(o => o.Name == "3rd Wing Options");
var damageTable = this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Damage Increase (1st and 3rd Wings)");
this._defenseBonusByLevelTable ??= this.GameConfiguration.ItemLevelBonusTables.First(t => t.Name == "Defense Bonus (1st and 2nd Wings)");
// Warrior Wings Wizard (12/242, Soul Master / Dark Wizard): magic damage option.
this.CreateWing(242, 5, 3, "Warrior Wings Wizard", 150, 65, 220, 100, 1, 0, 0, 0, 0, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.WizDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Warrior Wings Magic (12/243, Duel Master / Magic Gladiator).
this.CreateWing(243, 5, 3, "Warrior Wings Magic", 150, 65, 220, 100, 0, 0, 0, 1, 0, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Warrior Wings Elf (12/244, High Elf).
this.CreateWing(244, 5, 3, "Warrior Wings Elf", 150, 65, 220, 100, 0, 0, 1, 0, 0, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Warrior Wings Summoner (12/245, Dimension Master / Summoner): magic + curse damage.
this.CreateWing(245, 5, 3, "Warrior Wings Summoner", 150, 65, 220, 100, 0, 0, 0, 0, 0, 1, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.WizDamage), (0b10, OptionType.CurseDamage)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Warrior Cape (12/246, Lord Emperor / Dark Lord + Fist Master / Rage Fighter): defense 60.
this.CreateWing(246, 5, 3, "Warrior Cape", 150, 60, 220, 100, 0, 0, 0, 0, 1, 0, 1, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
// Warrior Wings Knight (12/247, Blade Master / Dark Knight).
this.CreateWing(247, 5, 3, "Warrior Wings Knight", 150, 65, 220, 100, 0, 1, 0, 0, 0, 0, 0, this.BuildOptions((0b00, OptionType.HealthRecover), (0b11, OptionType.PhysDamage), (0b10, OptionType.Defense)), 42, 42, damageTable, wingOptions, movementSpeed: MovementSpeedConstants.FastWingMovementSpeed);
}
private void CreateFeather()

View File

@@ -0,0 +1,40 @@
// <copyright file="Acheron.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Acheron map (imported map). Seeded empty; monster/NPC
/// spawns and gates are added via the admin panel.
/// </summary>
internal class Acheron : BaseMapInitializer
{
/// <summary>
/// The Number of the Map.
/// </summary>
internal const byte Number = 83;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Acheron";
/// <summary>
/// Initializes a new instance of the <see cref="Acheron"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Acheron(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}

View File

@@ -0,0 +1,42 @@
// <copyright file="Arkania.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Arkania map (imported map, world 83).
/// Seeded empty; monster/NPC spawns and gates are added via the admin panel.
/// </summary>
internal class Arkania : BaseMapInitializer
{
/// <summary>
/// The Number of the Map. OpenMU map numbers are 0-based; the MU client loads
/// world folder <c>World{Number + 1}</c> (e.g. Lorencia 0 -> World1). Arkania's
/// client assets are World83, so the server map number is 82.
/// </summary>
internal const byte Number = 82;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Arkania";
/// <summary>
/// Initializes a new instance of the <see cref="Arkania"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Arkania(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}

View File

@@ -0,0 +1,40 @@
// <copyright file="Debenter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Debenter map (imported map). Seeded empty; monster/NPC
/// spawns and gates are added via the admin panel.
/// </summary>
internal class Debenter : BaseMapInitializer
{
/// <summary>
/// The Number of the Map.
/// </summary>
internal const byte Number = 84;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Debenter";
/// <summary>
/// Initializes a new instance of the <see cref="Debenter"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Debenter(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}

View File

@@ -0,0 +1,40 @@
// <copyright file="Ferea.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Ferea map (imported map). Seeded empty; monster/NPC
/// spawns and gates are added via the admin panel.
/// </summary>
internal class Ferea : BaseMapInitializer
{
/// <summary>
/// The Number of the Map.
/// </summary>
internal const byte Number = 86;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Ferea";
/// <summary>
/// Initializes a new instance of the <see cref="Ferea"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Ferea(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}

View File

@@ -0,0 +1,40 @@
// <copyright file="Uruk.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The initialization for the Uruk map (imported map). Seeded empty; monster/NPC
/// spawns and gates are added via the admin panel.
/// </summary>
internal class Uruk : BaseMapInitializer
{
/// <summary>
/// The Number of the Map.
/// </summary>
internal const byte Number = 85;
/// <summary>
/// The Name of the Map.
/// </summary>
internal const string Name = "Uruk";
/// <summary>
/// Initializes a new instance of the <see cref="Uruk"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public Uruk(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc/>
protected override byte MapNumber => Number;
/// <inheritdoc/>
protected override string MapName => Name;
}

View File

@@ -0,0 +1,274 @@
// <copyright file="CastleSiegeContextTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
/// </summary>
[TestFixture]
public class CastleSiegeContextTest
{
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary>
[Test]
public void StartsInOwnership()
{
var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
}
/// <summary>Tests that force-starting moves the state machine into registration.</summary>
[Test]
public async Task ForceStartMovesToRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
}
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary>
[Test]
public async Task RegistrationAdvancesToPreparationAfterDurationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(4)); // still within registration
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation));
}
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary>
[Test]
public async Task FullCycleReturnsToOwnershipAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation
await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege));
await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
}
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary>
[Test]
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
ctx.RegisterGuild("Attackers");
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
}
/// <summary>Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne.</summary>
[Test]
public async Task FullSiegeObjectiveChainToOwnershipAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(2);
// Both switches held but defenses still up -> no capture.
ctx.SetSwitchHolder(217, "Attackers");
ctx.SetSwitchHolder(218, "Attackers");
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
ctx.NotifyDefenseDestroyed();
ctx.NotifyDefenseDestroyed();
// Only one switch held -> still no capture.
ctx.SetSwitchHolder(218, null);
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
// Both switches held by the same guild + defenses down -> capture at the Sinior/Crown.
ctx.SetSwitchHolder(218, "Attackers");
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
}
/// <summary>Tests that two different guilds each holding one switch cannot capture the throne.</summary>
[Test]
public async Task ThroneRequiresBothSwitchesBySameGuildAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "B");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False);
Assert.That(ctx.TryCaptureThrone("B").Success, Is.False);
}
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary>
[Test]
public void ThroneCaptureOutsideSiegeIsNoOp()
{
var ctx = new CastleSiegeContext(Config());
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "A");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False);
Assert.That(ctx.OccupierGuildName, Is.Null);
}
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary>
[Test]
public void RestoreStateSetsStateWithoutFiringPhaseChanged()
{
var ctx = new CastleSiegeContext(Config());
var phaseChangedFired = false;
ctx.PhaseChanged += _ => phaseChangedFired = true;
ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" });
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners"));
Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" }));
Assert.That(phaseChangedFired, Is.False);
Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty");
}
/// <summary>Tests that the weekly auto-schedule (stored in config) only fires on a matching day/time window.</summary>
[Test]
public void ScheduleFiresOnMatchingDayAndTimeWindow()
{
var ctx = new CastleSiegeContext(Config());
ctx.SetSchedule(new[] { DayOfWeek.Sunday }, new TimeOnly(20, 0));
var config = ctx.Configuration;
var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window
var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the 5s window
var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day
Assert.That(config.IsRegistrationOpenTime(sunday), Is.True);
Assert.That(config.IsRegistrationOpenTime(sundayLate), Is.False);
Assert.That(config.IsRegistrationOpenTime(monday), Is.False);
Assert.That(ctx.ConsumeDirty(), Is.True, "SetSchedule marks the state dirty");
ctx.SetSchedule(Array.Empty<DayOfWeek>(), null); // cleared -> never opens
Assert.That(config.IsRegistrationOpenTime(sunday), Is.False);
}
/// <summary>Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets.</summary>
[Test]
public async Task DirtyFlagTracksPersistableChangesAsync()
{
var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist");
await ctx.ForceStartRegistrationAsync(T0); // phase transition -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag");
ctx.RegisterGuild("Attackers"); // registration -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
ctx.SetOwner("Attackers"); // owner change -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
}
/// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
[Test]
public async Task RemainingSiegeTimeReflectsSiegePhaseAsync()
{
var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration
Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero");
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7)));
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero");
}
/// <summary>Tests that a guild master holding the crown (both switches held, defenses down) captures after the hold duration.</summary>
[Test]
public async Task CrownHoldCapturesAfterHoldDurationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "Attackers");
ctx.SetSwitchHolder(218, "Attackers");
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo("Attackers"));
var hold = TimeSpan.FromSeconds(60);
Assert.That(ctx.TickCrownHold("Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold("Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.OccupierGuildName, Is.Null);
var captured = ctx.TickCrownHold("Attackers", true, T0.AddSeconds(60), hold);
Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(captured.ShieldDown, Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
}
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
[Test]
public async Task CrownHoldResetsWhenSwitchLostAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "A");
Assert.That(ctx.TickCrownHold("A", true, T0, TimeSpan.FromSeconds(60)).Event, Is.EqualTo(CrownEvent.HoldStarted));
ctx.SetSwitchHolder(218, null); // lost a switch -> no longer eligible
var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), false, T0.AddSeconds(10), TimeSpan.FromSeconds(60));
Assert.That(reset.ShieldDown, Is.False);
Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset));
Assert.That(ctx.OccupierGuildName, Is.Null);
}
/// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary>
[Test]
public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
var hold = TimeSpan.FromSeconds(60);
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "A");
ctx.TickCrownHold("A", true, T0, hold);
Assert.That(ctx.TickCrownHold("A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("A"));
// A keeps holding — no re-registration loop, but the shield stays down (they hold it).
var after = ctx.TickCrownHold("A", true, T0.AddSeconds(61), hold);
Assert.That(after.Event, Is.EqualTo(CrownEvent.None));
Assert.That(after.ShieldDown, Is.True);
// B takes both switches and can contest/capture.
ctx.SetSwitchHolder(217, "B");
ctx.SetSwitchHolder(218, "B");
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("B"));
}
private static CastleSiegeConfiguration Config() => new()
{
RegistrationDuration = TimeSpan.FromMinutes(5),
PreparationDuration = TimeSpan.FromMinutes(2),
SiegeDuration = TimeSpan.FromMinutes(10),
};
}

View File

@@ -1,9 +1,10 @@
// <copyright file="ResetCharacterActionTest.cs" company="MUnique">
// <copyright file="ResetCharacterActionTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
@@ -11,6 +12,8 @@ using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Tests for <see cref="ResetCharacterAction"/>.
@@ -131,6 +134,60 @@ public class ResetCharacterActionTest
Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1_800));
}
/// <summary>
/// Verifies that a reset with <see cref="ResetConfiguration.MoveHome"/> moves the player through
/// the regular warp path, so the client is told about the new position.
/// Writing the character's position fields directly moves the player without notifying anyone,
/// which makes the character slide across the map on the client instead of respawning.
/// </summary>
[Test]
public async Task MoveHomeWarpsPlayerSoTheClientIsNotifiedAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.Attributes![Stats.Level] = 400;
player.Money = 1_000;
// The home map has to be one whose GameMap instance does not exist yet: the spawn gate is
// resolved once, in the GameMap constructor, so a map created earlier would ignore it.
var homeMap = CreateMapDefinitionWithSpawnGate(1, 125);
player.GameContext.Configuration.Maps.Add(homeMap);
Mock.Get(player.SelectedCharacter!.CharacterClass!).Setup(c => c.HomeMap).Returns(homeMap);
var configuration = this.CreateConfiguration(null);
configuration.MoveHome = true;
player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true);
var mapChangeView = Mock.Get(player.ViewPlugIns.GetPlugIn<IMapChangePlugIn>()!);
var action = new ResetCharacterAction(player);
await action.ResetCharacterAsync().ConfigureAwait(false);
Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1));
Assert.That(player.Position, Is.EqualTo(new Point(125, 125)));
mapChangeView.Verify(v => v.MapChangeAsync(), Times.Once);
}
private static GameMapDefinition CreateMapDefinitionWithSpawnGate(short number, byte spawnCoordinate)
{
var mapMock = new Mock<GameMapDefinition>();
mapMock.SetupAllProperties();
mapMock.Setup(m => m.DropItemGroups).Returns(new List<DropItemGroup>());
mapMock.Setup(m => m.MonsterSpawns).Returns(new List<MonsterSpawnArea>());
mapMock.Object.TerrainData = new byte[ushort.MaxValue + 3];
mapMock.Object.Number = number;
var spawnGate = new ExitGate
{
IsSpawnGate = true,
X1 = spawnCoordinate,
X2 = spawnCoordinate,
Y1 = spawnCoordinate,
Y2 = spawnCoordinate,
Map = mapMock.Object,
};
mapMock.Setup(m => m.ExitGates).Returns(new List<ExitGate> { spawnGate });
return mapMock.Object;
}
private static async ValueTask AddRequiredItemsAsync(Player player, ItemDefinition requiredItem, int count)
{
for (byte i = 0; i < count; i++)
@@ -165,7 +222,7 @@ public class ResetCharacterActionTest
return new NonPlayerCharacter(spawnArea, definition, map);
}
private ResetConfiguration CreateConfiguration(ItemDefinition requiredItem)
private ResetConfiguration CreateConfiguration(ItemDefinition? requiredItem)
{
return new ResetConfiguration
{