7 Commits

Author SHA1 Message Date
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
Acentech Dev
0fdb455cec Pin the production image to a commit-SHA tag instead of :latest
Some checks failed
.NET Core / build (push) Has been cancelled
Coolify does not re-pull a tag it already knows locally, so :latest silently
kept the server on a stale layer. On 2026-07-31 production was still running
the 26 July image while DockerHub :latest pointed at a newer digest, which is
why update 104 never appeared in the admin panel.

A commit-SHA tag has never been seen by the host, so the pull is unavoidable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:37:42 +03:00
71 changed files with 17132 additions and 618 deletions

View File

@@ -4,7 +4,12 @@
# set edilirse onu, edilmezse aşağıdaki default'u kullanır. # set edilirse onu, edilmezse aşağıdaki default'u kullanır.
services: services:
openmu-startup: openmu-startup:
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:latest} # <-- kendi DockerHub image'imiz (Task 3) # Production'da DAIMA commit-SHA etiketi kullanilir, asla :latest. :latest degisken
# bir isim oldugu icin Coolify onu yerelde tanidiginda yeniden cekmez ve eski katman
# 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:d249fca93514}
container_name: openmu-startup container_name: openmu-startup
networks: networks:
- coolify - coolify

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] [MemberOfAggregate]
public virtual ICollection<MiniGameDefinition> MiniGameDefinitions { get; protected set; } = null!; 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 /> /// <inheritdoc />
public override string ToString() public override string ToString()
{ {

View File

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

@@ -4,109 +4,151 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege; namespace MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary> /// <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. /// 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 /// <para>
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the /// The cycle uses the original Season 6 <see cref="CastleSiegeState"/> values, which are exactly the values
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne. /// the game client expects (see <c>CASTLESIEGE_STATE</c> in the client's <c>WSclient.h</c>). AdaMu drives only
/// The throne holder when the siege ends becomes the castle owner. /// 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> /// </summary>
public class CastleSiegeContext public class CastleSiegeContext
{ {
/// <summary>The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.</summary> /// <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 }; public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly List<string> _registeredGuilds = new(); private readonly Dictionary<Guid, string> _registeredGuilds = new();
private readonly Dictionary<short, string?> _switchHolders = new() { { 217, null }, { 218, null } }; private readonly Dictionary<short, CastleSiegeSwitchOperation?> _switches = new() { { 217, null }, { 218, null } };
private DateTime _phaseStartedUtc; private DateTime _stateStartedUtc;
private string? _occupier; private Guid? _occupier;
private string? _occupierName;
private int _defensesRemaining; private int _defensesRemaining;
private bool _dirty; private bool _dirty;
private string? _crownHoldGuild; private Guid? _crownHoldGuild;
private DateTime? _crownHoldStartUtc; private DateTime? _crownHoldStartUtc;
private Guid? _crownHoldRequestedBy;
private bool _lastShieldDown; private bool _lastShieldDown;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param> /// <param name="configuration">The cycle timing configuration.</param>
public CastleSiegeContext(CastleSiegeConfiguration configuration) public CastleSiegeContext(CastleSiegeSettings configuration)
{ {
this.Configuration = configuration; this.Configuration = configuration;
this.Phase = CastleSiegePhase.Ownership; this.State = CastleSiegeState.Idle1;
} }
/// <summary>Raised after the phase changes. Argument is the new phase.</summary> /// <summary>Raised after the state changes. Argument is the new state.</summary>
public event Action<CastleSiegePhase>? PhaseChanged; public event Action<CastleSiegeState>? StateChanged;
/// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config /// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
/// so AdminPanel edits take effect without a restart.</summary> /// 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> /// <summary>Gets the current state.</summary>
/// <param name="configuration">The live configuration.</param> public CastleSiegeState State { get; private set; }
public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
/// <summary>Gets the current phase.</summary> /// <summary>Gets the UTC time the current state started (used for persistence/restore).</summary>
public CastleSiegePhase Phase { get; private set; } public DateTime StateStartedUtc => this._stateStartedUtc;
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary> /// <summary>Gets the persistent identifier of the owner guild, or <see langword="null"/> if unowned.</summary>
public DateTime PhaseStartedUtc => this._phaseStartedUtc; 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; } public string? OwnerGuildName { get; private set; }
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary> /// <summary>Gets the guild currently holding the throne during the siege, or null.</summary>
public string? OccupierGuildName => this._occupier; 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> /// <summary>Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.</summary>
public int DefensesRemaining => this._defensesRemaining; public int DefensesRemaining => this._defensesRemaining;
/// <summary>Gets the guild names registered for the current cycle.</summary> /// <summary>Gets the persistent identifiers of the guilds registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds; 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> /// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param> /// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now) public ValueTask TickAsync(DateTime now)
{ {
switch (this.Phase) switch (this.State)
{ {
case CastleSiegePhase.Ownership: case CastleSiegeState.Idle1:
if (this.Configuration.IsRegistrationOpenTime(now)) if (this.Configuration.IsRegistrationOpenTime(now))
{ {
return this.ForceStartRegistrationAsync(now); return this.ForceStartRegistrationAsync(now);
} }
break; break;
case CastleSiegePhase.Registration: case CastleSiegeState.RegisterGuild:
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration) if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration)
{ {
return this.TransitionAsync(CastleSiegePhase.Preparation, now); return this.TransitionAsync(CastleSiegeState.Ready, now);
} }
break; break;
case CastleSiegePhase.Preparation: case CastleSiegeState.Ready:
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration) if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration)
{ {
return this.TransitionAsync(CastleSiegePhase.Siege, now); return this.TransitionAsync(CastleSiegeState.Start, now);
} }
break; break;
case CastleSiegePhase.Siege: case CastleSiegeState.Start:
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration) if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration)
{ {
return this.TransitionAsync(CastleSiegePhase.Settlement, now); return this.TransitionAsync(CastleSiegeState.End, now);
} }
break; break;
case CastleSiegePhase.Settlement: case CastleSiegeState.End:
// Winner = guild holding the throne at siege end. If none captured, owner unchanged. // Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier) if (this._occupier is { } occupier)
{ {
this.SetOwner(occupier); this.SetOwner(occupier, this._occupierName);
} }
this.ClearBattleState(); this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now); return this.TransitionAsync(CastleSiegeState.EndCycle, now);
case CastleSiegeState.EndCycle:
return this.TransitionAsync(CastleSiegeState.Idle1, now);
default: default:
break; break;
} }
@@ -114,62 +156,67 @@ public class CastleSiegeContext
return ValueTask.CompletedTask; 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> /// <param name="now">The current UTC time.</param>
public ValueTask ForceStartRegistrationAsync(DateTime now) public ValueTask ForceStartRegistrationAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this.ClearBattleState(); this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Registration, now); return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
} }
/// <summary>Admin: forces a specific phase now.</summary> /// <summary>Admin: forces a specific state now.</summary>
/// <param name="phase">The target phase.</param> /// <param name="state">The target state.</param>
/// <param name="now">The current UTC time.</param> /// <param name="now">The current UTC time.</param>
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now) public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
=> this.TransitionAsync(phase, 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> /// <param name="now">The current UTC time.</param>
public ValueTask ResetAsync(DateTime now) public ValueTask ResetAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this.ClearBattleState(); 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> /// <summary>Registers a guild for the current cycle. No-op outside the registration state.</summary>
/// <param name="guildName">The guild name.</param> /// <param name="guildId">The persistent guild identifier.</param>
public void RegisterGuild(string guildName) /// <param name="guildName">The guild name, for display.</param>
public void RegisterGuild(Guid guildId, string guildName)
{ {
if (this.Phase == CastleSiegePhase.Registration if (this.State == CastleSiegeState.RegisterGuild
&& !this._registeredGuilds.Contains(guildName)) && this._registeredGuilds.TryAdd(guildId, guildName))
{ {
this._registeredGuilds.Add(guildName);
this._dirty = true; this._dirty = true;
} }
} }
/// <summary>Admin: sets (or clears) the current owner guild name.</summary> /// <summary>Admin: sets (or clears) the current owner guild.</summary>
/// <param name="guildName">The owner guild name, or null to clear.</param> /// <param name="guildId">The owner guild identifier, or null to clear.</param>
public void SetOwner(string? guildName) /// <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; this._dirty = true;
} }
/// <summary> /// <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 /// siege, so their hunting-map gate and castle flag still reflect the current owner. Does not mark the
/// state dirty (these servers never persist). /// state dirty (these servers never persist).
/// </summary> /// </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> /// <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 /// 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. /// source of truth, so this is equivalent to editing the plugin config in the AdminPanel.
/// </summary> /// </summary>
@@ -192,83 +239,127 @@ public class CastleSiegeContext
/// <param name="nowUtc">The current UTC time.</param> /// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc) public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
{ {
if (this.Phase != CastleSiegePhase.Siege) if (!this.IsSiegeRunning)
{ {
return TimeSpan.Zero; 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; return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
} }
/// <summary> /// <summary>
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the /// Returns how much time is left in the current state, or <see cref="TimeSpan.Zero"/> when the state has
/// crown's shield is dropped for them), or null. Only meaningful during the siege. /// no duration (idle states wait for an admin command or the auto-open time).
/// </summary> /// </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; return null;
} }
var holder = this._switchHolders[217]; var first = this.GetHeldSwitchGuild(217);
return holder is not null && holder == this._switchHolders[218] ? holder : null; return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null;
} }
/// <summary> /// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held /// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is /// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>. /// hold both switches, so a click can never arm a hold the guild isn't entitled to.
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
/// </summary> /// </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="masterHolding">Whether that guild's master is on the crown.</param>
/// <param name="now">The current UTC time.</param> /// <param name="now">The current UTC time.</param>
/// <param name="holdDuration">How long the master must hold to capture.</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; var shieldChanged = shieldDown != this._lastShieldDown;
this._lastShieldDown = shieldDown; this._lastShieldDown = shieldDown;
if (this.Phase != CastleSiegePhase.Siege) if (!this.IsSiegeRunning)
{ {
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null; return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null);
} }
var wasHolding = this._crownHoldGuild is not 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). // 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) if (!canCapture || !masterHolding)
{ {
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null; return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
} }
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null) if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
{ {
this._crownHoldGuild = eligibleGuild; this._crownHoldGuild = eligibleGuild;
this._crownHoldStartUtc = now; 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) if (now - this._crownHoldStartUtc.Value >= holdDuration)
{ {
this._occupier = eligibleGuild; this._occupier = eligibleGuild;
this._crownHoldGuild = null; this._occupierName = eligibleGuildName;
this._crownHoldStartUtc = null; this.ResetCrownHold();
this._dirty = true; 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> /// <summary>
@@ -283,22 +374,27 @@ public class CastleSiegeContext
} }
/// <summary> /// <summary>
/// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without /// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
/// firing <see cref="PhaseChanged"/> or marking the state dirty. Battle state stays cleared. /// firing <see cref="StateChanged"/> or marking the state dirty. Battle state stays cleared.
/// </summary> /// </summary>
/// <param name="owner">The persisted owner guild name, or null.</param> /// <param name="ownerGuildId">The persisted owner guild identifier, or null.</param>
/// <param name="phase">The persisted phase.</param> /// <param name="ownerGuildName">The persisted owner guild name, or null.</param>
/// <param name="phaseStartedUtc">When the persisted phase started (UTC), or null to keep the default.</param> /// <param name="state">The persisted state.</param>
/// <param name="registeredGuilds">The persisted registered guild names, or null.</param> /// <param name="stateStartedUtc">When the persisted state started (UTC), or null to keep the default.</param>
public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable<string>? registeredGuilds) /// <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.OwnerGuildId = ownerGuildId;
this.Phase = phase; this.OwnerGuildName = ownerGuildId is null ? null : ownerGuildName;
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc; this.State = state;
this._stateStartedUtc = stateStartedUtc ?? this._stateStartedUtc;
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
if (registeredGuilds is not null) if (registeredGuilds is not null)
{ {
this._registeredGuilds.AddRange(registeredGuilds); foreach (var registration in registeredGuilds)
{
this._registeredGuilds[registration.Key] = registration.Value;
}
} }
this._dirty = false; 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> /// <summary>
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin /// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege. /// 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> /// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param> /// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The holding guild's name, or null.</param> /// <param name="guildId">The clicking player's guild identifier.</param>
public void SetSwitchHolder(short switchNumber, string? guildName) /// <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> /// <summary>
/// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC). /// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
/// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the /// area, and completes - which makes the switch count for the guild - once it ran <paramref name="pushDuration"/>.
/// guild is currently holding BOTH Crown Switches (a member standing on each).
/// </summary> /// </summary>
/// <param name="guildName">The capturing guild's name.</param> /// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns> /// <param name="operatorPresent">Whether the operating player is still in the switch's area.</param>
public (bool Success, string Reason) TryCaptureThrone(string guildName) /// <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 this._switches[switchNumber] = null;
? "Your guild already holds the throne." return (CastleSiegeSwitchEvent.Released, operation);
: $"The throne is already held by '{this._occupier}'.");
} }
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 (CastleSiegeSwitchEvent.None, operation);
{
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
}
this._occupier = guildName;
return (true, "throne captured");
} }
/// <summary>Returns a human-readable status summary for admin display.</summary> /// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText() public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, " => $"CS state={this.State}({(int)this.State}), owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}], " + $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds.Values)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, " + $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}"; + $"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() private void ClearBattleState()
{ {
this._switchHolders[217] = null; this._switches[217] = null;
this._switchHolders[218] = null; this._switches[218] = null;
this._defensesRemaining = 0; this._defensesRemaining = 0;
this._occupier = null; this._occupier = null;
this._crownHoldGuild = null; this._occupierName = null;
this._crownHoldStartUtc = null; this.ResetCrownHold();
this._lastShieldDown = false; this._lastShieldDown = false;
} }
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now) private ValueTask TransitionAsync(CastleSiegeState state, DateTime now)
{ {
this.Phase = phase; this.State = state;
this._phaseStartedUtc = now; this._stateStartedUtc = now;
this._dirty = true; this._dirty = true;
this.PhaseChanged?.Invoke(phase); this.StateChanged?.Invoke(state);
return ValueTask.CompletedTask; 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="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="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="Event">The event that occurred this tick.</param>
/// <param name="Guild">The guild the event refers to, if any.</param> /// <param name="GuildId">The guild the event refers to, if any.</param>
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild); /// <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; return;
} }
if (context.Phase != CastleSiegePhase.Registration) if (context.State != CastleSiegeState.RegisterGuild)
{ {
await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false); await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false);
return; return;
@@ -61,17 +61,15 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return; return;
} }
var guildName = guildStatus.GuildId.ToString(); // Registrations are keyed on the guild's persistent id, so a later rename cannot detach them.
if (player.GameContext is IGameServerContext serverContext) if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
{ {
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); await ShowAsync(player, "Your guild could not be resolved. Please try again in a moment.").ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name) return;
{
guildName = name;
}
} }
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); await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
return; return;
@@ -84,7 +82,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return; return;
} }
context.RegisterGuild(guildName); context.RegisterGuild(guild.Id, guildName);
await ShowAsync(player, fee > 0 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. ({fee} zen paid)"
: $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false); : $"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. // Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright> // </copyright>
@@ -7,15 +7,31 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary> /// <summary>
/// Configuration for the Castle Siege cycle. Rides on the plugin custom-configuration system (no dedicated /// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so
/// database table). A cycle runs: Ownership -> Registration -> Preparation -> Siege(war) -> Settlement, and /// it is editable in the AdminPanel and needs no dedicated database table.
/// auto-starts when the current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>. /// <para>
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute/second durations) are proxies over the /// This is deliberately separate from <see cref="DataModel.Configuration.CastleSiegeConfiguration"/>, which is
/// runtime fields, which are hidden from the editor to keep the form clean. /// 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> /// </summary>
public class CastleSiegeConfiguration public class CastleSiegeSettings
{ {
/// <summary> /// <summary>
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a /// 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> /// <summary>
/// Gets or sets the times of day (UTC) at which a new cycle opens registration. /// 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> /// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>(); 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)); 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> /// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild /// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee. /// for the siege. 0 disables the fee.
@@ -112,29 +117,23 @@ public class CastleSiegeConfiguration
[Browsable(false)] [Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); 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> /// <summary>
[Browsable(false)] /// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60); /// 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) --- // --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
// These ride on the plugin's custom-configuration JSON (stored in PostgreSQL), so the castle owner and the // Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
// current cycle survive server restarts. Written by CastleSiegeEventPlugIn; restored on startup. // 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)] [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)] [Browsable(false)]
public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership; public DateTime? PersistedStateStartedUtc { get; set; }
/// <summary>Gets or sets when the persisted phase started (UTC), or null if never persisted.</summary>
[Browsable(false)]
public DateTime? PersistedPhaseStartedUtc { get; set; }
/// <summary>Gets or sets the persisted registered guild names for the current cycle.</summary>
[Browsable(false)]
public IList<string> PersistedRegisteredGuilds { get; set; } = new List<string>();
/// <summary> /// <summary>
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls /// 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; 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); await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false);
return; return;
} }
var guildName = guildStatus.GuildId.ToString(); if (!context.IsRegistered(guild.Id))
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
}
if (!context.RegisteredGuilds.Contains(guildName))
{ {
await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false); await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false);
return; return;
} }
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state. // Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false); // 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))
{
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
return;
} }
private static string DescribeThroneStep(CastleSiegeContext context, string guildName) await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
{ {
if (context.Phase != CastleSiegePhase.Siege) if (!context.IsSiegeRunning)
{ {
return "The siege is not running yet."; return "The siege is not running yet.";
} }
if (context.DefensesRemaining > 0)
{
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
}
var eligible = context.GetShieldEligibleGuild(); var eligible = context.GetShieldEligibleGuild();
if (eligible is null) 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) private static ValueTask ShowAsync(Player player, string text)

View File

@@ -717,7 +717,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). // ADAMU-CUSTOM: Castle Siege PvP gate — true while the siege phase runs on Valley of Loren (map 30).
private bool IsCastleSiegeBattleActive() private bool IsCastleSiegeBattleActive()
=> this.CurrentMap?.Definition.Number == 30 => this.CurrentMap?.Definition.Number == 30
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.Phase == CastleSiege.CastleSiegePhase.Siege; && PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.IsSiegeRunning == true;
/// <inheritdoc/> /// <inheritdoc/>
public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null) public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)

