Review comments.

Fix suspicious mismatch of PlusValue and ItemSafetySetting that may be causing a visual bug relating to the plus value of some items.

Fix BBM messing up your inventory sort order.

MigrateDatabase.cmd to make it a bit easier. More FAQs and instruction for newbies.

Bad workaround fix for the client being confused about emblems during job changes.

Fix second slot job items not being properly recorded in the DB.

Promote Shout to cross-channel chat. Try and make some of the RPC stuff a little more asynchronous for performance reasons.
This commit is contained in:
Ryan Yappert 2025-08-02 01:11:58 -07:00 committed by Ryan Yappert
parent 46f572382e
commit 5af9f3cde2
16 changed files with 179 additions and 133 deletions

View file

@ -184,8 +184,7 @@ public interface IDatabase
List<EquipItem> SelectEquipItemByCharacter(uint characterCommonId);
// Job Items
bool InsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo);
bool ReplaceEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo);
bool UpsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo, DbConnection? connectionIn = null);
bool DeleteEquipJobItem(uint commonId, JobId job, ushort slotNo);
// CustomSkills

View file

@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Data.Common;
using Arrowgene.Ddon.Shared.Model;
@ -5,79 +6,35 @@ namespace Arrowgene.Ddon.Database.Sql.Core;
public partial class DdonSqlDb : SqlDb
{
private const string SqlDeleteEquipJobItem =
"DELETE FROM \"ddon_equip_job_item\" WHERE \"character_common_id\"=@character_common_id AND \"job\"=@job AND \"equip_slot\"=@equip_slot;";
protected static readonly string[] CDataEquipJobItemFields = new[]
{
"item_uid", "character_common_id", "job", "equip_slot"
};
private static readonly string SqlUpdateEquipJobItem =
$"UPDATE \"ddon_equip_job_item\" SET {BuildQueryUpdate(CDataEquipJobItemFields)} WHERE \"character_common_id\" = @character_common_id AND \"job\" = @job AND \"equip_slot\"=@equip_slot;";
private static readonly string SqlSelectEquipJobItems =
$"SELECT {BuildQueryField(CDataEquipJobItemFields)} FROM \"ddon_equip_job_item\" WHERE \"character_common_id\" = @character_common_id AND \"job\" = @job;";
private static readonly string SqlSelectEquipJobItemsByCharacter =
$"SELECT {BuildQueryField(CDataEquipJobItemFields)} FROM \"ddon_equip_job_item\" WHERE \"character_common_id\" = @character_common_id;";
private readonly string SqlInsertEquipJobItem =
$"INSERT INTO \"ddon_equip_job_item\" ({BuildQueryField(CDataEquipJobItemFields)}) VALUES ({BuildQueryInsert(CDataEquipJobItemFields)});";
private readonly string SqlUpsertEquipJobItem =
$@"INSERT INTO ""ddon_equip_job_item"" ({BuildQueryField(CDataEquipJobItemFields)}) VALUES ({BuildQueryInsert(CDataEquipJobItemFields)}) ON CONFLICT (character_common_id, job, equip_slot) DO UPDATE SET item_uid = EXCLUDED.item_uid;";
private readonly string SqlInsertIfNotExistsEquipJobItem =
$"INSERT INTO \"ddon_equip_job_item\" ({BuildQueryField(CDataEquipJobItemFields)}) SELECT {BuildQueryInsert(CDataEquipJobItemFields)} WHERE NOT EXISTS (SELECT 1 FROM \"ddon_equip_job_item\" WHERE \"character_common_id\" = @character_common_id AND \"job\" = @job);";
private const string SqlDeleteEquipJobItem =
"DELETE FROM \"ddon_equip_job_item\" WHERE \"character_common_id\"=@character_common_id AND \"job\"=@job AND \"equip_slot\"=@equip_slot;";
public bool InsertIfNotExistsEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo)
public override bool UpsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo, DbConnection? connectionIn = null)
{
using DbConnection connection = OpenNewConnection();
return InsertIfNotExistsEquipJobItem(connection, itemUId, commonId, job, slotNo);
}
public bool InsertIfNotExistsEquipJobItem(DbConnection conn, string itemUId, uint commonId, JobId job, ushort slotNo)
{
return ExecuteNonQuery(conn, SqlInsertIfNotExistsEquipJobItem, command =>
return ExecuteQuerySafe(connectionIn, connection =>
{
AddParameter(command, "item_uid", itemUId);
AddParameter(command, "character_common_id", commonId);
AddParameter(command, "job", (byte)job);
AddParameter(command, "equip_slot", slotNo);
}) == 1;
}
public override bool InsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo)
{
using DbConnection connection = OpenNewConnection();
return InsertEquipJobItem(connection, itemUId, commonId, job, slotNo);
}
public bool InsertEquipJobItem(DbConnection conn, string itemUId, uint commonId, JobId job, ushort slotNo)
{
return ExecuteNonQuery(conn, SqlInsertEquipJobItem, command =>
{
AddParameter(command, "item_uid", itemUId);
AddParameter(command, "character_common_id", commonId);
AddParameter(command, "job", (byte)job);
AddParameter(command, "equip_slot", slotNo);
}) == 1;
}
public override bool ReplaceEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo)
{
using DbConnection connection = OpenNewConnection();
return ReplaceEquipJobItem(connection, itemUId, commonId, job, slotNo);
}
public bool ReplaceEquipJobItem(DbConnection conn, string itemUId, uint commonId, JobId job, ushort slotNo)
{
Logger.Debug("Inserting equp job item.");
if (!InsertIfNotExistsEquipJobItem(conn, itemUId, commonId, job, slotNo))
{
Logger.Debug("Equip job item already exists, replacing.");
return UpdateEquipJobItem(conn, itemUId, commonId, job, slotNo);
}
return true;
return ExecuteNonQuery(connection, SqlUpsertEquipJobItem, command =>
{
AddParameter(command, "item_uid", itemUId);
AddParameter(command, "character_common_id", commonId);
AddParameter(command, "job", (byte)job);
AddParameter(command, "equip_slot", slotNo);
}) == 1;
});
}
public override bool DeleteEquipJobItem(uint commonId, JobId job, ushort slotNo)
@ -89,21 +46,4 @@ public partial class DdonSqlDb : SqlDb
AddParameter(command, "equip_slot", slotNo);
}) == 1;
}
public bool UpdateEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo)
{
using DbConnection connection = OpenNewConnection();
return UpdateEquipJobItem(connection, itemUId, commonId, job, slotNo);
}
public bool UpdateEquipJobItem(DbConnection connection, string itemUId, uint commonId, JobId job, ushort slotNo)
{
return ExecuteNonQuery(connection, SqlUpdateEquipJobItem, command =>
{
AddParameter(command, "item_uid", itemUId);
AddParameter(command, "character_common_id", commonId);
AddParameter(command, "job", (byte)job);
AddParameter(command, "equip_slot", slotNo);
}) == 1;
}
}

