## Summary
- **Add a self-referencing `InterpolationHandler` to `ValueStringBuilder`** that writes directly into the builder's buffer — zero intermediate allocation, works with `stackalloc`-backed builders
- **Replace all `System.Text.StringBuilder` usage** across the codebase with `ValueStringBuilder`
- **Convert `ValueStringBuilder.Create()` to `stackalloc`** at 10 sites where output length is provably bounded
- **Convert manual `Dispose()` to `using var`** where possible, and hoist loop-scoped builders outside loops with `Reset()`
- **Convert verbose `Append()` chains to `Append($"...")`** interpolation for readability
- **Add comprehensive documentation** for string handling patterns
## InterpolationHandler Design
`ValueStringBuilder` is a `ref struct`, which creates challenges for C#'s interpolated string handler pattern:
- **`ref` fields to ref structs are not allowed** (CS9050)
- **`[InterpolatedStringHandlerArgument("")]` passes struct receivers by value**, not by ref
- **`ISelfInterpolatedStringHandler` requires boxing** ref structs into interface fields
**Solution: Copy-and-reconcile pattern.** The handler receives a value copy of the builder. The copy shares the same underlying `char` buffer (`Span` points to the same `stackalloc`/pooled memory), so writes go to the original buffer. `Append()` reconciles by `this = handler._builder`, updating `_length` and any buffer references changed by `Grow()`.
This is safe because:
- The game loop is single-threaded — no concurrent access between handler construction and reconciliation
- If `Grow()` occurs in the copy, the original's stale buffer isn't accessed until `Append()` replaces it
- `Dispose()` correctly returns the reconciled buffer to the pool
## Changes by Category
### ValueStringBuilder (`Projects/Server/Buffers/ValueStringBuilder.cs`)
- Added nested `InterpolationHandler` ref struct with copy-and-reconcile pattern
- Added `Append([InterpolatedStringHandlerArgument("")] scoped ref InterpolationHandler)` method
- Removed `RawInterpolatedStringHandler` overloads (new handler replaces them)
- All `AppendFormatted` overloads delegate to existing `Append` methods (no code duplication)
- Alignment support via direct private field access (nested type privilege)
### StringBuilder → ValueStringBuilder (15 files)
Replaced all `new StringBuilder()` with `ValueStringBuilder.Create()` or `stackalloc`:
- ConPVP games: KingOfTheHill, DoubleDom, CTF, BombingRun, TourneyMatch
- ConPVP infrastructure: Tournament, Participant, TourneyParticipant
- ConPVP gumps: ArenaGump, TournamentBracketGump, AcceptTeamGump, ConfirmSignupGump
- Commands: Handlers, Logging, Add
- Other: TownCrier, SpeechLogGump, TestCenter
Key patterns:
- `sb = new StringBuilder()` reassignment → `sb.Reset()`
- `sb.AppendFormat("{0:N0}", value)` → `sb.Append($"{value:N0}")`
- `sb.Append(x).Append(y)` chains → separate statements (VSB returns void)
### Create() → stackalloc (10 files)
Converted heap-allocated builders to stackalloc where output is bounded:
- ClientVersion (32), MapSelection (160), HouseRaffleStone (48)
- HolySense (96), UnholySense (96), ClientVerification (192)
- AcceptTeamGump (64), ConfirmSignupGump (64)
- BaseWeapon (160), BaseArmor (128)
### Loop optimizations (2 files)
Hoisted `ValueStringBuilder` creation outside loops with `Reset()` per iteration:
- TourneyMatch.cs: `using var` inside for loop → stackalloc before loop
- ArenaGump.cs: `Create()` + `Dispose()` per iteration → stackalloc before loop
### Append chain → interpolation (5 files)
Converted multi-line `Append()` chains to `Append($"...")`:
- BountyMessage.cs: title switch (6 cases), paragraph (15→1 Append), description lines, closing
- AcceptTeamGump, ConfirmSignupGump, TournamentBracketGump: tournament type strings
- AdminGump: comment/tag formatting in loops
### Documentation
- `dev-docs/string-handling.md`: Full reference — construction, interpolation, disposal, decision guide
- `dev-docs/claude-skills/modernuo-string-handling.md`: Claude skill with quick reference
- `CLAUDE.md`: Added rule 17 (no StringBuilder), dev-docs table entry, skills table entry
- `dev-docs/code-standards.md`: Updated memory management section
## Test Plan
- [x] `dotnet build` — 0 errors, 0 warnings
- [x] `dotnet test` — 940/940 tests pass
- [x] 28 ValueStringBuilder tests covering all reconciliation scenarios:
- Stackalloc no-grow, stackalloc with grow (→pool transition)
- Heap no-grow, heap with grow, heap double grow
- Pre-existing content with and without grow
- Sequential multiple `Append($"...")` calls
- Mixed plain + interpolated Append
- Empty interpolation, literal-only, format specifiers
- Null string holes, ISpanFormattable types
- Dispose after stackalloc→pool grow
1098 lines
28 KiB
C#
1098 lines
28 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using ModernUO.Serialization;
|
|
using Server.Gumps;
|
|
using Server.Items;
|
|
using Server.Mobiles;
|
|
using Server.Text;
|
|
|
|
namespace Server.Engines.ConPVP;
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public sealed partial class DDBoard : Item
|
|
{
|
|
public DDTeamInfo m_TeamInfo;
|
|
|
|
[Constructible]
|
|
public DDBoard() : base(7774) => Movable = false;
|
|
|
|
public override string DefaultName => "scoreboard";
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (m_TeamInfo?.Game != null)
|
|
{
|
|
from.SendGump(new DDBoardGump(from, m_TeamInfo.Game));
|
|
}
|
|
}
|
|
}
|
|
|
|
public class DDBoardGump : Gump
|
|
{
|
|
private const int LabelColor32 = 0xFFFFFF;
|
|
private const int BlackColor32 = 0x000000;
|
|
|
|
public override bool Singleton => true;
|
|
|
|
public DDBoardGump(Mobile mob, DDGame game, DDTeamInfo section = null)
|
|
: base(60, 60)
|
|
{
|
|
// m_Game = game;
|
|
|
|
var ourTeam = game.GetTeamInfo(mob);
|
|
|
|
var entries = new List<IRankedCTF>();
|
|
|
|
if (section == null)
|
|
{
|
|
for (var i = 0; i < game.Context.Participants.Count; ++i)
|
|
{
|
|
var teamInfo = game.Controller.TeamInfo[i % game.Controller.TeamInfo.Length];
|
|
|
|
if (teamInfo != null)
|
|
{
|
|
entries.Add(teamInfo);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var player in section.Players.Values)
|
|
{
|
|
if (player.Score > 0)
|
|
{
|
|
entries.Add(player);
|
|
}
|
|
}
|
|
}
|
|
|
|
entries.Sort((a, b) => b.Score - a.Score);
|
|
|
|
var height = 0;
|
|
|
|
if (section == null)
|
|
{
|
|
height = 73 + entries.Count * 75 + 28;
|
|
}
|
|
|
|
Closable = false;
|
|
|
|
AddPage(0);
|
|
|
|
AddBackground(1, 1, 398, height, 3600);
|
|
|
|
AddImageTiled(16, 15, 369, height - 29, 3604);
|
|
|
|
for (var i = 0; i < entries.Count; i += 1)
|
|
{
|
|
AddImageTiled(22, 58 + i * 75, 357, 70, 0x2430);
|
|
}
|
|
|
|
AddAlphaRegion(16, 15, 369, height - 29);
|
|
|
|
AddImage(215, -45, 0xEE40);
|
|
// AddImage( 330, 141, 0x8BA );
|
|
|
|
AddBorderedText(22, 22, 294, 20, "DD Scoreboard".Center(), LabelColor32, BlackColor32);
|
|
|
|
AddImageTiled(32, 50, 264, 1, 9107);
|
|
AddImageTiled(42, 52, 264, 1, 9157);
|
|
|
|
if (section == null)
|
|
{
|
|
for (var i = 0; i < entries.Count; ++i)
|
|
{
|
|
if (entries[i] is not DDTeamInfo teamInfo)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AddImage(30, 70 + i * 75, 10152);
|
|
AddImage(30, 85 + i * 75, 10151);
|
|
AddImage(30, 100 + i * 75, 10151);
|
|
AddImage(30, 106 + i * 75, 10154);
|
|
|
|
AddImage(24, 60 + i * 75, teamInfo == ourTeam ? 9730 : 9727, teamInfo.Color - 1);
|
|
|
|
var borderColor = teamInfo.Color == 0x455 ? LabelColor32 : BlackColor32;
|
|
var nameColor = teamInfo.Color switch
|
|
{
|
|
0x47E => 0xFFFFFF,
|
|
0x4F2 => 0x3399FF,
|
|
0x4F7 => 0x33FF33,
|
|
0x4FC => 0xFF00FF,
|
|
0x021 => 0xFF3333,
|
|
0x01A => 0xFF66FF,
|
|
0x455 => 0x333333,
|
|
_ => LabelColor32
|
|
};
|
|
|
|
AddBorderedText(
|
|
60,
|
|
65 + i * 75,
|
|
250,
|
|
20,
|
|
$"{LadderGump.Rank(1 + i)}: {teamInfo.Name}",
|
|
nameColor,
|
|
borderColor
|
|
);
|
|
|
|
AddBorderedText(50 + 10, 85 + i * 75, 100, 20, "Score:", 0xFFC000, BlackColor32);
|
|
AddBorderedText(50 + 15, 105 + i * 75, 100, 20, teamInfo.Score.ToString("N0"), 0xFFC000, BlackColor32);
|
|
|
|
AddBorderedText(110 + 10, 85 + i * 75, 100, 20, "Kills:", 0xFFC000, BlackColor32);
|
|
AddBorderedText(110 + 15, 105 + i * 75, 100, 20, teamInfo.Kills.ToString("N0"), 0xFFC000, BlackColor32);
|
|
|
|
AddBorderedText(160 + 10, 85 + i * 75, 100, 20, "Captures:", 0xFFC000, BlackColor32);
|
|
AddBorderedText(
|
|
160 + 15,
|
|
105 + i * 75,
|
|
100,
|
|
20,
|
|
teamInfo.Captures.ToString("N0"),
|
|
0xFFC000,
|
|
BlackColor32
|
|
);
|
|
|
|
var pl = teamInfo.Leader;
|
|
|
|
AddBorderedText(235 + 10, 85 + i * 75, 250, 20, "Leader:", 0xFFC000, BlackColor32);
|
|
|
|
if (pl != null)
|
|
{
|
|
AddBorderedText(235 + 15, 105 + i * 75, 250, 20, pl.Player.Name, 0xFFC000, BlackColor32);
|
|
}
|
|
}
|
|
}
|
|
|
|
AddButton(314, height - 42, 247, 248, 1);
|
|
}
|
|
|
|
private void AddBorderedText(int x, int y, int width, int height, string text, int color, int borderColor)
|
|
{
|
|
AddColoredText(x - 1, y - 1, width, height, text, borderColor);
|
|
AddColoredText(x - 1, y + 1, width, height, text, borderColor);
|
|
AddColoredText(x + 1, y - 1, width, height, text, borderColor);
|
|
AddColoredText(x + 1, y + 1, width, height, text, borderColor);
|
|
AddColoredText(x, y, width, height, text, color);
|
|
}
|
|
|
|
private void AddColoredText(int x, int y, int width, int height, string text, int color)
|
|
{
|
|
AddHtml(x, y, width, height, color == 0 ? text : text.Color(color));
|
|
}
|
|
}
|
|
|
|
public sealed class DDPlayerInfo : IRankedCTF
|
|
{
|
|
private readonly DDTeamInfo m_TeamInfo;
|
|
private int m_Captures;
|
|
|
|
private int m_Kills;
|
|
|
|
private int m_Score;
|
|
|
|
public DDPlayerInfo(DDTeamInfo teamInfo, Mobile player)
|
|
{
|
|
m_TeamInfo = teamInfo;
|
|
Player = player;
|
|
}
|
|
|
|
public Mobile Player { get; }
|
|
|
|
public string Name => Player.Name;
|
|
|
|
public int Kills
|
|
{
|
|
get => m_Kills;
|
|
set
|
|
{
|
|
m_TeamInfo.Kills += value - m_Kills;
|
|
m_Kills = value;
|
|
}
|
|
}
|
|
|
|
public int Captures
|
|
{
|
|
get => m_Captures;
|
|
set
|
|
{
|
|
m_TeamInfo.Captures += value - m_Captures;
|
|
m_Captures = value;
|
|
}
|
|
}
|
|
|
|
public int Score
|
|
{
|
|
get => m_Score;
|
|
set
|
|
{
|
|
m_TeamInfo.Score += value - m_Score;
|
|
m_Score = value;
|
|
|
|
if (m_TeamInfo.Leader == null || m_Score > m_TeamInfo.Leader.Score)
|
|
{
|
|
m_TeamInfo.Leader = this;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
[PropertyObject]
|
|
public sealed class DDTeamInfo : IRankedCTF
|
|
{
|
|
public DDTeamInfo(int teamID)
|
|
{
|
|
TeamID = teamID;
|
|
Players = new Dictionary<Mobile, DDPlayerInfo>();
|
|
}
|
|
|
|
public DDTeamInfo(int teamID, IGenericReader ip)
|
|
{
|
|
TeamID = teamID;
|
|
Players = new Dictionary<Mobile, DDPlayerInfo>();
|
|
|
|
var version = ip.ReadEncodedInt();
|
|
|
|
switch (version)
|
|
{
|
|
case 0:
|
|
{
|
|
Board = ip.ReadEntity<DDBoard>();
|
|
TeamName = ip.ReadString();
|
|
Color = ip.ReadEncodedInt();
|
|
Origin = ip.ReadPoint3D();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public DDGame Game { get; set; }
|
|
|
|
public int TeamID { get; }
|
|
|
|
public DDPlayerInfo Leader { get; set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public DDBoard Board { get; set; }
|
|
|
|
public Dictionary<Mobile, DDPlayerInfo> Players { get; }
|
|
|
|
public DDPlayerInfo this[Mobile mob]
|
|
{
|
|
get
|
|
{
|
|
if (mob == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!Players.TryGetValue(mob, out var val))
|
|
{
|
|
Players[mob] = val = new DDPlayerInfo(this, mob);
|
|
}
|
|
|
|
return val;
|
|
}
|
|
}
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public int Color { get; set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public string TeamName { get; set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public Point3D Origin { get; set; }
|
|
|
|
public string Name => $"{TeamName} Team";
|
|
|
|
public int Kills { get; set; }
|
|
|
|
public int Captures { get; set; }
|
|
|
|
public int Score { get; set; }
|
|
|
|
public void Reset()
|
|
{
|
|
Kills = 0;
|
|
Captures = 0;
|
|
|
|
Score = 0;
|
|
|
|
Leader = null;
|
|
|
|
Players.Clear();
|
|
|
|
if (Board != null)
|
|
{
|
|
Board.m_TeamInfo = this;
|
|
}
|
|
}
|
|
|
|
public void Serialize(IGenericWriter op)
|
|
{
|
|
op.WriteEncodedInt(0); // version
|
|
|
|
op.Write(Board);
|
|
op.Write(TeamName);
|
|
op.WriteEncodedInt(Color);
|
|
op.Write(Origin);
|
|
}
|
|
|
|
public override string ToString() => "...";
|
|
}
|
|
|
|
public sealed class DDController : EventController
|
|
{
|
|
[Constructible]
|
|
public DDController()
|
|
{
|
|
Visible = false;
|
|
Movable = false;
|
|
|
|
Duration = TimeSpan.FromMinutes(30.0);
|
|
|
|
TeamInfo = new DDTeamInfo[2];
|
|
|
|
for (var i = 0; i < TeamInfo.Length; ++i)
|
|
{
|
|
TeamInfo[i] = new DDTeamInfo(i);
|
|
}
|
|
}
|
|
|
|
public DDController(Serial serial)
|
|
: base(serial)
|
|
{
|
|
}
|
|
|
|
public DDTeamInfo[] TeamInfo { get; private set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public DDTeamInfo Team1 => TeamInfo[0];
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public DDTeamInfo Team2 => TeamInfo[1];
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public DDWayPoint PointA { get; set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public DDWayPoint PointB { get; set; }
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public TimeSpan Duration { get; set; }
|
|
|
|
public override string Title => "DoubleDom";
|
|
|
|
public override string DefaultName => "DD Controller";
|
|
|
|
public override string GetTeamName(int teamID) => TeamInfo[teamID % TeamInfo.Length].Name;
|
|
|
|
public override EventGame Construct(DuelContext context) => new DDGame(this, context);
|
|
|
|
public override void Serialize(IGenericWriter writer)
|
|
{
|
|
base.Serialize(writer);
|
|
|
|
writer.Write(0);
|
|
|
|
writer.Write(Duration);
|
|
|
|
writer.WriteEncodedInt(TeamInfo.Length);
|
|
|
|
for (var i = 0; i < TeamInfo.Length; ++i)
|
|
{
|
|
TeamInfo[i].Serialize(writer);
|
|
}
|
|
|
|
writer.Write(PointA);
|
|
writer.Write(PointB);
|
|
}
|
|
|
|
public override void Deserialize(IGenericReader reader)
|
|
{
|
|
base.Deserialize(reader);
|
|
|
|
var version = reader.ReadInt();
|
|
|
|
switch (version)
|
|
{
|
|
case 0:
|
|
{
|
|
Duration = reader.ReadTimeSpan();
|
|
TeamInfo = new DDTeamInfo[reader.ReadEncodedInt()];
|
|
|
|
for (var i = 0; i < TeamInfo.Length; ++i)
|
|
{
|
|
TeamInfo[i] = new DDTeamInfo(i, reader);
|
|
}
|
|
|
|
PointA = reader.ReadEntity<DDWayPoint>();
|
|
PointB = reader.ReadEntity<DDWayPoint>();
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class DDGame : EventGame
|
|
{
|
|
private int m_CapStage;
|
|
|
|
private bool m_Capturable = true;
|
|
private TimerExecutionToken _captureTimerToken;
|
|
private TimerExecutionToken _finishTimerToken;
|
|
private TimerExecutionToken _uncaptureTimerToken;
|
|
|
|
public DDGame(DDController controller, DuelContext context) : base(context) => Controller = controller;
|
|
|
|
public DDController Controller { get; }
|
|
|
|
public Map Facet => m_Context.Arena?.Facet ?? Controller.Map;
|
|
|
|
public void Alert(string text)
|
|
{
|
|
m_Context.m_Tournament?.Alert(text);
|
|
|
|
for (var i = 0; i < m_Context.Participants.Count; ++i)
|
|
{
|
|
var p = m_Context.Participants[i];
|
|
|
|
for (var j = 0; j < p.Players.Length; ++j)
|
|
{
|
|
if (p.Players[j] != null)
|
|
{
|
|
p.Players[j].Mobile.SendMessage(0x35, text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public DDTeamInfo GetTeamInfo(Mobile mob)
|
|
{
|
|
var teamID = GetTeamID(mob);
|
|
|
|
if (teamID >= 0)
|
|
{
|
|
return Controller.TeamInfo[teamID % Controller.TeamInfo.Length];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public int GetTeamID(Mobile mob)
|
|
{
|
|
if (mob is not PlayerMobile pm)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (pm.DuelContext == null || pm.DuelContext != m_Context)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (pm.DuelPlayer?.Eliminated != false)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
return pm.DuelContext.Participants.IndexOf(pm.DuelPlayer.Participant);
|
|
}
|
|
|
|
public int GetColor(Mobile mob) => GetTeamInfo(mob)?.Color ?? -1;
|
|
|
|
private void ApplyHues(Participant p, int hueOverride)
|
|
{
|
|
for (var i = 0; i < p.Players.Length; ++i)
|
|
{
|
|
if (p.Players[i] != null)
|
|
{
|
|
p.Players[i].Mobile.SolidHueOverride = hueOverride;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void DelayBounce(TimeSpan ts, Mobile mob, Container corpse)
|
|
{
|
|
Timer.StartTimer(ts, () => DelayBounce_Callback(mob, corpse));
|
|
}
|
|
|
|
private void DelayBounce_Callback(Mobile mob, Container corpse)
|
|
{
|
|
var dp = (mob as PlayerMobile)?.DuelPlayer;
|
|
|
|
m_Context.RemoveAggressions(mob);
|
|
|
|
if (dp?.Eliminated == false)
|
|
{
|
|
mob.MoveToWorld(m_Context.Arena.GetBaseStartPoint(GetTeamID(mob)), Facet);
|
|
}
|
|
else
|
|
{
|
|
m_Context.SendOutside(mob);
|
|
}
|
|
|
|
m_Context.Refresh(mob, corpse);
|
|
DuelContext.Debuff(mob);
|
|
DuelContext.CancelSpell(mob);
|
|
mob.Frozen = false;
|
|
}
|
|
|
|
public override bool OnDeath(Mobile mob, Container corpse)
|
|
{
|
|
var killer = mob.FindMostRecentDamager(false);
|
|
|
|
if (killer?.Player == true)
|
|
{
|
|
var teamInfo = GetTeamInfo(killer);
|
|
var victInfo = GetTeamInfo(mob);
|
|
|
|
if (teamInfo != null && teamInfo != victInfo)
|
|
{
|
|
var playerInfo = teamInfo[killer];
|
|
|
|
if (playerInfo != null)
|
|
{
|
|
playerInfo.Kills += 1;
|
|
playerInfo.Score += 1; // base frag
|
|
|
|
// extra points for killing someone on the waypoint
|
|
if (Controller.PointA != null)
|
|
{
|
|
if (mob.InRange(Controller.PointA, 2))
|
|
{
|
|
playerInfo.Score += 1;
|
|
}
|
|
}
|
|
|
|
if (Controller.PointB != null)
|
|
{
|
|
if (mob.InRange(Controller.PointB, 2))
|
|
{
|
|
playerInfo.Score += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
playerInfo = victInfo[mob];
|
|
if (playerInfo != null)
|
|
{
|
|
playerInfo.Score -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
mob.SendGump(new DDBoardGump(mob, this));
|
|
|
|
m_Context.Requip(mob, corpse);
|
|
DelayBounce(TimeSpan.FromSeconds(30.0), mob, corpse);
|
|
|
|
return false;
|
|
}
|
|
|
|
public override void OnStart()
|
|
{
|
|
m_Capturable = true;
|
|
|
|
_captureTimerToken.Cancel();
|
|
_uncaptureTimerToken.Cancel();
|
|
|
|
for (var i = 0; i < Controller.TeamInfo.Length; ++i)
|
|
{
|
|
var teamInfo = Controller.TeamInfo[i];
|
|
|
|
teamInfo.Game = this;
|
|
teamInfo.Reset();
|
|
}
|
|
|
|
if (Controller.PointA != null)
|
|
{
|
|
Controller.PointA.Game = this;
|
|
}
|
|
|
|
if (Controller.PointB != null)
|
|
{
|
|
Controller.PointB.Game = this;
|
|
}
|
|
|
|
for (var i = 0; i < m_Context.Participants.Count; ++i)
|
|
{
|
|
ApplyHues(
|
|
m_Context.Participants[i],
|
|
Controller.TeamInfo[i % Controller.TeamInfo.Length].Color
|
|
);
|
|
}
|
|
|
|
_finishTimerToken.Cancel();
|
|
Timer.StartTimer(Controller.Duration, Finish_Callback, out _finishTimerToken);
|
|
}
|
|
|
|
private void Finish_Callback()
|
|
{
|
|
var teams = new List<DDTeamInfo>();
|
|
|
|
for (var i = 0; i < m_Context.Participants.Count; ++i)
|
|
{
|
|
var teamInfo = Controller.TeamInfo[i % Controller.TeamInfo.Length];
|
|
|
|
if (teamInfo != null)
|
|
{
|
|
teams.Add(teamInfo);
|
|
}
|
|
}
|
|
|
|
teams.Sort((a, b) => b.Score - a.Score);
|
|
|
|
var tourney = m_Context.m_Tournament;
|
|
|
|
using var sb = ValueStringBuilder.Create(256);
|
|
|
|
if (tourney != null)
|
|
{
|
|
if (tourney.TourneyType == TourneyType.FreeForAll)
|
|
{
|
|
sb.Append(m_Context.Participants.Count * tourney.PlayersPerParticipant);
|
|
sb.Append("-man FFA");
|
|
}
|
|
else if (tourney.TourneyType == TourneyType.RandomTeam)
|
|
{
|
|
sb.Append(tourney.ParticipantsPerMatch);
|
|
sb.Append("-team");
|
|
}
|
|
else if (tourney.TourneyType == TourneyType.RedVsBlue)
|
|
{
|
|
sb.Append("Red v Blue");
|
|
}
|
|
else if (tourney.TourneyType == TourneyType.Faction)
|
|
{
|
|
sb.Append(tourney.ParticipantsPerMatch);
|
|
sb.Append("-team Faction");
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < tourney.ParticipantsPerMatch; ++i)
|
|
{
|
|
if (sb.Length > 0)
|
|
{
|
|
sb.Append('v');
|
|
}
|
|
|
|
sb.Append(tourney.PlayersPerParticipant);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Controller != null)
|
|
{
|
|
sb.Append(' ');
|
|
sb.Append(Controller.Title);
|
|
}
|
|
|
|
var title = sb.ToString();
|
|
|
|
var winner = teams.Count > 0 ? teams[0] : null;
|
|
|
|
for (var i = 0; i < teams.Count; ++i)
|
|
{
|
|
var rank = TrophyRank.Bronze;
|
|
|
|
if (i == 0)
|
|
{
|
|
rank = TrophyRank.Gold;
|
|
}
|
|
else if (i == 1)
|
|
{
|
|
rank = TrophyRank.Silver;
|
|
}
|
|
|
|
var leader = teams[i].Leader;
|
|
|
|
foreach (var pl in teams[i].Players.Values)
|
|
{
|
|
var mob = pl.Player;
|
|
|
|
if (mob == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// "Red v Blue DD Champion"
|
|
|
|
sb.Reset();
|
|
|
|
sb.Append(title);
|
|
|
|
if (pl == leader)
|
|
{
|
|
sb.Append(" Leader");
|
|
}
|
|
|
|
if (pl.Score > 0)
|
|
{
|
|
sb.Append(": ");
|
|
|
|
sb.Append(pl.Score.ToString("N0"));
|
|
sb.Append(pl.Score == 1 ? " point" : " points");
|
|
|
|
if (pl.Kills > 0)
|
|
{
|
|
sb.Append(", ");
|
|
sb.Append(pl.Kills.ToString("N0"));
|
|
sb.Append(pl.Kills == 1 ? " kill" : " kills");
|
|
}
|
|
|
|
if (pl.Captures > 0)
|
|
{
|
|
sb.Append(", ");
|
|
sb.Append(pl.Captures.ToString("N0"));
|
|
sb.Append(pl.Captures == 1 ? " capture" : " captures");
|
|
}
|
|
}
|
|
|
|
Item item = new Trophy(sb.ToString(), rank);
|
|
|
|
if (pl == leader)
|
|
{
|
|
item.ItemID = 4810;
|
|
}
|
|
|
|
item.Name = $"{item.Name}, {teams[i].Name.ToLower()} team";
|
|
|
|
if (!mob.PlaceInBackpack(item))
|
|
{
|
|
mob.BankBox.DropItem(item);
|
|
}
|
|
|
|
var cash = pl.Score * 250;
|
|
|
|
if (cash > 0)
|
|
{
|
|
item = new BankCheck(cash);
|
|
|
|
if (!mob.PlaceInBackpack(item))
|
|
{
|
|
mob.BankBox.DropItem(item);
|
|
}
|
|
|
|
mob.SendMessage(
|
|
$"You have been awarded a {rank.ToString().ToLower()} trophy and {cash:N0}gp for your participation in this tournament."
|
|
);
|
|
}
|
|
else
|
|
{
|
|
mob.SendMessage(
|
|
$"You have been awarded a {rank.ToString().ToLower()} trophy for your participation in this tournament."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < m_Context.Participants.Count; ++i)
|
|
{
|
|
var p = m_Context.Participants[i];
|
|
|
|
for (var j = 0; j < p.Players.Length; ++j)
|
|
{
|
|
var dp = p.Players[j];
|
|
|
|
if (dp?.Mobile != null)
|
|
{
|
|
dp.Mobile.SendGump(new DDBoardGump(dp.Mobile, this));
|
|
}
|
|
}
|
|
|
|
if (i == winner?.TeamID)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (var j = 0; j < p.Players.Length; ++j)
|
|
{
|
|
if (p.Players[j] != null)
|
|
{
|
|
p.Players[j].Eliminated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (winner != null)
|
|
{
|
|
m_Context.Finish(m_Context.Participants[winner.TeamID]);
|
|
}
|
|
}
|
|
|
|
public override void OnStop()
|
|
{
|
|
for (var i = 0; i < Controller.TeamInfo.Length; ++i)
|
|
{
|
|
var teamInfo = Controller.TeamInfo[i];
|
|
|
|
if (teamInfo.Board != null)
|
|
{
|
|
teamInfo.Board.m_TeamInfo = null;
|
|
}
|
|
|
|
teamInfo.Game = null;
|
|
}
|
|
|
|
if (Controller.PointA != null)
|
|
{
|
|
Controller.PointA.Game = null;
|
|
}
|
|
|
|
if (Controller.PointB != null)
|
|
{
|
|
Controller.PointB.Game = null;
|
|
}
|
|
|
|
m_Capturable = false;
|
|
|
|
_captureTimerToken.Cancel();
|
|
_uncaptureTimerToken.Cancel();
|
|
|
|
for (var i = 0; i < m_Context.Participants.Count; ++i)
|
|
{
|
|
ApplyHues(m_Context.Participants[i], -1);
|
|
}
|
|
|
|
_finishTimerToken.Cancel();
|
|
}
|
|
|
|
public void Dominate(DDWayPoint point, Mobile from, DDTeamInfo team)
|
|
{
|
|
if (point == null || from == null || team == null || !m_Capturable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var wasDom = Controller.PointA?.TeamOwner == Controller.PointB?.TeamOwner &&
|
|
Controller.PointA?.TeamOwner != null;
|
|
|
|
point.TeamOwner = team;
|
|
Alert($"{team.Name} has captured {point.Name}!");
|
|
|
|
var isDom = Controller.PointA?.TeamOwner == Controller.PointB?.TeamOwner && Controller.PointA?.TeamOwner != null;
|
|
|
|
if (wasDom && !isDom)
|
|
{
|
|
Alert("Domination averted!");
|
|
|
|
Controller.PointA?.SetNonCaptureHue();
|
|
Controller.PointB?.SetNonCaptureHue();
|
|
_captureTimerToken.Cancel();
|
|
}
|
|
|
|
if (!wasDom && isDom)
|
|
{
|
|
m_CapStage = 0;
|
|
Timer.StartTimer(TimeSpan.Zero, TimeSpan.FromSeconds(1.0), CaptureTick, out _captureTimerToken);
|
|
}
|
|
}
|
|
|
|
private void CaptureTick()
|
|
{
|
|
var team = Controller.PointA?.TeamOwner ?? Controller.PointB?.TeamOwner;
|
|
|
|
if (team == null)
|
|
{
|
|
m_Capturable = true;
|
|
_captureTimerToken.Cancel();
|
|
return;
|
|
}
|
|
|
|
if (++m_CapStage < 10)
|
|
{
|
|
Alert($"{team.Name} is dominating... {10 - m_CapStage}");
|
|
|
|
Controller.PointA?.SetCaptureHue(m_CapStage);
|
|
Controller.PointB?.SetCaptureHue(m_CapStage);
|
|
}
|
|
else
|
|
{
|
|
Alert($"{team.Name} has scored!");
|
|
|
|
team.Score += 100;
|
|
team.Captures += 1;
|
|
|
|
m_Capturable = false;
|
|
m_CapStage = 0;
|
|
_captureTimerToken.Cancel();
|
|
|
|
if (Controller.PointA != null)
|
|
{
|
|
Controller.PointA.TeamOwner = null;
|
|
Controller.PointA.SetUncapturableHue();
|
|
}
|
|
|
|
if (Controller.PointB != null)
|
|
{
|
|
Controller.PointB.TeamOwner = null;
|
|
Controller.PointB.SetUncapturableHue();
|
|
}
|
|
|
|
Timer.StartTimer(TimeSpan.FromSeconds(30.0), UncaptureTick, out _uncaptureTimerToken);
|
|
}
|
|
}
|
|
|
|
private void UncaptureTick()
|
|
{
|
|
m_Capturable = true;
|
|
|
|
_captureTimerToken.Cancel();
|
|
_uncaptureTimerToken.Cancel();
|
|
|
|
if (Controller.PointA != null)
|
|
{
|
|
Controller.PointA.TeamOwner = null;
|
|
Controller.PointA.SetNonCaptureHue();
|
|
}
|
|
|
|
if (Controller.PointB != null)
|
|
{
|
|
Controller.PointB.TeamOwner = null;
|
|
Controller.PointB.SetNonCaptureHue();
|
|
}
|
|
}
|
|
}
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public partial class DDWayPoint : BaseAddon
|
|
{
|
|
public const int UncapturableHue = 0x497;
|
|
public const int NonCapturedHue = 0x38A;
|
|
private DDGame m_Game;
|
|
private DDTeamInfo m_TeamOwner;
|
|
|
|
[Constructible]
|
|
public DDWayPoint()
|
|
{
|
|
ItemID = 0x519;
|
|
Visible = true;
|
|
Name = "SET MY NAME";
|
|
|
|
AddComponent(new DDStep(0x7A8), -1, -1, -5);
|
|
AddComponent(new DDStep(0x7A6), 0, -1, -5);
|
|
AddComponent(new DDStep(0x7AA), 1, -1, -5);
|
|
|
|
AddComponent(new DDStep(0x7A5), 1, 0, -5);
|
|
|
|
AddComponent(new DDStep(0x7A9), 1, 1, -5);
|
|
AddComponent(new DDStep(0x7A4), 0, 1, -5);
|
|
AddComponent(new DDStep(0x7AB), -1, 1, -5);
|
|
|
|
AddComponent(new DDStep(0x7A7), -1, 0, -5);
|
|
|
|
SetUncapturableHue();
|
|
}
|
|
|
|
public override bool ShareHue => false;
|
|
|
|
public DDGame Game
|
|
{
|
|
get => m_Game;
|
|
set
|
|
{
|
|
m_Game = value;
|
|
m_TeamOwner = null;
|
|
|
|
if (m_Game != null)
|
|
{
|
|
SetNonCaptureHue();
|
|
}
|
|
else
|
|
{
|
|
SetUncapturableHue();
|
|
}
|
|
}
|
|
}
|
|
|
|
public DDTeamInfo TeamOwner
|
|
{
|
|
get => m_TeamOwner;
|
|
set
|
|
{
|
|
m_TeamOwner = value;
|
|
|
|
SetNonCaptureHue();
|
|
}
|
|
}
|
|
|
|
public void SetUncapturableHue()
|
|
{
|
|
for (var i = 0; i < Components.Count; i++)
|
|
{
|
|
Components[i].Hue = UncapturableHue;
|
|
}
|
|
|
|
Hue = UncapturableHue;
|
|
}
|
|
|
|
public void SetNonCaptureHue()
|
|
{
|
|
for (var i = 0; i < Components.Count; i++)
|
|
{
|
|
Components[i].Hue = NonCapturedHue;
|
|
}
|
|
|
|
if (m_TeamOwner != null)
|
|
{
|
|
Hue = m_TeamOwner.Color;
|
|
}
|
|
else
|
|
{
|
|
Hue = NonCapturedHue;
|
|
}
|
|
}
|
|
|
|
public void SetCaptureHue(int stage)
|
|
{
|
|
if (m_TeamOwner == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Hue = m_TeamOwner.Color;
|
|
|
|
for (var i = 0; i < Components.Count; i++)
|
|
{
|
|
if (i < stage)
|
|
{
|
|
Components[i].Hue = m_TeamOwner.Color;
|
|
}
|
|
else
|
|
{
|
|
Components[i].Hue = NonCapturedHue;
|
|
}
|
|
}
|
|
}
|
|
|
|
public override bool OnMoveOver(Mobile from)
|
|
{
|
|
if (m_Game == null)
|
|
{
|
|
SetUncapturableHue();
|
|
}
|
|
else if (from.Alive)
|
|
{
|
|
var team = m_Game.GetTeamInfo(from);
|
|
|
|
if (team != null && team != TeamOwner)
|
|
{
|
|
m_Game.Dominate(this, from, team);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public partial class DDStep : AddonComponent
|
|
{
|
|
public DDStep(int itemID) : base(itemID) => Visible = true;
|
|
|
|
public override bool OnMoveOver(Mobile m) => Addon.OnMoveOver(m);
|
|
}
|
|
}
|