View File

@@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns;
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary> /// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")] [Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
[PlugIn] [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)] [ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{ {
@@ -30,9 +30,11 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
public async ValueTask HandleCommandAsync(Player player, string command) public async ValueTask HandleCommandAsync(Player player, string command)
{ {
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); 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; return;
} }
@@ -43,7 +45,7 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
return; 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); 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; return;
} }
context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner); if (string.IsNullOrWhiteSpace(owner))
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false); {
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.Collections.Concurrent;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views;
@@ -15,65 +16,69 @@ using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.PlugIns;
using CastleSiegeDefinition = MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration;
/// <summary> /// <summary>
/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration. /// Drives the Castle Siege state machine: ticks it every second and carries its operational settings.
/// State is per-<see cref="IGameContext"/> and kept in memory (P1: no persistence). /// <para>
/// When the siege phase starts (P3), warps registered guild members to the Valley of Loren battle map. /// 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> /// </summary>
[PlugIn] [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")] [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 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 the CS gates.
private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for 277 private const short CrownNumber = 216;
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 CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK
private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE
private static readonly (short Number, byte X, byte Y)[] CatapultSpawns = private const int SwitchHoldRange = 3;
{
(CatapultDefenseNumber, 80, 140),
(CatapultDefenseNumber, 110, 140),
(CatapultDefenseNumber, 93, 178),
(CatapultAttackNumber, 74, 100),
(CatapultAttackNumber, 112, 100),
};
// Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y).
private static readonly (short SwitchNumber, byte X, byte Y)[] SwitchPositions =
{
(217, 167, 194),
(218, 184, 195),
};
// The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne.
private static readonly Point CrownPosition = new(176, 212);
private const int CrownHoldRange = 4; private 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 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; private byte[]? _cachedFlagLogo;
/// <inheritdoc /> /// <inheritdoc />
public CastleSiegeConfiguration? Configuration { get; set; } public CastleSiegeSettings? Configuration { get; set; }
/// <summary> /// <summary>
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it. /// 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; 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 /> /// <inheritdoc />
public object CreateDefaultConfig() => new CastleSiegeConfiguration(); public object CreateDefaultConfig() => new CastleSiegeSettings();
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext) public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{ {
var context = Contexts.GetOrAdd(gameContext, gc => var context = Contexts.GetOrAdd(gameContext, gc =>
{ {
var config = this.Configuration ?? new CastleSiegeConfiguration(); var settings = this.Configuration ?? new CastleSiegeSettings();
var created = new CastleSiegeContext(config); var created = new CastleSiegeContext(settings);
// Restore state persisted before the last restart (owner/phase/registrations) BEFORE subscribing, // Restore the cycle bookkeeping BEFORE subscribing, so restoring doesn't announce states or
// so restoring doesn't announce phases or re-spawn defenses. // re-spawn defenses. The owner and registrations are loaded from the database right after.
created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds); 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.StateChanged += state => _ = OnStateChangedAsync(gc, created, state);
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
return created; return created;
}); });
// Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live. // Point the context at the current (possibly AdminPanel-edited) settings so schedule/durations are live.
if (this.Configuration is { } liveConfig) 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 // 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 // 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)) if (!IsCastleSiegeServer(gameContext))
{ {
context.SyncOwnerFromConfig(); if (GetCounters(gameContext).NextCastleFlag())
if (DateTime.UtcNow.Second % 15 == 0)
{ {
await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
await this.BroadcastCastleFlagAsync(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); 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. // 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); 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()) if (context.ConsumeDirty())
{ {
await this.PersistStateAsync(gameContext, context).ConfigureAwait(false); 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). // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state).
if (DateTime.UtcNow.Second % 15 == 0) if (GetCounters(gameContext).NextCastleFlag())
{ {
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); 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 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)
{ {
case CastleSiegePhase.Registration: 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)
{
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); await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false);
break; 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 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 SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false);
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
break; break;
case CastleSiegePhase.Settlement: case CastleSiegeState.End:
// Stop the on-map countdown for everyone still on the battle map. // 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 BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false);
break; 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); await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false);
break; break;
default: default:
@@ -199,52 +316,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
catch (Exception ex) catch (Exception ex)
{ {
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>() gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error handling phase change to {phase}.", phase); .LogError(ex, "Castle Siege: error handling state change to {state}.", state);
}
}
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.");
} }
} }
@@ -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) private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context)
{ {
try try
{ {
if (gameContext is not GameContext concrete) if (gameContext is not GameContext concrete
|| GetDefinition(gameContext) is not { } definition
|| await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{ {
context.SetDefenseCount(0); context.SetDefenseCount(0);
return; return;
} }
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map is null)
{
context.SetDefenseCount(0);
return;
}
var spawned = 0;
for (var i = 0; i < DefenseSpawns.Length; i++)
{
var spawn = DefenseSpawns[i];
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number);
if (definition is null)
{
continue;
}
// The stock CS gate (277) definition has no HP and isn't destructible; make it breakable
// by borrowing the HP/attributes of a working destructible (BloodCastle gate 131).
var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber); var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
EnsureDestructible(definition, template); var spawned = 0;
var index = 0;
var spawnArea = new MonsterSpawnArea foreach (var npc in definition.NpcDefinitions.Where(n => n.IsPersistedToDatabase && n.MonsterDefinition is not null))
{ {
MonsterDefinition = definition, // The stock CS gate/statue definitions have no HP and aren't destructible; make them breakable
Quantity = 1, // by borrowing the HP/attributes of a working destructible (BloodCastle gate 131).
X1 = spawn.X, EnsureDestructible(npc.MonsterDefinition!, template);
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); var monster = await concrete.MapInitializer
if (npc is AttackableNpcBase attackable) .InitializeSpawnAsync(1000 + index++, map, CreateSpawnArea(npc))
.ConfigureAwait(false);
if (monster is AttackableNpcBase attackable)
{ {
attackable.Died += (_, _) => context.NotifyDefenseDestroyed(); attackable.Died += (_, _) => context.NotifyDefenseDestroyed();
spawned++; spawned++;
@@ -323,29 +379,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
context.SetDefenseCount(spawned); context.SetDefenseCount(spawned);
// Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses. index = 0;
for (var i = 0; i < CatapultSpawns.Length; i++) foreach (var npc in definition.NpcDefinitions.Where(IsCatapult))
{ {
var spawn = CatapultSpawns[i]; await concrete.MapInitializer
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number); .InitializeSpawnAsync(2000 + index++, map, CreateSpawnArea(npc))
if (definition is null) .ConfigureAwait(false);
{
continue;
}
var spawnArea = new MonsterSpawnArea
{
MonsterDefinition = definition,
Quantity = 1,
X1 = spawn.X,
X2 = spawn.X,
Y1 = spawn.Y,
Y2 = spawn.Y,
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
await concrete.MapInitializer.InitializeSpawnAsync(2000 + i, map, spawnArea).ConfigureAwait(false);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -356,54 +395,144 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
} }
} }
private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context) private static bool IsCatapult(CastleSiegeNpcDefinition npc)
=> npc.MonsterDefinition is { } monster
&& (monster.Number == CatapultAttackNumber || monster.Number == CatapultDefenseNumber);
private static MonsterSpawnArea CreateSpawnArea(CastleSiegeNpcDefinition npc) => new()
{ {
try 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)
{ {
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
if (map is null) {
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{ {
return; return;
} }
// Each Crown Switch is held by whichever registered guild currently has a member standing on it. foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
foreach (var (switchNumber, x, y) in SwitchPositions)
{ {
string? holder = null; if (context.GetSwitchOperation(switchNumber) is { } operation)
var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType<Player>();
foreach (var player in nearby)
{ {
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false); await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false);
if (guildName is not null && context.RegisteredGuilds.Contains(guildName)) await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false);
{ }
holder = guildName;
break;
} }
} }
context.SetSwitchHolder(switchNumber, holder); /// <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
{
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{
return;
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds));
// Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops; // A Crown Switch belongs to the player who clicked it, for as long as they stay in its area.
// that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable). foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{
if (context.GetSwitchOperation(switchNumber) is not { } operation)
{
continue;
}
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);
}
}
// 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(); var eligible = context.GetShieldEligibleGuild();
Player? masterPlayer = null; 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 if (player.GuildStatus?.Position != GuildPosition.GuildMaster)
&& await GetGuildNameAsync(player).ConfigureAwait(false) == eligible) {
continue;
}
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild && guild.Id == eligibleId)
{ {
masterPlayer = player; masterPlayer = player;
eligibleName = guild.Name;
break; 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). // Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal).
if (crown.ShieldChanged) if (crown.ShieldChanged)
@@ -414,13 +543,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
switch (crown.Event) switch (crown.Event)
{ {
case CrownEvent.HoldStarted when masterPlayer is not null: 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); await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
break; break;
case CrownEvent.HoldReset when masterPlayer is not null: case CrownEvent.HoldReset:
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
{
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
break; 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 ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false);
await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false); await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false);
break; break;
@@ -428,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
break; break;
} }
// Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just // 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. // loaded the battle map pick it up, without visibly resetting the second-counter too often. This
if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0) // 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 remaining = context.GetRemainingSiegeTime(now);
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); 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>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) private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func<ICastleSiegeStatusViewPlugIn, ValueTask> action)
=> gameContext.ForEachPlayerAsync(player => => gameContext.ForEachPlayerAsync(player =>
player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber
? player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(action).AsTask() ? player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(action).AsTask()
: Task.CompletedTask); : Task.CompletedTask);
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context) private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
{ {
try try
{ {
if (context.OwnerGuildName is not { Length: > 0 } owner) var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
if (map?.SafeZoneSpawnGate is not { } gate)
{ {
return; 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) if (logo is null)
{ {
return; 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; return this._cachedFlagLogo;
} }
if (gameContext is not IGameServerContext serverContext) if (gameContext is not IGameServerContext serverContext || ownerName is not { Length: > 0 })
{ {
return null; return null;
} }
var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false); var shortGuildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
if (guildId == 0) if (shortGuildId == 0)
{ {
return null; 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) if (guild?.Logo is not { Length: > 0 } logo)
{ {
return null; return null;
} }
this._cachedFlagOwner = ownerName; this._cachedFlagOwner = ownerId;
this._cachedFlagLogo = logo; this._cachedFlagLogo = logo;
return 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 (++counter < period)
if (map?.SafeZoneSpawnGate is not { } gate)
{ {
return; return false;
} }
await gameContext.ForEachPlayerAsync(async player => counter = 0;
{ return true;
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
{
await player.WarpToAsync(gate).ConfigureAwait(false);
} }
}).ConfigureAwait(false);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
}
}
private static async ValueTask<string?> GetGuildNameAsync(Player player)
{
if (player.GuildStatus is not { } guildStatus)
{
return null;
}
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
return name;
}
}
return guildStatus.GuildId.ToString();
} }
} }