View file

@ -374,8 +374,7 @@ public abstract class SqlDb : IDatabase
public abstract bool DeleteEquipItem(uint commonId, JobId job, EquipType equipType, byte equipSlot, DbConnection? connectionIn = null);
public abstract void DeleteAllEquipItems(uint commonId, DbConnection? connectionIn = null);
public abstract List<EquipItem> SelectEquipItemByCharacter(uint characterCommonId);
public abstract bool InsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo);
public abstract bool ReplaceEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo);
public abstract bool UpsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo, DbConnection? connectionIn = null);
public abstract bool DeleteEquipJobItem(uint commonId, JobId job, ushort slotNo);
public abstract bool InsertLearnedCustomSkill(uint commonId, CustomSkill skill, DbConnection? connectionIn = null);
public abstract bool UpdateLearnedCustomSkill(uint commonId, CustomSkill updatedSkill, DbConnection? connectionIn = null);

View file

@ -80,7 +80,7 @@ namespace Arrowgene.Ddon.GameServer.Characters
// EQUIP
Item item = server.Database.SelectStorageItemByUId(changeEquipJobItem.EquipJobItemUId);
characterToEquipTo.EquipmentTemplate.SetJobItem(item, characterToEquipTo.Job, changeEquipJobItem.EquipSlotNo);
server.Database.ReplaceEquipJobItem(item.UId, characterToEquipTo.CommonId, characterToEquipTo.Job, changeEquipJobItem.EquipSlotNo);
server.Database.UpsertEquipJobItem(item.UId, characterToEquipTo.CommonId, characterToEquipTo.Job, changeEquipJobItem.EquipSlotNo);
}
}

