24 Commits

Author SHA1 Message Date
Acentech Dev
df338578d1 Pin the production image to the OpenMU upstream Dalga A release (d60aad7d2f)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-08-13 09:27:10 +03:00
Acentech Dev
d60aad7d2f Load the log viewer script from the path it is actually served on
The admin panel sets StaticWebAssetBasePath to _content/<assembly>, so the
colocated module is served as
/_content/MUnique.OpenMU.Web.AdminPanel/Pages/LogFiles.razor.js. The import
asked for "./Pages/LogFiles.razor.js", which resolves against the page url
/logfiles and answers 404. The failure is caught and logged, so the viewer
still renders - it just never scrolls itself.

Build the path from the assembly name the way ThemeSelector and MapEditor
already do.
2026-08-13 09:13:56 +03:00
Acentech Dev
233ad41f6b Merge branch 'integrate/openmu-20260813a'
OpenMU upstream Dalga A: d4ca915c4..a7412572c aralığındaki düşük riskli
sekiz PR. #858, #854, #845 ve #864 uygulandı; #754 ve #860'ın gövdesi zaten
ağaçtaydı; #859 boş çıktığı için atlandı.

Ayrıca #860'ın eksik kalan testi ve #845'in düşürdüğü chat command kaynak
dizeleri tamamlandı.