View File

@@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
/// </summary> /// </summary>
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param> /// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
ValueTask AnnounceSealCapturedAsync(string guildName); 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> /// <param name="player">The player.</param>
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player; 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 /> /// <inheritdoc />
public async ValueTask SetBattleStateAsync(bool started) public async ValueTask SetBattleStateAsync(bool started)
{ {
@@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false); 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 /> /// <inheritdoc />
public async ValueTask AnnounceSealCapturedAsync(string guildName) public async ValueTask AnnounceSealCapturedAsync(string guildName)
{ {

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; 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 /> /// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.GameConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration) 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> /// </summary>
internal GameConfiguration? CurrentGameConfiguration { get; set; } 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/> /// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -58,6 +68,11 @@ public class EntityDataContext : ExtendedTypeContext
modelBuilder.Entity<Account>().Apply(); modelBuilder.Entity<Account>().Apply();
modelBuilder.Entity<Character>().Apply(); modelBuilder.Entity<Character>().Apply();
modelBuilder.Entity<CharacterClass>().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<DropItemGroup>().Apply();
modelBuilder.Entity<ExitGate>().Apply(); modelBuilder.Entity<ExitGate>().Apply();
modelBuilder.Entity<GameConfiguration>().Apply(); modelBuilder.Entity<GameConfiguration>().Apply();

View File

@@ -68,6 +68,55 @@ internal class EntityFrameworkContextBase : IContext
/// <inheritdoc/> /// <inheritdoc/>
public async ValueTask<bool> SaveChangesAsync(CancellationToken cancellationToken = default) public async ValueTask<bool> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 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)
{
attempt++;
try
{
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);
}
}
}
/// <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(); using var l = await this._lock.LockAsync();
@@ -252,6 +301,14 @@ internal class EntityFrameworkContextBase : IContext
GC.SuppressFinalize(this); 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> /// <summary>
/// Releases unmanaged and - optionally - managed resources. /// Releases unmanaged and - optionally - managed resources.
/// </summary> /// </summary>
@@ -343,7 +400,9 @@ internal class EntityFrameworkContextBase : IContext
} }
var changedEntries = this.Context.ChangeTracker.Entries() 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) foreach (var entry in changedEntries)
{ {
var (parent, parentCollectionNavigation) = this.GetParentInformation(entry); var (parent, parentCollectionNavigation) = this.GetParentInformation(entry);

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}" /> <Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup> </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'"> <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> </Target>
</Project> </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 #nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{ {
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc /> /// <inheritdoc />
public partial class AddIsQuestItemFlag : Migration public partial class AddIsQuestItemFlag : Migration
{ {

View File

@@ -1,10 +1,14 @@
using System; // <copyright file="20260712014203_AddBuff.cs" company="MUnique">
using Microsoft.EntityFrameworkCore.Migrations; // Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable #nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{ {
using System;
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc /> /// <inheritdoc />
public partial class AddBuff : Migration public partial class AddBuff : Migration
{ {
@@ -20,7 +24,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
MagicEffectDefinitionId = table.Column<Guid>(type: "uuid", nullable: true), MagicEffectDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
MonsterDefinitionId = table.Column<Guid>(type: "uuid", nullable: true), MonsterDefinitionId = table.Column<Guid>(type: "uuid", nullable: true),
MinimumLevel = table.Column<int>(type: "integer", 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 => 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"); 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 => modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1054,6 +1377,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Property<bool>("AreaSkillHitsPlayer") b.Property<bool>("AreaSkillHitsPlayer")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<Guid?>("CastleSiegeConfigurationId")
.HasColumnType("uuid");
b.Property<string>("CharacterNameRegex") b.Property<string>("CharacterNameRegex")
.HasColumnType("text"); .HasColumnType("text");
@@ -1145,6 +1471,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CastleSiegeConfigurationId")
.IsUnique();
b.HasIndex("DuelConfigurationId") b.HasIndex("DuelConfigurationId")
.IsUnique(); .IsUnique();
@@ -3638,6 +3967,139 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Navigation("RawMagicEffectDefinition"); 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 => modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{ {
b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) 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 => 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") b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", "RawDuelConfiguration")
.WithOne() .WithOne()
.HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId") .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.Navigation("RawCastleSiegeConfiguration");
b.Navigation("RawDuelConfiguration"); b.Navigation("RawDuelConfiguration");
}); });
@@ -5023,6 +5492,32 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
b.Navigation("RawEquippedItems"); 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 => modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
{ {
b.Navigation("JoinedDropItemGroups"); 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.Statistics.MiniGameRankingEntry>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Account>(); modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Account>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.AppearanceData>(); 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.Character>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CharacterQuestState>(); modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.CharacterQuestState>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Entities.Guild>(); 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.AreaSkillSettings>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.BattleZoneDefinition>(); modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.BattleZoneDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.Buff>(); 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.CharacterClass>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.ChatServerDefinition>(); modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.ChatServerDefinition>();
modelBuilder.Ignore<MUnique.OpenMU.DataModel.Configuration.ChatServerEndpoint>(); 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.RawCharacters).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account>().HasMany(entity => entity.RawAttributes).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<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.RawAttributes).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Character>().HasMany(entity => entity.RawLetters).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); 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.RawLeftGoal).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<BattleZoneDefinition>().HasOne(entity => entity.RawRightGoal).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<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.RawStatAttributes).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CharacterClass>().HasMany(entity => entity.RawAttributeCombinations).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); 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.RawGlobalBaseAttributeValues).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameConfiguration>().HasMany(entity => entity.RawPlugInConfigurations).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>().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.RawMonsterSpawns).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GameMapDefinition>().HasMany(entity => entity.RawEnterGates).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); 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 /> /// <inheritdoc />
public override MUnique.OpenMU.DataModel.Configuration.GameConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration) 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>() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.AppearanceData, MUnique.OpenMU.DataModel.Entities.AppearanceData>()
.Include<AppearanceData, BasicModel.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>() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Entities.Character, MUnique.OpenMU.DataModel.Entities.Character>()
.Include<Character, BasicModel.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>() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.Buff, MUnique.OpenMU.DataModel.Configuration.Buff>()
.Include<Buff, BasicModel.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>() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig<MUnique.OpenMU.DataModel.Configuration.CharacterClass, MUnique.OpenMU.DataModel.Configuration.CharacterClass>()
.Include<CharacterClass, BasicModel.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

