Like 0

Luno revised this gist 33 minutes ago · ba3eacc

2 files changed, 8 insertions, 12 deletions

Plugin.logger.cs
@@ -12,7 +12,7 @@ namespace NewMoon.Samples.NPC;
12 12 public static partial class NewMoonSampleNPCLogger
13 13 {
14 14 [LoggerMessage(1, LogLevel.Information, "Plugin started")]
15 - public static partial void LogStarted(this ILogger logger, int count);
15 + public static partial void LogStarted(this ILogger logger);
16 16
17 17 [LoggerMessage(2, LogLevel.Information, "Plugin ended")]
18 18 public static partial void LogEnded(this ILogger logger);
SampleNpc.cs
@@ -7,18 +7,14 @@ namespace NewMoon.Samples.NPC;
7 7 /// <c>base_boss</c> that carries the new classname, and the registry calls the lifecycle overrides below.
8 8 /// </summary>
9 9 /// <remarks>
10 - /// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
11 - /// spawner supplies <c>health</c> and <c>model</c>, for example <c>ent_create npc_bot_newmoon health 500 model models/bots/headless_hatman.mdl</c>. The registry needs an accessible parameterless constructor.
10 + /// The class only logs, through the <see cref="FCustomEntity.PLogger"/> the registry supplies. The spawner supplies <c>health</c>
11 + /// and <c>model</c>, for example <c>ent_create npc_bot_newmoon health 500 model models/bots/headless_hatman.mdl</c>. The registry
12 + /// constructs the class through dependency injection, so it needs a public constructor; any constructor parameters are resolved
13 + /// from the plugin's services. The plugin registers it automatically at start-up.
12 14 /// </remarks>
13 15 [CustomEntity("npc_bot_newmoon", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
14 16 internal sealed class NPCBotNewMoon : FCustomEntity
15 17 {
16 - /// <summary>
17 - /// The logger the callbacks write to; set by <see cref="CustomNpcSample"/> at start-up, because the registry creates this
18 - /// object through its parameterless constructor.
19 - /// </summary>
20 - internal static ILogger? Log { get; set; }
21 -
22 18 // Captured at spawn so the remove log never depends on the view still resolving while the instance is torn down.
23 19 private int _index = -1;
24 20
@@ -26,12 +22,12 @@ internal sealed class NPCBotNewMoon : FCustomEntity
26 22 protected override void OnSpawn()
27 23 {
28 24 _index = Entity.Index;
29 - Log?.LogNPCSpawn(_index);
25 + PLogger.LogNPCSpawn(_index);
30 26 }
31 27
32 28 /// <inheritdoc/>
33 - protected override void OnActivate() => Log?.LogNPCActivate(_index);
29 + protected override void OnActivate() => PLogger.LogNPCActivate(_index);
34 30
35 31 /// <inheritdoc/>
36 - protected override void OnRemoved() => Log?.LogNPCRemoved(_index);
32 + protected override void OnRemoved() => PLogger.LogNPCRemoved(_index);
37 33 }

screwdriver revised this gist 50 minutes ago · 3ccb3c7

2 files changed, 6 insertions, 11 deletions

Plugin.cs
@@ -4,15 +4,10 @@ namespace NewMoon.Samples.NPC;
4 4 /// An example gamemode that registers one custom NPC entity, <c>npc_bot_newmoon</c>, which fronts <c>base_boss</c>.
5 5 /// </summary>
6 6 [GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers a custom NPC entity as an example.")]
7 - public sealed partial class CustomNpcSample : FGamePlugin
7 + public sealed partial class NewMoonSampleNPC : FGamePlugin
8 8 {
9 9 /// <inheritdoc/>
10 - protected override void OnPluginStart()
11 - {
12 - // The logger is set first: adoption inside the registration can raise OnSpawn for NPCs that already exist.
13 - NPCBotNewMoon.Log = PLogger;
14 - PLogger.LogRegistered(RegisterCustomEntities(NewMoon.Generated.CustomEntityTable.All));
15 - }
10 + protected override void OnPluginStart() => PLogger.LogStarted();
16 11
17 12 /// <inheritdoc/>
18 13 protected override void OnPluginEnd() => PLogger.LogEnded();
Plugin.logger.cs
@@ -9,12 +9,12 @@ namespace NewMoon.Samples.NPC;
9 9 /// Source-generated log messages.
10 10 /// </summary>
11 11 [UsedImplicitly]
12 - public static partial class CustomNPCSampleLogger
12 + public static partial class NewMoonSampleNPCLogger
13 13 {
14 - [LoggerMessage(1, LogLevel.Information, "Custom NPC sample registered {Count} custom entity classes")]
15 - public static partial void LogRegistered(this ILogger logger, int count);
14 + [LoggerMessage(1, LogLevel.Information, "Plugin started")]
15 + public static partial void LogStarted(this ILogger logger, int count);
16 16
17 - [LoggerMessage(2, LogLevel.Information, "Custom NPC sample ended: OnPluginEnd ran before the scope released")]
17 + [LoggerMessage(2, LogLevel.Information, "Plugin ended")]
18 18 public static partial void LogEnded(this ILogger logger);
19 19
20 20 [LoggerMessage(3, LogLevel.Information, "npc_bot_newmoon OnSpawn {Index}")]

screwdriver revised this gist 1 hour ago · ec43675

7 files changed, 55 insertions, 311 deletions

CustomNpcSample.cs (file deleted)
@@ -1,26 +0,0 @@
1 - using NewMoon.TF2Helpers;
2 -
3 - namespace NewMoon.Samples.CustomNpc;
4 -
5 - /// <summary>
6 - /// A light sample gamemode that registers one custom NPC classname, <c>nm_sample_npc</c>, which fronts <c>base_boss</c>.
7 - /// </summary>
8 - /// <remarks>
9 - /// The generator emits <c>NewMoon.Generated.CustomEntityTable.All</c> for this assembly, one descriptor per <c>[CustomEntity]</c>
10 - /// class. <see cref="FGamePlugin.RegisterCustomEntities"/> hands them to the registry, which installs the classname as a factory
11 - /// alias of <c>base_boss</c>. The plugin scope removes the alias, and despawns the live NPCs, when the plugin unloads.
12 - /// </remarks>
13 - [GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers one custom NPC classname fronting base_boss.")]
14 - public sealed partial class CustomNpcSample : FGamePlugin
15 - {
16 - /// <inheritdoc/>
17 - protected override void OnPluginStart()
18 - {
19 - // The logger is set first: adoption inside the registration can raise OnSpawn for NPCs that already exist.
20 - SampleNpc.Log = PLogger;
21 - PLogger.LogRegistered(RegisterCustomEntities(NewMoon.Generated.CustomEntityTable.All));
22 - }
23 -
24 - /// <inheritdoc/>
25 - protected override void OnPluginEnd() => PLogger.LogEnded();
26 - }
CustomNpcSample.logger.cs (file deleted)
@@ -1,28 +0,0 @@
1 - using JetBrains.Annotations;
2 - using Microsoft.Extensions.Logging;
3 -
4 - #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
5 -
6 - namespace NewMoon.Samples.CustomNpc;
7 -
8 - /// <summary>
9 - /// Source-generated log messages for <see cref="CustomNpcSample"/> and <see cref="SampleNpc"/>.
10 - /// </summary>
11 - [UsedImplicitly]
12 - public static partial class CustomNpcSampleLogger
13 - {
14 - [LoggerMessage(1, LogLevel.Information, "Custom NPC sample registered {Count} custom entity classes.")]
15 - public static partial void LogRegistered(this ILogger logger, int count);
16 -
17 - [LoggerMessage(2, LogLevel.Information, "Custom NPC sample ended: OnPluginEnd ran before the scope released.")]
18 - public static partial void LogEnded(this ILogger logger);
19 -
20 - [LoggerMessage(3, LogLevel.Information, "nm_sample_npc OnSpawn {Index}")]
21 - public static partial void LogNpcSpawn(this ILogger logger, int index);
22 -
23 - [LoggerMessage(4, LogLevel.Information, "nm_sample_npc OnActivate {Index}")]
24 - public static partial void LogNpcActivate(this ILogger logger, int index);
25 -
26 - [LoggerMessage(5, LogLevel.Information, "nm_sample_npc OnRemoved {Index}")]
27 - public static partial void LogNpcRemoved(this ILogger logger, int index);
28 - }
NewMoon.Gamemodes.Sample.CustomNpc.csproj (file deleted)
@@ -1,44 +0,0 @@
1 - <Project Sdk="Microsoft.NET.Sdk">
2 - <PropertyGroup>
3 - <RootNamespace>NewMoon.Samples.CustomNpc</RootNamespace>
4 - <Version>1.0.0</Version>
5 - <Platforms>AnyCPU</Platforms>
6 - </PropertyGroup>
7 -
8 - <PropertyGroup>
9 - <Title>Custom NPC Sample Gamemode</Title>
10 - <Authors>Jon S. (Serafeline)</Authors>
11 - <PackageId>NewMoon.Gamemodes.Sample.CustomNpc</PackageId>
12 - <PackageVersion>$(Version)</PackageVersion>
13 - <IsPackable>false</IsPackable>
14 - <PackageDescription>A light sample gamemode that registers one custom NPC classname (nm_sample_npc) fronting base_boss through the TF2Helpers custom entity API.</PackageDescription>
15 - </PropertyGroup>
16 -
17 - <ItemGroup>
18 - <PackageReference Include="JetBrains.Annotations" />
19 - <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
20 - </ItemGroup>
21 -
22 - <ItemGroup>
23 - <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core\SourceNet.Core.csproj">
24 - <Private>false</Private>
25 - </ProjectReference>
26 - <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Engine.Source\SourceNet.Engine.Source.csproj">
27 - <Private>false</Private>
28 - </ProjectReference>
29 - <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core.SourceGeneration\SourceNet.Core.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
30 - <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Common\SourceNet.Common.csproj">
31 - <Private>false</Private>
32 - </ProjectReference>
33 - <ProjectReference Include="..\NewMoon.Shared.Utility\NewMoon.Shared.Utility.csproj" />
34 - </ItemGroup>
35 -
36 - <!-- Task 20's shared game mode props: the [GameMode] startup generator and the NMGM analyzers as one analyzer reference
37 - (analyzer references do not flow through project references), plus TeamFortress2.Api, SDKWrappers.Api, and
38 - TF2Helpers.Api as Private=false references (the host supplies them through PluginUseAPIAssembly). The sample uses no
39 - LoadoutSharp types, so that fourth API reference is left out. -->
40 - <PropertyGroup>
41 - <GameModeUsesLoadoutSharpApi>false</GameModeUsesLoadoutSharpApi>
42 - </PropertyGroup>
43 - <Import Project="..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props" />
44 - </Project>
Plugin.cs (file created)
@@ -0,0 +1,19 @@
1 + namespace NewMoon.Samples.NPC;
2 +
3 + /// <summary>
4 + /// An example gamemode that registers one custom NPC entity, <c>npc_bot_newmoon</c>, which fronts <c>base_boss</c>.
5 + /// </summary>
6 + [GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers a custom NPC entity as an example.")]
7 + public sealed partial class CustomNpcSample : FGamePlugin
8 + {
9 + /// <inheritdoc/>
10 + protected override void OnPluginStart()
11 + {
12 + // The logger is set first: adoption inside the registration can raise OnSpawn for NPCs that already exist.
13 + NPCBotNewMoon.Log = PLogger;
14 + PLogger.LogRegistered(RegisterCustomEntities(NewMoon.Generated.CustomEntityTable.All));
15 + }
16 +
17 + /// <inheritdoc/>
18 + protected override void OnPluginEnd() => PLogger.LogEnded();
19 + }
Plugin.logger.cs (file created)
@@ -0,0 +1,28 @@
1 + using JetBrains.Annotations;
2 + using Microsoft.Extensions.Logging;
3 +
4 + #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
5 +
6 + namespace NewMoon.Samples.NPC;
7 +
8 + /// <summary>
9 + /// Source-generated log messages.
10 + /// </summary>
11 + [UsedImplicitly]
12 + public static partial class CustomNPCSampleLogger
13 + {
14 + [LoggerMessage(1, LogLevel.Information, "Custom NPC sample registered {Count} custom entity classes")]
15 + public static partial void LogRegistered(this ILogger logger, int count);
16 +
17 + [LoggerMessage(2, LogLevel.Information, "Custom NPC sample ended: OnPluginEnd ran before the scope released")]
18 + public static partial void LogEnded(this ILogger logger);
19 +
20 + [LoggerMessage(3, LogLevel.Information, "npc_bot_newmoon OnSpawn {Index}")]
21 + public static partial void LogNPCSpawn(this ILogger logger, int index);
22 +
23 + [LoggerMessage(4, LogLevel.Information, "npc_bot_newmoon OnActivate {Index}")]
24 + public static partial void LogNPCActivate(this ILogger logger, int index);
25 +
26 + [LoggerMessage(5, LogLevel.Information, "npc_bot_newmoon OnRemoved {Index}")]
27 + public static partial void LogNPCRemoved(this ILogger logger, int index);
28 + }
SampleNpc.cs
@@ -1,19 +1,17 @@
1 1 using Microsoft.Extensions.Logging;
2 - using NewMoon.TF2Helpers;
3 - using NewMoon.TF2Helpers.Entities;
4 2
5 - namespace NewMoon.Samples.CustomNpc;
3 + namespace NewMoon.Samples.NPC;
6 4
7 5 /// <summary>
8 - /// The custom NPC: a <c>nm_sample_npc</c> classname that is a factory alias of <c>base_boss</c>. Spawning it yields a real
6 + /// The custom NPC: a <c>npc_bot_newmoon</c> classname that is a factory alias of <c>base_boss</c>. Spawning it yields a real
9 7 /// <c>base_boss</c> that carries the new classname, and the registry calls the lifecycle overrides below.
10 8 /// </summary>
11 9 /// <remarks>
12 10 /// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
13 - /// spawner supplies <c>health</c> and <c>model</c>, for example <c>ent_create nm_sample_npc health 500 model models/bots/headless_hatman.mdl</c>. The registry needs an accessible parameterless constructor.
11 + /// spawner supplies <c>health</c> and <c>model</c>, for example <c>ent_create npc_bot_newmoon health 500 model models/bots/headless_hatman.mdl</c>. The registry needs an accessible parameterless constructor.
14 12 /// </remarks>
15 - [CustomEntity("nm_sample_npc", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
16 - internal sealed class SampleNpc : FCustomEntity
13 + [CustomEntity("npc_bot_newmoon", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
14 + internal sealed class NPCBotNewMoon : FCustomEntity
17 15 {
18 16 /// <summary>
19 17 /// The logger the callbacks write to; set by <see cref="CustomNpcSample"/> at start-up, because the registry creates this
@@ -28,12 +26,12 @@ internal sealed class SampleNpc : FCustomEntity
28 26 protected override void OnSpawn()
29 27 {
30 28 _index = Entity.Index;
31 - Log?.LogNpcSpawn(_index);
29 + Log?.LogNPCSpawn(_index);
32 30 }
33 31
34 32 /// <inheritdoc/>
35 - protected override void OnActivate() => Log?.LogNpcActivate(_index);
33 + protected override void OnActivate() => Log?.LogNPCActivate(_index);
36 34
37 35 /// <inheritdoc/>
38 - protected override void OnRemoved() => Log?.LogNpcRemoved(_index);
36 + protected override void OnRemoved() => Log?.LogNPCRemoved(_index);
39 37 }
nm_sample_npc.fgd (file deleted)

Diff is too large to be shown

luno revised this gist 2 hours ago · 12fc2d7

3 files changed, 204 insertions, 63 deletions

README.md (file deleted)
@@ -1,61 +0,0 @@
1 - # Custom NPC sample
2 -
3 - A light gamemode that registers one custom NPC classname, `nm_sample_npc`, using the TF2Helpers friendly layer.
4 -
5 - ## What it shows
6 -
7 - - `SampleNpc` derives `FCustomEntity` and is declared `[CustomEntity("nm_sample_npc", "base_boss")]`. The classname is a factory
8 - alias of `base_boss`: spawning it gives a real `base_boss` (same ServerClass and datamap) that carries the new classname. It is
9 - not a new engine class.
10 - - The source generator emits `NewMoon.Generated.CustomEntityTable.All` for the assembly, and `CustomNpcSample.OnPluginStart`
11 - passes it to `RegisterCustomEntities`.
12 - - The NPC precaches `models/bots/headless_hatman.mdl` at registration and logs `OnSpawn`, `OnActivate` and `OnRemoved`.
13 - - Unloading the plugin despawns the live NPCs and removes the alias.
14 -
15 - ## Where it goes
16 -
17 - Drop the folder at the monorepo root, beside `NewMoon.Gamemodes.Sample.Bare`, and add it to `LoadoutSharp.slnx`. The csproj uses
18 - relative paths (`..\ThirdParty\...`, `..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props`) that assume that location.
19 -
20 - ## Build
21 -
22 - ```bash
23 - .claude/scripts/build-gate.sh dotnet build NewMoon.Gamemodes.Sample.CustomNpc/NewMoon.Gamemodes.Sample.CustomNpc.csproj
24 - ```
25 -
26 - The first build reports NMFG001: every game mode build checks its `[CustomEntity]` classes against the checked-in FGD
27 - (`NewMoon.Plugins.TF2Helpers.Api/Fgd/newmoon_custom_entities.fgd`), which has no block for `nm_sample_npc` yet. Regenerate the
28 - FGD, then build again:
29 -
30 - ```bash
31 - .claude/scripts/build-gate.sh ./build.sh GenerateCustomEntityFgd
32 - ```
33 -
34 - To try the sample without touching the FGD, build with `-p:NewMoonCustomEntityFgdCheck=false`.
35 -
36 - Run the same command again whenever you change the class's keys, inputs or outputs.
37 -
38 - ## Try it on a dev server
39 -
40 - Load the gamemode, then from a console:
41 -
42 - ```
43 - ent_create nm_sample_npc health 500 model models/bots/headless_hatman.mdl
44 - ```
45 -
46 - `ent_create` writes the keyvalues before spawning, which matters: a `base_boss` spawns with 0 health unless `health` is set
47 - before spawn, and it needs a `model`. Expect the log to show `nm_sample_npc OnSpawn <index>`, then `OnActivate <index>` on the
48 - next tick, and `OnRemoved <index>` when the NPC is killed, removed or the plugin unloads. The NPC stands still.
49 -
50 - From code, the equivalent is `Map.CreateEntity("nm_sample_npc").KeyValue("health", 500).Model("...").At(origin).Spawn()`.
51 -
52 - ## Limits
53 -
54 - - The NPC has no locomotion or AI. A bare `base_boss` stands still and has no animation driver, so it holds a pose. It also
55 - inherits the stock boss side effects (currency drop, crushing buildings, shoving players).
56 - - The class cannot set keyvalues or its model before spawn: `OnSpawn` runs after the native spawn. The spawner supplies them.
57 - - Keyvalue routing into `[CustomKey]` properties, `[CustomInput]` calls and `[CustomOutput]` firing are planned work
58 - (AB#29804 to AB#29807) and are not used here.
59 - - The engine sees only the `base_boss` datamap, so `ent_text` and `ent_dump` do not show managed state.
60 - - On a server's first map after start, plugins load after the map's entity lump is parsed, so a map-placed `nm_sample_npc` is not
61 - created on that map. `ent_create` works at any time.
SampleNpc.cs
@@ -1,7 +1,6 @@
1 1 using Microsoft.Extensions.Logging;
2 2 using NewMoon.TF2Helpers;
3 3 using NewMoon.TF2Helpers.Entities;
4 - using NewMoon.Utility;
5 4
6 5 namespace NewMoon.Samples.CustomNpc;
7 6
@@ -11,7 +10,7 @@ namespace NewMoon.Samples.CustomNpc;
11 10 /// </summary>
12 11 /// <remarks>
13 12 /// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
14 - /// spawner supplies <c>health</c> and <c>model</c> (see the README). The registry needs an accessible parameterless constructor.
13 + /// spawner supplies <c>health</c> and <c>model</c>, for example <c>ent_create nm_sample_npc health 500 model models/bots/headless_hatman.mdl</c>. The registry needs an accessible parameterless constructor.
15 14 /// </remarks>
16 15 [CustomEntity("nm_sample_npc", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
17 16 internal sealed class SampleNpc : FCustomEntity
nm_sample_npc.fgd (file created)

Diff is too large to be shown

screwdriver revised this gist 2 hours ago · 4052f86

3 files changed, 5 deletions

CustomNpcSample.cs
@@ -1,5 +1,4 @@
1 1 using NewMoon.TF2Helpers;
2 - using NewMoon.Utility;
3 2
4 3 namespace NewMoon.Samples.CustomNpc;
5 4
@@ -11,7 +10,6 @@ namespace NewMoon.Samples.CustomNpc;
11 10 /// class. <see cref="FGamePlugin.RegisterCustomEntities"/> hands them to the registry, which installs the classname as a factory
12 11 /// alias of <c>base_boss</c>. The plugin scope removes the alias, and despawns the live NPCs, when the plugin unloads.
13 12 /// </remarks>
14 - [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
15 13 [GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers one custom NPC classname fronting base_boss.")]
16 14 public sealed partial class CustomNpcSample : FGamePlugin
17 15 {
CustomNpcSample.logger.cs
@@ -1,6 +1,5 @@
1 1 using JetBrains.Annotations;
2 2 using Microsoft.Extensions.Logging;
3 - using NewMoon.Utility;
4 3
5 4 #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
6 5
@@ -9,7 +8,6 @@ namespace NewMoon.Samples.CustomNpc;
9 8 /// <summary>
10 9 /// Source-generated log messages for <see cref="CustomNpcSample"/> and <see cref="SampleNpc"/>.
11 10 /// </summary>
12 - [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
13 11 [UsedImplicitly]
14 12 public static partial class CustomNpcSampleLogger
15 13 {
SampleNpc.cs
@@ -13,7 +13,6 @@ namespace NewMoon.Samples.CustomNpc;
13 13 /// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
14 14 /// spawner supplies <c>health</c> and <c>model</c> (see the README). The registry needs an accessible parameterless constructor.
15 15 /// </remarks>
16 - [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
17 16 [CustomEntity("nm_sample_npc", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
18 17 internal sealed class SampleNpc : FCustomEntity
19 18 {

luno revised this gist 2 hours ago · 527c893

5 files changed, 204 insertions

CustomNpcSample.cs (file created)
@@ -0,0 +1,28 @@
1 + using NewMoon.TF2Helpers;
2 + using NewMoon.Utility;
3 +
4 + namespace NewMoon.Samples.CustomNpc;
5 +
6 + /// <summary>
7 + /// A light sample gamemode that registers one custom NPC classname, <c>nm_sample_npc</c>, which fronts <c>base_boss</c>.
8 + /// </summary>
9 + /// <remarks>
10 + /// The generator emits <c>NewMoon.Generated.CustomEntityTable.All</c> for this assembly, one descriptor per <c>[CustomEntity]</c>
11 + /// class. <see cref="FGamePlugin.RegisterCustomEntities"/> hands them to the registry, which installs the classname as a factory
12 + /// alias of <c>base_boss</c>. The plugin scope removes the alias, and despawns the live NPCs, when the plugin unloads.
13 + /// </remarks>
14 + [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
15 + [GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers one custom NPC classname fronting base_boss.")]
16 + public sealed partial class CustomNpcSample : FGamePlugin
17 + {
18 + /// <inheritdoc/>
19 + protected override void OnPluginStart()
20 + {
21 + // The logger is set first: adoption inside the registration can raise OnSpawn for NPCs that already exist.
22 + SampleNpc.Log = PLogger;
23 + PLogger.LogRegistered(RegisterCustomEntities(NewMoon.Generated.CustomEntityTable.All));
24 + }
25 +
26 + /// <inheritdoc/>
27 + protected override void OnPluginEnd() => PLogger.LogEnded();
28 + }
CustomNpcSample.logger.cs (file created)
@@ -0,0 +1,30 @@
1 + using JetBrains.Annotations;
2 + using Microsoft.Extensions.Logging;
3 + using NewMoon.Utility;
4 +
5 + #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
6 +
7 + namespace NewMoon.Samples.CustomNpc;
8 +
9 + /// <summary>
10 + /// Source-generated log messages for <see cref="CustomNpcSample"/> and <see cref="SampleNpc"/>.
11 + /// </summary>
12 + [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
13 + [UsedImplicitly]
14 + public static partial class CustomNpcSampleLogger
15 + {
16 + [LoggerMessage(1, LogLevel.Information, "Custom NPC sample registered {Count} custom entity classes.")]
17 + public static partial void LogRegistered(this ILogger logger, int count);
18 +
19 + [LoggerMessage(2, LogLevel.Information, "Custom NPC sample ended: OnPluginEnd ran before the scope released.")]
20 + public static partial void LogEnded(this ILogger logger);
21 +
22 + [LoggerMessage(3, LogLevel.Information, "nm_sample_npc OnSpawn {Index}")]
23 + public static partial void LogNpcSpawn(this ILogger logger, int index);
24 +
25 + [LoggerMessage(4, LogLevel.Information, "nm_sample_npc OnActivate {Index}")]
26 + public static partial void LogNpcActivate(this ILogger logger, int index);
27 +
28 + [LoggerMessage(5, LogLevel.Information, "nm_sample_npc OnRemoved {Index}")]
29 + public static partial void LogNpcRemoved(this ILogger logger, int index);
30 + }
NewMoon.Gamemodes.Sample.CustomNpc.csproj (file created)
@@ -0,0 +1,44 @@
1 + <Project Sdk="Microsoft.NET.Sdk">
2 + <PropertyGroup>
3 + <RootNamespace>NewMoon.Samples.CustomNpc</RootNamespace>
4 + <Version>1.0.0</Version>
5 + <Platforms>AnyCPU</Platforms>
6 + </PropertyGroup>
7 +
8 + <PropertyGroup>
9 + <Title>Custom NPC Sample Gamemode</Title>
10 + <Authors>Jon S. (Serafeline)</Authors>
11 + <PackageId>NewMoon.Gamemodes.Sample.CustomNpc</PackageId>
12 + <PackageVersion>$(Version)</PackageVersion>
13 + <IsPackable>false</IsPackable>
14 + <PackageDescription>A light sample gamemode that registers one custom NPC classname (nm_sample_npc) fronting base_boss through the TF2Helpers custom entity API.</PackageDescription>
15 + </PropertyGroup>
16 +
17 + <ItemGroup>
18 + <PackageReference Include="JetBrains.Annotations" />
19 + <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
20 + </ItemGroup>
21 +
22 + <ItemGroup>
23 + <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core\SourceNet.Core.csproj">
24 + <Private>false</Private>
25 + </ProjectReference>
26 + <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Engine.Source\SourceNet.Engine.Source.csproj">
27 + <Private>false</Private>
28 + </ProjectReference>
29 + <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core.SourceGeneration\SourceNet.Core.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
30 + <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Common\SourceNet.Common.csproj">
31 + <Private>false</Private>
32 + </ProjectReference>
33 + <ProjectReference Include="..\NewMoon.Shared.Utility\NewMoon.Shared.Utility.csproj" />
34 + </ItemGroup>
35 +
36 + <!-- Task 20's shared game mode props: the [GameMode] startup generator and the NMGM analyzers as one analyzer reference
37 + (analyzer references do not flow through project references), plus TeamFortress2.Api, SDKWrappers.Api, and
38 + TF2Helpers.Api as Private=false references (the host supplies them through PluginUseAPIAssembly). The sample uses no
39 + LoadoutSharp types, so that fourth API reference is left out. -->
40 + <PropertyGroup>
41 + <GameModeUsesLoadoutSharpApi>false</GameModeUsesLoadoutSharpApi>
42 + </PropertyGroup>
43 + <Import Project="..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props" />
44 + </Project>
README.md (file created)
@@ -0,0 +1,61 @@
1 + # Custom NPC sample
2 +
3 + A light gamemode that registers one custom NPC classname, `nm_sample_npc`, using the TF2Helpers friendly layer.
4 +
5 + ## What it shows
6 +
7 + - `SampleNpc` derives `FCustomEntity` and is declared `[CustomEntity("nm_sample_npc", "base_boss")]`. The classname is a factory
8 + alias of `base_boss`: spawning it gives a real `base_boss` (same ServerClass and datamap) that carries the new classname. It is
9 + not a new engine class.
10 + - The source generator emits `NewMoon.Generated.CustomEntityTable.All` for the assembly, and `CustomNpcSample.OnPluginStart`
11 + passes it to `RegisterCustomEntities`.
12 + - The NPC precaches `models/bots/headless_hatman.mdl` at registration and logs `OnSpawn`, `OnActivate` and `OnRemoved`.
13 + - Unloading the plugin despawns the live NPCs and removes the alias.
14 +
15 + ## Where it goes
16 +
17 + Drop the folder at the monorepo root, beside `NewMoon.Gamemodes.Sample.Bare`, and add it to `LoadoutSharp.slnx`. The csproj uses
18 + relative paths (`..\ThirdParty\...`, `..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props`) that assume that location.
19 +
20 + ## Build
21 +
22 + ```bash
23 + .claude/scripts/build-gate.sh dotnet build NewMoon.Gamemodes.Sample.CustomNpc/NewMoon.Gamemodes.Sample.CustomNpc.csproj
24 + ```
25 +
26 + The first build reports NMFG001: every game mode build checks its `[CustomEntity]` classes against the checked-in FGD
27 + (`NewMoon.Plugins.TF2Helpers.Api/Fgd/newmoon_custom_entities.fgd`), which has no block for `nm_sample_npc` yet. Regenerate the
28 + FGD, then build again:
29 +
30 + ```bash
31 + .claude/scripts/build-gate.sh ./build.sh GenerateCustomEntityFgd
32 + ```
33 +
34 + To try the sample without touching the FGD, build with `-p:NewMoonCustomEntityFgdCheck=false`.
35 +
36 + Run the same command again whenever you change the class's keys, inputs or outputs.
37 +
38 + ## Try it on a dev server
39 +
40 + Load the gamemode, then from a console:
41 +
42 + ```
43 + ent_create nm_sample_npc health 500 model models/bots/headless_hatman.mdl
44 + ```
45 +
46 + `ent_create` writes the keyvalues before spawning, which matters: a `base_boss` spawns with 0 health unless `health` is set
47 + before spawn, and it needs a `model`. Expect the log to show `nm_sample_npc OnSpawn <index>`, then `OnActivate <index>` on the
48 + next tick, and `OnRemoved <index>` when the NPC is killed, removed or the plugin unloads. The NPC stands still.
49 +
50 + From code, the equivalent is `Map.CreateEntity("nm_sample_npc").KeyValue("health", 500).Model("...").At(origin).Spawn()`.
51 +
52 + ## Limits
53 +
54 + - The NPC has no locomotion or AI. A bare `base_boss` stands still and has no animation driver, so it holds a pose. It also
55 + inherits the stock boss side effects (currency drop, crushing buildings, shoving players).
56 + - The class cannot set keyvalues or its model before spawn: `OnSpawn` runs after the native spawn. The spawner supplies them.
57 + - Keyvalue routing into `[CustomKey]` properties, `[CustomInput]` calls and `[CustomOutput]` firing are planned work
58 + (AB#29804 to AB#29807) and are not used here.
59 + - The engine sees only the `base_boss` datamap, so `ent_text` and `ent_dump` do not show managed state.
60 + - On a server's first map after start, plugins load after the map's entity lump is parsed, so a map-placed `nm_sample_npc` is not
61 + created on that map. `ent_create` works at any time.
SampleNpc.cs (file created)
@@ -0,0 +1,41 @@
1 + using Microsoft.Extensions.Logging;
2 + using NewMoon.TF2Helpers;
3 + using NewMoon.TF2Helpers.Entities;
4 + using NewMoon.Utility;
5 +
6 + namespace NewMoon.Samples.CustomNpc;
7 +
8 + /// <summary>
9 + /// The custom NPC: a <c>nm_sample_npc</c> classname that is a factory alias of <c>base_boss</c>. Spawning it yields a real
10 + /// <c>base_boss</c> that carries the new classname, and the registry calls the lifecycle overrides below.
11 + /// </summary>
12 + /// <remarks>
13 + /// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
14 + /// spawner supplies <c>health</c> and <c>model</c> (see the README). The registry needs an accessible parameterless constructor.
15 + /// </remarks>
16 + [AgentGenerated("claude-sonnet-5-5", "2026-11-01")]
17 + [CustomEntity("nm_sample_npc", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
18 + internal sealed class SampleNpc : FCustomEntity
19 + {
20 + /// <summary>
21 + /// The logger the callbacks write to; set by <see cref="CustomNpcSample"/> at start-up, because the registry creates this
22 + /// object through its parameterless constructor.
23 + /// </summary>
24 + internal static ILogger? Log { get; set; }
25 +
26 + // Captured at spawn so the remove log never depends on the view still resolving while the instance is torn down.
27 + private int _index = -1;
28 +
29 + /// <inheritdoc/>
30 + protected override void OnSpawn()
31 + {
32 + _index = Entity.Index;
33 + Log?.LogNpcSpawn(_index);
34 + }
35 +
36 + /// <inheritdoc/>
37 + protected override void OnActivate() => Log?.LogNpcActivate(_index);
38 +
39 + /// <inheritdoc/>
40 + protected override void OnRemoved() => Log?.LogNpcRemoved(_index);
41 + }