1.295 test başarılı, 6 atlandı, 0 hata.
2026-08-13 09:03:28 +03:00
Eduardo
72874567c6 replace Bazored.Toast with custom Toast component 2026-08-13 08:53:09 +03:00
Acentech Dev
23bc36fd65 Restore the chat command resource strings the log viewer merge dropped
The admin panel log viewer (#845) reordered Resources.resx around the
chat command entries. AdaMu never took the admin panel page which
displays them (upstream PR #851), so the conflict resolution left the
entries out - but Resources.Designer.cs still declares the twelve
properties, and each would have returned null at runtime.

Add the entries back, unchanged from upstream, so resx and the generated
designer agree again.
2026-08-13 08:53:09 +03:00
Claude
79922204c5 Fix build and behavior issues in the admin panel log viewer
- Resources.resx: close the unterminated DownloadFile data element, which
  made the file invalid XML, and remove the duplicated Actions and Refresh
  entries which already exist.
- Resources.Designer.cs: restore the UTF-8 BOM and put the new properties
  into the alphabetical order the strongly typed resource builder produces,
  so the file matches its generated form again.
- LogFiles.razor: import the collocated script from ./Pages/LogFiles.razor.js.
  The _content/{PackageId} prefix only applies to razor class libraries, so
  the import failed for this web application and the module was never loaded.
- LogFiles.razor: follow the new entries in live mode again by using the
  isScrolledToBottom helper, so the terminal scrolls along unless the user
  scrolled up to read the history.
- LogFiles.razor: only catch the expected javascript interop exceptions and
  log a failing module import instead of swallowing it silently.
- LogFiles.razor: restore the BOM and the trailing newline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vjs6n29WzQx8pGg3KJPGXk
(cherry picked from commit 7be969ea7b2a9b7441a8c5a65cc8a0928009d595)
2026-08-13 08:52:04 +03:00
Rhefew
f5b4292af1 fix(admin): address code review feedback on log viewer localization, CSP JS module, timer disposal, and scroll UX 2026-08-13 08:51:08 +03:00
Rhefew
074f91bd47 style(admin): apply code formatting, static helper functions and ConfigureWait to LogFiles.razor 2026-08-13 08:51:08 +03:00
Rhefew
f050e56e91 feat(admin): auto-scroll terminal to bottom on log refreshes 2026-08-13 08:51:08 +03:00
Rhefew
f7f6526c16 feat(admin): layout log files page side-by-side when viewing 2026-08-13 08:51:08 +03:00
Rhefew
31a5e64f40 feat(admin): implement live log viewer and searcher in log files page 2026-08-13 08:51:07 +03:00
Acentech Dev
790a101f23 Cover the configuration change publishing filter with its test
The Castle Siege persistence import (#860) brought
EntityFrameworkContextBase.PublishesConfigurationChanges into the tree,
but not the test which pins its behaviour, and not the guard which keeps
the two initialization test fixtures from configuring the connection
twice.

Add both, unchanged from upstream.
2026-08-13 08:50:53 +03:00
nolt
52950362ff Document the cross-player persistence lock-ordering invariant
The packet handler funnel now holds each player's persistence lock for the
whole handler. Acquiring a second player's lock from inside a handler is
therefore a lock-ordering hazard; note the invariant on the guard method so a
future cross-player save cannot silently open an AB-BA cycle. Documentation
only, no behavioural change.
2026-08-13 08:46:48 +03:00
nolt
d3dd57f620 Absorb the remaining off-funnel mutation races against the periodic save
The per-player persistence lock serializes the packet handler funnel and the
save, but a few structural mutations happen off that funnel: the offline/bot
MuHelper loots and maintains its inventory on a 500ms timer, and combat
destroys depleted ammunition or a dead pet on the attacker's or a monster's
thread. Those can still run while the periodic save enumerates the change
tracker and corrupt it.

Two additions:

- Run the whole offline MuHelper tick under the player's persistence lock.
  Bots are in the saved player list and loot continuously, so this was the
  most likely remaining reproducer. The tick has no internal delays, so the
  lock is held only briefly, and it is the bot's own lock (no cross-player
  deadlock).

- Retry the save a bounded number of times on the transient exceptions a
  concurrent change-tracker mutation produces. The corruption surfaces as
  several types depending on where change detection was (a modified
  collection, a transiently-null key, an out-of-range index), so the retry
  covers that family rather than a single type. A genuinely persistent error
  rethrows once the attempts are exhausted. This absorbs the rare, bursty
  combat sources that no lock is held for.
2026-08-13 08:46:47 +03:00
nolt
7671fe2d94 Serialize player context mutations against the periodic save
A player's progress is persisted from two unrelated flows: action
handlers triggered by incoming packets (sequential per connection) and
the periodic save, which runs on an independent timer. Action handlers
mutate tracked entities with plain field and collection writes - for
example crafting toggles item.ItemOptions, and item stacking and NPC
selling delete item rows - which bypass the persistence context's own
lock. When such a mutation runs while SaveChangesAsync enumerates the
change tracker, the save throws (collection-modified, or a
DbUpdateConcurrency "affected 0 rows" since the context has no
concurrency tokens). SaveChanges is atomic, so every following save
fails too and the whole session never persists: on relog the player
rolls back, losing progress and items.

Add a per-player re-entrant persistence lock and acquire it around both
the packet-handling funnel and SaveProgressAsync, so a player's
mutations and saves can never overlap. The lock is re-entrant per
asynchronous flow (an instance AsyncLocal), so an inline save inside an
already-serialized handler does not deadlock; and it is per player, so a
trade still acquires the trading partner's lock separately.

Add regression tests covering mutual exclusion and re-entrancy of the
lock.
2026-08-13 08:45:38 +03:00
nolt
ebf6958068 bots: accept party invitations regardless of level gap
The reset-aware level gate in BotPartyHandler rejected nearly every party
invitation on servers with resets, because it folded reset count into the level
scale (one reset ~= 400 points against a 500 cap). A veteran player inviting a
freshly generated bot was always over the limit, so the invitation was silently
declined. Remove the gate so a bot accepts any inviter who is alive and in the
world, matching OpenMU's own party action; the situational safeguards (shopping,
revenge, mini game, pending invite, human companion) stay in place.
2026-08-13 08:45:29 +03:00
Acentech Dev
0ff0707212 Pin the production image to the TvT NPC rename release (ede52f4176)
Some checks failed
.NET Core / build (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:24:57 +03:00
Acentech Dev
ede52f4176 feat(tvt): rename the Lorencia event NPC to "TvT Guard"
Some checks failed
.NET Core / build (push) Has been cancelled
The NPC (560) that opens the TvT registration stands in Lorencia and was seeded as
"TvT Event Gorevlisi". Renaming the seed only helps fresh databases, so update 106
renames it on existing ones too - that name is what the admin panel lists.

The name players see over its head comes from the client's own table
(Data\Local\<lang>\Npcname(<lang>).txt), not from this designation, and is changed
there separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:23:02 +03:00
Acentech Dev
797e17b666 Pin the production image to the Castle Siege release (d249fca935)
Some checks failed
.NET Core / build (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:53:09 +03:00
Acentech Dev
d249fca935 Merge branch 'feature/castle-siege-upstream-model'
Castle Siege on the upstream data model: guild ownership by id, click-and-hold
Crown Switches, click-to-capture crown, and the build fix that kept the generated
persistence model in sync with the data model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:45:31 +03:00
Acentech Dev
f8e856c7c6 feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled
The switches used to be held by simply standing near them, and the crown captured
by standing near it - clicking a switch only produced the client's "not implemented
yet" message. This drives both from the original interaction instead:

- Clicking a Crown Switch starts an operation which completes after
  CastleSiegeSettings.SwitchPushSeconds (15) and keeps the switch for the guild
  until its operator leaves the switch's area. One player per switch; anybody else
  clicking it is told another team is on it (C1 B2 14 state 2).
- While one guild holds both switches the crown's shield drops for it, and its
  guild master captures the throne by CLICKING the crown and holding it for
  CrownHoldTimeSeconds - seeded to 60 now, to match the countdown the client's
  registration panel hardcodes. The throne stays contestable until the siege ends.
- The shield now depends on the switches alone, as in the original; the gates and
  statues remain what they always were, the obstacle in the way.

The switch info packet (C1 B2 20) is broadcast before any switch-state packet
because the client's "switch released" handler reads its switch table without
checking that it exists - that table is only allocated when the info packet
arrives, so the wrong order crashes the client.

Also fixed while in here:
- The crown registration panel could never be closed: the cancel was only sent
  while the master still stood on the crown, which is precisely when the hold does
  NOT break. The panel is now closed for the player it was opened for.
- A contested switch was decided by enumeration order.
- Panels opened by the siege are closed when it ends.
- TryCaptureThrone was dead code carrying a second, diverged rule set.
- /csphase advertised the pre-refactor phase names to the client.
- The periodic broadcasts keyed off "UtcNow.Second % n", which silently skips when
  a tick runs late; they count ticks now.

The unit tests never compiled against the refactored model - they are migrated to
the state machine and guild ids, and cover the new switch and crown rules. A new
test proves update 105 writes the configuration into an existing database.

ApplyPendingUpdatesTool applies pending configuration updates without the admin
panel; it is [Explicit], so it never runs in a normal test pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:44:12 +03:00
Acentech Dev
af46499279 fix(build): run the persistence generator against the current data model
The PreBuild targets ran the generator with "--no-build", so it used whatever
assemblies happened to sit in its output folder. When that copy of the data model
was older than a newly added type, the generator regenerated the checked-in
*.Generated.cs files WITHOUT that type and overwrote them in the source tree.

Nothing failed at build time: the C# compile stayed green and docker builds
(-p:ci=true) skip the generator entirely, so they compiled whatever was in the
tree. The damage only surfaced at runtime, when EF validated the model and found
the inherited GameConfiguration.CastleSiegeConfiguration navigation pointing at a
keyless type - the server died on startup with "The entity type
'CastleSiegeConfiguration' requires a primary key to be defined".

Dropping the switch makes the generator build first, so its output always matches
the data model. The regenerated files here are that missing output: the Castle
Siege mappings, and the packet tests for packets whose XML was already committed.

TypedContextModelTests builds the typed context the startup reads its plugin
configurations through - the first one to touch the model - so this class of
breakage fails in seconds without a database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:43:39 +03:00
Acentech Dev
6f7e58ff35 refactor(castle-siege): drive the cycle on the client's state numbers and persist guilds by id
Moves AdaMu's working Castle Siege onto the upstream data model that the
previous commit introduced, without changing how the siege plays.

State model
- CastleSiegePhase is replaced by DataModel's CastleSiegeState, whose values are
  exactly what the game client's CASTLESIEGE_STATE enum expects. The cycle now
  runs Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) ->
  EndCycle(9) -> Idle1(0).
- Idle2(2), RegisterMark(3), Idle3(4) and Notify(5) keep their numbers for client
  compatibility but are never entered: AdaMu registers guilds directly and has no
  Mark of Lord step.

Guild identity
- Guilds are now identified by their persistent Guid instead of by name, so a
  rename (or a delete and re-create under the same name) can no longer hand
  castle ownership to the wrong guild. Names are carried alongside only for
  display and for the packets that send a name to the client.
- Interfaces.Guild deliberately has no id and the guild server's short ids are
  in-memory only, so the persistent id is resolved through the guild name once
  and cached per process. This avoids adding a method to IGuildServer, which
  upstream keeps changing.

Persistence
- The castle owner is stored in the CastleSiegeData row and the registrations in
  CastleSiegeGuildRegistration rows, replacing the previous plugin-configuration
  JSON blob. Only the current state and when it started still ride on the plugin
  configuration, because they have no column in the upstream schema.

Castle NPCs
- The hard-coded gate, catapult, crown and switch coordinates are gone. They are
  read from GameConfiguration.CastleSiegeConfiguration, seeded by
  CastleSiegeInitializer. Definitions flagged IsPersistedToDatabase are the
  breakable defenses and count towards the throne, which additionally brings in
  the 4 guardian statues the previous implementation did not spawn.
- The crown hold time now comes from the seeded configuration instead of the
  plugin settings.

The AdaMu operational settings (cycle durations, registration fee, designated
server id, auto-open schedule) moved to a renamed CastleSiegeSettings class, so
they no longer collide with upstream's CastleSiegeConfiguration entity.

Verified: full server build succeeds with 0 errors.
Not yet done: the 0xB2 0x00 CastleSiegeState request handler, and the docker /
local run.
2026-08-04 03:37:37 +03:00
Acentech Dev
3aa9815b10 feat(castle-siege): adopt the upstream Castle Siege data model and persistence
Brings in the database layer of upstream OpenMU PRs #754 and #860 without
touching AdaMu's working Castle Siege gameplay. This is purely additive: the
existing 5-phase implementation still runs exactly as before.

What is included:

- DataModel: CastleSiegeState (the original Season 6 values 0-9, which are
  exactly what the game client's CASTLESIEGE_STATE enum expects),
  CastleSiegeJoinSide, and the zone/NPC/upgrade definition types.
- Entities: CastleSiegeData, CastleSiegeGuildRegistration, CastleSiegeNpcState.
  These identify a guild by its persistent Guid rather than by name.
- Generated persistence: 8 BasicModel + 8 EntityFramework model classes,
  CastleSiegeExtensions, and the regenerated ExtendedTypeContext,
  MapsterConfigurator and GameConfiguration partials.
- Migrations: 20260730194321_AddCastleSiege and
  20260801162427_ConfigureCastleSiegePersistence, plus the model snapshot.
- EntityDataContext gains the two DbSets and the five model registrations.
- EntityFrameworkContextBase only publishes configuration changes for entities
  in the configuration schema, so siege state writes are no longer broadcast as
  configuration changes.

AdaMu-specific adaptations:

- UpdateVersion.AddCastleSiegeData is 105, not upstream's 100. AdaMu already
  ships 95-104, and the applied-update bookkeeping is keyed on this value, so a
  collision would skip or re-run updates on live databases.
- CastleSiegeInitializer does not seed a weekly StateSchedule. Upstream drives
  the cycle from a fixed Saturday schedule; AdaMu drives it manually from
  CastleSiegeEventPlugIn and the AdminPanel, so the schedule is left empty and
  nothing reads it.

The seeded NPC definitions match AdaMu's existing hard-coded coordinates
exactly (6 gates, the two crown switches and the crown), and additionally
provide 4 guardian statues, 6 guardsmen and real gate/statue hit point tables
that the current implementation does not have yet.

Two pre-existing migrations were restyled by upstream (copyright header, using
placement, trailing comma). No functional change.

Verified: full server build succeeds with 0 errors.
2026-08-04 03:28:10 +03:00
111 changed files with 18543 additions and 757 deletions

View File

@@ -9,7 +9,7 @@ services:
# sessizce calismaya devam eder (2026-07-31'de tam olarak bu oldu: sunucu 26 Temmuz
# image'ini calistiriyordu). SHA etiketi daha once gorulmedigi icin pull zorunlu olur.
# Yeni surum yayinlarken bu satiri yeni SHA ile guncelle.
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:452f0bb139de}
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:d60aad7d2f55}
container_name: openmu-startup
networks:
- coolify

View File

@@ -238,8 +238,10 @@ hunt the leader's maps. The elf heals, the buffs are shared, the party
experience bonus applies. Parties re-form every hour.
A player may invite a bot into their own party: it accepts after a human-like
pause of a few seconds, provided the level gap is sane and it is not in the
middle of an errand. A living player takes precedence over the bot's own company
pause of a few seconds, as long as it is not in the middle of an errand. There is
no level gate — just like OpenMU's own party action, a bot accepts an inviter of
any level, since it is the player who invites and the bot leaves once it gets
bored. A living player takes precedence over the bot's own company
— a bot hunting with other bots leaves them for the inviter, and breaks that bot
party up if it was leading it, so a player never has to guess which bot happens
to be free. In a party the bot follows its leader, defers a due reset, and

View File

@@ -1,12 +1,6 @@
@inherits LayoutComponentBase
<div class="page">
<!--
<div class="sidebar">
<NavMenu />
</div>
<BlazoredToasts />
-->
<main>
<div class="top-row px-4">
<BreadcrumbNavigation />

View File

@@ -9,9 +9,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.Shared

View File

@@ -0,0 +1,152 @@
// <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.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// Main configuration for the castle siege event.
/// </summary>
[Cloneable]
public partial class CastleSiegeConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the castle siege feature is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the number of seconds a guild must hold the crown to capture the castle.
/// </summary>
public int CrownHoldTimeSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the minimum combined level of a guild master required to register for the siege.
/// </summary>
public int RegisterMinLevel { get; set; } = 200;
/// <summary>
/// Gets or sets the minimum number of guild members required to register for the siege.
/// </summary>
public int RegisterMinMembers { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum number of seconds a participant must be present in the battle to be eligible for a reward.
/// </summary>
public int ParticipantRewardMinSeconds { get; set; }
/// <summary>
/// Gets or sets the maximum number of attacking alliance slots.
/// </summary>
public int MaxAttackingGuilds { get; set; } = 3;
/// <summary>
/// Gets or sets the guild score awarded to the guild that wins the siege.
/// </summary>
public int GuildScoreCastleSiege { get; set; }
/// <summary>
/// Gets or sets the guild score awarded to alliance member guilds of the winning side.
/// </summary>
public int GuildScoreCastleSiegeMembers { get; set; }
/// <summary>
/// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed gate.
/// </summary>
public int GateBuyPrice { get; set; }
/// <summary>
/// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed statue.
/// </summary>
public int StatueBuyPrice { get; set; }
/// <summary>
/// Gets or sets the map definition for the Valley of Loren (map 30), where the siege takes place.
/// </summary>
public virtual GameMapDefinition? CastleSiegeMapDefinition { get; set; }
/// <summary>
/// Gets or sets the map definition for the Land of Trials (map 31), the castle-owner's exclusive zone.
/// </summary>
public virtual GameMapDefinition? LandOfTrialsMapDefinition { get; set; }
/// <summary>
/// Gets or sets the item definition for the participation reward item.
/// </summary>
public virtual ItemDefinition? RewardItemDefinition { get; set; }
/// <summary>
/// Gets or sets the schedule entries that define when each siege state begins.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeStateScheduleEntry> StateSchedule { get; protected set; } = null!;
/// <summary>
/// Gets or sets the definitions for all castle siege NPCs (gates, statues, etc.).
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeNpcDefinition> NpcDefinitions { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for gate defense.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> GateDefenseUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for gate maximum HP.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> GateLifeUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue defense.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueDefenseUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue maximum HP.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueLifeUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue HP regeneration.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueRegenUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zones on the siege map where attacking siege machines may be placed.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeZoneDefinition> AttackMachineZones { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zones on the siege map where defensive siege machines may be placed.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeZoneDefinition> DefenseMachineZones { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zone where defending players respawn during the siege.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeZoneDefinition? DefenseRespawnArea { get; set; }
/// <summary>
/// Gets or sets the zone where attacking players respawn during the siege.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeZoneDefinition? AttackRespawnArea { get; set; }
/// <inheritdoc />
public override string ToString()
{
return "Castle Siege Configuration";
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="CastleSiegeJoinSide.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Defines the side (defending or attacking) a guild or NPC belongs to in the castle siege.
/// </summary>
public enum CastleSiegeJoinSide : byte
{
/// <summary>
/// No side assigned.
/// </summary>
None = 0,
/// <summary>
/// The defending guild side.
/// </summary>
Defense = 1,
/// <summary>
/// The first attacking alliance slot.
/// </summary>
Attack1 = 2,
/// <summary>
/// The second attacking alliance slot.
/// </summary>
Attack2 = 3,
/// <summary>
/// The third attacking alliance slot.
/// </summary>
Attack3 = 4,
}

View File

@@ -0,0 +1,55 @@
// <copyright file="CastleSiegeNpcDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a castle siege NPC instance, including its spawn location, side, and persistence settings.
/// </summary>
[Cloneable]
public partial class CastleSiegeNpcDefinition
{
/// <summary>
/// Gets or sets the monster definition template for this NPC.
/// </summary>
public virtual MonsterDefinition? MonsterDefinition { get; set; }
/// <summary>
/// Gets or sets the unique instance identifier within its NPC type.
/// </summary>
public byte InstanceId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this NPC's state is persisted to the database between sieges.
/// </summary>
public bool IsPersistedToDatabase { get; set; }
/// <summary>
/// Gets or sets the default join side this NPC belongs to.
/// </summary>
public CastleSiegeJoinSide DefaultSide { get; set; }
/// <summary>
/// Gets or sets the X coordinate of the NPC's spawn position.
/// </summary>
public byte SpawnX { get; set; }
/// <summary>
/// Gets or sets the Y coordinate of the NPC's spawn position.
/// </summary>
public byte SpawnY { get; set; }
/// <summary>
/// Gets or sets the facing direction of the NPC at spawn.
/// </summary>
public Direction Direction { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.MonsterDefinition} #{this.InstanceId} at ({this.SpawnX},{this.SpawnY})";
}
}

View File

@@ -0,0 +1,61 @@
// <copyright file="CastleSiegeState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The state of the castle siege event cycle.
/// </summary>
public enum CastleSiegeState : byte
{
/// <summary>
/// Idle state before guild registration opens.
/// </summary>
Idle1 = 0,
/// <summary>
/// Guilds may register for the siege.
/// </summary>
RegisterGuild = 1,
/// <summary>
/// Idle state after guild registration.
/// </summary>
Idle2 = 2,
/// <summary>
/// Guilds may register emblems (Marks of Lord) to determine the attacking guilds.
/// </summary>
RegisterMark = 3,
/// <summary>
/// Idle state after mark registration.
/// </summary>
Idle3 = 4,
/// <summary>
/// Players are notified that the siege is about to start.
/// </summary>
Notify = 5,
/// <summary>
/// The siege map is prepared and entry is allowed.
/// </summary>
Ready = 6,
/// <summary>
/// The siege battle is in progress.
/// </summary>
Start = 7,
/// <summary>
/// The siege battle has ended and results are being processed.
/// </summary>
End = 8,
/// <summary>
/// The full siege cycle has completed.
/// </summary>
EndCycle = 9,
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeStateScheduleEntry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a scheduled transition to a specific <see cref="CastleSiegeState"/> at a given day and time.
/// </summary>
[Cloneable]
public partial class CastleSiegeStateScheduleEntry
{
/// <summary>
/// Gets or sets the siege state that becomes active at the scheduled time.
/// </summary>
public CastleSiegeState State { get; set; }
/// <summary>
/// Gets or sets the day of the week on which this state transition occurs.
/// </summary>
public DayOfWeek DayOfWeek { get; set; }
/// <summary>
/// Gets or sets the hour (023) at which this state transition occurs.
/// </summary>
public byte Hour { get; set; }
/// <summary>
/// Gets or sets the minute (059) at which this state transition occurs.
/// </summary>
public byte Minute { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.State} on {this.DayOfWeek} at {this.Hour:D2}:{this.Minute:D2}";
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeUpgradeDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines one level of an upgrade that the castle owner can apply to a gate or statue NPC.
/// </summary>
[Cloneable]
public partial class CastleSiegeUpgradeDefinition
{
/// <summary>
/// Gets or sets the upgrade level (03), where 0 represents the base/unupgraded state.
/// </summary>
public byte Level { get; set; }
/// <summary>
/// Gets or sets the number of Jewels of Guardian required to perform this upgrade.
/// </summary>
public int RequiredJewelOfGuardianCount { get; set; }
/// <summary>
/// Gets or sets the amount of Zen required to perform this upgrade.
/// </summary>
public int RequiredZen { get; set; }
/// <summary>
/// Gets or sets the resulting stat value granted by this upgrade level (defense or max HP).
/// </summary>
public int Value { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"Level {this.Level}: Value={this.Value}, Jewels={this.RequiredJewelOfGuardianCount}, Zen={this.RequiredZen}";
}
}

View File

@@ -0,0 +1,31 @@
// <copyright file="CastleSiegeUpgradeType.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The type of upgrade applied to a castle siege NPC (gate or statue).
/// </summary>
public enum CastleSiegeUpgradeType : byte
{
/// <summary>
/// No upgrade type assigned.
/// </summary>
Undefined = 0,
/// <summary>
/// Increases the defense stat of the NPC.
/// </summary>
Defense = 1,
/// <summary>
/// Increases the HP regeneration rate of the NPC.
/// </summary>
Regen = 2,
/// <summary>
/// Increases the maximum HP of the NPC.
/// </summary>
Life = 3,
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeZoneDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a rectangular zone on the castle siege map, used for spawn areas and machine zones.
/// </summary>
[Cloneable]
public partial class CastleSiegeZoneDefinition
{
/// <summary>
/// Gets or sets the top-left X coordinate of the zone.
/// </summary>
public byte X1 { get; set; }
/// <summary>
/// Gets or sets the top-left Y coordinate of the zone.
/// </summary>
public byte Y1 { get; set; }
/// <summary>
/// Gets or sets the bottom-right X coordinate of the zone.
/// </summary>
public byte X2 { get; set; }
/// <summary>
/// Gets or sets the bottom-right Y coordinate of the zone.
/// </summary>
public byte Y2 { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.X1} / {this.Y1} to {this.X2} / {this.Y2}";
}
}

View File

@@ -300,6 +300,12 @@ public partial class GameConfiguration
[MemberOfAggregate]
public virtual ICollection<MiniGameDefinition> MiniGameDefinitions { get; protected set; } = null!;
/// <summary>
/// Gets or sets the castle siege configuration.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeConfiguration? CastleSiegeConfiguration { get; set; }
/// <inheritdoc />
public override string ToString()
{

View File

@@ -165,6 +165,16 @@ public enum NpcWindow
/// The dialog for the legacy quest system.
/// </summary>
LegacyQuest,
/// <summary>
/// The castle siege gate NPC interaction window.
/// </summary>
CastleSiegeGateNpc,
/// <summary>
/// The castle siege lever NPC interaction window.
/// </summary>
CastleSiegeLeverNpc,
}
/// <summary>

View File

@@ -0,0 +1,67 @@
// <copyright file="CastleSiegeData.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Persistent state of the castle siege, stored as a single row across siege cycles.
/// </summary>
[AggregateRoot]
public class CastleSiegeData
{
/// <summary>
/// Gets or sets the unique identifier of this record.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the persistent identifier of the guild that currently owns the castle.
/// <see langword="null"/> when no guild owns the castle.
/// </summary>
public Guid? OwnerGuildId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether any guild currently occupies the castle.
/// </summary>
public bool IsOccupied { get; set; }
/// <summary>
/// Gets or sets the Chaos Machine tax rate applied by the castle owner (03).
/// </summary>
public byte TaxChaos { get; set; }
/// <summary>
/// Gets or sets the personal store tax rate applied by the castle owner (03).
/// </summary>
public byte TaxStore { get; set; }
/// <summary>
/// Gets or sets the entry fee (in Zen) for the castle owner's hunt zone (0300000).
/// </summary>
public int TaxHunt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the hunt zone (Land of Trials) is currently open to the public.
/// </summary>
public bool IsHuntZoneEnabled { get; set; }
/// <summary>
/// Gets or sets the accumulated tribute money collected from the hunt zone and taxes.
/// </summary>
public long TributeMoney { get; set; }
/// <summary>
/// Gets or sets the persisted states of all castle NPCs.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeNpcState> NpcStates { get; protected set; } = null!;
/// <inheritdoc />
public override string ToString()
{
return this.IsOccupied
? $"Castle owned by guild {this.OwnerGuildId}"
: "Castle unoccupied";
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CastleSiegeGuildRegistration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Stores a guild's registration data for the current castle siege cycle,
/// including the number of emblems submitted to determine attacking guilds.
/// </summary>
[AggregateRoot]
public class CastleSiegeGuildRegistration
{
/// <summary>
/// Gets or sets the unique identifier of this registration record.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the persistent identifier of the registered guild.
/// </summary>
public Guid GuildId { get; set; }
/// <summary>
/// Gets or sets the guild name, denormalized for convenience to avoid extra lookups during siege processing.
/// </summary>
public string GuildName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of Emblems of Lord registered by this guild.
/// </summary>
public int Marks { get; set; }
/// <summary>
/// Gets or sets the insertion order of this registration, used for tie-breaking when guilds have equal marks.
/// </summary>
public int RegistrationOrder { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.GuildName} (Marks={this.Marks}, Order={this.RegistrationOrder})";
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="CastleSiegeNpcState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Persistent state of a single castle siege NPC between siege cycles.
/// </summary>
public class CastleSiegeNpcState
{
/// <summary>
/// Gets or sets the unique identifier of this NPC state.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the monster definition number that identifies the NPC template.
/// </summary>
public short MonsterNumber { get; set; }
/// <summary>
/// Gets or sets the instance identifier matching <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition.InstanceId"/>.
/// </summary>
public byte InstanceId { get; set; }
/// <summary>
/// Gets or sets the current defense upgrade level (03).
/// </summary>
public byte DefenseLevel { get; set; }
/// <summary>
/// Gets or sets the current HP regeneration upgrade level (03).
/// </summary>
public byte RegenLevel { get; set; }
/// <summary>
/// Gets or sets the current maximum HP upgrade level (03).
/// </summary>
public byte LifeLevel { get; set; }
/// <summary>
/// Gets or sets the current HP of the NPC. A value of 0 means the NPC is destroyed.
/// </summary>
public int CurrentHp { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"NPC {this.MonsterNumber} #{this.InstanceId} (HP={this.CurrentHp})";
}
}

View File

@@ -6,7 +6,6 @@
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BlazorInputFile" Version="0.2.0" />
<PackageVersion Include="Blazored.Toast" Version="4.2.1" />
<PackageVersion Include="BuildWebCompiler2022" Version="1.14.15" />
<PackageVersion Include="DG.AdvancedDataGridView" Version="1.2.30115.18" />
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.1" />

View File

@@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline;
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): the invitation is accepted after a short
/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
/// in <see cref="BotNavigator"/>) until it gets bored and politely leaves. Safeguards keep it
/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is
/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is
/// re-validated when the delay has passed - the inviter may have joined another party or left.
/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has
/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the
/// inviter may have joined another party or left. There is no level gate, matching OpenMU's own party
/// action: it is the player who invites, and the bot leaves again once it gets bored.
/// </summary>
internal static class BotPartyHandler
{
/// <summary>
/// The maximum difference of the reset-aware effective level (see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>) between the bot and the inviter. Within one
/// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a
/// fresh character with a 15-resets veteran would only be a power-leveling service. On servers
/// without the reset feature the plain levels always lie within this bound, matching OpenMU's own
/// party action, which has no level gate at all.
/// </summary>
private const int MaxEffectiveLevelGap = 500;
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
@@ -67,7 +58,7 @@ internal static class BotPartyHandler
return false;
}
if (!IsRequesterEligible(bot, requester))
if (!IsRequesterEligible(requester))
{
return false;
}
@@ -141,7 +132,7 @@ internal static class BotPartyHandler
{
// Re-validate: between the invitation and this answer, the bot may have joined a human's party
// and the inviter may have died, left the game or joined another party.
if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester))
if (HasHumanCompanion(bot) || !IsRequesterEligible(requester))
{
bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
return;
@@ -209,14 +200,8 @@ internal static class BotPartyHandler
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
private static bool IsRequesterEligible(OfflinePlayer bot, Player requester)
private static bool IsRequesterEligible(Player requester)
{
if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld)
{
return false;
}
var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester));
return levelGap <= MaxEffectiveLevelGap;
return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld;
}
}

View File

@@ -4,109 +4,151 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// In-memory Castle Siege phase state machine and battle contention (P3).
/// In-memory Castle Siege state machine and battle contention.
/// 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.
/// <para>
/// The cycle uses the original Season 6 <see cref="CastleSiegeState"/> values, which are exactly the values
/// the game client expects (see <c>CASTLESIEGE_STATE</c> in the client's <c>WSclient.h</c>). AdaMu drives only
/// a subset of them, because it registers guilds directly and has no Mark of Lord step:
/// </para>
/// <code>
/// Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0)
/// </code>
/// <para>
/// The skipped states (<see cref="CastleSiegeState.Idle2"/>, <see cref="CastleSiegeState.RegisterMark"/>,
/// <see cref="CastleSiegeState.Idle3"/>, <see cref="CastleSiegeState.Notify"/>) keep their numbers so the
/// client stays compatible; the server simply never enters them.
/// </para>
/// <para>
/// Guilds are identified by their persistent <see cref="Guid"/>, not by name. A guild rename (or a delete and
/// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names
/// are carried alongside purely for display and for the packets that send a name to the client.
/// </para>
/// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is
/// operated by clicking it and then staying in its area: the operation needs
/// <see cref="CastleSiegeSettings.SwitchPushSeconds"/> to complete, after which the switch counts as held
/// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its
/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the
/// switches do; whoever holds it 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 readonly Dictionary<Guid, string> _registeredGuilds = new();
private readonly Dictionary<short, CastleSiegeSwitchOperation?> _switches = new() { { 217, null }, { 218, null } };
private DateTime _stateStartedUtc;
private Guid? _occupier;
private string? _occupierName;
private int _defensesRemaining;
private bool _dirty;
private string? _crownHoldGuild;
private Guid? _crownHoldGuild;
private DateTime? _crownHoldStartUtc;
private Guid? _crownHoldRequestedBy;
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)
public CastleSiegeContext(CastleSiegeSettings configuration)
{
this.Configuration = configuration;
this.Phase = CastleSiegePhase.Ownership;
this.State = CastleSiegeState.Idle1;
}
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
public event Action<CastleSiegePhase>? PhaseChanged;
/// <summary>Raised after the state changes. Argument is the new state.</summary>
public event Action<CastleSiegeState>? StateChanged;
/// <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; }
public CastleSiegeSettings 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 state.</summary>
public CastleSiegeState State { get; private set; }
/// <summary>Gets the current phase.</summary>
public CastleSiegePhase Phase { get; private set; }
/// <summary>Gets the UTC time the current state started (used for persistence/restore).</summary>
public DateTime StateStartedUtc => this._stateStartedUtc;
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary>
public DateTime PhaseStartedUtc => this._phaseStartedUtc;
/// <summary>Gets the persistent identifier of the owner guild, or <see langword="null"/> if unowned.</summary>
public Guid? OwnerGuildId { get; private set; }
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
/// <summary>Gets the owner guild's name for display and client packets, or null.</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 guild currently holding the throne during the siege, or null.</summary>
public Guid? OccupierGuildId => this._occupier;
/// <summary>Gets the throne holder's name for display and client packets, or null.</summary>
public string? OccupierGuildName => this._occupierName;
/// <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>Gets the persistent identifiers of the guilds registered for the current cycle.</summary>
public IReadOnlyCollection<Guid> RegisteredGuildIds => this._registeredGuilds.Keys;
/// <summary>Gets the names of the guilds registered for the current cycle (display only).</summary>
public IReadOnlyCollection<string> RegisteredGuildNames => this._registeredGuilds.Values;
/// <summary>Gets a value indicating whether the siege battle is currently running.</summary>
public bool IsSiegeRunning => this.State == CastleSiegeState.Start;
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
/// <param name="configuration">The live configuration.</param>
public void UpdateConfiguration(CastleSiegeSettings configuration) => this.Configuration = configuration;
/// <summary>Returns whether the given guild is registered for the current cycle.</summary>
/// <param name="guildId">The persistent guild identifier.</param>
public bool IsRegistered(Guid guildId) => this._registeredGuilds.ContainsKey(guildId);
/// <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)
switch (this.State)
{
case CastleSiegePhase.Ownership:
case CastleSiegeState.Idle1:
if (this.Configuration.IsRegistrationOpenTime(now))
{
return this.ForceStartRegistrationAsync(now);
}
break;
case CastleSiegePhase.Registration:
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
case CastleSiegeState.RegisterGuild:
if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
return this.TransitionAsync(CastleSiegeState.Ready, now);
}
break;
case CastleSiegePhase.Preparation:
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
case CastleSiegeState.Ready:
if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Siege, now);
return this.TransitionAsync(CastleSiegeState.Start, now);
}
break;
case CastleSiegePhase.Siege:
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
case CastleSiegeState.Start:
if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration)
{
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
return this.TransitionAsync(CastleSiegeState.End, now);
}
break;
case CastleSiegePhase.Settlement:
case CastleSiegeState.End:
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier);
this.SetOwner(occupier, this._occupierName);
}
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
return this.TransitionAsync(CastleSiegeState.EndCycle, now);
case CastleSiegeState.EndCycle:
return this.TransitionAsync(CastleSiegeState.Idle1, now);
default:
break;
}
@@ -114,62 +156,67 @@ public class CastleSiegeContext
return ValueTask.CompletedTask;
}
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
/// <summary>Admin: forces the cycle into guild registration now (from any state).</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);
return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
}
/// <summary>Admin: forces a specific phase now.</summary>
/// <param name="phase">The target phase.</param>
/// <summary>Admin: forces a specific state now.</summary>
/// <param name="state">The target state.</param>
/// <param name="now">The current UTC time.</param>
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
=> this.TransitionAsync(phase, now);
public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
=> this.TransitionAsync(state, now);
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
/// <summary>Admin: resets to the idle (resting) state 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);
return this.TransitionAsync(CastleSiegeState.Idle1, 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)
/// <summary>Registers a guild for the current cycle. No-op outside the registration state.</summary>
/// <param name="guildId">The persistent guild identifier.</param>
/// <param name="guildName">The guild name, for display.</param>
public void RegisterGuild(Guid guildId, string guildName)
{
if (this.Phase == CastleSiegePhase.Registration
&& !this._registeredGuilds.Contains(guildName))
if (this.State == CastleSiegeState.RegisterGuild
&& this._registeredGuilds.TryAdd(guildId, 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)
/// <summary>Admin: sets (or clears) the current owner guild.</summary>
/// <param name="guildId">The owner guild identifier, or null to clear.</param>
/// <param name="guildName">The owner guild name, or null.</param>
public void SetOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildName = guildName;
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
this._dirty = true;
}
/// <summary>
/// Mirrors the shared castle owner from the configuration. Used on game servers that do NOT host the
/// Mirrors the shared castle owner loaded from the database. 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()
/// <param name="guildId">The owner guild identifier, or null.</param>
/// <param name="guildName">The owner guild name, or null.</param>
public void SyncOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
}
/// <summary>
/// Sets the weekly auto-schedule (days of week + UTC time) into the configuration and marks the state
/// Sets the auto-open 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>
@@ -192,83 +239,127 @@ public class CastleSiegeContext
/// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
{
if (this.Phase != CastleSiegePhase.Siege)
if (!this.IsSiegeRunning)
{
return TimeSpan.Zero;
}
var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
var remaining = (this._stateStartedUtc + 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.
/// Returns how much time is left in the current state, or <see cref="TimeSpan.Zero"/> when the state has
/// no duration (idle states wait for an admin command or the auto-open time).
/// </summary>
public string? GetShieldEligibleGuild()
/// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingStateTime(DateTime nowUtc)
{
if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
var duration = this.State switch
{
CastleSiegeState.RegisterGuild => this.Configuration.RegistrationDuration,
CastleSiegeState.Ready => this.Configuration.PreparationDuration,
CastleSiegeState.Start => this.Configuration.SiegeDuration,
_ => TimeSpan.Zero,
};
if (duration == TimeSpan.Zero)
{
return TimeSpan.Zero;
}
var remaining = (this._stateStartedUtc + duration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>
/// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it,
/// or null. Only meaningful during the siege.
/// </summary>
public Guid? GetShieldEligibleGuild()
{
if (!this.IsSiegeRunning)
{
return null;
}
var holder = this._switchHolders[217];
return holder is not null && holder == this._switchHolders[218] ? holder : null;
var first = this.GetHeldSwitchGuild(217);
return first is not null && first == this.GetHeldSwitchGuild(218) ? first : 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).
/// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
/// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
/// hold both switches, so a click can never arm a hold the guild isn't entitled to.
/// </summary>
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
/// <param name="guildId">The requesting guild master's guild identifier.</param>
/// <returns><see langword="true"/> if the request was accepted.</returns>
public bool RequestCrownHold(Guid guildId)
{
if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId)
{
return false;
}
this._crownHoldRequestedBy = guildId;
return true;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild holding both switches
/// (shield down) and <paramref name="masterHolding"/> is whether that guild's master stands on the crown.
/// The hold only runs after the master requested it via <see cref="RequestCrownHold"/>; it captures the
/// throne once it ran for <paramref name="holdDuration"/>. Losing a switch or the master leaving the crown
/// resets the hold, and the crown has to be clicked again (contestable until the siege ends).
/// </summary>
/// <param name="eligibleGuild">The guild holding both switches, or null.</param>
/// <param name="eligibleGuildName">That guild's name, for display.</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)
public CrownTickResult TickCrownHold(Guid? eligibleGuild, string? eligibleGuildName, bool masterHolding, DateTime now, TimeSpan holdDuration)
{
var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null;
var shieldDown = this.IsSiegeRunning && eligibleGuild is not null;
var shieldChanged = shieldDown != this._lastShieldDown;
this._lastShieldDown = shieldDown;
if (this.Phase != CastleSiegePhase.Siege)
if (!this.IsSiegeRunning)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null);
this.ResetCrownHold();
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, 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).
// 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;
var canCapture = eligibleGuild is not null
&& eligibleGuild != this._occupier
&& this._crownHoldRequestedBy == eligibleGuild;
if (!canCapture || !masterHolding)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
this.ResetCrownHold();
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
}
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
{
this._crownHoldGuild = eligibleGuild;
this._crownHoldStartUtc = now;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild, eligibleGuildName);
}
if (now - this._crownHoldStartUtc.Value >= holdDuration)
{
this._occupier = eligibleGuild;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._occupierName = eligibleGuildName;
this.ResetCrownHold();
this._dirty = true;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
}
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild, eligibleGuildName);
}
/// <summary>
@@ -283,22 +374,27 @@ public class CastleSiegeContext
}
/// <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.
/// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
/// firing <see cref="StateChanged"/> 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)
/// <param name="ownerGuildId">The persisted owner guild identifier, or null.</param>
/// <param name="ownerGuildName">The persisted owner guild name, or null.</param>
/// <param name="state">The persisted state.</param>
/// <param name="stateStartedUtc">When the persisted state started (UTC), or null to keep the default.</param>
/// <param name="registeredGuilds">The persisted registrations (id to name), or null.</param>
public void RestoreState(Guid? ownerGuildId, string? ownerGuildName, CastleSiegeState state, DateTime? stateStartedUtc, IEnumerable<KeyValuePair<Guid, string>>? registeredGuilds)
{
this.OwnerGuildName = owner;
this.Phase = phase;
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc;
this.OwnerGuildId = ownerGuildId;
this.OwnerGuildName = ownerGuildId is null ? null : ownerGuildName;
this.State = state;
this._stateStartedUtc = stateStartedUtc ?? this._stateStartedUtc;
this._registeredGuilds.Clear();
if (registeredGuilds is not null)
{
this._registeredGuilds.AddRange(registeredGuilds);
foreach (var registration in registeredGuilds)
{
this._registeredGuilds[registration.Key] = registration.Value;
}
}
this._dirty = false;
@@ -317,79 +413,124 @@ public class CastleSiegeContext
}
}
/// <summary>Returns who is currently operating a Crown Switch, or <see langword="null"/>.</summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber)
=> this._switches.TryGetValue(switchNumber, out var operation) ? operation : null;
/// <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.
/// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
/// player at a time: while somebody else is on it, the click is refused and the caller is told who holds
/// it, which is what the client shows as "another siege team is running the crown switch".
/// </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)
/// <param name="guildId">The clicking player's guild identifier.</param>
/// <param name="guildName">The clicking player's guild name, for display.</param>
/// <param name="playerId">The clicking player's object identifier on the map.</param>
/// <param name="playerName">The clicking player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="now">The current UTC time.</param>
/// <returns>The outcome, and the current operation when the switch is taken.</returns>
public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation(
short switchNumber,
Guid guildId,
string guildName,
ushort playerId,
string playerName,
ushort switchObjectId,
DateTime now)
{
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber))
{
this._switchHolders[switchNumber] = guildName;
return (CastleSiegeSwitchPush.SiegeNotRunning, null);
}
if (this._switches[switchNumber] is { } current)
{
return current.PlayerId == playerId
? (CastleSiegeSwitchPush.AlreadyYours, current)
: (CastleSiegeSwitchPush.TakenByOther, current);
}
var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now);
this._switches[switchNumber] = operation;
return (CastleSiegeSwitchPush.Started, operation);
}
/// <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).
/// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
/// area, and completes - which makes the switch count for the guild - once it ran <paramref name="pushDuration"/>.
/// </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)
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="operatorPresent">Whether the operating player is still in the switch's area.</param>
/// <param name="now">The current UTC time.</param>
/// <param name="pushDuration">How long operating the switch takes.</param>
/// <returns>What happened to the switch in this tick, and the operation it happened to.</returns>
public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch(
short switchNumber,
bool operatorPresent,
DateTime now,
TimeSpan pushDuration)
{
if (this.Phase != CastleSiegePhase.Siege)
if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null)
{
return (false, "The siege is not running.");
return (CastleSiegeSwitchEvent.None, null);
}
if (this._occupier is not null)
if (!this.IsSiegeRunning || !operatorPresent)
{
return (false, this._occupier == guildName
? "Your guild already holds the throne."
: $"The throne is already held by '{this._occupier}'.");
this._switches[switchNumber] = null;
return (CastleSiegeSwitchEvent.Released, operation);
}
if (this._defensesRemaining > 0)
if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration)
{
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
operation.MarkHeld();
return (CastleSiegeSwitchEvent.Held, operation);
}
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");
return (CastleSiegeSwitchEvent.None, operation);
}
/// <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] ?? "-"}";
=> $"CS state={this.State}({(int)this.State}), owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds.Values)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
private Guid? GetHeldSwitchGuild(short switchNumber)
=> this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null;
private string DescribeSwitch(short switchNumber)
=> this._switches[switchNumber] is { } operation
? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}"
: "-";
private void ResetCrownHold()
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._crownHoldRequestedBy = null;
}
private void ClearBattleState()
{
this._switchHolders[217] = null;
this._switchHolders[218] = null;
this._switches[217] = null;
this._switches[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._occupierName = null;
this.ResetCrownHold();
this._lastShieldDown = false;
}
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
private ValueTask TransitionAsync(CastleSiegeState state, DateTime now)
{
this.Phase = phase;
this._phaseStartedUtc = now;
this.State = state;
this._stateStartedUtc = now;
this._dirty = true;
this.PhaseChanged?.Invoke(phase);
this.StateChanged?.Invoke(state);
return ValueTask.CompletedTask;
}
}
@@ -414,5 +555,6 @@ public enum CrownEvent
/// <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);
/// <param name="GuildId">The guild the event refers to, if any.</param>
/// <param name="GuildName">That guild's name, for display and client packets.</param>
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, Guid? GuildId, string? GuildName);