@@ -529,4 +529,14 @@ public enum UpdateVersion
/// The version of the <see cref="RepairImportedMapWarpsSeason6"/>. /// The version of the <see cref="RepairImportedMapWarpsSeason6"/>.
/// </summary> /// </summary>
RepairImportedMapWarpsSeason6 = 104, 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,
} }

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 BloodCastleInitializer(this.Context, this.GameConfiguration).Initialize();
new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize(); new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize();
new HeykelSavasiInitializer(this.Context, this.GameConfiguration).Initialize(); new HeykelSavasiInitializer(this.Context, this.GameConfiguration).Initialize();
new CastleSiegeInitializer(this.Context, this.GameConfiguration).Initialize();
} }
private void CreateJewelMixes() private void CreateJewelMixes()

View File

@@ -41,8 +41,15 @@
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" /> <Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup> </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'"> <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> </Target>
</Project> </Project>

View File

@@ -3904,6 +3904,24 @@ public class PacketStructureTests
"Packet length mismatch: declared length does not match calculated size"); "Packet length mismatch: declared length does not match calculated size");
} }
/// <summary>
/// Tests the packet size calculation for HeykelSavasiTeamSelect.
/// </summary>
[Test]
public void HeykelSavasiTeamSelect_PacketSizeValidation()
{
// Fixed-length packet validation
const int expectedLength = 4;
var actualLength = HeykelSavasiTeamSelectRef.Length;
Assert.That(actualLength, Is.EqualTo(expectedLength),
"Packet length mismatch: declared length does not match calculated size");
// Validate field 'Team' boundary
Assert.That(3 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'Team' exceeds packet boundary");
}
/// <summary> /// <summary>
/// Tests the packet size calculation for ChatCommandListRequest. /// Tests the packet size calculation for ChatCommandListRequest.
/// </summary> /// </summary>

View File

@@ -6415,10 +6415,102 @@ public class PacketStructureTests
} }
/// <summary> /// <summary>
/// Tests the packet size calculation for ChatCommandInfo. /// Tests the packet size calculation for HeykelSavasiOpenTeamPanel.
/// </summary> /// </summary>
[Test] [Test]
public void ChatCommandInfo_PacketSizeValidation() public void HeykelSavasiOpenTeamPanel_PacketSizeValidation()
{
// Fixed-length packet validation
const int expectedLength = 5;
var actualLength = HeykelSavasiOpenTeamPanelRef.Length;
Assert.That(actualLength, Is.EqualTo(expectedLength),
"Packet length mismatch: declared length does not match calculated size");
// Validate field 'RedCount' boundary
Assert.That(3 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'RedCount' exceeds packet boundary");
// Validate field 'BlueCount' boundary
Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'BlueCount' exceeds packet boundary");
}
/// <summary>
/// Tests the packet size calculation for HeykelSavasiHudState.
/// </summary>
[Test]
public void HeykelSavasiHudState_PacketSizeValidation()
{
// Fixed-length packet validation
const int expectedLength = 11;
var actualLength = HeykelSavasiHudStateRef.Length;
Assert.That(actualLength, Is.EqualTo(expectedLength),
"Packet length mismatch: declared length does not match calculated size");
// Validate field 'Phase' boundary
Assert.That(3 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'Phase' exceeds packet boundary");
// Validate field 'MyTeam' boundary
Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'MyTeam' exceeds packet boundary");
// Validate field 'RedCount' boundary
Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'RedCount' exceeds packet boundary");
// Validate field 'BlueCount' boundary
Assert.That(6 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'BlueCount' exceeds packet boundary");
// Validate field 'RedProgress' boundary
Assert.That(7 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'RedProgress' exceeds packet boundary");
// Validate field 'BlueProgress' boundary
Assert.That(8 + 1, Is.LessThanOrEqualTo(expectedLength),
"Field 'BlueProgress' exceeds packet boundary");
// Validate field 'RemainingSeconds' boundary
Assert.That(9 + 2, Is.LessThanOrEqualTo(expectedLength),
"Field 'RemainingSeconds' exceeds packet boundary");
}
/// <summary>
/// Tests the packet size calculation for HeykelSavasiTeamRoster.
/// </summary>
[Test]
public void HeykelSavasiTeamRoster_PacketSizeValidation()
{
// Basic packet validation
// Validate header type and field boundaries
// Field 'Count' starts at index 3 with size 1
Assert.That(3, Is.GreaterThanOrEqualTo(0),
"Field 'Count' has invalid negative index");
}
/// <summary>
/// Tests the packet size calculation for HeykelSavasiScoreboard.
/// </summary>
[Test]
public void HeykelSavasiScoreboard_PacketSizeValidation()
{
// Basic packet validation
// Validate header type and field boundaries
// Field 'Count' starts at index 3 with size 1
Assert.That(3, Is.GreaterThanOrEqualTo(0),
"Field 'Count' has invalid negative index");
}
/// <summary>
/// Tests the packet size calculation for AvailableChatCommand.
/// </summary>
[Test]
public void AvailableChatCommand_PacketSizeValidation()
{ {
// Basic packet validation // Basic packet validation
// Validate header type and field boundaries // Validate header type and field boundaries

View File

@@ -0,0 +1,58 @@
// <copyright file="ApplyPendingUpdatesTool.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.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A manual tool, not a test: applies the pending configuration updates to the database configured in
/// <c>ConnectionSettings.xml</c> - the same thing the admin panel's "Updates" page does, for when there is
/// no browser at hand. It is <see cref="ExplicitAttribute"/> so it never runs in a normal test pass.
/// </summary>
[TestFixture]
[Explicit("Writes to the configured database. Run it deliberately, not as part of the test suite.")]
internal class ApplyPendingUpdatesTool
{
/// <summary>
/// Applies every configuration update which is not installed yet.
/// </summary>
[Test]
public async Task ApplyPendingUpdatesAsync()
{
// The server registers these at startup; without them the configuration JSON cannot be read back.
JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter());
JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter());
var loggerFactory = new NullLoggerFactory();
var contextProvider = new PersistenceContextProvider(loggerFactory, null);
var plugInManager = new PlugInManager(null, loggerFactory, null, null);
plugInManager.DiscoverAndRegisterPlugIns();
var service = new DataUpdateService(contextProvider, plugInManager);
var pending = (await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false)).ToList();
TestContext.Out.WriteLine($"Pending updates: {pending.Count}");
foreach (var update in pending)
{
TestContext.Out.WriteLine($" {(int)update.Version} - {update.Name}");
}
if (pending.Count == 0)
{
return;
}
var progress = new Progress<(UpdateVersion CurrentUpdatingVersion, bool IsCompleted)>(
p => TestContext.Out.WriteLine($" applying {(int)p.CurrentUpdatingVersion} completed={p.IsCompleted}"));
await service.ApplyUpdatesAsync(pending, progress).ConfigureAwait(false);
var left = await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false);
Assert.That(left, Is.Empty, "all updates should be installed now");
}
}

View File

@@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.Initialization.Updates; using MUnique.OpenMU.Persistence.Initialization.Updates;
@@ -91,6 +92,48 @@ internal class TestInitializationWithEfCore
Assert.That(groups[0].PossibleItems.Single().Number, Is.EqualTo((short)14)); Assert.That(groups[0].PossibleItems.Single().Number, Is.EqualTo((short)14));
} }
/// <summary>
/// Tests that the Castle Siege update writes its configuration into an existing Season 6 database, and
/// that applying it twice does not duplicate anything. This is the update which existing servers run to
/// get the castle: without it there is no Castle Siege configuration for the event to read.
/// </summary>
[Test]
public async Task TestSeason6CastleSiegeUpdatePlugInAsync()
{
var contextProvider = new InMemoryPersistenceContextProvider();
var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory());
await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false);
using var context = contextProvider.CreateNewContext();
var gameConfiguration = (await context.GetAsync<GameConfiguration>().ConfigureAwait(false)).First();
gameConfiguration.CastleSiegeConfiguration = null;
var update = new AddCastleSiegeDataUpdatePlugIn();
await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
var castleSiege = gameConfiguration.CastleSiegeConfiguration;
Assert.That(castleSiege, Is.Not.Null);
Assert.That(castleSiege!.Enabled, Is.True);
// The client's crown registration panel counts down from 60 seconds, so the server has to match it.
Assert.That(castleSiege.CrownHoldTimeSeconds, Is.EqualTo(60));
// Both Crown Switches, the crown and the throne have to be there, or the siege cannot be finished.
var npcNumbers = castleSiege.NpcDefinitions.Select(n => n.MonsterDefinition?.Number).ToList();
Assert.That(npcNumbers, Does.Contain((short)217), "Crown Switch 1");
Assert.That(npcNumbers, Does.Contain((short)218), "Crown Switch 2");
Assert.That(npcNumbers, Does.Contain((short)216), "Crown");
// Applying it twice must not double the NPC definitions.
Assert.That(npcNumbers.Count(n => n == 217), Is.EqualTo(1));
Assert.That(npcNumbers.Count(n => n == 218), Is.EqualTo(1));
var data = (await context.GetAsync<CastleSiegeData>().ConfigureAwait(false)).ToList();
Assert.That(data, Has.Count.EqualTo(1), "exactly one castle state row");
Assert.That(data[0].IsOccupied, Is.False, "a fresh castle has no owner");
}
/// <summary> /// <summary>
/// Tests the data initialization using the in-memory persistence. /// Tests the data initialization using the in-memory persistence.
/// </summary> /// </summary>

