Files
AdamuSw/src/GameLogic/CastleSiege/CastleSiegeSettings.cs
Acentech Dev f8e856c7c6
Some checks failed
.NET Core / build (push) Has been cancelled
feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
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

193 lines
7.8 KiB
C#

// <copyright file="CastleSiegeSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so
/// it is editable in the AdminPanel and needs no dedicated database table.
/// <para>
/// This is deliberately separate from <see cref="DataModel.Configuration.CastleSiegeConfiguration"/>, which is
/// the upstream, database-backed configuration holding the NPC/zone/upgrade definitions and the crown hold
/// time. Keeping AdaMu's operational knobs out of that entity means upstream schema changes apply cleanly and
/// no hand-editing of the generated persistence code is needed.
/// </para>
/// <para>
/// What lives where:
/// <list type="bullet">
/// <item>Castle owner and guild registrations: database (<c>CastleSiegeData</c>, <c>CastleSiegeGuildRegistration</c>).</item>
/// <item>NPC/zone/upgrade definitions and crown hold time: database (<c>GameConfiguration.CastleSiegeConfiguration</c>).</item>
/// <item>Cycle durations, registration fee, designated server and the current state: here.</item>
/// </list>
/// </para>
/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the
/// current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute durations) are proxies over the runtime
/// fields, which are hidden from the editor to keep the form clean.
/// </summary>
public class CastleSiegeSettings
{
/// <summary>
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a
/// time). Editable in the AdminPanel as check boxes; proxies the runtime <see cref="RegistrationOpenDays"/>.
/// </summary>
[JsonIgnore]
public CastleSiegeScheduleDays OpenDays
{
get
{
var days = CastleSiegeScheduleDays.None;
foreach (var day in this.RegistrationOpenDays)
{
days |= (CastleSiegeScheduleDays)(1 << (int)day);
}
return days;
}
set => this.RegistrationOpenDays = Enum.GetValues<DayOfWeek>()
.Where(day => value.HasFlag((CastleSiegeScheduleDays)(1 << (int)day)))
.ToList();
}
/// <summary>
/// Gets or sets the times of day (UTC) at which a new cycle opens registration.
/// Empty = no auto-start (admins start cycles manually via the chat command or the AdminPanel).
/// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
/// <summary>Gets or sets how long the registration period lasts, in minutes (guilds may register).</summary>
[JsonIgnore]
public int RegistrationMinutes
{
get => (int)this.RegistrationDuration.TotalMinutes;
set => this.RegistrationDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
/// <summary>Gets or sets how long the preparation period lasts, in minutes (between registration and war).</summary>
[JsonIgnore]
public int PreparationMinutes
{
get => (int)this.PreparationDuration.TotalMinutes;
set => this.PreparationDuration = TimeSpan.FromMinutes(Math.Max(0, value));
}
/// <summary>Gets or sets how long the war (siege) period lasts, in minutes.</summary>
[JsonIgnore]
public int SiegeMinutes
{
get => (int)this.SiegeDuration.TotalMinutes;
set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
/// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.
/// </summary>
public int RegistrationFee { get; set; } = 100000;
/// <summary>
/// Gets or sets the game server (SW) id on which the Castle Siege runs. With multiple game servers each
/// has its own map instances, so the siege must happen on ONE designated server; the others skip it (they
/// only honor the shared castle owner for the rewards). Players must be on this server to take part.
/// </summary>
public byte CastleSiegeServerId { get; set; }
// --- Runtime fields (hidden from the AdminPanel; proxied by the friendly properties above) ---
/// <summary>Gets or sets the days of week registration auto-opens (UTC). Empty = every day.</summary>
[Browsable(false)]
public IList<DayOfWeek> RegistrationOpenDays { get; set; } = new List<DayOfWeek>();
/// <summary>Gets or sets how long the registration period lasts.</summary>
[Browsable(false)]
public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Gets or sets how long the preparation period lasts.</summary>
[Browsable(false)]
public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>Gets or sets how long the war (siege) period lasts.</summary>
[Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
/// The player has to stay in the switch's area for that long, and keeps it until they leave.
/// </summary>
public int SwitchPushSeconds { get; set; } = 15;
// --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
// Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
// owner and the guild registrations live in real database tables, so they are not duplicated here.
/// <summary>Gets or sets the persisted current state, so the cycle resumes after a restart.</summary>
[Browsable(false)]
public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1;
/// <summary>Gets or sets when the persisted state started (UTC), or null if never persisted.</summary>
[Browsable(false)]
public DateTime? PersistedStateStartedUtc { get; set; }
/// <summary>
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls
/// within a 5-second window of a configured open time. When <see cref="RegistrationOpenDays"/> is empty,
/// the day is not restricted (every day). When no times are configured, auto-start is disabled.
/// </summary>
/// <param name="now">The current UTC time.</param>
public bool IsRegistrationOpenTime(DateTime now)
{
if (this.RegistrationOpenTimes.Count == 0)
{
return false;
}
if (this.RegistrationOpenDays.Count > 0 && !this.RegistrationOpenDays.Contains(now.DayOfWeek))
{
return false;
}
var nowTime = TimeOnly.FromDateTime(now);
var earlier = nowTime.Add(TimeSpan.FromSeconds(-5));
return this.RegistrationOpenTimes.Any(p => p.IsBetween(earlier, nowTime));
}
}
/// <summary>
/// The days of week on which the Castle Siege registration auto-opens. A [Flags] enum so the AdminPanel
/// renders it as a set of check boxes.
/// </summary>
[Flags]
public enum CastleSiegeScheduleDays
{
/// <summary>No day (auto-start day-unrestricted; still needs a time).</summary>
None = 0,
/// <summary>Sunday.</summary>
Sunday = 1 << 0,
/// <summary>Monday.</summary>
Monday = 1 << 1,
/// <summary>Tuesday.</summary>
Tuesday = 1 << 2,
/// <summary>Wednesday.</summary>
Wednesday = 1 << 3,
/// <summary>Thursday.</summary>
Thursday = 1 << 4,
/// <summary>Friday.</summary>
Friday = 1 << 5,
/// <summary>Saturday.</summary>
Saturday = 1 << 6,
}