View file

@ -142,7 +142,6 @@ namespace Arrowgene.Ddon.GameServer.Characters
changeJobNotice.EquipJobItemList = jobItems;
// TODO: Unk0
updateCharacterItemNtc.UpdateType = ItemNoticeType.ChangeJob;
S2CJobChangeJobRes changeJobResponse = new S2CJobChangeJobRes();
@ -158,7 +157,7 @@ namespace Arrowgene.Ddon.GameServer.Characters
.FirstOrDefault(new CDataPlayPointData());
changeJobResponse.Unk0.Unk0 = (byte)jobId;
changeJobResponse.Unk0.Unk1 = character.Storage.GetAllStoragesAsCDataCharacterItemSlotInfoList();
client.Enqueue(changeJobResponse, queue);
client.Enqueue(updateCharacterItemNtc, queue);
foreach (GameClient otherClient in Server.ClientLookup.GetAll())
@ -167,8 +166,6 @@ namespace Arrowgene.Ddon.GameServer.Characters
}
queue.AddRange(Server.CharacterManager.UpdateCharacterExtendedParamsNtc(client, common));
return queue;
}
else if (common is Pawn)
{
@ -209,12 +206,32 @@ namespace Arrowgene.Ddon.GameServer.Characters
queue.AddRange(Server.CharacterManager.UpdateCharacterExtendedParamsNtc(client, common));
return queue;
}
else
{
throw new Exception("Unknown character type");
}
var equippedJobEmblems = client.Character.JobEmblems
.GetValueOrDefault(common.Job)
?.UIDs
.Select(x => common.Equipment.Storage.FindItemByUId(x))
.Where(x => x is not null);
if (equippedJobEmblems is not null && equippedJobEmblems.Any())
{
var extraUpdateItemNtc = new S2CItemUpdateCharacterItemNtc()
{
UpdateType = ItemNoticeType.EmblemStatUpdate // ???
};
foreach (var (slot, item, _) in equippedJobEmblems)
{
extraUpdateItemNtc.UpdateItemList.Add(Server.ItemManager.CreateItemUpdateResult(common, item, common.Equipment.Storage.Type, slot, 1, 1));
}
client.Enqueue(extraUpdateItemNtc, queue);
}
return queue;
}
private List<CDataItemUpdateResult> SwapEquipmentAndStorage(GameClient client, CharacterCommon common, JobId oldJobId, JobId newJobId, EquipType equipType, DbConnection? connectionIn = null)

View file

@ -193,6 +193,12 @@ namespace Arrowgene.Ddon.GameServer.Chat
}
case LobbyChatMsgType.Shout:
response.Recipients.AddRange(_Server.ClientLookup.GetAll());
if (_Server.GameSettings.ChatCommandsSettings.CrossChannelShout)
{
_Server.RpcManager.AnnounceShoutChat(client, response);
}
break;
case LobbyChatMsgType.Party:
PartyGroup party = client.Party;

View file

