Repository navigation
Adding a player feature
This guide builds a per-character feature end to end: packets, game tables, saved state, the manager on the player, handlers and an optional GM command. It's the path the Galactic Archive took, and upstream's CurrencyManager has the same manager shape.
Important
Five of the steps below compile fine on their own and fail silently when they're missing. They're marked, and Check it is how you catch them.
A feature whose state is loaded with the character, saved with it, sent to the client after every world entry, and changed by packets, game events or commands.
| Layer | Project | You add |
|---|---|---|
| Wire | Network.World |
Message models, if they don't exist |
| Static data |
GameTable, Game.Static
|
[GameData] on the tables you read; enums for their values |
| Storage | Database.Character |
A model, a table and a migration |
| Contracts | Game.Abstract |
Interfaces for the feature and a property on IPlayer
|
| Logic | Game |
The manager and its items, and four edits to Player
|
| Hosts | WorldServer |
Message handlers, and a GM command if you want one |
Handlers and message models need no registration. Both are found by reflection at start-up.
Look the feature up before writing anything: are its opcodes in Source/NexusForever.Network/Message/GameMessageOpcode.cs, do models exist under Source/NexusForever.Network.World/Message/Model/, and does anything reference them yet?
Warning
A complete, carefully commented model can still be wrong. The archive's models had the article id as 32 bits where the client uses 14, which pushed every field after it out of place. Check each field's width before you build on a model, and remember the client's own UI scripts name the events and functions a feature uses.
- Find the tables the feature reads in
Source/NexusForever.GameTable/GameTableManager.cs. - Add
[GameData]to each property. Without it the table never loads: the property stays null, and the first use throws, long after start-up. - Start a Debug build. It checks each row model against its file and stops on a mismatch; a Release build would misread the rows silently.
- Add enums for the values the feature switches on to
Source/NexusForever.Game.Static/<Feature>/, one per file, with explicit values.
In Source/NexusForever.Database.Character/:
- A model,
Model/Character<Thing>Model.cs. - An
ICollection<>of it onModel/CharacterModel.cs. - A
DbSetand a fluent block inCharacterContext.cs: tablecharacter_<thing>,lowerCamelcolumns, the key namedPRIMARY, and the foreign key namedFK__character_<thing>_id__character_id. Thecharacter_datacubeblock is a good template. -
An
.Includefor the collection inCharacterDatabase.GetCharacters. Silent if missing: the feature loads empty every time.
The design-time factory connects to MySQL to read the server version, so the database has to be reachable.
-
Stop the servers but leave the MySQL container running.
-
In the
WorldServer.jsonin the world server'sbin\Debug\net10.0folder, pointDatabase:Character:ConnectionStringat the container: its loopback port, userroot, and its root password, all shown on the Aspire dashboard, and databasecharacterdb. -
From
Source\:dotnet ef migrations add <Name> --project NexusForever.Database.Character --startup-project NexusForever.WorldServer --context CharacterContext
-
Put the config back. The migration runs at the next start.
In Source/NexusForever.Game.Abstract/Entity/:
-
I<Thing>.csfor one item andI<Thing>Manager.csfor the manager. The manager extendsIDatabaseCharacter, plusIUpdateif it needs ticking, and declaresInitialise(IPlayer owner, CharacterModel model). - A property for the manager on
IPlayer.cs.
In Source/NexusForever.Game/Entity/:
- The item class, with a nested save mask (
None,Create, then a flag per saved field) and two constructors: one for a new item, one from the database model. - The manager. It takes the services it needs through its constructor, in a
#region Dependency Injection, and builds its items from the model's collection inInitialise. - A registration for the manager,
AddTransient, inServiceCollectionExtentions.csbesideCurrencyManager's.
That's upstream's newer shape, and the one new work should follow. Most older managers, titles and datacubes among them, are built with new XManager(this, model) instead (Contributing code patterns). The archive's managers moved to the newer shape after its in-game checks, so this shape is read from the code, not yet run.
Important
In the constructor that loads from the database, reset the save mask to None at the end. Setting the fields through their setters marks them changed, and without the reset every loaded item is written back on every save.
Five separate edits to Source/NexusForever.Game/Entity/Player.cs. Each one compiles without the others.
| Edit | Where | If you miss it |
|---|---|---|
| The property | Beside the other managers, { get; }
|
Compile error |
| Take it in the constructor | A parameter on Player's constructor, assigned beside CurrencyManager
|
A null reference at login |
| Initialise it |
Initialise, beside CurrencyManager.Initialise
|
Silent: nothing loads, then a null reference when the manager acts |
| Save it | Save(CharacterContext) |
Silent: changes are lost on relog |
| Send it | SendPacketsAfterAddToMap |
Silent: the client shows nothing |
Add a sixth, a call in Update, if the manager ticks.
If the feature needs an index built from the game tables once, like the archive's unlock rules, give it a global manager:
- An interface in
Game.Abstractand a plain class inGame. - Register it with
AddSingletonin anAddGame<Feature>method, and call that fromAddGame. - Inject it into
HostedServiceand call itsInitialisefromStartAsync, after anything it reads.
ChatFormatManager and the archive's index have this shape. Older global managers derive Singleton<T> and register with AddSingletonLegacy so .Instance keeps working.
Add Source/NexusForever.WorldServer/Network/Message/Handler/<Feature>/<Message>Handler.cs, implementing IMessageHandler<IWorldSession, TMessage>. Keep it thin: hand the work to the manager on session.Player.
Throwing InvalidPacketValueException disconnects the client. For a value that's merely stale, such as an id the tables don't have, log it and return instead.
Send an update from the manager whenever its state changes, and the whole state from SendInitialPackets after every world entry. Some client panels discard their state on a world change, so send everything again each time, not only at login.
- A new value in
Source/NexusForever.Game.Static/RBAC/Permission.cs, explicitly numbered. -
A row for it in the auth database:
HasDatainSource/NexusForever.Database.Auth/AuthContext.cs, plus an auth migration.Migrations/20230917125110_EntityThreatPermissions.csis the template. Silent if missing: the command answers "Unable to invoke command" for everyone. - A category in
Source/NexusForever.WorldServer/Command/Handler/<Thing>CommandCategory.cs, with[Command]on the class and each method, and[CommandTarget(typeof(IPlayer))]if it acts on a character.
Run Verifying a feature. It catches the silent steps:
- Missing include: the feature is empty after a relog, though the rows are in the database.
- Missing initialise: the feature is empty from the start, and the first change throws.
- Missing save: the rows never appear.
- Missing send: the database is right, but the client shows nothing after a world change.
- Missing permission row: the command is refused even from the web console.
Then follow Taking a change upstream for the branch and the pull request, and Contributing code style for how the code should look.
| Symptom | Cause | Fix |
|---|---|---|
| A null reference on first use of a table | No [GameData] on its property |
Add the attribute |
| Start-up stops on a table layout error, in Debug | The row model doesn't match the file | Fix the model's field order or types |
Received unknown packet in the world log |
No model registered for the opcode | Add the model with [Message]
|
Received unhandled packet |
A model, but no handler | Add the handler |
Failed to read entire contents of packet |
The model reads fewer bits than the client sent | Check the field widths |
| Every save rewrites every row | The save mask isn't reset after loading | Reset it at the end of the database constructor |
dotnet ef can't connect |
The design-time factory needs a live MySQL | Point the world server's config at the running container |
Login fails resolving Player
|
The manager isn't registered | Register it with AddTransient
|
| Step | File | Silent if missing |
|---|---|---|
| Opcode | Network/Message/GameMessageOpcode.cs |
|
| Message model | Network.World/Message/Model/<Feature>/ |
|
[GameData] |
GameTable/GameTableManager.cs |
Until first use |
| Enums | Game.Static/<Feature>/ |
|
| Saved model and table |
Database.Character/Model/, CharacterContext.cs
|
|
| Migration | Database.Character/Migrations/ |
|
| Include | Database.Character/CharacterDatabase.cs |
Yes |
| Interfaces |
Game.Abstract/Entity/, IPlayer.cs
|
|
| Manager and items | Game/Entity/ |
|
| Manager registration | Game/Entity/ServiceCollectionExtentions.cs |
|
| Five player edits | Game/Entity/Player.cs |
Initialise, save and send |
| Global index |
Game/<Feature>/, AddGame, HostedService.cs
|
|
| Handlers | WorldServer/Network/Message/Handler/<Feature>/ |
|
| Permission and its row |
Game.Static/RBAC/Permission.cs, Database.Auth/AuthContext.cs
|
The row |
| Command | WorldServer/Command/Handler/ |
Project paths are under Source/NexusForever..
Guides
- Running it locally
- Using the web console
- Adding a player feature
- Verifying a feature
- Taking a change upstream
- Syncing with upstream
Explanation
- Server architecture
- Login handshake
- Game tables
- Branches and workflow
- Upstream branches
- Contributing
- Galactic Archive
- Account entitlements
Reference
- Processes and projects
- File formats
- Databases
- Web console
- Galactic Archive messages and storage
- Code style
- Code patterns
- Pull requests
Design
About this wiki