View File

@@ -49,7 +49,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
if (context.Phase != CastleSiegePhase.Registration)
if (context.State != CastleSiegeState.RegisterGuild)
{
await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false);
return;
@@ -61,17 +61,15 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
// Registrations are keyed on the guild's persistent id, so a later rename cannot detach them.
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
await ShowAsync(player, "Your guild could not be resolved. Please try again in a moment.").ConfigureAwait(false);
return;
}
if (context.RegisteredGuilds.Contains(guildName))
var guildName = guild.Name;
if (context.IsRegistered(guild.Id))
{
await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
return;
@@ -84,7 +82,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
context.RegisterGuild(guildName);
context.RegisterGuild(guild.Id, 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);

View File

@@ -1,26 +0,0 @@
// <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

@@ -1,4 +1,4 @@
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
// <copyright file="CastleSiegeSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
@@ -7,15 +7,31 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
using MUnique.OpenMU.DataModel.Configuration;
/// <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.
/// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so
/// it is editable in the AdminPanel and needs no dedicated database table.
/// <para>
/// This is deliberately separate from <see cref="DataModel.Configuration.CastleSiegeConfiguration"/>, which is
/// the upstream, database-backed configuration holding the NPC/zone/upgrade definitions and the crown hold
/// time. Keeping AdaMu's operational knobs out of that entity means upstream schema changes apply cleanly and
/// no hand-editing of the generated persistence code is needed.
/// </para>
/// <para>
/// What lives where:
/// <list type="bullet">
/// <item>Castle owner and guild registrations: database (<c>CastleSiegeData</c>, <c>CastleSiegeGuildRegistration</c>).</item>
/// <item>NPC/zone/upgrade definitions and crown hold time: database (<c>GameConfiguration.CastleSiegeConfiguration</c>).</item>
/// <item>Cycle durations, registration fee, designated server and the current state: here.</item>
/// </list>
/// </para>
/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the
/// current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute durations) are proxies over the runtime
/// fields, which are hidden from the editor to keep the form clean.
/// </summary>
public class CastleSiegeConfiguration
public class CastleSiegeSettings
{
/// <summary>
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a
@@ -42,7 +58,7 @@ public class CastleSiegeConfiguration
/// <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).
/// Empty = no auto-start (admins start cycles manually via the chat command or the AdminPanel).
/// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
@@ -70,17 +86,6 @@ public class CastleSiegeConfiguration
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.
@@ -112,29 +117,23 @@ public class CastleSiegeConfiguration
[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);
/// <summary>
/// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
/// The player has to stay in the switch's area for that long, and keeps it until they leave.
/// </summary>
public int SwitchPushSeconds { get; set; } = 15;
// --- 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.
// --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
// Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
// owner and the guild registrations live in real database tables, so they are not duplicated here.
/// <summary>Gets or sets the persisted castle owner guild name (null = unowned).</summary>
/// <summary>Gets or sets the persisted current state, so the cycle resumes after a restart.</summary>
[Browsable(false)]
public string? PersistedOwnerGuildName { get; set; }
public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1;
/// <summary>Gets or sets the persisted current phase, so the cycle resumes after a restart.</summary>
/// <summary>Gets or sets when the persisted state started (UTC), or null if never persisted.</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>();
public DateTime? PersistedStateStartedUtc { get; set; }
/// <summary>
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls

View File

@@ -0,0 +1,20 @@
// <copyright file="CastleSiegeSwitchEvent.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>
/// What happened to a Crown Switch during one tick.
/// </summary>
public enum CastleSiegeSwitchEvent
{
/// <summary>Nothing worth reporting.</summary>
None,
/// <summary>The operation completed, so the switch now counts for the operator's guild.</summary>
Held,
/// <summary>The operator left (or the siege ended), so the switch is free again.</summary>
Released,
}

View File

@@ -0,0 +1,55 @@
// <copyright file="CastleSiegeSwitchOperation.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>
/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by
/// staying in its area; the switch counts as held for the guild once the operation has run its time.
/// </summary>
public class CastleSiegeSwitchOperation
{
/// <summary>Initializes a new instance of the <see cref="CastleSiegeSwitchOperation"/> class.</summary>
/// <param name="guildId">The operating player's guild identifier.</param>
/// <param name="guildName">The operating player's guild name, for display.</param>
/// <param name="playerId">The operating player's object identifier on the map.</param>
/// <param name="playerName">The operating player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="startedUtc">When the operation started (UTC).</param>
public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc)
{
this.GuildId = guildId;
this.GuildName = guildName;
this.PlayerId = playerId;
this.PlayerName = playerName;
this.SwitchObjectId = switchObjectId;
this.StartedUtc = startedUtc;
}
/// <summary>Gets the operating player's guild identifier.</summary>
public Guid GuildId { get; }
/// <summary>Gets the operating player's guild name.</summary>
public string GuildName { get; }
/// <summary>Gets the operating player's object identifier on the map.</summary>
public ushort PlayerId { get; }
/// <summary>Gets the operating player's name.</summary>
public string PlayerName { get; }
/// <summary>Gets the switch NPC's object identifier on the map, which the client's packets refer to.</summary>
public ushort SwitchObjectId { get; }
/// <summary>Gets the point in time (UTC) when the operation started.</summary>
public DateTime StartedUtc { get; }
/// <summary>
/// Gets a value indicating whether the operation ran its time, so the switch counts for the guild.
/// </summary>
public bool IsHeld { get; private set; }
/// <summary>Marks the operation as completed, which makes the switch count for the guild.</summary>
internal void MarkHeld() => this.IsHeld = true;
}

View File

@@ -0,0 +1,23 @@
// <copyright file="CastleSiegeSwitchPush.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 outcome of a player clicking a Crown Switch.
/// </summary>
public enum CastleSiegeSwitchPush
{
/// <summary>The player started operating the switch.</summary>
Started,
/// <summary>The player is already operating this switch.</summary>
AlreadyYours,
/// <summary>Somebody else is operating this switch.</summary>
TakenByOther,
/// <summary>The siege is not running, so the switches do nothing.</summary>
SiegeNotRunning,
}

View File

@@ -0,0 +1,92 @@
// <copyright file="CastleSiegeSwitchTalkPlugIn.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.GameLogic.Views.CastleSiege;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the
/// switch, which the client shows as a progress box; the switch counts for the guild once the operation
/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can
/// operate a switch at a time - anybody else clicking it is told that another team is on it.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")]
[PlugIn]
[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")]
public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn
{
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number))
{
return;
}
// We drive the switch ourselves, 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 (!context.IsSiegeRunning)
{
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
return;
}
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild
|| !context.IsRegistered(guild.Id))
{
await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false);
return;
}
var (result, operation) = context.TryStartSwitchOperation(
npc.Definition.Number,
guild.Id,
guild.Name,
player.Id,
player.Name,
npc.Id,
DateTime.UtcNow);
switch (result)
{
case CastleSiegeSwitchPush.Started:
// The info packet goes first: it is what makes every client allocate its switch table, which
// the "switch released" packet later reads without checking that it exists.
await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.TakenByOther when operation is { } other:
// State 2 makes the client name the player who is already on it.
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.AlreadyYours:
break;
default:
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
break;
}
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -43,56 +43,58 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
if (player.GuildStatus is not { } guildStatus)
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
{
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))
if (!context.IsRegistered(guild.Id))
{
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)
// Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
// stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing.
var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster;
if (isGuildMaster && context.RequestCrownHold(guild.Id))
{
return "The siege is not running yet.";
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
return;
}
if (context.DefensesRemaining > 0)
await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
{
if (!context.IsSiegeRunning)
{
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
return "The siege is not running yet.";
}
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.";
return context.DefensesRemaining > 0
? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)."
: "Hold BOTH Crown Switches with your guild - the Crown's shield will drop.";
}
if (eligible == guildName)
if (eligible != guildId)
{
return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!";
return "Another guild is holding both switches. Take a switch back to raise their shield.";
}
return $"Guild '{eligible}' is holding both switches. Take a switch back to raise their shield.";
if (context.OccupierGuildId == guildId)
{
return "Your guild already holds the throne - keep it until the siege ends.";
}
return isGuildMaster
? "Your guild holds both switches, but the Crown cannot be registered right now."
: "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!";
}
private static ValueTask ShowAsync(Player player, string text)

View File

@@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
// Run the whole tick under the player's persistence lock so its structural mutations
// (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap
// this bot's periodic progress save, which runs on a separate timer. The tick has no
// internal delays, so the lock is held only for its brief duration.
await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)

View File

@@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private readonly AsyncLock _moveLock = new();
private readonly AsyncLock _experienceLock = new();
/// <summary>
/// Serializes context mutations done by this player's action handlers against the periodic and
/// disconnect progress saves, which run on an independent timer flow. See
/// <see cref="RunPersistenceExclusiveAsync{T}"/>.
/// </summary>
private readonly AsyncLock _persistenceLock = new();
/// <summary>
/// Tracks, per asynchronous flow, whether <see cref="_persistenceLock"/> is already held, so the
/// lock can be re-entered (Nito's <see cref="AsyncLock"/> is not reentrant). It is an instance
/// field on purpose: reentrancy must be tracked per player, so a flow holding player A's lock
/// still acquires player B's lock (e.g. during a trade) instead of wrongly skipping it.
/// </summary>
private readonly AsyncLocal<bool> _persistenceLockHeld = new();
private readonly Walker _walker;
private readonly AppearanceDataAdapter _appearanceData;
@@ -717,7 +732,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
// 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;
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.IsSiegeRunning == true;
/// <inheritdoc/>
public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
@@ -1879,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <returns>Success of the save operation.</returns>
public async ValueTask<bool> SaveProgressAsync(CancellationToken cancellationToken = default)
{
if (!this.IsTemplatePlayer)
if (this.IsTemplatePlayer)
{
return await this.PersistenceContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
return true;
return await this.RunPersistenceExclusiveAsync(
() => this.PersistenceContext.SaveChangesAsync(cancellationToken),
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock, so that context
/// mutations and progress saves for the player never run concurrently.
/// </summary>
/// <remarks>
/// The periodic progress save (<see cref="PlugIns.PeriodicSaveProgressPlugIn"/>) runs on an
/// independent timer flow. Action handlers mutate tracked entities with plain field/collection
/// writes (e.g. crafting toggling <c>item.ItemOptions</c>) which bypass the persistence context's
/// own lock; if such a mutation runs while <see cref="IContext.SaveChangesAsync"/> enumerates the
/// change tracker, the save throws (collection-modified / DbUpdateConcurrency) and every following
/// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel
/// and the save against each other closes that window. The lock is re-entrant per asynchronous
/// flow, so an inline save inside an already-serialized handler does not deadlock.
/// <para>
/// Invariant: never acquire another player's persistence lock (via their
/// <see cref="SaveProgressAsync"/> or <see cref="RunPersistenceExclusiveAsync{T}"/>) from inside a
/// packet handler, which already holds this player's lock, unless a global lock order is enforced.
/// Today only the trade accept does a cross-player save, and it cannot form a cycle because a trade
/// has a single accepting side (so the A-then-B acquisition order has no concurrent B-then-A
/// counterpart). A second cross-player caller with the opposite order could deadlock.
/// </para>
/// </remarks>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The result of the operation.</returns>
public async ValueTask<T> RunPersistenceExclusiveAsync<T>(Func<ValueTask<T>> operation, CancellationToken cancellationToken = default)
{
if (this._persistenceLockHeld.Value)
{
return await operation().ConfigureAwait(false);
}
using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
this._persistenceLockHeld.Value = true;
try
{
return await operation().ConfigureAwait(false);
}
finally
{
this._persistenceLockHeld.Value = false;
}
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock.
/// See <see cref="RunPersistenceExclusiveAsync{T}"/> for the rationale.
/// </summary>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A value task which completes when the operation completed.</returns>
public async ValueTask RunPersistenceExclusiveAsync(Func<ValueTask> operation, CancellationToken cancellationToken = default)
{
if (this._persistenceLockHeld.Value)
{
await operation().ConfigureAwait(false);
return;
}
using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
this._persistenceLockHeld.Value = true;
try
{
await operation().ConfigureAwait(false);
}
finally
{
this._persistenceLockHeld.Value = false;
}
}
/// <summary>

View File

@@ -14,7 +14,7 @@ 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>")]
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Idle1|RegisterGuild|Ready|Start|End|EndCycle>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{
@@ -30,9 +30,11 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
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))
// The cycle uses the original Season 6 state names the client knows. AdaMu drives only this subset;
// Idle2, RegisterMark, Idle3 and Notify exist for client compatibility but are never entered.
if (parts.Length < 2 || !Enum.TryParse<CastleSiegeState>(parts[1], true, out var phase))
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Ownership|Registration|Preparation|Siege|Settlement>", MessageType.BlueNormal)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Idle1|RegisterGuild|Ready|Start|End|EndCycle>", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -43,7 +45,7 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
return;
}
await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
await context.ForceStateAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -38,7 +38,22 @@ public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
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);
if (string.IsNullOrWhiteSpace(owner))
{
context.SetOwner(null, null);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege owner cleared.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
// Ownership is stored by the guild's persistent id, so the name given here is resolved once.
var guildId = await CastleSiegeEventPlugIn.ResolveGuildIdByNameAsync(player.GameContext, owner).ConfigureAwait(false);
if (guildId is not { } id)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"No guild named '{owner}' was found.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
context.SetOwner(id, owner);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using System.Collections.Concurrent;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views;
@@ -15,65 +16,69 @@ using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
using CastleSiegeDefinition = MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration;
/// <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.
/// Drives the Castle Siege state machine: ticks it every second and carries its operational settings.
/// <para>
/// The cycle uses the original Season 6 state numbers the client expects. The castle owner and the guild
/// registrations are persisted in real database tables (<see cref="CastleSiegeData"/> and
/// <see cref="CastleSiegeGuildRegistration"/>) and keyed by the guild's persistent <see cref="Guid"/>, so a
/// guild rename can no longer move castle ownership to the wrong guild. Only the current state and when it
/// started ride on the plugin's custom-configuration JSON.
/// </para>
/// <para>
/// Castle NPCs (gates, statues, catapults, the crown and its switches) are read from
/// <see cref="GameConfiguration.CastleSiegeConfiguration"/>, which the CastleSiegeInitializer seeds, instead
/// of being hard-coded here.
/// </para>
/// When the siege starts, registered guild members are warped to the Valley of Loren battle map.
/// </summary>
[PlugIn]
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (phase state machine, scheduling, siege warp).")]
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (state machine, scheduling, siege warp).")]
[Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")]
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeSettings>, 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 short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for the CS gates.
private const short CrownNumber = 216;
private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK
private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE
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;
/// <summary>How many ticks (the periodic task runs once per second) between two countdown broadcasts.</summary>
private const int SiegeStateBroadcastTicks = 10;
/// <summary>How many ticks between two castle-flag broadcasts.</summary>
private const int CastleFlagBroadcastTicks = 15;
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
private string? _cachedFlagOwner;
/// <summary>
/// The player whose client currently shows the crown registration panel, per game context. The panel is
/// opened for exactly one guild master, and it has to be closed for that same player - by the time the
/// hold breaks they are usually no longer on the crown, so they can't be found by position any more.
/// </summary>
private static readonly ConcurrentDictionary<IGameContext, Player> CrownHoldPlayers = new();
/// <summary>
/// Tick counters per game context, used to space out the periodic broadcasts. Counting ticks (instead of
/// matching a clock second) keeps a broadcast from being skipped when a tick runs late.
/// </summary>
private static readonly ConcurrentDictionary<IGameContext, BroadcastCounters> Counters = new();
/// <summary>
/// Maps the in-memory guild id (assigned by the guild server, not stable across restarts) to the guild's
/// persistent identifier. Populated lazily; a miss costs one database lookup per guild per process.
/// </summary>
private static readonly ConcurrentDictionary<uint, Guid> PersistentGuildIds = new();
private Guid? _cachedFlagOwner;
private byte[]? _cachedFlagLogo;
/// <inheritdoc />
public CastleSiegeConfiguration? Configuration { get; set; }
public CastleSiegeSettings? Configuration { get; set; }
/// <summary>
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it.
@@ -99,41 +104,80 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
return serverContext.Id == context.Configuration.CastleSiegeServerId;
}
/// <summary>
/// Resolves the persistent identifier of the player's guild, or <see langword="null"/> when the player is
/// not in a guild or the guild cannot be resolved.
/// </summary>
/// <remarks>
/// <see cref="Interfaces.Guild"/> deliberately carries no id: the guild server assigns short ids in memory
/// only. The persistent <see cref="Guid"/> is therefore resolved through the guild name and cached, which
/// avoids adding a method to <see cref="IGuildServer"/> that upstream would keep changing.
/// </remarks>
/// <param name="player">The player.</param>
public static async ValueTask<(Guid Id, string Name)?> GetPersistentGuildAsync(Player player)
{
if (player.GuildStatus is not { } guildStatus
|| player.GameContext is not IGameServerContext serverContext)
{
return null;
}
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is not { Length: > 0 } name)
{
return null;
}
if (PersistentGuildIds.TryGetValue(guildStatus.GuildId, out var cached))
{
return (cached, name);
}
var resolved = await ResolveGuildIdByNameAsync(player.GameContext, name).ConfigureAwait(false);
if (resolved is not { } id)
{
return null;
}
PersistentGuildIds[guildStatus.GuildId] = id;
return (id, name);
}
/// <inheritdoc />
public object CreateDefaultConfig() => new CastleSiegeConfiguration();
public object CreateDefaultConfig() => new CastleSiegeSettings();
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var context = Contexts.GetOrAdd(gameContext, gc =>
{
var config = this.Configuration ?? new CastleSiegeConfiguration();
var created = new CastleSiegeContext(config);
var settings = this.Configuration ?? new CastleSiegeSettings();
var created = new CastleSiegeContext(settings);
// 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);
// Restore the cycle bookkeeping BEFORE subscribing, so restoring doesn't announce states or
// re-spawn defenses. The owner and registrations are loaded from the database right after.
created.RestoreState(null, null, settings.PersistedState, settings.PersistedStateStartedUtc, null);
_ = LoadPersistedStateAsync(gc, created);
// Announce phase changes to the whole server and, when the siege begins, warp registered members.
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
created.StateChanged += state => _ = OnStateChangedAsync(gc, created, state);
return created;
});
// Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live.
if (this.Configuration is { } liveConfig)
// Point the context at the current (possibly AdminPanel-edited) settings so schedule/durations are live.
if (this.Configuration is { } liveSettings)
{
context.UpdateConfiguration(liveConfig);
context.UpdateConfiguration(liveSettings);
}
// 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.
// castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere.
if (!IsCastleSiegeServer(gameContext))
{
context.SyncOwnerFromConfig();
if (DateTime.UtcNow.Second % 15 == 0)
if (GetCounters(gameContext).NextCastleFlag())
{
await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
@@ -143,19 +187,19 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
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)
if (context.IsSiegeRunning)
{
await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false);
}
// Persist owner/phase/registrations to the database whenever they changed, so they survive a restart.
// Persist owner/state/registrations 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)
// Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state).
if (GetCounters(gameContext).NextCastleFlag())
{
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
@@ -171,25 +215,98 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase)
/// <summary>Gets the seeded Castle Siege definition, or <see langword="null"/> when it was not initialized.</summary>
/// <param name="gameContext">The game context.</param>
private static CastleSiegeDefinition? GetDefinition(IGameContext gameContext)
=> gameContext.Configuration.CastleSiegeConfiguration;
/// <summary>Resolves a guild's persistent identifier from its name, or null when there is no such guild.</summary>
/// <param name="gameContext">The game context.</param>
/// <param name="guildName">The guild name.</param>
internal static async ValueTask<Guid?> ResolveGuildIdByNameAsync(IGameContext gameContext, string guildName)
{
try
{
switch (phase)
using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration);
var guilds = await context.GetAsync<DataModel.Entities.Guild>().ConfigureAwait(false);
return guilds.FirstOrDefault(guild => guild.Name == guildName)?.Id;
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: could not resolve the persistent id of guild '{guildName}'.", guildName);
return null;
}
}
private static async ValueTask<string?> ResolveGuildNameByIdAsync(IGameContext gameContext, Guid guildId)
{
try
{
using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration);
var guild = await context.GetByIdAsync<DataModel.Entities.Guild>(guildId).ConfigureAwait(false);
return guild?.Name;
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: could not resolve the name of guild {guildId}.", guildId);
return null;
}
}
/// <summary>Loads the persisted castle owner and guild registrations from the database into the context.</summary>
private static async ValueTask LoadPersistedStateAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration);
var data = (await dataContext.GetAsync<CastleSiegeData>().ConfigureAwait(false)).FirstOrDefault();
Guid? ownerId = data?.IsOccupied == true ? data.OwnerGuildId : null;
var ownerName = ownerId is { } id ? await ResolveGuildNameByIdAsync(gameContext, id).ConfigureAwait(false) : null;
using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration);
var registrations = await registrationContext.GetAsync<CastleSiegeGuildRegistration>().ConfigureAwait(false);
var restored = new List<KeyValuePair<Guid, string>>();
foreach (var registration in registrations)
{
case CastleSiegePhase.Registration:
var name = await ResolveGuildNameByIdAsync(gameContext, registration.GuildId).ConfigureAwait(false);
restored.Add(new KeyValuePair<Guid, string>(registration.GuildId, name ?? registration.GuildId.ToString()));
}
context.RestoreState(ownerId, ownerName, context.State, context.StateStartedUtc, restored);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while loading the persisted state.");
}
}
private static async Task OnStateChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegeState state)
{
try
{
switch (state)
{
case CastleSiegeState.RegisterGuild:
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:
case CastleSiegeState.Start:
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.
case CastleSiegeState.End:
// Stop the on-map countdown for everyone still on the battle map, and close the panels
// the siege opened - the battle state is dropped right after this, so whoever was
// operating a switch or holding the crown would keep a dead progress box on screen.
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false);
break;
case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner:
case CastleSiegeState.Idle1 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:
@@ -199,52 +316,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
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.");
.LogError(ex, "Castle Siege: error handling state change to {state}.", state);
}
}
@@ -269,52 +341,36 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
/// <summary>
/// Spawns the castle defenses from the seeded NPC definitions. Definitions flagged
/// <see cref="CastleSiegeNpcDefinition.IsPersistedToDatabase"/> are the breakable defenses (gates and
/// guardian statues) and are counted towards the throne; the catapults are pure war atmosphere.
/// </summary>
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)
if (gameContext is not GameContext concrete
|| GetDefinition(gameContext) is not { } definition
|| await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{
context.SetDefenseCount(0);
return;
}
var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
var spawned = 0;
for (var i = 0; i < DefenseSpawns.Length; i++)
var index = 0;
foreach (var npc in definition.NpcDefinitions.Where(n => n.IsPersistedToDatabase && n.MonsterDefinition is not null))
{
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
// The stock CS gate/statue definitions have no HP and aren't destructible; make them 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);
EnsureDestructible(npc.MonsterDefinition!, 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)
var monster = await concrete.MapInitializer
.InitializeSpawnAsync(1000 + index++, map, CreateSpawnArea(npc))
.ConfigureAwait(false);
if (monster is AttackableNpcBase attackable)
{
attackable.Died += (_, _) => context.NotifyDefenseDestroyed();
spawned++;
@@ -323,29 +379,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
context.SetDefenseCount(spawned);
// Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses.
for (var i = 0; i < CatapultSpawns.Length; i++)
index = 0;
foreach (var npc in definition.NpcDefinitions.Where(IsCatapult))
{
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);
await concrete.MapInitializer
.InitializeSpawnAsync(2000 + index++, map, CreateSpawnArea(npc))
.ConfigureAwait(false);
}
}
catch (Exception ex)
@@ -356,54 +395,144 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
private static bool IsCatapult(CastleSiegeNpcDefinition npc)
=> npc.MonsterDefinition is { } monster
&& (monster.Number == CatapultAttackNumber || monster.Number == CatapultDefenseNumber);
private static MonsterSpawnArea CreateSpawnArea(CastleSiegeNpcDefinition npc) => new()
{
MonsterDefinition = npc.MonsterDefinition,
Quantity = 1,
X1 = npc.SpawnX,
X2 = npc.SpawnX,
Y1 = npc.SpawnY,
Y2 = npc.SpawnY,
Direction = npc.Direction,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
private static BroadcastCounters GetCounters(IGameContext gameContext)
=> Counters.GetOrAdd(gameContext, _ => new BroadcastCounters());
/// <summary>
/// Closes the client panels the siege opened: the crown registration panel of the master who was holding
/// it, and the switch progress box of whoever was operating a switch. Called when the siege ends, before
/// the battle state is dropped.
/// </summary>
private static async Task CloseSiegePanelsAsync(IGameContext gameContext, CastleSiegeContext context)
{
if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
{
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{
return;
}
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{
if (context.GetSwitchOperation(switchNumber) is { } operation)
{
await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false);
await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false);
}
}
}
/// <summary>
/// Closes the switch progress box on the client of the player who was operating it. Object identifiers
/// are recycled when a player leaves, so the name is checked too - otherwise a newly connected player
/// could inherit the id and get a message box about a switch they never touched.
/// </summary>
private static async Task CloseSwitchBoxAsync(GameMap map, CastleSiegeSwitchOperation operation)
{
if (map.GetObject(operation.PlayerId) is Player player && player.Name == operation.PlayerName)
{
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(
p => p.SetCrownSwitchStateAsync(operation.SwitchObjectId, operation.PlayerId, 0)).ConfigureAwait(false);
}
}
private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber)
{
var npc = GetDefinition(gameContext)?.NpcDefinitions
.FirstOrDefault(n => n.MonsterDefinition?.Number == monsterNumber);
return npc is null ? null : new Point(npc.SpawnX, npc.SpawnY);
}
private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map is null)
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{
return;
}
// Each Crown Switch is held by whichever registered guild currently has a member standing on it.
foreach (var (switchNumber, x, y) in SwitchPositions)
var now = DateTime.UtcNow;
var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds));
// A Crown Switch belongs to the player who clicked it, for as long as they stay in its area.
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{
string? holder = null;
var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType<Player>();
foreach (var player in nearby)
if (context.GetSwitchOperation(switchNumber) is not { } operation)
{
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
{
holder = guildName;
break;
}
continue;
}
context.SetSwitchHolder(switchNumber, holder);
var stillOnIt = GetNpcPosition(gameContext, switchNumber) is { } position
&& map.GetAttackablesInRange(position, SwitchHoldRange)
.OfType<Player>()
.Any(p => p.Id == operation.PlayerId && p.IsAlive);
var (switchEvent, affected) = context.TickSwitch(switchNumber, stillOnIt, now, pushDuration);
if (affected is null)
{
continue;
}
if (switchEvent == CastleSiegeSwitchEvent.Held)
{
// Repeat the info so the HUD picks up the names (the client only stores them from the
// second packet on, because the first one allocates its table).
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, affected).ConfigureAwait(false);
}
else if (switchEvent == CastleSiegeSwitchEvent.Released)
{
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, null).ConfigureAwait(false);
// Close the client's progress box of the player who left, if they are still around.
await CloseSwitchBoxAsync(map, affected).ConfigureAwait(false);
}
}
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).
// Crown-hold capture: while one guild holds both switches the crown shield drops for it, and its
// master captures the throne by clicking the crown and holding it for the configured time.
var eligible = context.GetShieldEligibleGuild();
Player? masterPlayer = null;
if (eligible is not null)
string? eligibleName = null;
if (eligible is { } eligibleId && GetNpcPosition(gameContext, CrownNumber) is { } crownPosition)
{
foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType<Player>())
foreach (var player in map.GetAttackablesInRange(crownPosition, CrownHoldRange).OfType<Player>())
{
if (player.GuildStatus?.Position == GuildPosition.GuildMaster
&& await GetGuildNameAsync(player).ConfigureAwait(false) == eligible)
if (player.GuildStatus?.Position != GuildPosition.GuildMaster)
{
continue;
}
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild && guild.Id == eligibleId)
{
masterPlayer = player;
eligibleName = guild.Name;
break;
}
}
}
var crown = context.TickCrownHold(eligible, masterPlayer is not null, now, context.Configuration.CrownHoldDuration);
var holdDuration = TimeSpan.FromSeconds(GetDefinition(gameContext)?.CrownHoldTimeSeconds ?? 60);
var crown = context.TickCrownHold(eligible, eligibleName, masterPlayer is not null, now, holdDuration);
// Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal).
if (crown.ShieldChanged)
@@ -414,13 +543,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
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.
// The registration panel is shown ONLY to the master taking the crown. Remember them:
// the hold usually breaks BECAUSE they walked off the crown, and their client still has
// the panel open, so the cancel has to reach the player we started it for.
CrownHoldPlayers[gameContext] = masterPlayer;
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);
case CrownEvent.HoldReset:
if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
{
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
break;
case CrownEvent.Captured when crown.Guild is { } captured:
case CrownEvent.Captured when crown.GuildName is { } captured:
if (CrownHoldPlayers.TryRemove(gameContext, out var capturingPlayer))
{
await capturingPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(1, 0)).ConfigureAwait(false);
}
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;
@@ -428,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
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)
// Keep the client's on-map countdown armed and in sync. Resend every 10 ticks so players who just
// loaded the battle map pick it up, without visibly resetting the second-counter too often. This
// counts ticks instead of matching a clock second, which a delayed tick would skip silently.
if (GetCounters(gameContext).NextSiegeState())
{
var remaining = context.GetRemainingSiegeTime(now);
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
@@ -464,22 +606,155 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
});
/// <summary>Invokes the Castle Siege status view for every player currently on the battle map.</summary>
/// <summary>
/// Tells everybody on the battle map who is operating a Crown Switch. Besides driving the client's HUD
/// list, this is what makes the client allocate its switch table, so it has to be sent before any
/// "switch released" packet reaches that client.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="switchObjectId">The switch NPC's object identifier.</param>
/// <param name="operation">The operation, or <see langword="null"/> when the switch became free.</param>
internal static ValueTask BroadcastSwitchInfoAsync(IGameContext gameContext, ushort switchObjectId, CastleSiegeSwitchOperation? operation)
=> ForEachOnBattleMapAsync(
gameContext,
p => p.SetCrownSwitchInfoAsync(
switchObjectId,
operation is null ? (byte)0 : (byte)1,
(byte)CastleSiegeJoinSide.Attack1,
operation?.GuildName ?? string.Empty,
operation?.PlayerName ?? string.Empty));
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)
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
if (context.OwnerGuildName is not { Length: > 0 } owner)
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map?.SafeZoneSpawnGate is not { } gate)
{
return;
}
var logo = await this.GetOwnerLogoAsync(gameContext, owner).ConfigureAwait(false);
await gameContext.ForEachPlayerAsync(async player =>
{
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild
&& context.IsRegistered(guild.Id))
{
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.");
}
}
/// <summary>
/// Writes the castle owner and the guild registrations to their database tables, and the cycle
/// bookkeeping (current state and when it started) to the plugin's custom-configuration JSON.
/// </summary>
private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context)
{
try
{
await this.PersistOwnerAsync(gameContext, context).ConfigureAwait(false);
await PersistRegistrationsAsync(gameContext, context).ConfigureAwait(false);
await this.PersistCycleBookkeepingAsync(gameContext, context).ConfigureAwait(false);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while persisting state to the database.");
}
}
private async ValueTask PersistOwnerAsync(GameContext gameContext, CastleSiegeContext context)
{
using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration);
var data = (await dataContext.GetAsync<CastleSiegeData>().ConfigureAwait(false)).FirstOrDefault()
?? dataContext.CreateNew<CastleSiegeData>();
data.OwnerGuildId = context.OwnerGuildId;
data.IsOccupied = context.OwnerGuildId is not null;
await dataContext.SaveChangesAsync().ConfigureAwait(false);
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogInformation(
"Castle Siege: persisted owner={owner} state={state}.",
context.OwnerGuildName ?? "(none)",
context.State);
}
private static async ValueTask PersistRegistrationsAsync(GameContext gameContext, CastleSiegeContext context)
{
using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration);
var existing = (await registrationContext.GetAsync<CastleSiegeGuildRegistration>().ConfigureAwait(false)).ToList();
var wanted = context.RegisteredGuildIds.ToHashSet();
foreach (var registration in existing)
{
if (!wanted.Remove(registration.GuildId))
{
await registrationContext.DeleteAsync(registration).ConfigureAwait(false);
}
}
foreach (var guildId in wanted)
{
var registration = registrationContext.CreateNew<CastleSiegeGuildRegistration>();
registration.GuildId = guildId;
}
await registrationContext.SaveChangesAsync().ConfigureAwait(false);
}
private async ValueTask PersistCycleBookkeepingAsync(GameContext gameContext, CastleSiegeContext context)
{
if (this.Configuration is not { } settings)
{
return;
}
settings.PersistedState = context.State;
settings.PersistedStateStartedUtc = context.StateStartedUtc;
// Find our plugin-configuration row via the in-memory config graph to get its id, then rewrite its
// custom-configuration JSON in its own (non-caching) context.
var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID;
var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId);
if (inMemory is null)
{
return;
}
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(settings, gameContext.PlugInManager.CustomConfigReferenceHandler);
await ctx.SaveChangesAsync().ConfigureAwait(false);
}
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
if (context.OwnerGuildId is not { } owner)
{
return;
}
var logo = await this.GetOwnerLogoAsync(gameContext, owner, context.OwnerGuildName).ConfigureAwait(false);
if (logo is null)
{
return;
@@ -500,77 +775,58 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
private async ValueTask<byte[]?> GetOwnerLogoAsync(IGameContext gameContext, string ownerName)
private async ValueTask<byte[]?> GetOwnerLogoAsync(IGameContext gameContext, Guid ownerId, string? ownerName)
{
if (this._cachedFlagOwner == ownerName && this._cachedFlagLogo is not null)
if (this._cachedFlagOwner == ownerId && this._cachedFlagLogo is not null)
{
return this._cachedFlagLogo;
}
if (gameContext is not IGameServerContext serverContext)
if (gameContext is not IGameServerContext serverContext || ownerName is not { Length: > 0 })
{
return null;
}
var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
if (guildId == 0)
var shortGuildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
if (shortGuildId == 0)
{
return null;
}
var guild = await serverContext.GuildServer.GetGuildAsync(guildId).ConfigureAwait(false);
var guild = await serverContext.GuildServer.GetGuildAsync(shortGuildId).ConfigureAwait(false);
if (guild?.Logo is not { Length: > 0 } logo)
{
return null;
}
this._cachedFlagOwner = ownerName;
this._cachedFlagOwner = ownerId;
this._cachedFlagLogo = logo;
return logo;
}
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
/// <summary>
/// Counts the ticks between the periodic broadcasts of one game context.
/// </summary>
private sealed class BroadcastCounters
{
try
private int _siegeState;
private int _castleFlag;
/// <summary>Advances the countdown-broadcast counter and tells whether it is due.</summary>
public bool NextSiegeState() => Due(ref this._siegeState, SiegeStateBroadcastTicks);
/// <summary>Advances the castle-flag-broadcast counter and tells whether it is due.</summary>
public bool NextCastleFlag() => Due(ref this._castleFlag, CastleFlagBroadcastTicks);
private static bool Due(ref int counter, int period)
{
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map?.SafeZoneSpawnGate is not { } gate)
if (++counter < period)
{
return;
return false;
}
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);
counter = 0;
return true;
}
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