View File

@@ -0,0 +1,56 @@
// <copyright file="TypedContextModelTests.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.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Tests that the typed contexts can build their entity model. A typed context keeps only the edited type
/// (plus its aggregate) and ignores every other type, so a type which is only mapped in the full context
/// slips through the build and blows up at runtime instead. The startup reads the plugin configurations
/// through such a context before anything else, so a broken model there means the server doesn't start.
/// No database is needed: building the model already runs the EF model validation.
/// </summary>
[TestFixture]
internal class TypedContextModelTests
{
/// <summary>
/// Builds the model of the typed context which the startup uses to read the plugin configurations.
/// A failing connection is fine here (there may be no database); a failing model is not.
/// </summary>
[Test]
public void PlugInConfigurationContextBuildsModel()
{
var provider = new PersistenceContextProvider(new NullLoggerFactory(), null);
using var context = provider.CreateNewTypedContext(typeof(PlugInConfiguration), false);
try
{
_ = context.GetAsync<PlugInConfiguration>().AsTask().GetAwaiter().GetResult();
}
catch (Exception ex)
{
AssertNoModelError(ex);
}
}
private static void AssertNoModelError(Exception exception)
{
for (var ex = exception; ex is not null; ex = ex.InnerException!)
{
if (ex is InvalidOperationException && ex.Message.Contains("requires a primary key"))
{
Assert.Fail($"The entity model of the typed context is broken: {ex.Message}");
}
if (ex.InnerException is null)
{
break;
}
}
}
}