@ -99,6 +99,15 @@ namespace Arrowgene.Ddon.GameServer.Handler
VisualEquipItemList = client.Character.Equipment.AsCDataEquipItemInfo(EquipType.Visual),
});
client.Send(new S2CItemSortGetItemSortdataBinNtc()
{
SortData = [.. ItemManager.ItemBagStorageTypes.Select(x => new CDataItemSort()
{
StorageType = x,
Bin = client.Character.Storage.GetStorage(x).SortData
})]
});
return new S2CCharacterSwitchGameModeRes()
{
GameMode = packet.GameMode,

View file

@ -183,11 +183,16 @@ namespace Arrowgene.Ddon.GameServer
}
}
public void AnnounceAsync(ushort channelId, string route, RpcInternalCommand command, object data)
{
Task.Run(() => Announce(channelId, route, command, data));
}
public void AnnounceAll(string route, RpcInternalCommand command, object data)
{
foreach (var channel in Server.AssetRepository.ServerList)
{
Announce(channel.Id, route, command, data);
AnnounceAsync(channel.Id, route, command, data);
}
}
@ -196,7 +201,7 @@ namespace Arrowgene.Ddon.GameServer
foreach (var channel in Server.AssetRepository.ServerList)
{
if (channel.Id == Server.Id) continue;
Announce(channel.Id, route, command, data);
AnnounceAsync(channel.Id, route, command, data);
}
}
@ -211,7 +216,7 @@ namespace Arrowgene.Ddon.GameServer
if (channel.Value.Any(x => x.Value.ClanId == clanId))
{
Announce(channel.Key, route, command, data);
AnnounceAsync(channel.Key, route, command, data);
}
}
}
@ -327,6 +332,23 @@ namespace Arrowgene.Ddon.GameServer
#endregion
#region Chat
public void AnnounceShoutChat(GameClient client, ChatResponse chatResponse)
{
RpcChatData chatData = new RpcChatData()
{
HandleId = 0,
Type = LobbyChatMsgType.Shout,
MessageFlavor = chatResponse.MessageFlavor,
PhrasesCategory = chatResponse.PhrasesCategory,
PhrasesIndex = chatResponse.PhrasesIndex,
Message = chatResponse.Message,
Deliver = false,
SourceData = new RpcCharacterData(client.Character)
};
AnnounceOthers("internal/chat", RpcInternalCommand.SendShoutMessage, chatData);
}
public void AnnounceClanChat(GameClient client, ChatResponse chatResponse)
{
if (client.Character.ClanId == 0) return;
@ -372,7 +394,7 @@ namespace Arrowgene.Ddon.GameServer
}
};
Announce(targetServer, "internal/chat", RpcInternalCommand.SendTellMessage, chatData);
AnnounceAsync(targetServer, "internal/chat", RpcInternalCommand.SendTellMessage, chatData);
}
#endregion

View file

@ -7,7 +7,6 @@ using Arrowgene.Logging;
using Arrowgene.WebServer;
using System.Linq;
using System.Threading.Tasks;
using static Arrowgene.Ddon.GameServer.RpcManager;
namespace Arrowgene.Ddon.Rpc.Web.Route.Internal
{
@ -103,6 +102,36 @@ namespace Arrowgene.Ddon.Rpc.Web.Route.Internal
Message = $"SendTellMessage ID {data.SourceData.CharacterId} -> {data.TargetData.CharacterId}"
};
}
case RpcInternalCommand.SendShoutMessage:
{
RpcChatData data = _entry.GetData<RpcChatData>();
ChatResponse response = new()
{
HandleId = 0,
Deliver = false,
FirstName = data.SourceData.FirstName,
LastName = data.SourceData.LastName,
ClanName = data.SourceData.ClanName,
CharacterId = data.SourceData.CharacterId,
Type = LobbyChatMsgType.Shout,
Message = data.Message,
MessageFlavor = data.MessageFlavor,
PhrasesCategory = data.PhrasesCategory,
PhrasesIndex = data.PhrasesIndex
};
response.Recipients.AddRange(gameServer.ClientLookup
.GetAll()
.Where(x => x.Character != null)
);
gameServer.ChatManager.Send(response);
return new RpcCommandResult(this, true)
{
Message = $"SendShoutMessage {data.SourceData.FirstName} {data.SourceData.LastName}: {data.Message}"
};
}
default:
return new RpcCommandResult(this, false);
}

View file

@ -25,5 +25,22 @@ namespace Arrowgene.Ddon.Server.Settings
}
}
private const bool _DisableAccountTypeCheck = false;
/// <summary>
/// Controls whether Shout is sent to all channels or not.
/// </summary>
[DefaultValue(_CrossChannelShout)]
public bool CrossChannelShout
{
set
{
SetSetting("CrossChannelShout", value);
}
get
{
return TryGetSetting("CrossChannelShout", _CrossChannelShout);
}
}
private const bool _CrossChannelShout = true;
}
}

View file

@ -18,6 +18,7 @@ namespace Arrowgene.Ddon.Shared.Model.Rpc
//InternalChatRoute
SendTellMessage, // RpcChatData
SendClanMessage, // RpcChatData
SendShoutMessage, // RpcChatData
//PacketRoute
AnnouncePacketAll, // RpcPacketData

View file

@ -327,7 +327,7 @@ namespace Arrowgene.Ddon.Shared.Model
{
ItemId = (ushort) x.ItemId,
ColorNo = x.Color,
PlusValue = x.SafetySetting,
PlusValue = x.PlusValue,
EquipElementParamList = x.EquipElementParamList,
AddStatusParamList = x.AddStatusParamList,
})