@@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
/// </summary>
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
ValueTask AnnounceSealCapturedAsync(string guildName);
/// <summary>
/// Sends the state of a Crown Switch (C1 B2 14): state 0 = released (the client closes its progress
/// box), 1 = this player is operating it (the client opens its hold progress box), 2 = somebody else
/// is already operating it.
/// </summary>
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
/// <param name="playerObjectId">The operating player's object identifier.</param>
/// <param name="state">The switch state (0 released, 1 operated by this player, 2 operated by another).</param>
ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state);
/// <summary>
/// Sends who is operating a Crown Switch (C1 B2 20), which the client lists on the siege HUD.
/// <para>
/// This has to reach a client BEFORE any <see cref="SetCrownSwitchStateAsync"/> with state 0: the client
/// allocates its switch table when this arrives, and its "switch released" handler reads that table
/// without checking whether it exists.
/// </para>
/// </summary>
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
/// <param name="switchState">0 when nobody operates it, 1 while it is operated.</param>
/// <param name="joinSide">The operating side (see the castle siege join sides).</param>
/// <param name="guildName">The operating guild's name (max 8 bytes), empty when free.</param>
/// <param name="playerName">The operating player's name (max 10 bytes), empty when free.</param>
ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName);
}

View File

@@ -26,6 +26,17 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
/// <param name="player">The player.</param>
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
private static void WriteName(string name, Span<byte> target)
{
if (name.Length == 0)
{
return;
}
var bytes = System.Text.Encoding.UTF8.GetBytes(name);
bytes.AsSpan(0, Math.Min(target.Length - 1, bytes.Length)).CopyTo(target);
}
/// <inheritdoc />
public async ValueTask SetBattleStateAsync(bool started)
{
@@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 09 B2 14 <switchId:2 BE> <playerId:2 BE> <state>
var span = connection.Output.GetSpan(9)[..9];
span.Clear();
span[0] = 0xC1;
span[1] = 0x09;
span[2] = 0xB2;
span[3] = 0x14;
span[4] = (byte)(switchObjectId >> 8);
span[5] = (byte)switchObjectId;
span[6] = (byte)(playerObjectId >> 8);
span[7] = (byte)playerObjectId;
span[8] = state;
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 1B B2 20 <switchId:2 BE> <switchState> <joinSide> <guildName[8]> <playerName[11]>
var span = connection.Output.GetSpan(27)[..27];
span.Clear();
span[0] = 0xC1;
span[1] = 0x1B;
span[2] = 0xB2;
span[3] = 0x20;
span[4] = (byte)(switchObjectId >> 8);
span[5] = (byte)switchObjectId;
span[6] = switchState;
span[7] = joinSide;
WriteName(guildName, span.Slice(8, 8));
WriteName(playerName, span.Slice(16, 10));
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AnnounceSealCapturedAsync(string guildName)
{

View File

@@ -151,7 +151,7 @@ public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress
this.Logger.LogDebug("[C->S] {0}", buffer.ToArray().AsString());
}
await this.MainPacketHandler.HandlePacketAsync(this, buffer).ConfigureAwait(false);
await this.RunPersistenceExclusiveAsync(() => this.MainPacketHandler.HandlePacketAsync(this, buffer)).ConfigureAwait(false);
}
finally
{

View File

@@ -0,0 +1,342 @@
// <copyright file="CastleSiegeConfiguration.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeConfiguration"/>.
/// </summary>
public partial class CastleSiegeConfiguration : MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration, IIdentifiable, IConvertibleTo<CastleSiegeConfiguration>
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets the raw collection of <see cref="StateSchedule" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("stateSchedule")]
public ICollection<CastleSiegeStateScheduleEntry> RawStateSchedule { get; } = new List<CastleSiegeStateScheduleEntry>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry> StateSchedule
{
get => base.StateSchedule ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, CastleSiegeStateScheduleEntry>(this.RawStateSchedule);
protected set
{
this.StateSchedule.Clear();
foreach (var item in value)
{
this.StateSchedule.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="NpcDefinitions" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("npcDefinitions")]
public ICollection<CastleSiegeNpcDefinition> RawNpcDefinitions { get; } = new List<CastleSiegeNpcDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition> NpcDefinitions
{
get => base.NpcDefinitions ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, CastleSiegeNpcDefinition>(this.RawNpcDefinitions);
protected set
{
this.NpcDefinitions.Clear();
foreach (var item in value)
{
this.NpcDefinitions.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="GateDefenseUpgrades" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("gateDefenseUpgrades")]
public ICollection<CastleSiegeUpgradeDefinition> RawGateDefenseUpgrades { get; } = new List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> GateDefenseUpgrades
{
get => base.GateDefenseUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawGateDefenseUpgrades);
protected set
{
this.GateDefenseUpgrades.Clear();
foreach (var item in value)
{
this.GateDefenseUpgrades.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="GateLifeUpgrades" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("gateLifeUpgrades")]
public ICollection<CastleSiegeUpgradeDefinition> RawGateLifeUpgrades { get; } = new List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> GateLifeUpgrades
{
get => base.GateLifeUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawGateLifeUpgrades);
protected set
{
this.GateLifeUpgrades.Clear();
foreach (var item in value)
{
this.GateLifeUpgrades.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="StatueDefenseUpgrades" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("statueDefenseUpgrades")]
public ICollection<CastleSiegeUpgradeDefinition> RawStatueDefenseUpgrades { get; } = new List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueDefenseUpgrades
{
get => base.StatueDefenseUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueDefenseUpgrades);
protected set
{
this.StatueDefenseUpgrades.Clear();
foreach (var item in value)
{
this.StatueDefenseUpgrades.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="StatueLifeUpgrades" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("statueLifeUpgrades")]
public ICollection<CastleSiegeUpgradeDefinition> RawStatueLifeUpgrades { get; } = new List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueLifeUpgrades
{
get => base.StatueLifeUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueLifeUpgrades);
protected set
{
this.StatueLifeUpgrades.Clear();
foreach (var item in value)
{
this.StatueLifeUpgrades.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="StatueRegenUpgrades" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("statueRegenUpgrades")]
public ICollection<CastleSiegeUpgradeDefinition> RawStatueRegenUpgrades { get; } = new List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueRegenUpgrades
{
get => base.StatueRegenUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueRegenUpgrades);
protected set
{
this.StatueRegenUpgrades.Clear();
foreach (var item in value)
{
this.StatueRegenUpgrades.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="AttackMachineZones" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("attackMachineZones")]
public ICollection<CastleSiegeZoneDefinition> RawAttackMachineZones { get; } = new List<CastleSiegeZoneDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition> AttackMachineZones
{
get => base.AttackMachineZones ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, CastleSiegeZoneDefinition>(this.RawAttackMachineZones);
protected set
{
this.AttackMachineZones.Clear();
foreach (var item in value)
{
this.AttackMachineZones.Add(item);
}
}
}
/// <summary>
/// Gets the raw collection of <see cref="DefenseMachineZones" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("defenseMachineZones")]
public ICollection<CastleSiegeZoneDefinition> RawDefenseMachineZones { get; } = new List<CastleSiegeZoneDefinition>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition> DefenseMachineZones
{
get => base.DefenseMachineZones ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, CastleSiegeZoneDefinition>(this.RawDefenseMachineZones);
protected set
{
this.DefenseMachineZones.Clear();
foreach (var item in value)
{
this.DefenseMachineZones.Add(item);
}
}
}
/// <summary>
/// Gets the raw object of <see cref="CastleSiegeMapDefinition" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("castleSiegeMapDefinition")]
public GameMapDefinition RawCastleSiegeMapDefinition
{
get => base.CastleSiegeMapDefinition as GameMapDefinition;
set => base.CastleSiegeMapDefinition = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition CastleSiegeMapDefinition
{
get => base.CastleSiegeMapDefinition;
set => base.CastleSiegeMapDefinition = value;
}
/// <summary>
/// Gets the raw object of <see cref="LandOfTrialsMapDefinition" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("landOfTrialsMapDefinition")]
public GameMapDefinition RawLandOfTrialsMapDefinition
{
get => base.LandOfTrialsMapDefinition as GameMapDefinition;
set => base.LandOfTrialsMapDefinition = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition LandOfTrialsMapDefinition
{
get => base.LandOfTrialsMapDefinition;
set => base.LandOfTrialsMapDefinition = value;
}
/// <summary>
/// Gets the raw object of <see cref="RewardItemDefinition" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("rewardItemDefinition")]
public ItemDefinition RawRewardItemDefinition
{
get => base.RewardItemDefinition as ItemDefinition;
set => base.RewardItemDefinition = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.Items.ItemDefinition RewardItemDefinition
{
get => base.RewardItemDefinition;
set => base.RewardItemDefinition = value;
}
/// <summary>
/// Gets the raw object of <see cref="DefenseRespawnArea" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("defenseRespawnArea")]
public CastleSiegeZoneDefinition RawDefenseRespawnArea
{
get => base.DefenseRespawnArea as CastleSiegeZoneDefinition;
set => base.DefenseRespawnArea = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition DefenseRespawnArea
{
get => base.DefenseRespawnArea;
set => base.DefenseRespawnArea = value;
}
/// <summary>
/// Gets the raw object of <see cref="AttackRespawnArea" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("attackRespawnArea")]
public CastleSiegeZoneDefinition RawAttackRespawnArea
{
get => base.AttackRespawnArea as CastleSiegeZoneDefinition;
set => base.AttackRespawnArea = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition AttackRespawnArea
{
get => base.AttackRespawnArea;
set => base.AttackRespawnArea = value;
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeConfiguration();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeConfiguration Convert() => this;
}

View File

@@ -0,0 +1,67 @@
// <copyright file="CastleSiegeData.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeData"/>.
/// </summary>
public partial class CastleSiegeData : MUnique.OpenMU.DataModel.Entities.CastleSiegeData, IIdentifiable, IConvertibleTo<CastleSiegeData>
{
/// <summary>
/// Gets the raw collection of <see cref="NpcStates" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("npcStates")]
public ICollection<CastleSiegeNpcState> RawNpcStates { get; } = new List<CastleSiegeNpcState>();
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override ICollection<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState> NpcStates
{
get => base.NpcStates ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, CastleSiegeNpcState>(this.RawNpcStates);
protected set
{
this.NpcStates.Clear();
foreach (var item in value)
{
this.NpcStates.Add(item);
}
}
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeData Convert() => this;
}

View File

@@ -0,0 +1,46 @@
// <copyright file="CastleSiegeGuildRegistration.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeGuildRegistration"/>.
/// </summary>
public partial class CastleSiegeGuildRegistration : MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration, IIdentifiable, IConvertibleTo<CastleSiegeGuildRegistration>
{
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeGuildRegistration Convert() => this;
}

View File

@@ -0,0 +1,81 @@
// <copyright file="CastleSiegeNpcDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeNpcDefinition"/>.
/// </summary>
public partial class CastleSiegeNpcDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, IIdentifiable, IConvertibleTo<CastleSiegeNpcDefinition>
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets the raw object of <see cref="MonsterDefinition" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("monsterDefinition")]
public MonsterDefinition RawMonsterDefinition
{
get => base.MonsterDefinition as MonsterDefinition;
set => base.MonsterDefinition = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.MonsterDefinition MonsterDefinition
{
get => base.MonsterDefinition;
set => base.MonsterDefinition = value;
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeNpcDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeNpcDefinition Convert() => this;
}

View File

@@ -0,0 +1,46 @@
// <copyright file="CastleSiegeNpcState.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeNpcState"/>.
/// </summary>
public partial class CastleSiegeNpcState : MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, IIdentifiable, IConvertibleTo<CastleSiegeNpcState>
{
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeNpcState Convert() => this;
}

View File

@@ -0,0 +1,63 @@
// <copyright file="CastleSiegeStateScheduleEntry.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeStateScheduleEntry"/>.
/// </summary>
public partial class CastleSiegeStateScheduleEntry : MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, IIdentifiable, IConvertibleTo<CastleSiegeStateScheduleEntry>
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeStateScheduleEntry();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeStateScheduleEntry Convert() => this;
}

View File

@@ -0,0 +1,63 @@
// <copyright file="CastleSiegeUpgradeDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeUpgradeDefinition"/>.
/// </summary>
public partial class CastleSiegeUpgradeDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, IIdentifiable, IConvertibleTo<CastleSiegeUpgradeDefinition>
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeUpgradeDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeUpgradeDefinition Convert() => this;
}

View File

@@ -0,0 +1,63 @@
// <copyright file="CastleSiegeZoneDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.BasicModel;
using MUnique.OpenMU.Persistence.Json;
/// <summary>
/// A plain implementation of <see cref="CastleSiegeZoneDefinition"/>.
/// </summary>
public partial class CastleSiegeZoneDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, IIdentifiable, IConvertibleTo<CastleSiegeZoneDefinition>
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeZoneDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
/// <inheritdoc/>
public CastleSiegeZoneDefinition Convert() => this;
}

View File

@@ -484,6 +484,24 @@ public partial class GameConfiguration : MUnique.OpenMU.DataModel.Configuration.
set => base.DuelConfiguration = value;
}
/// <summary>
/// Gets the raw object of <see cref="CastleSiegeConfiguration" />.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("castleSiegeConfiguration")]
public CastleSiegeConfiguration RawCastleSiegeConfiguration
{
get => base.CastleSiegeConfiguration as CastleSiegeConfiguration;
set => base.CastleSiegeConfiguration = value;
}
/// <inheritdoc/>
[System.Text.Json.Serialization.JsonIgnore]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration CastleSiegeConfiguration
{
get => base.CastleSiegeConfiguration;
set => base.CastleSiegeConfiguration = value;
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.GameConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{

View File

@@ -21,6 +21,16 @@ public class EntityDataContext : ExtendedTypeContext
/// </summary>
internal GameConfiguration? CurrentGameConfiguration { get; set; }
/// <summary>
/// Gets the persistent Castle Siege state.
/// </summary>
internal DbSet<CastleSiegeData> CastleSiegeData => this.Set<CastleSiegeData>();
/// <summary>
/// Gets the Castle Siege guild registrations.
/// </summary>
internal DbSet<CastleSiegeGuildRegistration> CastleSiegeGuildRegistrations => this.Set<CastleSiegeGuildRegistration>();
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -58,6 +68,11 @@ public class EntityDataContext : ExtendedTypeContext
modelBuilder.Entity<Account>().Apply();
modelBuilder.Entity<Character>().Apply();
modelBuilder.Entity<CharacterClass>().Apply();
modelBuilder.Entity<CastleSiegeConfiguration>().Apply();
modelBuilder.Entity<CastleSiegeData>().Apply();
modelBuilder.Entity<CastleSiegeGuildRegistration>().Apply();
modelBuilder.Entity<CastleSiegeNpcDefinition>().Apply();
modelBuilder.Entity<CastleSiegeNpcState>().Apply();
modelBuilder.Entity<DropItemGroup>().Apply();
modelBuilder.Entity<ExitGate>().Apply();
modelBuilder.Entity<GameConfiguration>().Apply();
@@ -98,4 +113,4 @@ public class EntityDataContext : ExtendedTypeContext
GuildContext.ConfigureModel(modelBuilder);
FriendContext.ConfigureModel(modelBuilder);
}
}
}

View File

@@ -69,40 +69,26 @@ internal class EntityFrameworkContextBase : IContext
/// <inheritdoc/>
public async ValueTask<bool> SaveChangesAsync(CancellationToken cancellationToken = default)
{
using var l = await this._lock.LockAsync();
// when we have a change publisher attached, we want to get the changed entries before accepting them.
// Otherwise, we can accept them.
var acceptChanges = true;
object? sender = null;
SavedChangesEventArgs? args = null;
if (this._changeListener is { })
// A player's entities can be mutated by game logic on a flow that is not serialized against
// this save (for example item destruction on an attacker's thread during combat). Such a
// concurrent mutation makes change detection throw while it enumerates a tracked collection.
// The mutation is a single, quick operation, so a bounded retry lands on a stable moment
// instead of failing the whole save - which would otherwise leave the session unpersisted and
// roll the player back on relog.
const int maxAttempts = 3;
var attempt = 0;
while (true)
{
this.Context.SavedChanges += OnSavedChanges;
acceptChanges = false;
}
try
{
await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
if (args is not null)
attempt++;
try
{
await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
return await this.SaveChangesCoreAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (attempt < maxAttempts && IsTransientConcurrencyConflict(ex))
{
this._logger.LogWarning(ex, "Transient concurrency conflict while saving (attempt {Attempt}/{MaxAttempts}); retrying.", attempt, maxAttempts);
await Task.Delay(attempt * 10, cancellationToken).ConfigureAwait(false);
}
}
finally
{
this.Context.SavedChanges -= OnSavedChanges;
}
return true;
void OnSavedChanges(object? s, SavedChangesEventArgs e)
{
sender = s;
args = e;
}
}
@@ -252,6 +238,14 @@ internal class EntityFrameworkContextBase : IContext
GC.SuppressFinalize(this);
}
/// <summary>
/// Determines whether changes of an entity type are published as configuration changes.
/// </summary>
/// <param name="entityType">The entity type.</param>
/// <returns><see langword="true"/> when the entity belongs to the configuration schema.</returns>
internal static bool PublishesConfigurationChanges(IReadOnlyEntityType entityType)
=> entityType.GetSchema() == SchemaNames.Configuration;
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
@@ -266,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext
this.Context.Dispose();
}
/// <summary>
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
/// racing this save, and is therefore worth retrying.
/// </summary>
/// <param name="exception">The exception thrown by the save.</param>
/// <returns><c>true</c> if the save should be retried.</returns>
private static bool IsTransientConcurrencyConflict(Exception exception)
{
// A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
// Depending on exactly where change detection was, it surfaces as one of several types - a
// modified collection (InvalidOperationException), a transiently-null internal key
// (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
// the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
// A genuinely persistent error of the same type is not masked - it rethrows once the retries
// are exhausted. The deterministic serialization (per-player persistence lock) is the primary
// guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
return exception is DbUpdateConcurrencyException
or InvalidOperationException
or ArgumentNullException
or NullReferenceException
or IndexOutOfRangeException
or KeyNotFoundException;
}
private async ValueTask<bool> SaveChangesCoreAsync(CancellationToken cancellationToken)
{
using var l = await this._lock.LockAsync();
// when we have a change publisher attached, we want to get the changed entries before accepting them.
// Otherwise, we can accept them.
var acceptChanges = true;
object? sender = null;
SavedChangesEventArgs? args = null;
if (this._changeListener is { })
{
this.Context.SavedChanges += OnSavedChanges;
acceptChanges = false;
}
try
{
await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
if (args is not null)
{
await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
}
}
finally
{
this.Context.SavedChanges -= OnSavedChanges;
}
return true;
void OnSavedChanges(object? s, SavedChangesEventArgs e)
{
sender = s;
args = e;
}
}
private bool DetachInternal(object item)
{
var entry = this.Context.Entry(item);
@@ -343,7 +400,9 @@ internal class EntityFrameworkContextBase : IContext
}
var changedEntries = this.Context.ChangeTracker.Entries()
.Where(entity => entity.State != EntityState.Unchanged).ToList();
.Where(entity => entity.State != EntityState.Unchanged
&& PublishesConfigurationChanges(entity.Metadata))
.ToList();
foreach (var entry in changedEntries)
{
var (parent, parentCollectionNavigation) = this.GetParentInformation(entry);
@@ -413,4 +472,4 @@ internal class EntityFrameworkContextBase : IContext
return (parent ?? parentId, parentCollectionNavigation);
}
}
}

View File

@@ -0,0 +1,85 @@
// <copyright file="CastleSiegeExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Extensions for Castle Siege-related <see cref="EntityTypeBuilder"/>s.
/// </summary>
internal static class CastleSiegeExtensions
{
/// <summary>
/// Applies the settings for the <see cref="CastleSiegeConfiguration"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CastleSiegeConfiguration> builder)
{
builder.Property(configuration => configuration.CrownHoldTimeSeconds).HasDefaultValue(30);
builder.Property(configuration => configuration.RegisterMinLevel).HasDefaultValue(200);
builder.Property(configuration => configuration.RegisterMinMembers).HasDefaultValue(20);
builder.Property(configuration => configuration.MaxAttackingGuilds).HasDefaultValue(3);
builder.HasOne(configuration => configuration.RawCastleSiegeMapDefinition)
.WithMany()
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(configuration => configuration.RawLandOfTrialsMapDefinition)
.WithMany()
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(configuration => configuration.RawRewardItemDefinition)
.WithMany()
.OnDelete(DeleteBehavior.Restrict);
}
/// <summary>
/// Applies the settings for the <see cref="CastleSiegeNpcDefinition"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CastleSiegeNpcDefinition> builder)
{
builder.HasOne(definition => definition.RawMonsterDefinition)
.WithMany()
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(definition => new { definition.MonsterDefinitionId, definition.InstanceId });
}
/// <summary>
/// Applies the settings for the <see cref="CastleSiegeData"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CastleSiegeData> builder)
{
builder.HasOne<Guild>()
.WithMany()
.HasForeignKey(data => data.OwnerGuildId)
.OnDelete(DeleteBehavior.SetNull);
}
/// <summary>
/// Applies the settings for the <see cref="CastleSiegeNpcState"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CastleSiegeNpcState> builder)
{
builder.HasIndex(state => new { state.MonsterNumber, state.InstanceId }).IsUnique();
}
/// <summary>
/// Applies the settings for the <see cref="CastleSiegeGuildRegistration"/> entity.
/// </summary>
/// <param name="builder">The builder.</param>
public static void Apply(this EntityTypeBuilder<CastleSiegeGuildRegistration> builder)
{
builder.Property(registration => registration.GuildName).HasMaxLength(8).IsRequired();
builder.HasIndex(registration => registration.GuildId).IsUnique();
builder.HasOne<Guild>()
.WithMany()
.HasForeignKey(registration => registration.GuildId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -48,8 +48,15 @@
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup>
<!--
The generator overwrites the checked-in *.Generated.cs files, so it must run against the current data
model. Do NOT add the "no build" switch here: it would reuse whatever assemblies happen to lie in the
generator's output folder, and a stale copy of the data model silently regenerates the model files
without the types added since. The build still succeeds and only fails at runtime, when EF validates
the model.
-->
<Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(ci)'!='true'">
<Exec Command="dotnet run --project ../SourceGenerator/MUnique.OpenMU.Persistence.SourceGenerator.csproj -c:$(ConfigurationName) MUnique.OpenMU.Persistence.EntityFramework &quot;$(ProjectDir)Model&quot; --no-build" />
<Exec Command="dotnet run --project ../SourceGenerator/MUnique.OpenMU.Persistence.SourceGenerator.csproj -c:$(ConfigurationName) MUnique.OpenMU.Persistence.EntityFramework &quot;$(ProjectDir)Model&quot;" />
</Target>
</Project>

View File

@@ -1,9 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations;
// <copyright file="20260710205741_AddIsQuestItemFlag.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddIsQuestItemFlag : Migration
{

View File

@@ -1,10 +1,14 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
// <copyright file="20260712014203_AddBuff.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddBuff : Migration
{
@@ -20,7 +24,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
MagicEffectDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
MonsterDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
MinimumLevel = table.Column<int>(type: "integer", nullable: true),
MaximumLevel = table.Column<int>(type: "integer", nullable: true)
MaximumLevel = table.Column<int>(type: "integer", nullable: true),
},
constraints: table =>
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
// <copyright file="20260730194321_AddCastleSiege.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddCastleSiege : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CastleSiegeConfigurationId",
schema: "config",
table: "GameConfiguration",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "CastleSiegeData",
schema: "data",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OwnerGuildId = table.Column<Guid>(type: "uuid", nullable: true),
IsOccupied = table.Column<bool>(type: "boolean", nullable: false),
TaxChaos = table.Column<byte>(type: "smallint", nullable: false),
TaxStore = table.Column<byte>(type: "smallint", nullable: false),
TaxHunt = table.Column<int>(type: "integer", nullable: false),
IsHuntZoneEnabled = table.Column<bool>(type: "boolean", nullable: false),
TributeMoney = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeData", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CastleSiegeNpcState",
schema: "data",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CastleSiegeDataId = table.Column<Guid>(type: "uuid", nullable: true),
MonsterNumber = table.Column<short>(type: "smallint", nullable: false),
InstanceId = table.Column<byte>(type: "smallint", nullable: false),
DefenseLevel = table.Column<byte>(type: "smallint", nullable: false),
RegenLevel = table.Column<byte>(type: "smallint", nullable: false),
LifeLevel = table.Column<byte>(type: "smallint", nullable: false),
CurrentHp = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeNpcState", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeNpcState_CastleSiegeData_CastleSiegeDataId",
column: x => x.CastleSiegeDataId,
principalSchema: "data",
principalTable: "CastleSiegeData",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CastleSiegeConfiguration",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CastleSiegeMapDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
LandOfTrialsMapDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
RewardItemDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
DefenseRespawnAreaId = table.Column<Guid>(type: "uuid", nullable: true),
AttackRespawnAreaId = table.Column<Guid>(type: "uuid", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
CrownHoldTimeSeconds = table.Column<int>(type: "integer", nullable: false),
RegisterMinLevel = table.Column<int>(type: "integer", nullable: false),
RegisterMinMembers = table.Column<int>(type: "integer", nullable: false),
ParticipantRewardMinSeconds = table.Column<int>(type: "integer", nullable: false),
MaxAttackingGuilds = table.Column<int>(type: "integer", nullable: false),
GuildScoreCastleSiege = table.Column<int>(type: "integer", nullable: false),
GuildScoreCastleSiegeMembers = table.Column<int>(type: "integer", nullable: false),
GateBuyPrice = table.Column<int>(type: "integer", nullable: false),
StatueBuyPrice = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeConfiguration", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_CastleSiegeMapDe~",
column: x => x.CastleSiegeMapDefinitionId,
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id");
table.ForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_LandOfTrialsMapD~",
column: x => x.LandOfTrialsMapDefinitionId,
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id");
table.ForeignKey(
name: "FK_CastleSiegeConfiguration_ItemDefinition_RewardItemDefinitio~",
column: x => x.RewardItemDefinitionId,
principalSchema: "config",
principalTable: "ItemDefinition",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "CastleSiegeNpcDefinition",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MonsterDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId = table.Column<Guid>(type: "uuid", nullable: true),
InstanceId = table.Column<byte>(type: "smallint", nullable: false),
IsPersistedToDatabase = table.Column<bool>(type: "boolean", nullable: false),
DefaultSide = table.Column<byte>(type: "smallint", nullable: false),
SpawnX = table.Column<byte>(type: "smallint", nullable: false),
SpawnY = table.Column<byte>(type: "smallint", nullable: false),
Direction = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeNpcDefinition", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeNpcDefinition_CastleSiegeConfiguration_CastleSie~",
column: x => x.CastleSiegeConfigurationId,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeNpcDefinition_MonsterDefinition_MonsterDefinitio~",
column: x => x.MonsterDefinitionId,
principalSchema: "config",
principalTable: "MonsterDefinition",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "CastleSiegeStateScheduleEntry",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CastleSiegeConfigurationId = table.Column<Guid>(type: "uuid", nullable: true),
State = table.Column<byte>(type: "smallint", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: false),
Hour = table.Column<byte>(type: "smallint", nullable: false),
Minute = table.Column<byte>(type: "smallint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeStateScheduleEntry", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeStateScheduleEntry_CastleSiegeConfiguration_Cast~",
column: x => x.CastleSiegeConfigurationId,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CastleSiegeUpgradeDefinition",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CastleSiegeConfigurationId = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId1 = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId2 = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId3 = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId4 = table.Column<Guid>(type: "uuid", nullable: true),
Level = table.Column<byte>(type: "smallint", nullable: false),
RequiredJewelOfGuardianCount = table.Column<int>(type: "integer", nullable: false),
RequiredZen = table.Column<int>(type: "integer", nullable: false),
Value = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeUpgradeDefinition", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Castl~",
column: x => x.CastleSiegeConfigurationId,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~1",
column: x => x.CastleSiegeConfigurationId1,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~2",
column: x => x.CastleSiegeConfigurationId2,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~3",
column: x => x.CastleSiegeConfigurationId3,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~4",
column: x => x.CastleSiegeConfigurationId4,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CastleSiegeZoneDefinition",
schema: "config",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CastleSiegeConfigurationId = table.Column<Guid>(type: "uuid", nullable: true),
CastleSiegeConfigurationId1 = table.Column<Guid>(type: "uuid", nullable: true),
X1 = table.Column<byte>(type: "smallint", nullable: false),
Y1 = table.Column<byte>(type: "smallint", nullable: false),
X2 = table.Column<byte>(type: "smallint", nullable: false),
Y2 = table.Column<byte>(type: "smallint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeZoneDefinition", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeZoneDefinition_CastleSiegeConfiguration_CastleSi~",
column: x => x.CastleSiegeConfigurationId,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CastleSiegeZoneDefinition_CastleSiegeConfiguration_CastleS~1",
column: x => x.CastleSiegeConfigurationId1,
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GameConfiguration_CastleSiegeConfigurationId",
schema: "config",
table: "GameConfiguration",
column: "CastleSiegeConfigurationId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeConfiguration_AttackRespawnAreaId",
schema: "config",
table: "CastleSiegeConfiguration",
column: "AttackRespawnAreaId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeConfiguration_CastleSiegeMapDefinitionId",
schema: "config",
table: "CastleSiegeConfiguration",
column: "CastleSiegeMapDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeConfiguration_DefenseRespawnAreaId",
schema: "config",
table: "CastleSiegeConfiguration",
column: "DefenseRespawnAreaId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeConfiguration_LandOfTrialsMapDefinitionId",
schema: "config",
table: "CastleSiegeConfiguration",
column: "LandOfTrialsMapDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeConfiguration_RewardItemDefinitionId",
schema: "config",
table: "CastleSiegeConfiguration",
column: "RewardItemDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcDefinition_CastleSiegeConfigurationId",
schema: "config",
table: "CastleSiegeNpcDefinition",
column: "CastleSiegeConfigurationId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcDefinition_MonsterDefinitionId",
schema: "config",
table: "CastleSiegeNpcDefinition",
column: "MonsterDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcState_CastleSiegeDataId",
schema: "data",
table: "CastleSiegeNpcState",
column: "CastleSiegeDataId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeStateScheduleEntry_CastleSiegeConfigurationId",
schema: "config",
table: "CastleSiegeStateScheduleEntry",
column: "CastleSiegeConfigurationId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeUpgradeDefinition_CastleSiegeConfigurationId",
schema: "config",
table: "CastleSiegeUpgradeDefinition",
column: "CastleSiegeConfigurationId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeUpgradeDefinition_CastleSiegeConfigurationId1",
schema: "config",
table: "CastleSiegeUpgradeDefinition",
column: "CastleSiegeConfigurationId1");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeUpgradeDefinition_CastleSiegeConfigurationId2",
schema: "config",
table: "CastleSiegeUpgradeDefinition",
column: "CastleSiegeConfigurationId2");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeUpgradeDefinition_CastleSiegeConfigurationId3",
schema: "config",
table: "CastleSiegeUpgradeDefinition",
column: "CastleSiegeConfigurationId3");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeUpgradeDefinition_CastleSiegeConfigurationId4",
schema: "config",
table: "CastleSiegeUpgradeDefinition",
column: "CastleSiegeConfigurationId4");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeZoneDefinition_CastleSiegeConfigurationId",
schema: "config",
table: "CastleSiegeZoneDefinition",
column: "CastleSiegeConfigurationId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeZoneDefinition_CastleSiegeConfigurationId1",
schema: "config",
table: "CastleSiegeZoneDefinition",
column: "CastleSiegeConfigurationId1");
migrationBuilder.AddForeignKey(
name: "FK_GameConfiguration_CastleSiegeConfiguration_CastleSiegeConfi~",
schema: "config",
table: "GameConfiguration",
column: "CastleSiegeConfigurationId",
principalSchema: "config",
principalTable: "CastleSiegeConfiguration",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_CastleSiegeZoneDefinition_AttackRe~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "AttackRespawnAreaId",
principalSchema: "config",
principalTable: "CastleSiegeZoneDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_CastleSiegeZoneDefinition_DefenseR~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "DefenseRespawnAreaId",
principalSchema: "config",
principalTable: "CastleSiegeZoneDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GameConfiguration_CastleSiegeConfiguration_CastleSiegeConfi~",
schema: "config",
table: "GameConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_CastleSiegeZoneDefinition_AttackRe~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_CastleSiegeZoneDefinition_DefenseR~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropTable(
name: "CastleSiegeNpcDefinition",
schema: "config");
migrationBuilder.DropTable(
name: "CastleSiegeNpcState",
schema: "data");
migrationBuilder.DropTable(
name: "CastleSiegeStateScheduleEntry",
schema: "config");
migrationBuilder.DropTable(
name: "CastleSiegeUpgradeDefinition",
schema: "config");
migrationBuilder.DropTable(
name: "CastleSiegeData",
schema: "data");
migrationBuilder.DropTable(
name: "CastleSiegeZoneDefinition",
schema: "config");
migrationBuilder.DropTable(
name: "CastleSiegeConfiguration",
schema: "config");
migrationBuilder.DropIndex(
name: "IX_GameConfiguration_CastleSiegeConfigurationId",
schema: "config",
table: "GameConfiguration");
migrationBuilder.DropColumn(
name: "CastleSiegeConfigurationId",
schema: "config",
table: "GameConfiguration");
}
}
}

View File

@@ -0,0 +1,343 @@
// <copyright file="20260801162427_ConfigureCastleSiegePersistence.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class ConfigureCastleSiegePersistence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_CastleSiegeMapDe~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_LandOfTrialsMapD~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_ItemDefinition_RewardItemDefinitio~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeNpcDefinition_MonsterDefinition_MonsterDefinitio~",
schema: "config",
table: "CastleSiegeNpcDefinition");
migrationBuilder.DropIndex(
name: "IX_CastleSiegeNpcDefinition_MonsterDefinitionId",
schema: "config",
table: "CastleSiegeNpcDefinition");
migrationBuilder.Sql(
"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM config."CastleSiegeNpcDefinition" WHERE "MonsterDefinitionId" IS NULL) THEN
RAISE EXCEPTION 'CastleSiegeNpcDefinition contains rows without a MonsterDefinitionId. Repair or remove these rows before applying this migration.';
END IF;
END
$$;
""");
migrationBuilder.AlterColumn<Guid>(
name: "MonsterDefinitionId",
schema: "config",
table: "CastleSiegeNpcDefinition",
type: "uuid",
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "RegisterMinMembers",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
defaultValue: 20,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "RegisterMinLevel",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
defaultValue: 200,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "MaxAttackingGuilds",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
defaultValue: 3,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "CrownHoldTimeSeconds",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.CreateTable(
name: "CastleSiegeGuildRegistration",
schema: "data",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GuildId = table.Column<Guid>(type: "uuid", nullable: false),
GuildName = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
Marks = table.Column<int>(type: "integer", nullable: false),
RegistrationOrder = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CastleSiegeGuildRegistration", x => x.Id);
table.ForeignKey(
name: "FK_CastleSiegeGuildRegistration_Guild_GuildId",
column: x => x.GuildId,
principalSchema: "guild",
principalTable: "Guild",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcState_MonsterNumber_InstanceId",
schema: "data",
table: "CastleSiegeNpcState",
columns: new[] { "MonsterNumber", "InstanceId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcDefinition_MonsterDefinitionId_InstanceId",
schema: "config",
table: "CastleSiegeNpcDefinition",
columns: new[] { "MonsterDefinitionId", "InstanceId" });
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeData_OwnerGuildId",
schema: "data",
table: "CastleSiegeData",
column: "OwnerGuildId");
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeGuildRegistration_GuildId",
schema: "data",
table: "CastleSiegeGuildRegistration",
column: "GuildId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_CastleSiegeMapDe~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "CastleSiegeMapDefinitionId",
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_LandOfTrialsMapD~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "LandOfTrialsMapDefinitionId",
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_ItemDefinition_RewardItemDefinitio~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "RewardItemDefinitionId",
principalSchema: "config",
principalTable: "ItemDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeData_Guild_OwnerGuildId",
schema: "data",
table: "CastleSiegeData",
column: "OwnerGuildId",
principalSchema: "guild",
principalTable: "Guild",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeNpcDefinition_MonsterDefinition_MonsterDefinitio~",
schema: "config",
table: "CastleSiegeNpcDefinition",
column: "MonsterDefinitionId",
principalSchema: "config",
principalTable: "MonsterDefinition",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_CastleSiegeMapDe~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_LandOfTrialsMapD~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeConfiguration_ItemDefinition_RewardItemDefinitio~",
schema: "config",
table: "CastleSiegeConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeData_Guild_OwnerGuildId",
schema: "data",
table: "CastleSiegeData");
migrationBuilder.DropForeignKey(
name: "FK_CastleSiegeNpcDefinition_MonsterDefinition_MonsterDefinitio~",
schema: "config",
table: "CastleSiegeNpcDefinition");
migrationBuilder.DropTable(
name: "CastleSiegeGuildRegistration",
schema: "data");
migrationBuilder.DropIndex(
name: "IX_CastleSiegeNpcState_MonsterNumber_InstanceId",
schema: "data",
table: "CastleSiegeNpcState");
migrationBuilder.DropIndex(
name: "IX_CastleSiegeNpcDefinition_MonsterDefinitionId_InstanceId",
schema: "config",
table: "CastleSiegeNpcDefinition");
migrationBuilder.DropIndex(
name: "IX_CastleSiegeData_OwnerGuildId",
schema: "data",
table: "CastleSiegeData");
migrationBuilder.AlterColumn<Guid>(
name: "MonsterDefinitionId",
schema: "config",
table: "CastleSiegeNpcDefinition",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AlterColumn<int>(
name: "RegisterMinMembers",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldDefaultValue: 20);
migrationBuilder.AlterColumn<int>(
name: "RegisterMinLevel",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldDefaultValue: 200);
migrationBuilder.AlterColumn<int>(
name: "MaxAttackingGuilds",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldDefaultValue: 3);
migrationBuilder.AlterColumn<int>(
name: "CrownHoldTimeSeconds",
schema: "config",
table: "CastleSiegeConfiguration",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldDefaultValue: 30);
migrationBuilder.CreateIndex(
name: "IX_CastleSiegeNpcDefinition_MonsterDefinitionId",
schema: "config",
table: "CastleSiegeNpcDefinition",
column: "MonsterDefinitionId");
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_CastleSiegeMapDe~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "CastleSiegeMapDefinitionId",
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_GameMapDefinition_LandOfTrialsMapD~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "LandOfTrialsMapDefinitionId",
principalSchema: "config",
principalTable: "GameMapDefinition",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeConfiguration_ItemDefinition_RewardItemDefinitio~",
schema: "config",
table: "CastleSiegeConfiguration",
column: "RewardItemDefinitionId",
principalSchema: "config",
principalTable: "ItemDefinition",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CastleSiegeNpcDefinition_MonsterDefinition_MonsterDefinitio~",
schema: "config",
table: "CastleSiegeNpcDefinition",
column: "MonsterDefinitionId",
principalSchema: "config",
principalTable: "MonsterDefinition",
principalColumn: "Id");
}
}
}

View File

@@ -378,6 +378,329 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.ToTable("Buff", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AttackRespawnAreaId")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeMapDefinitionId")
.HasColumnType("uuid");
b.Property<int>("CrownHoldTimeSeconds")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(30);
b.Property<Guid?>("DefenseRespawnAreaId")
.HasColumnType("uuid");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("GateBuyPrice")
.HasColumnType("integer");
b.Property<int>("GuildScoreCastleSiege")
.HasColumnType("integer");
b.Property<int>("GuildScoreCastleSiegeMembers")
.HasColumnType("integer");
b.Property<Guid?>("LandOfTrialsMapDefinitionId")
.HasColumnType("uuid");
b.Property<int>("MaxAttackingGuilds")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<int>("ParticipantRewardMinSeconds")
.HasColumnType("integer");
b.Property<int>("RegisterMinLevel")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(200);
b.Property<int>("RegisterMinMembers")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(20);
b.Property<Guid?>("RewardItemDefinitionId")
.HasColumnType("uuid");
b.Property<int>("StatueBuyPrice")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AttackRespawnAreaId")
.IsUnique();
b.HasIndex("CastleSiegeMapDefinitionId");
b.HasIndex("DefenseRespawnAreaId")
.IsUnique();
b.HasIndex("LandOfTrialsMapDefinitionId");
b.HasIndex("RewardItemDefinitionId");
b.ToTable("CastleSiegeConfiguration", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsHuntZoneEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsOccupied")
.HasColumnType("boolean");
b.Property<Guid?>("OwnerGuildId")
.HasColumnType("uuid");
b.Property<byte>("TaxChaos")
.HasColumnType("smallint");
b.Property<int>("TaxHunt")
.HasColumnType("integer");
b.Property<byte>("TaxStore")
.HasColumnType("smallint");
b.Property<long>("TributeMoney")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("OwnerGuildId");
b.ToTable("CastleSiegeData", "data");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuildRegistration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("GuildId")
.HasColumnType("uuid");
b.Property<string>("GuildName")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<int>("Marks")
.HasColumnType("integer");
b.Property<int>("RegistrationOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GuildId")
.IsUnique();
b.ToTable("CastleSiegeGuildRegistration", "data");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<byte>("DefaultSide")
.HasColumnType("smallint");
b.Property<int>("Direction")
.HasColumnType("integer");
b.Property<byte>("InstanceId")
.HasColumnType("smallint");
b.Property<bool>("IsPersistedToDatabase")
.HasColumnType("boolean");
b.Property<Guid>("MonsterDefinitionId")
.HasColumnType("uuid");
b.Property<byte>("SpawnX")
.HasColumnType("smallint");
b.Property<byte>("SpawnY")
.HasColumnType("smallint");
b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId");
b.HasIndex("MonsterDefinitionId", "InstanceId");
b.ToTable("CastleSiegeNpcDefinition", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeDataId")
.HasColumnType("uuid");
b.Property<int>("CurrentHp")
.HasColumnType("integer");
b.Property<byte>("DefenseLevel")
.HasColumnType("smallint");
b.Property<byte>("InstanceId")
.HasColumnType("smallint");
b.Property<byte>("LifeLevel")
.HasColumnType("smallint");
b.Property<short>("MonsterNumber")
.HasColumnType("smallint");
b.Property<byte>("RegenLevel")
.HasColumnType("smallint");
b.HasKey("Id");
b.HasIndex("CastleSiegeDataId");
b.HasIndex("MonsterNumber", "InstanceId")
.IsUnique();
b.ToTable("CastleSiegeNpcState", "data");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeStateScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<int>("DayOfWeek")
.HasColumnType("integer");
b.Property<byte>("Hour")
.HasColumnType("smallint");
b.Property<byte>("Minute")
.HasColumnType("smallint");
b.Property<byte>("State")
.HasColumnType("smallint");
b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId");
b.ToTable("CastleSiegeStateScheduleEntry", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeUpgradeDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId1")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId2")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId3")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId4")
.HasColumnType("uuid");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<int>("RequiredJewelOfGuardianCount")
.HasColumnType("integer");
b.Property<int>("RequiredZen")
.HasColumnType("integer");
b.Property<int>("Value")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId");
b.HasIndex("CastleSiegeConfigurationId1");
b.HasIndex("CastleSiegeConfigurationId2");
b.HasIndex("CastleSiegeConfigurationId3");
b.HasIndex("CastleSiegeConfigurationId4");
b.ToTable("CastleSiegeUpgradeDefinition", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<Guid?>("CastleSiegeConfigurationId1")
.HasColumnType("uuid");
b.Property<byte>("X1")
.HasColumnType("smallint");
b.Property<byte>("X2")
.HasColumnType("smallint");
b.Property<byte>("Y1")
.HasColumnType("smallint");
b.Property<byte>("Y2")
.HasColumnType("smallint");
b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId");
b.HasIndex("CastleSiegeConfigurationId1");
b.ToTable("CastleSiegeZoneDefinition", "config");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{
b.Property<Guid>("Id")
@@ -1054,6 +1377,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Property<bool>("AreaSkillHitsPlayer")
.HasColumnType("boolean");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<string>("CharacterNameRegex")
.HasColumnType("text");
@@ -1145,6 +1471,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId")
.IsUnique();
b.HasIndex("DuelConfigurationId")
.IsUnique();
@@ -3638,6 +3967,139 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Navigation("RawMagicEffectDefinition");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", "RawAttackRespawnArea")
.WithOne()
.HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "AttackRespawnAreaId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawCastleSiegeMapDefinition")
.WithMany()
.HasForeignKey("CastleSiegeMapDefinitionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", "RawDefenseRespawnArea")
.WithOne()
.HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "DefenseRespawnAreaId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawLandOfTrialsMapDefinition")
.WithMany()
.HasForeignKey("LandOfTrialsMapDefinitionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawRewardItemDefinition")
.WithMany()
.HasForeignKey("RewardItemDefinitionId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("RawAttackRespawnArea");
b.Navigation("RawCastleSiegeMapDefinition");
b.Navigation("RawDefenseRespawnArea");
b.Navigation("RawLandOfTrialsMapDefinition");
b.Navigation("RawRewardItemDefinition");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null)
.WithMany()
.HasForeignKey("OwnerGuildId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuildRegistration", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null)
.WithMany()
.HasForeignKey("GuildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcDefinition", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawNpcDefinitions")
.HasForeignKey("CastleSiegeConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonsterDefinition")
.WithMany()
.HasForeignKey("MonsterDefinitionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("RawMonsterDefinition");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcState", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", null)
.WithMany("RawNpcStates")
.HasForeignKey("CastleSiegeDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeStateScheduleEntry", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawStateSchedule")
.HasForeignKey("CastleSiegeConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeUpgradeDefinition", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawGateDefenseUpgrades")
.HasForeignKey("CastleSiegeConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawGateLifeUpgrades")
.HasForeignKey("CastleSiegeConfigurationId1")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~1");
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawStatueDefenseUpgrades")
.HasForeignKey("CastleSiegeConfigurationId2")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~2");
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawStatueLifeUpgrades")
.HasForeignKey("CastleSiegeConfigurationId3")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~3");
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawStatueRegenUpgrades")
.HasForeignKey("CastleSiegeConfigurationId4")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~4");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawAttackMachineZones")
.HasForeignKey("CastleSiegeConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null)
.WithMany("RawDefenseMachineZones")
.HasForeignKey("CastleSiegeConfigurationId1")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_CastleSiegeZoneDefinition_CastleSiegeConfiguration_CastleS~1");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null)
@@ -3887,11 +4349,18 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b =>
{
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "RawCastleSiegeConfiguration")
.WithOne()
.HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "CastleSiegeConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", "RawDuelConfiguration")
.WithOne()
.HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("RawCastleSiegeConfiguration");
b.Navigation("RawDuelConfiguration");
});
@@ -5023,6 +5492,32 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Navigation("RawEquippedItems");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b =>
{
b.Navigation("RawAttackMachineZones");
b.Navigation("RawDefenseMachineZones");
b.Navigation("RawGateDefenseUpgrades");
b.Navigation("RawGateLifeUpgrades");
b.Navigation("RawNpcDefinitions");
b.Navigation("RawStateSchedule");
b.Navigation("RawStatueDefenseUpgrades");
b.Navigation("RawStatueLifeUpgrades");
b.Navigation("RawStatueRegenUpgrades");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b =>
{
b.Navigation("RawNpcStates");
});
modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{
b.Navigation("JoinedDropItemGroups");

View File

@@ -0,0 +1,276 @@
// <copyright file="CastleSiegeConfiguration.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration"/>.
/// </summary>
[Table(nameof(CastleSiegeConfiguration), Schema = SchemaNames.Configuration)]
internal partial class CastleSiegeConfiguration : MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration, IIdentifiable
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets the raw collection of <see cref="StateSchedule" />.
/// </summary>
public ICollection<CastleSiegeStateScheduleEntry> RawStateSchedule { get; } = new EntityFramework.List<CastleSiegeStateScheduleEntry>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry> StateSchedule => base.StateSchedule ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, CastleSiegeStateScheduleEntry>(this.RawStateSchedule);
/// <summary>
/// Gets the raw collection of <see cref="NpcDefinitions" />.
/// </summary>
public ICollection<CastleSiegeNpcDefinition> RawNpcDefinitions { get; } = new EntityFramework.List<CastleSiegeNpcDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition> NpcDefinitions => base.NpcDefinitions ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, CastleSiegeNpcDefinition>(this.RawNpcDefinitions);
/// <summary>
/// Gets the raw collection of <see cref="GateDefenseUpgrades" />.
/// </summary>
public ICollection<CastleSiegeUpgradeDefinition> RawGateDefenseUpgrades { get; } = new EntityFramework.List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> GateDefenseUpgrades => base.GateDefenseUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawGateDefenseUpgrades);
/// <summary>
/// Gets the raw collection of <see cref="GateLifeUpgrades" />.
/// </summary>
public ICollection<CastleSiegeUpgradeDefinition> RawGateLifeUpgrades { get; } = new EntityFramework.List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> GateLifeUpgrades => base.GateLifeUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawGateLifeUpgrades);
/// <summary>
/// Gets the raw collection of <see cref="StatueDefenseUpgrades" />.
/// </summary>
public ICollection<CastleSiegeUpgradeDefinition> RawStatueDefenseUpgrades { get; } = new EntityFramework.List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueDefenseUpgrades => base.StatueDefenseUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueDefenseUpgrades);
/// <summary>
/// Gets the raw collection of <see cref="StatueLifeUpgrades" />.
/// </summary>
public ICollection<CastleSiegeUpgradeDefinition> RawStatueLifeUpgrades { get; } = new EntityFramework.List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueLifeUpgrades => base.StatueLifeUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueLifeUpgrades);
/// <summary>
/// Gets the raw collection of <see cref="StatueRegenUpgrades" />.
/// </summary>
public ICollection<CastleSiegeUpgradeDefinition> RawStatueRegenUpgrades { get; } = new EntityFramework.List<CastleSiegeUpgradeDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition> StatueRegenUpgrades => base.StatueRegenUpgrades ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, CastleSiegeUpgradeDefinition>(this.RawStatueRegenUpgrades);
/// <summary>
/// Gets the raw collection of <see cref="AttackMachineZones" />.
/// </summary>
public ICollection<CastleSiegeZoneDefinition> RawAttackMachineZones { get; } = new EntityFramework.List<CastleSiegeZoneDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition> AttackMachineZones => base.AttackMachineZones ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, CastleSiegeZoneDefinition>(this.RawAttackMachineZones);
/// <summary>
/// Gets the raw collection of <see cref="DefenseMachineZones" />.
/// </summary>
public ICollection<CastleSiegeZoneDefinition> RawDefenseMachineZones { get; } = new EntityFramework.List<CastleSiegeZoneDefinition>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition> DefenseMachineZones => base.DefenseMachineZones ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, CastleSiegeZoneDefinition>(this.RawDefenseMachineZones);
/// <summary>
/// Gets or sets the identifier of <see cref="CastleSiegeMapDefinition"/>.
/// </summary>
public Guid? CastleSiegeMapDefinitionId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="CastleSiegeMapDefinition" />.
/// </summary>
[ForeignKey(nameof(CastleSiegeMapDefinitionId))]
public GameMapDefinition RawCastleSiegeMapDefinition
{
get => base.CastleSiegeMapDefinition as GameMapDefinition;
set => base.CastleSiegeMapDefinition = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition CastleSiegeMapDefinition
{
get => base.CastleSiegeMapDefinition;set
{
base.CastleSiegeMapDefinition = value;
this.CastleSiegeMapDefinitionId = this.RawCastleSiegeMapDefinition?.Id;
}
}
/// <summary>
/// Gets or sets the identifier of <see cref="LandOfTrialsMapDefinition"/>.
/// </summary>
public Guid? LandOfTrialsMapDefinitionId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="LandOfTrialsMapDefinition" />.
/// </summary>
[ForeignKey(nameof(LandOfTrialsMapDefinitionId))]
public GameMapDefinition RawLandOfTrialsMapDefinition
{
get => base.LandOfTrialsMapDefinition as GameMapDefinition;
set => base.LandOfTrialsMapDefinition = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition LandOfTrialsMapDefinition
{
get => base.LandOfTrialsMapDefinition;set
{
base.LandOfTrialsMapDefinition = value;
this.LandOfTrialsMapDefinitionId = this.RawLandOfTrialsMapDefinition?.Id;
}
}
/// <summary>
/// Gets or sets the identifier of <see cref="RewardItemDefinition"/>.
/// </summary>
public Guid? RewardItemDefinitionId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="RewardItemDefinition" />.
/// </summary>
[ForeignKey(nameof(RewardItemDefinitionId))]
public ItemDefinition RawRewardItemDefinition
{
get => base.RewardItemDefinition as ItemDefinition;
set => base.RewardItemDefinition = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.Items.ItemDefinition RewardItemDefinition
{
get => base.RewardItemDefinition;set
{
base.RewardItemDefinition = value;
this.RewardItemDefinitionId = this.RawRewardItemDefinition?.Id;
}
}
/// <summary>
/// Gets or sets the identifier of <see cref="DefenseRespawnArea"/>.
/// </summary>
public Guid? DefenseRespawnAreaId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="DefenseRespawnArea" />.
/// </summary>
[ForeignKey(nameof(DefenseRespawnAreaId))]
public CastleSiegeZoneDefinition RawDefenseRespawnArea
{
get => base.DefenseRespawnArea as CastleSiegeZoneDefinition;
set => base.DefenseRespawnArea = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition DefenseRespawnArea
{
get => base.DefenseRespawnArea;set
{
base.DefenseRespawnArea = value;
this.DefenseRespawnAreaId = this.RawDefenseRespawnArea?.Id;
}
}
/// <summary>
/// Gets or sets the identifier of <see cref="AttackRespawnArea"/>.
/// </summary>
public Guid? AttackRespawnAreaId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="AttackRespawnArea" />.
/// </summary>
[ForeignKey(nameof(AttackRespawnAreaId))]
public CastleSiegeZoneDefinition RawAttackRespawnArea
{
get => base.AttackRespawnArea as CastleSiegeZoneDefinition;
set => base.AttackRespawnArea = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition AttackRespawnArea
{
get => base.AttackRespawnArea;set
{
base.AttackRespawnArea = value;
this.AttackRespawnAreaId = this.RawAttackRespawnArea?.Id;
}
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeConfiguration();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="CastleSiegeData.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Entities.CastleSiegeData"/>.
/// </summary>
[Table(nameof(CastleSiegeData), Schema = SchemaNames.AccountData)]
internal partial class CastleSiegeData : MUnique.OpenMU.DataModel.Entities.CastleSiegeData, IIdentifiable
{
/// <summary>
/// Gets the raw collection of <see cref="NpcStates" />.
/// </summary>
public ICollection<CastleSiegeNpcState> RawNpcStates { get; } = new EntityFramework.List<CastleSiegeNpcState>();
/// <inheritdoc/>
[NotMapped]
public override ICollection<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState> NpcStates => base.NpcStates ??= new CollectionAdapter<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, CastleSiegeNpcState>(this.RawNpcStates);
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,47 @@
// <copyright file="CastleSiegeGuildRegistration.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration"/>.
/// </summary>
[Table(nameof(CastleSiegeGuildRegistration), Schema = SchemaNames.AccountData)]
internal partial class CastleSiegeGuildRegistration : MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration, IIdentifiable
{
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,91 @@
// <copyright file="CastleSiegeNpcDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition"/>.
/// </summary>
[Table(nameof(CastleSiegeNpcDefinition), Schema = SchemaNames.Configuration)]
internal partial class CastleSiegeNpcDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, IIdentifiable
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the identifier of <see cref="MonsterDefinition"/>.
/// </summary>
public Guid? MonsterDefinitionId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="MonsterDefinition" />.
/// </summary>
[ForeignKey(nameof(MonsterDefinitionId))]
public MonsterDefinition RawMonsterDefinition
{
get => base.MonsterDefinition as MonsterDefinition;
set => base.MonsterDefinition = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.MonsterDefinition MonsterDefinition
{
get => base.MonsterDefinition;set
{
base.MonsterDefinition = value;
this.MonsterDefinitionId = this.RawMonsterDefinition?.Id;
}
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeNpcDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,47 @@
// <copyright file="CastleSiegeNpcState.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState"/>.
/// </summary>
[Table(nameof(CastleSiegeNpcState), Schema = SchemaNames.AccountData)]
internal partial class CastleSiegeNpcState : MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, IIdentifiable
{
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,65 @@
// <copyright file="CastleSiegeStateScheduleEntry.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry"/>.
/// </summary>
[Table(nameof(CastleSiegeStateScheduleEntry), Schema = SchemaNames.Configuration)]
internal partial class CastleSiegeStateScheduleEntry : MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, IIdentifiable
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeStateScheduleEntry();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,65 @@
// <copyright file="CastleSiegeUpgradeDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition"/>.
/// </summary>
[Table(nameof(CastleSiegeUpgradeDefinition), Schema = SchemaNames.Configuration)]
internal partial class CastleSiegeUpgradeDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, IIdentifiable
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeUpgradeDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,65 @@
// <copyright file="CastleSiegeZoneDefinition.Generated.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
//------------------------------------------------------------------------------
// <auto-generated>
// This source code was auto-generated by a roslyn code generator.
// </auto-generated>
//------------------------------------------------------------------------------
// ReSharper disable All
namespace MUnique.OpenMU.Persistence.EntityFramework.Model;
using System.ComponentModel.DataAnnotations.Schema;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The Entity Framework Core implementation of <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition"/>.
/// </summary>
[Table(nameof(CastleSiegeZoneDefinition), Schema = SchemaNames.Configuration)]
internal partial class CastleSiegeZoneDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, IIdentifiable
{
/// <summary>
/// Gets or sets the identifier of this instance.
/// </summary>
public Guid Id { get; set; }
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
var clone = new CastleSiegeZoneDefinition();
clone.AssignValuesOf(this, gameConfiguration);
return clone;
}
/// <inheritdoc />
public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
base.AssignValuesOf(other, gameConfiguration);
this.Id = other.GetId();
}
/// <inheritdoc/>
public override bool Equals(object obj)
{
var baseObject = obj as IIdentifiable;
if (baseObject != null)
{
return baseObject.Id == this.Id;
}
return base.Equals(obj);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -27,6 +27,9 @@ public class ExtendedTypeContext : Microsoft.EntityFrameworkCore.DbContext
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Statistics.MiniGameRankingEntry>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Account>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.AppearanceData>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CastleSiegeData>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Character>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CharacterQuestState>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Guild>();
@@ -41,6 +44,11 @@ public class ExtendedTypeContext : Microsoft.EntityFrameworkCore.DbContext
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.AreaSkillSettings>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.BattleZoneDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.Buff>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.CharacterClass>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.ChatServerDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.ChatServerEndpoint>();
@@ -118,6 +126,7 @@ public class ExtendedTypeContext : Microsoft.EntityFrameworkCore.DbContext
modelBuilder.Entity<Account>().HasMany(entity => entity.RawCharacters).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account>().HasMany(entity => entity.RawAttributes).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AppearanceData>().HasMany(entity => entity.RawEquippedItems).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeData>().HasMany(entity => entity.RawNpcStates).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Character>().HasMany(entity => entity.RawAttributes).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Character>().HasMany(entity => entity.RawLetters).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Character>().HasMany(entity => entity.RawLearnedSkills).WithOne().OnDelete(DeleteBehavior.Cascade);
@@ -131,6 +140,17 @@ public class ExtendedTypeContext : Microsoft.EntityFrameworkCore.DbContext
modelBuilder.Entity<BattleZoneDefinition>().HasOne(entity => entity.RawLeftGoal).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<BattleZoneDefinition>().HasOne(entity => entity.RawRightGoal).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Buff>().HasOne(entity => entity.RawMagicEffectDefinition).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawStateSchedule).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawNpcDefinitions).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawGateDefenseUpgrades).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawGateLifeUpgrades).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawStatueDefenseUpgrades).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawStatueLifeUpgrades).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawStatueRegenUpgrades).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawAttackMachineZones).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasMany(entity => entity.RawDefenseMachineZones).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasOne(entity => entity.RawDefenseRespawnArea).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CastleSiegeConfiguration>().HasOne(entity => entity.RawAttackRespawnArea).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CharacterClass>().HasMany(entity => entity.RawStatAttributes).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CharacterClass>().HasMany(entity => entity.RawAttributeCombinations).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CharacterClass>().HasMany(entity => entity.RawBaseAttributeValues).WithOne().OnDelete(DeleteBehavior.Cascade);
@@ -159,6 +179,7 @@ public class ExtendedTypeContext : Microsoft.EntityFrameworkCore.DbContext
modelBuilder.Entity<GameConfiguration>().HasMany(entity => entity.RawGlobalBaseAttributeValues).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameConfiguration>().HasMany(entity => entity.RawPlugInConfigurations).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameConfiguration>().HasMany(entity => entity.RawMiniGameDefinitions).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameConfiguration>().HasOne(entity => entity.RawCastleSiegeConfiguration).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameMapDefinition>().HasMany(entity => entity.RawMonsterSpawns).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameMapDefinition>().HasMany(entity => entity.RawEnterGates).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameMapDefinition>().HasOne(entity => entity.RawBattleZone).WithOne().OnDelete(DeleteBehavior.Cascade);

View File

@@ -243,6 +243,32 @@ internal partial class GameConfiguration : MUnique.OpenMU.DataModel.Configuratio
}
}
/// <summary>
/// Gets or sets the identifier of <see cref="CastleSiegeConfiguration"/>.
/// </summary>
public Guid? CastleSiegeConfigurationId { get; set; }
/// <summary>
/// Gets the raw object of <see cref="CastleSiegeConfiguration" />.
/// </summary>
[ForeignKey(nameof(CastleSiegeConfigurationId))]
public CastleSiegeConfiguration RawCastleSiegeConfiguration
{
get => base.CastleSiegeConfiguration as CastleSiegeConfiguration;
set => base.CastleSiegeConfiguration = value;
}
/// <inheritdoc/>
[NotMapped]
public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration CastleSiegeConfiguration
{
get => base.CastleSiegeConfiguration;set
{
base.CastleSiegeConfiguration = value;
this.CastleSiegeConfigurationId = this.RawCastleSiegeConfiguration?.Id;
}
}
/// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.GameConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{

View File

@@ -44,6 +44,15 @@ public static class MapsterConfigurator
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.AppearanceData, MUnique.OpenMU.DataModel.Entities.AppearanceData>()
.Include<AppearanceData, BasicModel.AppearanceData>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.CastleSiegeData, MUnique.OpenMU.DataModel.Entities.CastleSiegeData>()
.Include<CastleSiegeData, BasicModel.CastleSiegeData>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration, MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration>()
.Include<CastleSiegeGuildRegistration, BasicModel.CastleSiegeGuildRegistration>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState>()
.Include<CastleSiegeNpcState, BasicModel.CastleSiegeNpcState>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.Character, MUnique.OpenMU.DataModel.Entities.Character>()
.Include<Character, BasicModel.Character>();
@@ -86,6 +95,21 @@ public static class MapsterConfigurator
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.Buff, MUnique.OpenMU.DataModel.Configuration.Buff>()
.Include<Buff, BasicModel.Buff>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration, MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration>()
.Include<CastleSiegeConfiguration, BasicModel.CastleSiegeConfiguration>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition>()
.Include<CastleSiegeNpcDefinition, BasicModel.CastleSiegeNpcDefinition>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry>()
.Include<CastleSiegeStateScheduleEntry, BasicModel.CastleSiegeStateScheduleEntry>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition>()
.Include<CastleSiegeUpgradeDefinition, BasicModel.CastleSiegeUpgradeDefinition>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition>()
.Include<CastleSiegeZoneDefinition, BasicModel.CastleSiegeZoneDefinition>();
Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CharacterClass, MUnique.OpenMU.DataModel.Configuration.CharacterClass>()
.Include<CharacterClass, BasicModel.CharacterClass>();

View File

@@ -0,0 +1,59 @@
// <copyright file="AddCastleSiegeDataUpdatePlugIn.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.DataModel.Entities;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Adds the Castle Siege configuration and persistent state to an existing Season 6 database.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("CD201E33-37C9-4C85-95CC-16042B28E974")]
public class AddCastleSiegeDataUpdatePlugIn : UpdatePlugInBase
{
/// <summary>
/// The plug-in name.
/// </summary>
internal const string PlugInName = "Add Castle Siege data";
/// <summary>
/// The plug-in description.
/// </summary>
internal const string PlugInDescription = "This update adds the Castle Siege configuration and persistent state.";
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.AddCastleSiegeData;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override bool IsMandatory => true;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 07, 28, 20, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
var initializer = new CastleSiegeInitializer(context, gameConfiguration);
var configuration = initializer.InitializeConfiguration();
if (!(await context.GetAsync<CastleSiegeData>().ConfigureAwait(false)).Any())
{
initializer.InitializeData(configuration);
}
}
}

View File

@@ -99,7 +99,7 @@ public class AddHeykelSavasiEventUpdateSeason6 : UpdatePlugInBase
var npc = context.CreateNew<MonsterDefinition>();
gameConfiguration.Monsters.Add(npc);
npc.Number = 560;
npc.Designation = "TvT Event Gorevlisi";
npc.Designation = "TvT Guard";
npc.NpcWindow = NpcWindow.Undefined;
npc.ObjectKind = NpcObjectKind.PassiveNpc;
npc.SetGuid(npc.Number);

View File

@@ -0,0 +1,62 @@
// <copyright file="RenameTvTEventNpcUpdatePlugIn.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.PlugIns;
/// <summary>
/// Renames the TvT event NPC (560) which stands in Lorencia. The name is what players see over its
/// head and what the admin panel lists, so it has to change on existing databases too - the seed
/// only covers new ones.
/// </summary>
[PlugIn]
[Display(Name = PlugInName, Description = PlugInDescription)]
[Guid("7B1C5E42-9A08-4D6E-9F31-2C4A5D6E7F80")]
public class RenameTvTEventNpcUpdatePlugIn : UpdatePlugInBase
{
/// <summary>
/// The plug-in name.
/// </summary>
internal const string PlugInName = "Rename the TvT event NPC";
/// <summary>
/// The plug-in description.
/// </summary>
internal const string PlugInDescription = "This update renames the TvT event NPC (560) in Lorencia to 'TvT Guard'.";
/// <summary>The monster number of the TvT event NPC in Lorencia.</summary>
private const short TvTEventNpcNumber = 560;
/// <inheritdoc />
public override string Name => PlugInName;
/// <inheritdoc />
public override string Description => PlugInDescription;
/// <inheritdoc />
public override UpdateVersion Version => UpdateVersion.RenameTvTEventNpc;
/// <inheritdoc />
public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
/// <inheritdoc />
public override bool IsMandatory => false;
/// <inheritdoc />
public override DateTime CreatedAt => new(2026, 08, 09, 0, 0, 0, DateTimeKind.Utc);
/// <inheritdoc />
protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
if (gameConfiguration.Monsters.FirstOrDefault(m => m.Number == TvTEventNpcNumber) is { } npc)
{
npc.Designation = "TvT Guard";
}
return ValueTask.CompletedTask;
}
}

View File

@@ -529,4 +529,19 @@ public enum UpdateVersion
/// The version of the <see cref="RepairImportedMapWarpsSeason6"/>.
/// </summary>
RepairImportedMapWarpsSeason6 = 104,
/// <summary>
/// The version of the <see cref="AddCastleSiegeDataUpdatePlugIn"/>.
/// </summary>
/// <remarks>
/// Upstream numbers this update 100. AdaMu already uses 95-104 for its own updates, so it is
/// renumbered to 105 here. Never reuse a number that has already shipped: the applied-update
/// bookkeeping is keyed on this value, so a collision would skip or re-run updates on live databases.
/// </remarks>
AddCastleSiegeData = 105,
/// <summary>
/// The version of the <see cref="RenameTvTEventNpcUpdatePlugIn"/>.
/// </summary>
RenameTvTEventNpc = 106,
}

View File

@@ -0,0 +1,228 @@
// <copyright file="CastleSiegeInitializer.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.Events;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
/// <summary>
/// Initializes the Castle Siege configuration and persistent state.
/// </summary>
internal sealed class CastleSiegeInitializer : InitializerBase
{
private const short GateMonsterNumber = 277;
private const short StatueMonsterNumber = 283;
/// <summary>
/// Initializes a new instance of the <see cref="CastleSiegeInitializer"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="gameConfiguration">The game configuration.</param>
public CastleSiegeInitializer(IContext context, GameConfiguration gameConfiguration)
: base(context, gameConfiguration)
{
}
/// <inheritdoc />
public override void Initialize()
{
var configuration = this.InitializeConfiguration();
this.InitializeData(configuration);
}
/// <summary>
/// Initializes the Castle Siege configuration, if it does not exist yet.
/// </summary>
/// <returns>The Castle Siege configuration.</returns>
internal CastleSiegeConfiguration InitializeConfiguration()
{
if (this.GameConfiguration.CastleSiegeConfiguration is { } existingConfiguration)
{
return existingConfiguration;
}
var configuration = this.Context.CreateNew<CastleSiegeConfiguration>();
configuration.Enabled = true;
configuration.CrownHoldTimeSeconds = 60; // The client's crown registration panel counts down from 60s.
configuration.RegisterMinLevel = 200;
configuration.RegisterMinMembers = 20;
configuration.ParticipantRewardMinSeconds = 60;
configuration.MaxAttackingGuilds = 3;
configuration.GuildScoreCastleSiege = 0;
configuration.GuildScoreCastleSiegeMembers = 0;
configuration.GateBuyPrice = 9_500_000;
configuration.StatueBuyPrice = 4_500_000;
configuration.CastleSiegeMapDefinition = this.GameConfiguration.Maps.Single(map => map.Number == ValleyOfLoren.Number);
configuration.LandOfTrialsMapDefinition = this.GameConfiguration.Maps.Single(map => map.Number == LandOfTrials.Number);
// AdaMu deliberately leaves StateSchedule empty. Upstream drives the cycle from a fixed weekly
// schedule; AdaMu drives it from CastleSiegeEventPlugIn (manual /cs commands, AdminPanel durations
// and the optional auto-open times in the plugin configuration). Nothing reads StateSchedule here.
this.InitializeNpcDefinitions(configuration);
this.InitializeUpgradeDefinitions(configuration);
this.InitializeMachineZones(configuration);
configuration.DefenseRespawnArea = this.CreateZone(74, 144, 115, 154);
configuration.AttackRespawnArea = this.CreateZone(35, 11, 144, 48);
this.GameConfiguration.CastleSiegeConfiguration = configuration;
return configuration;
}
/// <summary>
/// Initializes the persistent Castle Siege state.
/// </summary>
/// <param name="configuration">The Castle Siege configuration.</param>
/// <returns>The persistent Castle Siege state.</returns>
internal CastleSiegeData InitializeData(CastleSiegeConfiguration configuration)
{
var data = this.Context.CreateNew<CastleSiegeData>();
data.OwnerGuildId = null;
data.IsOccupied = false;
data.TaxChaos = 0;
data.TaxStore = 0;
data.TaxHunt = 0;
data.IsHuntZoneEnabled = false;
data.TributeMoney = 0;
var gateHitPoints = configuration.GateLifeUpgrades.Single(upgrade => upgrade.Level == 0).Value;
var statueHitPoints = configuration.StatueLifeUpgrades.Single(upgrade => upgrade.Level == 0).Value;
foreach (var npcDefinition in configuration.NpcDefinitions.Where(definition => definition.IsPersistedToDatabase))
{
var npcState = this.Context.CreateNew<CastleSiegeNpcState>();
npcState.MonsterNumber = npcDefinition.MonsterDefinition!.Number;
npcState.InstanceId = npcDefinition.InstanceId;
npcState.DefenseLevel = 0;
npcState.RegenLevel = 0;
npcState.LifeLevel = 0;
npcState.CurrentHp = npcState.MonsterNumber switch
{
GateMonsterNumber => gateHitPoints,
StatueMonsterNumber => statueHitPoints,
_ => throw new InvalidOperationException($"The persisted Castle Siege NPC monster number {npcState.MonsterNumber} is unsupported."),
};
data.NpcStates.Add(npcState);
}
return data;
}
private void InitializeNpcDefinitions(CastleSiegeConfiguration configuration)
{
this.AddNpc(configuration, 216, 1, false, CastleSiegeJoinSide.Attack1, 176, 212, Direction.SouthWest);
this.AddNpc(configuration, 217, 1, false, CastleSiegeJoinSide.Attack1, 167, 194, Direction.NorthWest);
this.AddNpc(configuration, 218, 1, false, CastleSiegeJoinSide.Attack1, 184, 195, Direction.NorthWest);
this.AddNpc(configuration, 219, 1, false, CastleSiegeJoinSide.Defense, 93, 208, Direction.SouthWest);
this.AddNpc(configuration, 219, 2, false, CastleSiegeJoinSide.Defense, 81, 165, Direction.SouthWest);
this.AddNpc(configuration, 219, 3, false, CastleSiegeJoinSide.Defense, 107, 165, Direction.SouthWest);
this.AddNpc(configuration, 219, 4, false, CastleSiegeJoinSide.Defense, 67, 118, Direction.SouthWest);
this.AddNpc(configuration, 219, 5, false, CastleSiegeJoinSide.Defense, 93, 118, Direction.SouthWest);
this.AddNpc(configuration, 219, 6, false, CastleSiegeJoinSide.Defense, 119, 118, Direction.SouthWest);
this.AddNpc(configuration, 221, 1, false, CastleSiegeJoinSide.Attack1, 63, 19, Direction.NorthEast);
this.AddNpc(configuration, 221, 2, false, CastleSiegeJoinSide.Attack1, 119, 19, Direction.NorthEast);
this.AddNpc(configuration, 222, 1, false, CastleSiegeJoinSide.Defense, 80, 188, Direction.SouthWest);
this.AddNpc(configuration, 222, 2, false, CastleSiegeJoinSide.Defense, 105, 188, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 1, true, CastleSiegeJoinSide.Defense, 93, 204, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 2, true, CastleSiegeJoinSide.Defense, 81, 161, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 3, true, CastleSiegeJoinSide.Defense, 107, 161, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 4, true, CastleSiegeJoinSide.Defense, 67, 114, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 5, true, CastleSiegeJoinSide.Defense, 93, 114, Direction.SouthWest);
this.AddNpc(configuration, GateMonsterNumber, 6, true, CastleSiegeJoinSide.Defense, 119, 114, Direction.SouthWest);
this.AddNpc(configuration, StatueMonsterNumber, 1, true, CastleSiegeJoinSide.Defense, 94, 227, Direction.SouthWest);
this.AddNpc(configuration, StatueMonsterNumber, 2, true, CastleSiegeJoinSide.Defense, 94, 182, Direction.SouthWest);
this.AddNpc(configuration, StatueMonsterNumber, 3, true, CastleSiegeJoinSide.Defense, 82, 130, Direction.SouthWest);
this.AddNpc(configuration, StatueMonsterNumber, 4, true, CastleSiegeJoinSide.Defense, 107, 130, Direction.SouthWest);
}
private void InitializeUpgradeDefinitions(CastleSiegeConfiguration configuration)
{
this.AddUpgrade(configuration.GateDefenseUpgrades, 0, 0, 0, 100);
this.AddUpgrade(configuration.GateDefenseUpgrades, 1, 2, 3_000_000, 180);
this.AddUpgrade(configuration.GateDefenseUpgrades, 2, 3, 3_000_000, 300);
this.AddUpgrade(configuration.GateDefenseUpgrades, 3, 4, 3_000_000, 520);
this.AddUpgrade(configuration.StatueDefenseUpgrades, 0, 0, 0, 80);
this.AddUpgrade(configuration.StatueDefenseUpgrades, 1, 3, 3_000_000, 180);
this.AddUpgrade(configuration.StatueDefenseUpgrades, 2, 5, 3_000_000, 340);
this.AddUpgrade(configuration.StatueDefenseUpgrades, 3, 7, 3_000_000, 550);
this.AddUpgrade(configuration.GateLifeUpgrades, 0, 0, 0, 1_900_000);
this.AddUpgrade(configuration.GateLifeUpgrades, 1, 2, 1_000_000, 2_500_000);
this.AddUpgrade(configuration.GateLifeUpgrades, 2, 3, 1_000_000, 3_500_000);
this.AddUpgrade(configuration.GateLifeUpgrades, 3, 4, 1_000_000, 5_200_000);
this.AddUpgrade(configuration.StatueLifeUpgrades, 0, 0, 0, 1_500_000);
this.AddUpgrade(configuration.StatueLifeUpgrades, 1, 3, 1_000_000, 2_200_000);
this.AddUpgrade(configuration.StatueLifeUpgrades, 2, 5, 1_000_000, 3_400_000);
this.AddUpgrade(configuration.StatueLifeUpgrades, 3, 7, 1_000_000, 5_000_000);
this.AddUpgrade(configuration.StatueRegenUpgrades, 0, 0, 0, 0);
this.AddUpgrade(configuration.StatueRegenUpgrades, 1, 3, 5_000_000, 1);
this.AddUpgrade(configuration.StatueRegenUpgrades, 2, 5, 5_000_000, 2);
this.AddUpgrade(configuration.StatueRegenUpgrades, 3, 7, 5_000_000, 3);
}
private void InitializeMachineZones(CastleSiegeConfiguration configuration)
{
configuration.AttackMachineZones.Add(this.CreateZone(62, 103, 72, 112));
configuration.AttackMachineZones.Add(this.CreateZone(88, 104, 124, 111));
configuration.AttackMachineZones.Add(this.CreateZone(116, 105, 124, 112));
configuration.AttackMachineZones.Add(this.CreateZone(73, 86, 105, 103));
configuration.DefenseMachineZones.Add(this.CreateZone(61, 88, 93, 108));
configuration.DefenseMachineZones.Add(this.CreateZone(92, 89, 127, 111));
configuration.DefenseMachineZones.Add(this.CreateZone(84, 52, 102, 66));
}
private void AddNpc(
CastleSiegeConfiguration configuration,
short monsterNumber,
byte instanceId,
bool isPersisted,
CastleSiegeJoinSide defaultSide,
byte spawnX,
byte spawnY,
Direction direction)
{
var definition = this.Context.CreateNew<CastleSiegeNpcDefinition>();
definition.MonsterDefinition = this.GameConfiguration.Monsters.Single(monster => monster.Number == monsterNumber);
definition.InstanceId = instanceId;
definition.IsPersistedToDatabase = isPersisted;
definition.DefaultSide = defaultSide;
definition.SpawnX = spawnX;
definition.SpawnY = spawnY;
definition.Direction = direction;
configuration.NpcDefinitions.Add(definition);
}
private void AddUpgrade(
ICollection<CastleSiegeUpgradeDefinition> target,
byte level,
int jewelCount,
int zen,
int value)
{
var upgrade = this.Context.CreateNew<CastleSiegeUpgradeDefinition>();
upgrade.Level = level;
upgrade.RequiredJewelOfGuardianCount = jewelCount;
upgrade.RequiredZen = zen;
upgrade.Value = value;
target.Add(upgrade);
}
private CastleSiegeZoneDefinition CreateZone(byte x1, byte y1, byte x2, byte y2)
{
var zone = this.Context.CreateNew<CastleSiegeZoneDefinition>();
zone.X1 = x1;
zone.Y1 = y1;
zone.X2 = x2;
zone.Y2 = y2;
return zone;
}
}

View File

@@ -89,6 +89,7 @@ public class GameConfigurationInitializer : GameConfigurationInitializerBase
new BloodCastleInitializer(this.Context, this.GameConfiguration).Initialize();
new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize();
new HeykelSavasiInitializer(this.Context, this.GameConfiguration).Initialize();
new CastleSiegeInitializer(this.Context, this.GameConfiguration).Initialize();
}
private void CreateJewelMixes()

View File

@@ -816,7 +816,7 @@ internal partial class NpcInitialization : Version095d.NpcInitialization
{
var def = this.Context.CreateNew<MonsterDefinition>();
def.Number = 560;
def.Designation = "TvT Event Gorevlisi";
def.Designation = "TvT Guard";
def.NpcWindow = NpcWindow.Undefined; // routed via IPlayerTalkToNpcPlugIn in a later task
def.ObjectKind = NpcObjectKind.PassiveNpc;
this.GameConfiguration.Monsters.Add(def);

View File

@@ -41,8 +41,15 @@
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup>
<!--
The generator overwrites the checked-in *.Generated.cs files, so it must run against the current data
model. Do NOT add the "no build" switch here: it would reuse whatever assemblies happen to lie in the
generator's output folder, and a stale copy of the data model silently regenerates the model files
without the types added since. The build still succeeds and only fails at runtime, when EF validates
the model.
-->
<Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(ci)'!='true'">
<Exec Command="dotnet run --project SourceGenerator/MUnique.OpenMU.Persistence.SourceGenerator.csproj -c:$(ConfigurationName) MUnique.OpenMU.Persistence &quot;$(ProjectDir)BasicModel&quot; --no-build" />
<Exec Command="dotnet run --project SourceGenerator/MUnique.OpenMU.Persistence.SourceGenerator.csproj -c:$(ConfigurationName) MUnique.OpenMU.Persistence &quot;$(ProjectDir)BasicModel&quot;" />
</Target>
</Project>

View File

@@ -8,7 +8,7 @@
@implements IDisposable
@inject CreationPanelService Panel
@inject Blazored.Toast.Services.IToastService ToastService
@inject IToastService ToastService
@if (this.Panel.Current is { } session)
{

View File

@@ -39,7 +39,7 @@
</div>
</div>
<BlazoredToasts />
<ToastContainer />
<article class="content px-4 py-3">
@Body

View File

@@ -25,7 +25,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Blazored.Toast" />
<PackageReference Include="BlazorInputFile" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
<PackageReference Include="Nito.AsyncEx" />

View File

@@ -6,12 +6,12 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -6,13 +6,13 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
@@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -8,7 +8,6 @@ using System.Collections;
using System.ComponentModel;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.QuickGrid;
using Microsoft.Extensions.Logging;
@@ -18,6 +17,7 @@ using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
@@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components;
using MUnique.OpenMU.Web.Shared.Components.MapEditor;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -1,57 +1,439 @@
@page "/logfiles"
@using System.IO
@using Microsoft.Extensions.Logging
@using MUnique.OpenMU.Web.AdminPanel.Properties
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@inject ILogger<LogFiles> Logger
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
<div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>@Resources.FileName</th>
<th>@Resources.LastUpdate</th>
<th>@Resources.Size</th>
</tr>
</thead>
<tbody>
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
{
<tr>
<td>
<a href="logs/@entry.Name">@entry.Name</a>
</td>
<td>@entry.LastWriteTime</td>
<td>@FormatFileSize(entry.Length)</td>
</tr>
}
</tbody>
</table>
<div class="row">
<!-- Left Column: File List -->
<div class="@(this._selectedFile != null ? "col-lg-3 col-md-4" : "col-12") transition-all">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-light py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<strong class="m-0">@Resources.LogFiles</strong>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" @onclick="this.RefreshFileList" title="@Resources.ReloadFileList">
<span class="oi oi-reload" style="font-size: 11px;"></span>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 620px; overflow-y: auto;">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th>@Resources.FileName</th>
@if (this._selectedFile == null)
{
<th>@Resources.LastUpdate</th>
<th>@Resources.Size</th>
}
<th class="text-end px-3">@Resources.Actions</th>
</tr>
</thead>
<tbody>
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
{
var isSelected = this._selectedFile?.FullName == entry.FullName;
<tr class="@(isSelected ? "table-info" : "")">
<td>
<button type="button" class="btn btn-link p-0 text-start font-monospace text-decoration-none fw-bold text-truncate" style="max-width: @(this._selectedFile != null ? "140px" : "100%");" @onclick="() => this.SelectFile(entry)" title="@entry.Name">
<span class="oi oi-terminal me-1 @(isSelected ? "text-primary" : "text-muted")"></span>@entry.Name
</button>
@if (this._selectedFile != null)
{
<div class="text-muted" style="font-size: 11px;">@FormatFileSize(entry.Length)</div>
}
</td>
@if (this._selectedFile == null)
{
<td>@entry.LastWriteTime</td>
<td>@FormatFileSize(entry.Length)</td>
}
<td class="text-end px-3">
<a href="logs/@entry.Name" download class="btn btn-sm btn-outline-secondary py-0 px-2" title="@Resources.DownloadFile">
<span class="oi oi-data-transfer-download" aria-hidden="true"></span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Right Column: Log Viewer Terminal -->
@if (this._selectedFile != null)
{
<div class="col-lg-9 col-md-8">
<div class="card border-secondary shadow-lg mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center py-2 px-3">
<div class="d-flex align-items-center text-truncate me-2">
<span class="oi oi-terminal text-info me-2" aria-hidden="true"></span>
<span class="me-2 d-none d-sm-inline">@Resources.LogViewer:</span>
<span class="badge bg-secondary font-monospace text-truncate" style="max-width: 250px;">@this._selectedFile.Name</span>
</div>
<div class="d-flex align-items-center gap-2 gap-sm-3 flex-shrink-0">
<div class="form-check form-switch m-0 d-flex align-items-center gap-2">
<input class="form-check-input cursor-pointer" type="checkbox" id="liveUpdateSwitch" @onchange="this.ToggleLiveUpdate" checked="@this._liveUpdate">
<label class="form-check-label text-light select-none cursor-pointer" for="liveUpdateSwitch" style="font-size: 13px;">@Resources.Live</label>
</div>
<button class="btn btn-sm btn-outline-info d-flex align-items-center gap-1 py-1" @onclick="this.RefreshLogLines">
<span class="oi oi-reload" style="font-size: 11px;"></span> @Resources.Refresh
</button>
<button class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1 py-1" @onclick="this.CloseViewer">
<span class="oi oi-x" style="font-size: 11px;"></span> @Resources.Close
</button>
</div>
</div>
<div class="card-body bg-dark p-3" style="background-color: #121214 !important;">
<div class="row g-2 mb-3">
<div class="col">
<div class="input-group">
<span class="input-group-text bg-secondary text-white border-0"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span>
<input type="text" class="form-control bg-secondary text-white border-0" placeholder="@Resources.FilterLogEntries" value="@this._searchText" @oninput="this.OnSearchInput" style="background-color: #2b2b30 !important; color: #fff !important;" />
@if (!string.IsNullOrEmpty(this._searchText))
{
<button class="btn btn-secondary border-0" @onclick="this.ClearSearch"><span class="oi oi-x" aria-hidden="true"></span></button>
}
</div>
</div>
</div>
<div id="@TerminalElementId" class="p-3 rounded" style="height: 480px; overflow-y: auto; font-family: 'Consolas', 'Liberation Mono', Menlo, Courier, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; background-color: #0c0c0d !important; border: 1px solid #2d2d30;">
@if (this._logLines.Count == 0)
{
<div class="text-muted text-center py-5">@Resources.NoLogEntriesFound</div>
}
else if (this._filteredLines.Count == 0)
{
<div class="text-muted text-center py-5">@Resources.NoLogEntriesMatchFilter</div>
}
else
{
@foreach (var line in this._filteredLines)
{
<div style="@GetLineColorStyle(line)">@line</div>
}
}
</div>
<div class="d-flex justify-content-between align-items-center mt-2 text-muted" style="font-size: 12px;">
<div>
@string.Format(Resources.ShowingXOfYLines, this._filteredLines.Count, this._logLines.Count, MaxLogLinesToRead)
</div>
<button class="btn btn-sm btn-outline-secondary py-1 px-2" style="font-size: 12px; color: #a0a0a8;" @onclick="this.ScrollToBottomAsync">
<span class="oi oi-arrow-bottom" aria-hidden="true"></span> @Resources.ScrollToBottom
</button>
</div>
</div>
</div>
</div>
}
</div>
@code {
private readonly List<FileInfo> _files = new ();
private const int MaxLogLinesToRead = 300;
private const long LogReadBufferSizeBytes = 102400; // 100 KB
private const int LiveUpdateIntervalMs = 2000;
private const string TerminalElementId = "log-terminal";
/// <summary>
/// Initializes a new instance of class <see cref="LogFiles"/>.
/// </summary>
public LogFiles()
// The admin panel publishes its static web assets under _content/<assembly>, so a path relative
// to the page url ("./Pages/...") does not resolve. Mirrors ThemeSelector and MapEditor.
private static readonly string JsModulePath =
$"./_content/{typeof(LogFiles).Assembly.GetName().Name}/Pages/{nameof(LogFiles)}.razor.js";
private readonly List<FileInfo> _files = new();
private FileInfo? _selectedFile;
private long _lastFileLength;
private DateTime _lastFileWriteTime;
private List<string> _logLines = new();
private List<string> _filteredLines = new();
private string _searchText = string.Empty;
private bool _liveUpdate;
private System.Threading.Timer? _timer;
private bool _shouldScrollToBottom;
private bool _disposed;
private IJSObjectReference? _jsModule;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs"));
foreach (var filePath in files)
this._disposed = true;
this._timer?.Dispose();
if (this._jsModule != null)
{
this._files.Add(new FileInfo(filePath));
try
{
await this._jsModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
// The circuit is already gone, so the module is disposed anyway.
}
}
}
private string FormatFileSize(long size)
/// <inheritdoc />
protected override void OnInitialized()
{
this.RefreshFileList();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
this._jsModule = await this.JSRuntime.InvokeAsync<IJSObjectReference>("import", JsModulePath);
}
catch (JSException ex)
{
// Without the module, the viewer still works - only the automatic scrolling is unavailable.
this.Logger.LogWarning(ex, "Could not load the log viewer javascript module.");
}
catch (JSDisconnectedException)
{
// The circuit is gone; nothing to do.
}
}
if (this._shouldScrollToBottom)
{
this._shouldScrollToBottom = false;
await this.ScrollToBottomAsync();
}
}
private static string FormatFileSize(long size)
{
return size switch
{
(< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB",
(< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB",
(< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB",
_ => $"{size} bytes"
};
< 1024 => $"{size} bytes",
< 1024 * 1024 => $"{Math.Round(size / 1024D, 2)} KiB",
< 1024L * 1024 * 1024 => $"{Math.Round(size / (1024D * 1024D), 2)} MiB",
_ => $"{Math.Round(size / (1024D * 1024D * 1024D), 2)} GiB",
};
}
private static List<string> ReadLastLines(string path, int maxLines)
{
var lines = new List<string>();
try
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
long offset = Math.Max(0, fs.Length - LogReadBufferSizeBytes);
fs.Seek(offset, SeekOrigin.Begin);
using var reader = new StreamReader(fs, System.Text.Encoding.UTF8);
if (offset > 0)
{
// Discard partial line
reader.ReadLine();
}
string? line;
while ((line = reader.ReadLine()) != null)
{
lines.Add(line);
}
if (lines.Count > maxLines)
{
lines = lines.Skip(lines.Count - maxLines).ToList();
}
}
catch (Exception ex)
{
lines.Add($"Error reading log file: {ex.Message}");
}
return lines;
}
private static string GetLineColorStyle(string line)
{
if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase))
{
return "color: #ff6b6b; font-weight: bold;";
}
if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase))
{
return "color: #feca57;";
}
if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase))
{
return "color: #8a8d93; font-style: italic;";
}
if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase))
{
return "color: #1dd1a1;";
}
return "color: #d1d2d6;";
}
private void RefreshFileList()
{
this._files.Clear();
var logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (Directory.Exists(logsPath))
{
var files = Directory.GetFiles(logsPath);
foreach (var filePath in files)
{
this._files.Add(new FileInfo(filePath));
}
}
}
private void SelectFile(FileInfo file)
{
this._selectedFile = file;
this._searchText = string.Empty;
this._lastFileLength = -1;
this.RefreshLogLines();
this._shouldScrollToBottom = true;
this.SetupTimer();
}
private void CloseViewer()
{
this._selectedFile = null;
this._searchText = string.Empty;
this._logLines.Clear();
this._filteredLines.Clear();
this._liveUpdate = false;
this.SetupTimer();
}
private void ClearSearch()
{
this._searchText = string.Empty;
this.UpdateFilteredLines();
}
private void OnSearchInput(ChangeEventArgs e)
{
this._searchText = e.Value?.ToString() ?? string.Empty;
this.UpdateFilteredLines();
}
private void ToggleLiveUpdate(ChangeEventArgs e)
{
this._liveUpdate = (bool)(e.Value ?? false);
this.SetupTimer();
}
private void SetupTimer()
{
if (this._liveUpdate && this._selectedFile != null)
{
this._timer ??= new System.Threading.Timer(_ =>
{
if (this._disposed)
{
return;
}
this.InvokeAsync(async () =>
{
if (this._disposed || this._selectedFile == null)
{
return;
}
var updatedInfo = new FileInfo(this._selectedFile.FullName);
if (updatedInfo.Length == this._lastFileLength && updatedInfo.LastWriteTimeUtc == this._lastFileWriteTime)
{
return;
}
// Only follow the new entries when the user didn't scroll up to read the history.
var isFollowing = await this.IsScrolledToBottomAsync();
this.RefreshLogLines();
this._shouldScrollToBottom = isFollowing;
this.StateHasChanged();
});
}, null, 0, LiveUpdateIntervalMs);
}
else
{
this._timer?.Dispose();
this._timer = null;
}
}
private void RefreshLogLines()
{
if (this._selectedFile == null)
{
return;
}
var fileInfo = new FileInfo(this._selectedFile.FullName);
this._selectedFile = fileInfo;
this._lastFileLength = fileInfo.Length;
this._lastFileWriteTime = fileInfo.LastWriteTimeUtc;
this._logLines = ReadLastLines(fileInfo.FullName, MaxLogLinesToRead);
this.UpdateFilteredLines();
}
private void UpdateFilteredLines()
{
if (string.IsNullOrWhiteSpace(this._searchText))
{
this._filteredLines = this._logLines;
}
else
{
this._filteredLines = this._logLines
.Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}
private async Task ScrollToBottomAsync()
{
if (this._jsModule is null)
{
return;
}
try
{
await this._jsModule.InvokeVoidAsync("scrollToBottom", TerminalElementId);
}
catch (JSDisconnectedException)
{
// The circuit is gone; nothing to do.
}
}
private async ValueTask<bool> IsScrolledToBottomAsync()
{
if (this._jsModule is null)
{
return true;
}
try
{
return await this._jsModule.InvokeAsync<bool>("isScrolledToBottom", TerminalElementId);
}
catch (JSDisconnectedException)
{
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
export function scrollToBottom(elementId) {
const el = document.getElementById(elementId);
if (el) {
el.scrollTop = el.scrollHeight;
}
}
export function isScrolledToBottom(elementId) {
const el = document.getElementById(elementId);
if (el) {
return Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) < 50;
}
return true;
}

View File

@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.QuickGrid;
using Microsoft.AspNetCore.Components.Routing;
@@ -16,6 +15,7 @@ using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Toast;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>

View File

@@ -2,7 +2,6 @@
@using System.ComponentModel
@using Microsoft.Extensions.DependencyInjection
@using Blazored.Toast.Services
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.Web.AdminPanel.Properties

View File

@@ -249,6 +249,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Close.
/// </summary>
public static string Close {
get {
return ResourceManager.GetString("Close", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Command.
/// </summary>
@@ -468,6 +477,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Download File.
/// </summary>
public static string DownloadFile {
get {
return ResourceManager.GetString("DownloadFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Drop item groups.
/// </summary>
@@ -558,6 +576,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Filter log entries....
/// </summary>
public static string FilterLogEntries {
get {
return ResourceManager.GetString("FilterLogEntries", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Finished! Have fun :).
/// </summary>
@@ -774,6 +801,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Live.
/// </summary>
public static string Live {
get {
return ResourceManager.GetString("Live", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Live Map.
/// </summary>
@@ -819,6 +855,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Log Viewer.
/// </summary>
public static string LogViewer {
get {
return ResourceManager.GetString("LogViewer", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Major.
/// </summary>
@@ -972,6 +1017,24 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to No log entries found..
/// </summary>
public static string NoLogEntriesFound {
get {
return ResourceManager.GetString("NoLogEntriesFound", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No log entries match your filter..
/// </summary>
public static string NoLogEntriesMatchFilter {
get {
return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This command has no parameters..
/// </summary>
@@ -1197,6 +1260,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Reload File List.
/// </summary>
public static string ReloadFileList {
get {
return ResourceManager.GetString("ReloadFileList", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Remove.
/// </summary>
@@ -1260,6 +1332,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Scroll to Bottom.
/// </summary>
public static string ScrollToBottom {
get {
return ResourceManager.GetString("ScrollToBottom", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search.
/// </summary>
@@ -1377,6 +1458,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded)..
/// </summary>
public static string ShowingXOfYLines {
get {
return ResourceManager.GetString("ShowingXOfYLines", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Size.
/// </summary>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
@@ -612,4 +612,70 @@
<data name="Target" xml:space="preserve">
<value>Target</value>
</data>
<data name="LogViewer" xml:space="preserve">
<value>Log Viewer</value>
</data>
<data name="Live" xml:space="preserve">
<value>Live</value>
</data>
<data name="Close" xml:space="preserve">
<value>Close</value>
</data>
<data name="FilterLogEntries" xml:space="preserve">
<value>Filter log entries...</value>
</data>
<data name="NoLogEntriesFound" xml:space="preserve">
<value>No log entries found.</value>
</data>
<data name="NoLogEntriesMatchFilter" xml:space="preserve">
<value>No log entries match your filter.</value>
</data>
<data name="ShowingXOfYLines" xml:space="preserve">
<value>Showing {0} of {1} lines (Last {2} lines loaded).</value>
</data>
<data name="ScrollToBottom" xml:space="preserve">
<value>Scroll to Bottom</value>
</data>
<data name="ReloadFileList" xml:space="preserve">
<value>Reload File List</value>
</data>
<data name="DownloadFile" xml:space="preserve">
<value>Download File</value>
</data>
<data name="ChatCommands" xml:space="preserve">
<value>Chat commands</value>
</data>
<data name="CommandColumn" xml:space="preserve">
<value>Command</value>
</data>
<data name="CommandDescription" xml:space="preserve">
<value>Description</value>
</data>
<data name="CommandUsage" xml:space="preserve">
<value>Usage</value>
</data>
<data name="MinimumCharacterStatus" xml:space="preserve">
<value>Required status</value>
</data>
<data name="NoParameters" xml:space="preserve">
<value>This command has no parameters.</value>
</data>
<data name="ParameterName" xml:space="preserve">
<value>Name</value>
</data>
<data name="ParameterShortName" xml:space="preserve">
<value>Short name</value>
</data>
<data name="ParameterType" xml:space="preserve">
<value>Type</value>
</data>
<data name="ParameterValidValues" xml:space="preserve">
<value>Valid values</value>
</data>
<data name="ParametersOf" xml:space="preserve">
<value>Parameters of {0}</value>
</data>
<data name="Required" xml:space="preserve">
<value>Required</value>
</data>
</root>

View File

@@ -5,7 +5,6 @@
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@@ -61,7 +60,7 @@ public class Startup
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddToasts();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());

View File

@@ -5,7 +5,6 @@
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.DependencyInjection;
@@ -66,7 +65,7 @@ public static class WebApplicationExtensions
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddToasts();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());

View File

@@ -10,9 +10,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.AdminPanel
@@ -20,6 +17,7 @@
@using MUnique.OpenMU.Web.Shared
@using MUnique.OpenMU.Web.Shared.Components
@using MUnique.OpenMU.Web.Shared.Components.Toast
@using MUnique.OpenMU.Web.Shared.Components.Modal
@using MUnique.OpenMU.Web.Shared.Components.Form
@using MUnique.OpenMU.Web.Shared.Components.Form.Modal

View File

@@ -0,0 +1,63 @@
// <copyright file="IToastService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
using System;
using System.Collections.Generic;
/// <summary>
/// Service for showing toast notifications.
/// </summary>
public interface IToastService
{
/// <summary>
/// Occurs when the list of toasts has changed (added, closed, cleared).
/// </summary>
event Action? StateChanged;
/// <summary>
/// Gets the currently shown toasts.
/// </summary>
IReadOnlyList<ToastInstance> Toasts { get; }
/// <summary>
/// Shows a success toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowSuccess(string message, string? heading = null);
/// <summary>
/// Shows an info toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowInfo(string message, string? heading = null);
/// <summary>
/// Shows a warning toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowWarning(string message, string? heading = null);
/// <summary>
/// Shows an error toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowError(string message, string? heading = null);
/// <summary>
/// Closes the specified toast (triggers its closing animation).
/// </summary>
/// <param name="toast">The toast to close.</param>
void Close(ToastInstance toast);
/// <summary>
/// Closes all currently shown toasts.
/// </summary>
void Clear();
}

View File

@@ -0,0 +1,66 @@
@using MUnique.OpenMU.Web.Shared.Components.Toast
@using MUnique.OpenMU.Web.Shared.Services
@inject ToastService ToastService
@implements IDisposable
@if (this.ToastService.Toasts.Count > 0)
{
<div class="toast-container position-fixed top-0 end-0 p-3" aria-live="polite" aria-atomic="true">
@foreach (var toast in this.ToastService.Toasts)
{
var (iconClass, accentClass) = this.GetStyling(toast.Level);
<div @key="toast.Key" class="toast @accentClass @(toast.IsClosing ? "closing" : "show")" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body d-flex align-items-start">
<span class="oi @iconClass toast__icon" aria-hidden="true"></span>
<div class="ms-2 w-100">
@if (toast.Heading is { } heading)
{
<strong class="d-block">@heading</strong>
}
<div>@toast.Message</div>
</div>
</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" @onclick="() => this.CloseAsync(toast)"></button>
</div>
</div>
}
</div>
}
@code {
/// <inheritdoc />
protected override void OnInitialized()
{
this.ToastService.StateChanged += this.OnStateChanged;
}
/// <inheritdoc />
public void Dispose()
{
this.ToastService.StateChanged -= this.OnStateChanged;
}
private void OnStateChanged()
{
_ = this.InvokeAsync(this.StateHasChanged);
}
private Task CloseAsync(ToastInstance toast)
{
this.ToastService.Close(toast);
return Task.CompletedTask;
}
private (string IconClass, string AccentClass) GetStyling(ToastLevel level)
{
return level switch
{
ToastLevel.Success => ("oi-circle-check", "text-success"),
ToastLevel.Info => ("oi-info", "text-primary"),
ToastLevel.Warning => ("oi-warning", "text-warning"),
ToastLevel.Error => ("oi-bolt", "text-danger"),
_ => ("oi-info", "text-primary"),
};
}
}

View File

@@ -0,0 +1,34 @@
.toast-container {
z-index: 1080;
}
.toast {
min-width: 18rem;
border-left: 4px solid currentColor;
background-color: var(--bs-body-bg);
box-shadow: var(--bs-box-shadow-lg);
animation: toast-slide-in 0.2s ease-out;
opacity: 1;
transition: opacity 0.3s ease;
}
.toast.closing {
opacity: 0;
}
.toast__icon {
font-size: 1.1rem;
line-height: 1;
align-self: center;
}
@keyframes toast-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="ToastInstance.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
using System;
/// <summary>
/// Represents a single toast message shown in the <see cref="ToastContainer"/>.
/// </summary>
public sealed class ToastInstance
{
/// <summary>
/// Initializes a new instance of the <see cref="ToastInstance"/> class.
/// </summary>
/// <param name="level">The level.</param>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
internal ToastInstance(ToastLevel level, string message, string? heading)
{
this.Key = Guid.NewGuid();
this.Level = level;
this.Message = message;
this.Heading = heading;
}
/// <summary>
/// Gets a stable key identifying this toast, used as a render key.
/// </summary>
public Guid Key { get; }
/// <summary>
/// Gets the level.
/// </summary>
public ToastLevel Level { get; }
/// <summary>
/// Gets the message.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets the optional heading.
/// </summary>
public string? Heading { get; }
/// <summary>
/// Gets or sets a value indicating whether the toast is performing its closing animation.
/// </summary>
internal bool IsClosing { get; set; }
}

View File

@@ -0,0 +1,31 @@
// <copyright file="ToastLevel.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// The level of a toast message.
/// </summary>
public enum ToastLevel
{
/// <summary>
/// Informational message.
/// </summary>
Info,
/// <summary>
/// Success message.
/// </summary>
Success,
/// <summary>
/// Warning message.
/// </summary>
Warning,
/// <summary>
/// Error message.
/// </summary>
Error,
}

View File

@@ -52,7 +52,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Blazored.Toast" />
<PackageReference Include="BlazorInputFile" />
<PackageReference Include="Mapster" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />

View File

@@ -0,0 +1,172 @@
// <copyright file="ToastService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Services;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// Default implementation of <see cref="IToastService"/>.
/// </summary>
public sealed class ToastService : IToastService, IDisposable
{
private static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ClosingDuration = TimeSpan.FromMilliseconds(300);
private readonly object _lock = new();
private readonly List<ToastInstance> _toasts = new();
private readonly List<CancellationTokenSource> _cancellations = new();
/// <inheritdoc />
public event Action? StateChanged;
/// <inheritdoc />
public IReadOnlyList<ToastInstance> Toasts
{
get
{
lock (this._lock)
{
return this._toasts.ToArray();
}
}
}
/// <inheritdoc />
public void ShowSuccess(string message, string? heading = null)
{
this.Show(ToastLevel.Success, message, heading);
}
/// <inheritdoc />
public void ShowInfo(string message, string? heading = null)
{
this.Show(ToastLevel.Info, message, heading);
}
/// <inheritdoc />
public void ShowWarning(string message, string? heading = null)
{
this.Show(ToastLevel.Warning, message, heading);
}
/// <inheritdoc />
public void ShowError(string message, string? heading = null)
{
this.Show(ToastLevel.Error, message, heading);
}
/// <inheritdoc />
public void Close(ToastInstance toast)
{
this.StartClosing(toast);
}
/// <inheritdoc />
public void Clear()
{
lock (this._lock)
{
foreach (var cts in this._cancellations)
{
cts.Cancel();
}
this._cancellations.Clear();
this._toasts.Clear();
}
this.StateChanged?.Invoke();
}
/// <inheritdoc />
public void Dispose()
{
lock (this._lock)
{
foreach (var cts in this._cancellations)
{
cts.Dispose();
}
this._cancellations.Clear();
this._toasts.Clear();
}
}
private void Show(ToastLevel level, string message, string? heading)
{
var toast = new ToastInstance(level, message, heading);
var cts = new CancellationTokenSource();
lock (this._lock)
{
this._toasts.Add(toast);
this._cancellations.Add(cts);
}
this.StateChanged?.Invoke();
_ = this.AutoCloseAsync(toast, cts);
}
private async Task AutoCloseAsync(ToastInstance toast, CancellationTokenSource cts)
{
try
{
await Task.Delay(DefaultDuration, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
this.StartClosing(toast);
}
private void StartClosing(ToastInstance toast)
{
lock (this._lock)
{
var index = this._toasts.IndexOf(toast);
if (index < 0 || toast.IsClosing)
{
return;
}
toast.IsClosing = true;
this._cancellations[index].Cancel();
}
this.StateChanged?.Invoke();
_ = this.FinishClosingAsync(toast);
}
private Task FinishClosingAsync(ToastInstance toast)
{
return Task.Run(async () =>
{
await Task.Delay(ClosingDuration).ConfigureAwait(false);
lock (this._lock)
{
var index = this._toasts.IndexOf(toast);
if (index < 0)
{
return;
}
this._cancellations[index].Dispose();
this._cancellations.RemoveAt(index);
this._toasts.RemoveAt(index);
}
this.StateChanged?.Invoke();
});
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="ToastServiceCollectionExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// Extension methods for registering the toast service.
/// </summary>
public static class ToastServiceCollectionExtensions
{
/// <summary>
/// Adds the toast service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddToasts(this IServiceCollection services)
{
services.AddScoped<ToastService>();
services.AddScoped<IToastService>(sp => sp.GetRequiredService<ToastService>());
return services;
}
}

Some files were not shown because too many files have changed in this diff Show More