View File

@@ -4,143 +4,208 @@
namespace MUnique.OpenMU.Tests.CastleSiege; namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary> /// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock). /// Tests for the Castle Siege state machine (time-driven, injected clock). The cycle runs through the
/// original Season 6 state numbers the client knows: Idle1 -> RegisterGuild -> Ready -> Start -> End
/// -> EndCycle -> Idle1. Guilds are identified by their persistent id, not by their (renameable) name.
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class CastleSiegeContextTest public class CastleSiegeContextTest
{ {
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly Guid GuildA = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid GuildB = new("22222222-2222-2222-2222-222222222222");
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary> /// <summary>Tests that a fresh context rests in the idle state.</summary>
[Test] [Test]
public void StartsInOwnership() public void StartsInIdle()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
} }
/// <summary>Tests that force-starting moves the state machine into registration.</summary> /// <summary>Tests that force-starting moves the state machine into guild registration.</summary>
[Test] [Test]
public async Task ForceStartMovesToRegistrationAsync() public async Task ForceStartMovesToRegistrationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
} }
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary> /// <summary>Tests that registration advances to the preparation state once its duration elapses.</summary>
[Test] [Test]
public async Task RegistrationAdvancesToPreparationAfterDurationAsync() public async Task RegistrationAdvancesToReadyAfterDurationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(4)); // still within registration await ctx.TickAsync(T0.AddMinutes(4)); // still within registration
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Ready));
} }
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary> /// <summary>Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle.</summary>
[Test] [Test]
public async Task FullCycleReturnsToOwnershipAsync() public async Task FullCycleReturnsToIdleAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation await ctx.TickAsync(T0.AddMinutes(5)); // -> Ready
await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege await ctx.TickAsync(T0.AddMinutes(7)); // +2 preparation -> Start
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Start));
await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> End
await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership await ctx.TickAsync(T0.AddMinutes(17)); // End -> EndCycle
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); await ctx.TickAsync(T0.AddMinutes(17)); // EndCycle -> Idle1
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
} }
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary> /// <summary>Tests that guilds are collected by id during the registration state.</summary>
[Test] [Test]
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync() public async Task RegisterGuildCollectsGuildsDuringRegistrationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
ctx.RegisterGuild("Attackers"); ctx.RegisterGuild(GuildA, "Attackers");
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
Assert.That(ctx.IsRegistered(GuildA), Is.True);
Assert.That(ctx.RegisteredGuildNames, Does.Contain("Attackers"));
} }
/// <summary>Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne.</summary> /// <summary>
/// Tests the full siege objective chain: operate both Crown Switches, which drops the crown's shield,
/// then hold the crown to take the throne, which becomes the castle ownership when the siege ends.
/// </summary>
[Test] [Test]
public async Task FullSiegeObjectiveChainToOwnershipAsync() public async Task FullSiegeObjectiveChainToOwnershipAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
ctx.SetDefenseCount(2); var hold = TimeSpan.FromSeconds(60);
// Both switches held but defenses still up -> no capture. // A switch which is still being operated does not count yet.
ctx.SetSwitchHolder(217, "Attackers"); StartSwitch(ctx, 217, GuildA, 1);
ctx.SetSwitchHolder(218, "Attackers"); StartSwitch(ctx, 218, GuildA, 2);
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
ctx.NotifyDefenseDestroyed(); CompleteSwitch(ctx, 217);
ctx.NotifyDefenseDestroyed(); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null, "one completed switch is not enough");
// Only one switch held -> still no capture. CompleteSwitch(ctx, 218);
ctx.SetSwitchHolder(218, null); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
// Both switches held by the same guild + defenses down -> capture at the Sinior/Crown. // The crown only starts counting after the guild master clicked it.
ctx.SetSwitchHolder(218, "Attackers"); Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold);
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement await ctx.TickAsync(T0.AddMinutes(20)); // siege time is up: Start -> End
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership await ctx.TickAsync(T0.AddMinutes(20)); // End hands the castle to the guild on the throne
Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
} }
/// <summary>Tests that two different guilds each holding one switch cannot capture the throne.</summary> /// <summary>Tests that two different guilds each holding one switch keep the crown's shield up.</summary>
[Test] [Test]
public async Task ThroneRequiresBothSwitchesBySameGuildAsync() public async Task ShieldRequiresBothSwitchesBySameGuildAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
ctx.SetDefenseCount(0); StartSwitch(ctx, 217, GuildA, 1);
ctx.SetSwitchHolder(217, "A"); StartSwitch(ctx, 218, GuildB, 2);
ctx.SetSwitchHolder(218, "B"); CompleteSwitch(ctx, 217);
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); CompleteSwitch(ctx, 218);
Assert.That(ctx.TryCaptureThrone("B").Success, Is.False);
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
} }
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary> /// <summary>Tests that the switches don't work at all outside the running siege.</summary>
[Test] [Test]
public void ThroneCaptureOutsideSiegeIsNoOp() public void SwitchesDoNothingOutsideSiege()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
ctx.SetSwitchHolder(217, "A"); var (result, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "player", 100, T0);
ctx.SetSwitchHolder(218, "A");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(result, Is.EqualTo(CastleSiegeSwitchPush.SiegeNotRunning));
Assert.That(ctx.OccupierGuildName, Is.Null); Assert.That(ctx.GetSwitchOperation(217), Is.Null);
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
} }
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary> /// <summary>Tests that a switch belongs to the first player who clicked it, until they leave its area.</summary>
[Test] [Test]
public void RestoreStateSetsStateWithoutFiringPhaseChanged() public async Task SwitchIsTakenByOnePlayerUntilTheyLeaveAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
var phaseChangedFired = false; await ctx.ForceStartRegistrationAsync(T0);
ctx.PhaseChanged += _ => phaseChangedFired = true; await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
var push = TimeSpan.FromSeconds(15);
ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }); var (first, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "first", 100, T0);
Assert.That(first, Is.EqualTo(CastleSiegeSwitchPush.Started));
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); // Somebody else clicking it is refused and learns who is on it.
var (second, blocking) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(1));
Assert.That(second, Is.EqualTo(CastleSiegeSwitchPush.TakenByOther));
Assert.That(blocking?.PlayerId, Is.EqualTo(1));
// It only counts once the push ran its time.
Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(14), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.None));
Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.False);
Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(15), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.Held));
Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.True);
// Leaving the area frees it for everybody.
var (released, freed) = ctx.TickSwitch(217, false, T0.AddSeconds(20), push);
Assert.That(released, Is.EqualTo(CastleSiegeSwitchEvent.Released));
Assert.That(freed?.PlayerId, Is.EqualTo(1));
Assert.That(ctx.GetSwitchOperation(217), Is.Null);
var (afterRelease, _) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(21));
Assert.That(afterRelease, Is.EqualTo(CastleSiegeSwitchPush.Started));
}
/// <summary>Tests that losing a switch while the crown is being held drops the guild's eligibility.</summary>
[Test]
public async Task LosingASwitchRaisesTheShieldAgainAsync()
{
var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
ctx.TickSwitch(218, false, T0.AddSeconds(20), TimeSpan.FromSeconds(15));
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
}
/// <summary>Tests that restoring persisted state sets state/owner/registrations without raising StateChanged.</summary>
[Test]
public void RestoreStateSetsStateWithoutFiringStateChanged()
{
var ctx = new CastleSiegeContext(Config());
var stateChangedFired = false;
ctx.StateChanged += _ => stateChangedFired = true;
ctx.RestoreState(
GuildA,
"Winners",
CastleSiegeState.Idle1,
T0,
new[] { new KeyValuePair<Guid, string>(GuildA, "Winners"), new KeyValuePair<Guid, string>(GuildB, "Losers") });
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners")); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners"));
Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" })); Assert.That(ctx.RegisteredGuildNames, Is.EquivalentTo(new[] { "Winners", "Losers" }));
Assert.That(phaseChangedFired, Is.False); Assert.That(stateChangedFired, Is.False);
Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty");
} }
/// <summary>Tests that the weekly auto-schedule (stored in config) only fires on a matching day/time window.</summary> /// <summary>Tests that the weekly auto-schedule (stored in the settings) only fires on a matching day/time window.</summary>
[Test] [Test]
public void ScheduleFiresOnMatchingDayAndTimeWindow() public void ScheduleFiresOnMatchingDayAndTimeWindow()
{ {
@@ -149,7 +214,7 @@ public class CastleSiegeContextTest
var config = ctx.Configuration; var config = ctx.Configuration;
var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window
var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the 5s window var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the window
var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day
Assert.That(config.IsRegistrationOpenTime(sunday), Is.True); Assert.That(config.IsRegistrationOpenTime(sunday), Is.True);
@@ -168,26 +233,26 @@ public class CastleSiegeContextTest
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist"); Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist");
await ctx.ForceStartRegistrationAsync(T0); // phase transition -> dirty await ctx.ForceStartRegistrationAsync(T0); // state transition -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.True);
Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag"); Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag");
ctx.RegisterGuild("Attackers"); // registration -> dirty ctx.RegisterGuild(GuildA, "Attackers"); // registration -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.True);
ctx.SetOwner("Attackers"); // owner change -> dirty ctx.SetOwner(GuildA, "Attackers"); // owner change -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.True);
} }
/// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary> /// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
[Test] [Test]
public async Task RemainingSiegeTimeReflectsSiegePhaseAsync() public async Task RemainingSiegeTimeReflectsSiegeStateAsync()
{ {
var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration
Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero"); Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero");
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7))); Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7)));
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero"); Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero");
@@ -197,75 +262,90 @@ public class CastleSiegeContextTest
[Test] [Test]
public async Task CrownHoldCapturesAfterHoldDurationAsync() public async Task CrownHoldCapturesAfterHoldDurationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "Attackers");
ctx.SetSwitchHolder(218, "Attackers");
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo("Attackers"));
var hold = TimeSpan.FromSeconds(60); var hold = TimeSpan.FromSeconds(60);
Assert.That(ctx.TickCrownHold("Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold("Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.OccupierGuildName, Is.Null);
var captured = ctx.TickCrownHold("Attackers", true, T0.AddSeconds(60), hold); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.OccupierGuildId, Is.Null);
var captured = ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold);
Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(captured.ShieldDown, Is.True); Assert.That(captured.ShieldDown, Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
} }
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary> /// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
[Test] [Test]
public async Task CrownHoldResetsWhenSwitchLostAsync() public async Task CrownHoldResetsWhenSwitchLostAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
await ctx.ForceStartRegistrationAsync(T0); var hold = TimeSpan.FromSeconds(60);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "A");
Assert.That(ctx.TickCrownHold("A", true, T0, TimeSpan.FromSeconds(60)).Event, Is.EqualTo(CrownEvent.HoldStarted)); Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
ctx.SetSwitchHolder(218, null); // lost a switch -> no longer eligible ctx.TickSwitch(218, false, T0.AddSeconds(5), TimeSpan.FromSeconds(15)); // lost a switch -> no longer eligible
var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), false, T0.AddSeconds(10), TimeSpan.FromSeconds(60)); var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), null, false, T0.AddSeconds(10), hold);
Assert.That(reset.ShieldDown, Is.False); Assert.That(reset.ShieldDown, Is.False);
Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset)); Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset));
Assert.That(ctx.OccupierGuildName, Is.Null); Assert.That(ctx.OccupierGuildId, Is.Null);
} }
/// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary> /// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary>
[Test] [Test]
public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync() public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
var hold = TimeSpan.FromSeconds(60); var hold = TimeSpan.FromSeconds(60);
var push = TimeSpan.FromSeconds(15);
ctx.SetSwitchHolder(217, "A"); ctx.RequestCrownHold(GuildA);
ctx.SetSwitchHolder(218, "A"); ctx.TickCrownHold(GuildA, "A", true, T0, hold);
ctx.TickCrownHold("A", true, T0, hold); Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.TickCrownHold("A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("A"));
// A keeps holding no re-registration loop, but the shield stays down (they hold it). // A keeps holding - no re-registration loop, but the shield stays down (they hold it).
var after = ctx.TickCrownHold("A", true, T0.AddSeconds(61), hold); Assert.That(ctx.RequestCrownHold(GuildA), Is.False, "the occupier cannot re-register its own throne");
var after = ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(61), hold);
Assert.That(after.Event, Is.EqualTo(CrownEvent.None)); Assert.That(after.Event, Is.EqualTo(CrownEvent.None));
Assert.That(after.ShieldDown, Is.True); Assert.That(after.ShieldDown, Is.True);
// B takes both switches and can contest/capture. // B takes both switches and can contest/capture.
ctx.SetSwitchHolder(217, "B"); ctx.TickSwitch(217, false, T0.AddSeconds(61), push);
ctx.SetSwitchHolder(218, "B"); ctx.TickSwitch(218, false, T0.AddSeconds(61), push);
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); StartSwitch(ctx, 217, GuildB, 3);
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured)); StartSwitch(ctx, 218, GuildB, 4);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("B")); CompleteSwitch(ctx, 217);
CompleteSwitch(ctx, 218);
Assert.That(ctx.RequestCrownHold(GuildB), Is.True);
Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildB));
} }
private static CastleSiegeConfiguration Config() => new() private static async Task<CastleSiegeContext> SiegeWithSwitchesHeldAsync(Guid guildId)
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
StartSwitch(ctx, 217, guildId, 1);
StartSwitch(ctx, 218, guildId, 2);
CompleteSwitch(ctx, 217);
CompleteSwitch(ctx, 218);
return ctx;
}
private static void StartSwitch(CastleSiegeContext ctx, short switchNumber, Guid guildId, ushort playerId)
=> ctx.TryStartSwitchOperation(switchNumber, guildId, guildId.ToString()[..4], playerId, $"p{playerId}", (ushort)switchNumber, T0);
private static void CompleteSwitch(CastleSiegeContext ctx, short switchNumber)
=> ctx.TickSwitch(switchNumber, true, T0.AddSeconds(30), TimeSpan.FromSeconds(15));
private static CastleSiegeSettings Config() => new()
{ {
RegistrationDuration = TimeSpan.FromMinutes(5), RegistrationDuration = TimeSpan.FromMinutes(5),
PreparationDuration = TimeSpan.FromMinutes(2), PreparationDuration = TimeSpan.FromMinutes(2),