View file

@ -242,7 +242,6 @@ namespace Arrowgene.Ddon.Test.Database
public bool InsertConnection(Connection connection) { return true; }
public int InsertContact(uint requestingCharacterId, uint requestedCharacterId, ContactListStatus status, ContactListType type, bool requesterFavorite, bool requestedFavorite) { return 1; }
public bool InsertEquipItem(uint commonId, JobId job, EquipType equipType, byte equipSlot, string itemUId) { return true; }
public bool InsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo) { return true; }
public bool InsertEquippedAbility(uint commonId, JobId equipptedToJob, byte slotNo, Ability ability) { return true; }
public bool InsertEquippedCustomSkill(uint commonId, byte slotNo, CustomSkill skill) { return true; }
public bool InsertGainExtendParam(uint commonId, CDataOrbGainExtendParam Param) { return true; }
@ -273,7 +272,6 @@ namespace Arrowgene.Ddon.Test.Database
public bool ReplaceCharacterJobData(uint commonId, CDataCharacterJobData replacedCharacterJobData, DbConnection? connectionIn = null) { return true; }
public bool ReplaceCommunicationShortcut(uint characterId, CDataCommunicationShortCut communicationShortcut, DbConnection? connectionIn = null) { return true; }
public bool ReplaceEquipItem(uint commonId, JobId job, EquipType equipType, byte equipSlot, string itemUId, DbConnection? connectionIn = null) { return true; }
public bool ReplaceEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo) { return true; }
public bool ReplaceEquippedAbilities(uint commonId, JobId equippedToJob, List<Ability> abilities) { return true; }
public bool ReplaceEquippedAbility(uint commonId, JobId equipptedToJob, byte slotNo, Ability ability) { return true; }
public bool ReplaceEquippedCustomSkill(uint commonId, byte slotNo, CustomSkill skill) { return true; }
@ -539,6 +537,8 @@ namespace Arrowgene.Ddon.Test.Database
public List<CDataPawnHistory> SelectPawnHistory(uint pawnId, DbConnection? connectionIn = null) { return []; }
public CDataPawnTotalScore SelectPawnTotalScore(uint pawnId, DbConnection? connectionIn = null) { return new(); }
public bool UpsertEquipJobItem(string itemUId, uint commonId, JobId job, ushort slotNo, DbConnection? connectionIn = null) { return true; }
public void AddParameter(DbCommand command, string name, object? value, DbType type) { }
public void AddParameter(DbCommand command, string name, string value) { }
public void AddParameter(DbCommand command, string name, Int32 value) { }

View file

@ -37,6 +37,17 @@ The project is intended for educational purpose only.
. Debug the Project
.. Run the `Ddon.Cli`-Project with arguments `server start`
== User Setup
. Clone the repository
.. `git clone https://github.com/sebastian-heinz/Arrowgene.DragonsDogmaOnline.git`
. Install .NET 9.0 SDK or later https://dotnet.microsoft.com/download
. Run `publish.cmd` or `publish.sh` to build the server files.
. If updating from a previous build of the server...
.. Make sure that your database file `db.sqlite` is in the proper location, usually `Arrowgene.DragonsDogmaOnline\publish\win-x64-#.#.#.#\Server\Files\Database`.
.. You may have to migrate your database schema by running `MigrateDatabase.cmd`, which can be found in `Arrowgene.DragonsDogmaOnline\publish\win-x64-#.#.#.#`.
. Launch the server with `StartServer.cmd`, which can be found in `Arrowgene.DragonsDogmaOnline\publish\win-x64-#.#.#.#`.
. For frequently asked questions, see https://github.com/sebastian-heinz/Arrowgene.DragonsDogmaOnline/blob/develop/docs/faq.md
== Deployment
The application (server) requires the (ASP).NET runtime 9 to function. Alternatively the SDK also works and is required when building from source.
@ -96,46 +107,7 @@ Launch the client with the following args:
`"DDO.exe" "addr=localhost port=52100 token=00000000000000000000 DL=http://127.0.0.1:52099/win/ LVer=03.04.003.20181115.0 RVer=3040008"`
== Progress
=== Login Server
* [x] Account
* [x] Character Creation
=== Game Server
==== Party Management (Party List)
* [ ] Party Members
** [x] View Arisen Profile
** [ ] Send Tell
** [ ] Send Friend Request
** [ ] View Status and Equipment
** [x] Promote to Party Leader
** [x] Kick from Party
** [ ] Invite to Group Chat
** [x] Disband Party
** [ ] Invite to Entryboard
** [ ] Follow with Autorun
** [ ] Cancel Party Invite
** [ ] Decline Party Invite
** [ ] View Party List
** [x] Leave
** [ ] Invite Directly to Clan
* [ ] Main Pawns
** [ ] View Pawn Profile
** [x] Invite to Party
** [x] Kick from Party
** [ ] View Status and Equipment
* [ ] Support Pawns
* [ ] Party Search
** [ ] Search
** [ ] Simple Request
* [ ] Player Search
** [ ] View Arisen Profile
** [x] Invite to Party
** [ ] Send Tell
** [ ] Send Friend Request
** [ ] Invite to Group Chat
** [ ] Invite to Entryboard
** [x] Search
See the https://github.com/sebastian-heinz/Arrowgene.DragonsDogmaOnline/wiki/What-Works%3F[wiki].
== Guidelines
=== Git

View file

@ -0,0 +1,3 @@
pushd "%~dp0 "
cd ./Server.
start Arrowgene.Ddon.Cli.exe dbmigration

32
docs/faq.md Normal file
View file

@ -0,0 +1,32 @@
# Frequently Asked Questions
## When I launch the server, it says "Please update the database..."
As features are developed, we must often change the structure of the database. To stay up to date with these changes, you'll need to migrate your database. If you built the server using `publish.cmd` or `publish.sh`, you can use the provided `MigrateDatabase.cmd` to do this. If you built the server using VS or another tool, you can navigate to `Arrowgene.Ddon.Cli` and run `dotnet run dbmigration` from the command line or PowerShell.
## How do I update the translation?
* Option 1: Use the new and improved [DDON Launcher](https://github.com/D00MK1D/DDON-Launcher/releases). Place this in your DDON directory (near `ddon.exe`), run the launcher, and click the `大A` button. By default, this downloads and installs the most recent *English* translation, or you can provide the github URL for another translation project.
* Option 2: Use the legacy local patcher. Place a translation file (`gmd.csv`) in `[Your DDON Directory]/nativePC/Server/Files/Client`, then drag the folder `[Your DDON Directory]/nativePC/rom` onto `pack_gmd_english.cmd` in the same directory.
## How do I use admin commands?
You'll need to modify your account status in the server's database. If you're running locally and using the default SQLite DB, [DB4S](https://sqlitebrowser.org/) is a convenient tool for this. Make sure you've logged in at least once, open up the DB (`Server\Files\Database\db.sqlite`) in DB4S, navigate to the `account` table, and set the `state` column for your account to `100`.
Alternatively, you can disable the account status check, by editing the server setting scripts. See below for details.
## How do I place monsters and loot?
The most convenient tool for this is [DDONTools](https://github.com/alborrajo/DDOn-Tools).
## How do I change settings like EXP multipliers?
See the [README](https://github.com/sebastian-heinz/Arrowgene.DragonsDogmaOnline/blob/develop/Arrowgene.Ddon.Scripts/scripts/settings/README.md).
## How do I open my game up for multiplayer?
* Ensure that the IP addresses and ports in `Server/Files/Arrowgene.Ddon.config.json` are publicly accessible.
* Update `Server/Files/Assets/GameServerList.csv` with your publicly facing IP address/ports.
* Players can add your server to their launcher by clicking the gear icon in the bottom left.
## How do I run multiple channels at once?
Each channel is a separately running server process. One server acts as the leader, managing login and some periodic tasks, as well as its own GameServer instance, while also directing players to the subordinate channels. In general, to prevent issues, all channels should run from the same asset folder and same build of the server.
* Make a copy of your config file, then change the `Port` and `Id` values so that they do not overlap with any other channel's ports and IDs. Web, login, and game servers must all have unique ids. The copy can be in the same folder as the original.
* Add the new channel in a new row of `GameServerList.csv`. The values here should reflect the config files.
* `IsHide` will prevent players from seeing this channel in the channel list.
* `PreventLogin` will prevent automatic load balancing from placing new characters on this channel.
* Launch each separate instance with `dotnet run server start --config=[PATH TO CONFIG FILE]`.