diff --git a/SECURITY.md b/SECURITY.md
index 9a90b4038..1c85363a4 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,4 +1,4 @@
# Reporting a Vulnerability
Contact a @Mod Developer on [Discord](https://discord.gg/c-rpg) or send an
-email to [hello@c-rpg.eu](mailto:hello@c-rpg.eu).
+email to [hello@namidaka.fr](mailto:hello@namidaka.fr).
diff --git a/deploy/group_vars/all.yml b/deploy/group_vars/all.yml
index 0f1b0e830..1b907de96 100644
--- a/deploy/group_vars/all.yml
+++ b/deploy/group_vars/all.yml
@@ -15,28 +15,24 @@ steam_api_key: !vault |
epic_games_client_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 30633266323035383864653436643932396433663739313636613531313732623064643462636161
- 6330386538663935316566343730343130663662396130610a643363666138316337356164303234
- 64343039343232346562363162653934626562376631316563613565396163356264356636313663
- 6236383639633361380a373535306563326538643638376463363630323930326235643837373165
- 35303537386133376264663038626164373164373666383866633332333163643835373734306532
- 6333353234666531373062623732323437393337633037303435
-
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
microsoft_client_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 65336566656238663362376330353964333330303266663633616635326333363437653539333737
- 3439333666343931353632636334666466323431373964350a663864376464326137323932313666
- 34373462353133626337353134333064346538326636336632393266306339616131353835303133
- 6631663465353164350a633961323336356338373835613662376263333533613839313332306231
- 61626232356330653561626130313136393361373464353863663635353830396135643537303030
- 3563386630386336353465376130643462383132343063383131
-
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
crpg_game_server_api_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 32326433613332653435346566636265643466643030643234383735333663383433663836656334
- 6263343930313465663436303339343262633130653864340a306439393363616435613732323437
- 31643762313537653230353636346363383430393765653933303665336339616264316138663038
- 3435316531373164640a616437393663666161653465303461306334623362343034386637356162
- 62353136616336613035623733333161333235366361643733336534343564643065
+ 37326237663864613932666364383863373430303930353934326662313034306137306432303531
+ 3830373066613633333165356230626163613233383937300a613736646331303631333536643163
+ 37363938643363346530346166653733396264313036343238636536653837646564326132353936
+ 3836613332616234360a383330306238386639653539333732663439353438306437643636313539
+ 63383937363866363633653933623336613564616339303832303637633065393233
-crpg_api_domain_name: api.c-rpg.eu
+crpg_api_domain_name: api.namidaka.fr
diff --git a/deploy/group_vars/centralservers.yml b/deploy/group_vars/centralservers.yml
index 0622125a0..e79a76cfb 100644
--- a/deploy/group_vars/centralservers.yml
+++ b/deploy/group_vars/centralservers.yml
@@ -2,11 +2,11 @@
ansible_sudo_pass: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 66323536646461616430383436323736313532653037363330346537336431666432366361653438
- 3332333437643932353834383338336363616635306362610a623032656162623466653239613437
- 39393333333334613531333930336562323637643135303338363365653837643634366131646163
- 6464396461366162300a383766366539633839393539346439653662313334363565333731353236
- 32393961366339366233393731383165373039346638313661323864353130363835
+ 32326165616133323332366631633136663436323037633536643730363333313234356538303437
+ 3036326266636130353664326263313165333036313034330a306537376130376636323062303863
+ 30666637363636626236393832333163653162666337343137326339666232663434626532343539
+ 3733376466396562650a633061383938306533316235366163356239356666656166396334663137
+ 3439
crpg_reverse_proxy_service_name: crpg-reverse-proxy
@@ -18,18 +18,18 @@ crpg_api_log_file: "/var/log/crpg/{{ crpg_api_service_name }}.log"
crpg_ui_path: "/var/www/{{ crpg_domain_name }}"
-crpg_domain_name: c-rpg.eu
+crpg_domain_name: namidaka.fr
crpg_db_service_name: crpg-db
crpg_db: crpg
crpg_db_user: crpg
crpg_db_user_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 38303737393930623462303066366563363435616265376637656538383336373931333532326362
- 3364653166383839383735346266336661613566373265650a613637626666656635653134623165
- 38626562646536376361343732396539373764663132663262333566326339383239653065356138
- 3434356465313665390a643036346635343562646136653063646166366264396664313530326334
- 3163
+ 36666130613663333435323864656237653566383531326463383433343831363038303732656131
+ 3662643863663838396534643766616262626365346563650a623136393037343630313063653561
+ 31363665626230626535323437623461323037656631303564323864643265616461613963333263
+ 6338623462376663340a313138616563353562313738643862343533396563646431363639616563
+ 32666435623364313032643564306565646131346430336134613032393033336436
datadog_db_user: dd-agent # Same username as Datadog agent so ident authentication can be used
@@ -40,21 +40,19 @@ datadog_opentracing_version: 1.3.5
datadog_api_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 63336331303239353839636366306365663262326338343831616564396133386631616539386636
- 6436303036373834386662363836623266336536376365330a653532333535666436353230623332
- 34386236323063306233383730343034333632643532396532353962653335393863653139653336
- 3730653536623865660a356565313035373337313030613966646164623736623937626464373966
- 66663735636134326334333164343430616634623761326163316434306530363833626236633430
- 6537393033613533636438646634643235666337383030336130
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
datadog_application_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 33323830303038316239393336613633303062613765303737343364663533393462396439353865
- 6232646534353762356632656436653863383661643961620a366337396531313032636665646331
- 65373062643737353431656238623264613239326364623939623566393631373035386364383363
- 6330363838333033390a366637373134626562373132333938613630653036313738636531666337
- 65346338633837646361386362393363336461383065346338353530623461643930326632326565
- 3239616561353364363634616233333934633865633262393137
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
datadog_checks:
nginx:
@@ -192,21 +190,23 @@ patreon_access_token: !vault |
afdian_access_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 39343663386135383239343237633366373636376337643731376638306231376163626238643838
- 3861323337343066623666373761643837313838313763340a333364353066303366643331626232
- 32616234373235663231393462636664396263623361646564616431663961616465383035383734
- 6665353666386261620a383036633833303030623837366330333938613265373563663639316639
- 62623365356162663637343239306532623761326334636132613339323736323062666332623766
- 6237373533363461343439303630356438376337343765386232
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
github_access_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 37396232626466353962303235373030326335363866303362643234616461663830646530363565
- 3038346164373634623838313365313639376133313331390a663064393837323239326662393635
- 63373237626336616634313132343231303161633131653461393366303762393737653139613234
- 6166363334326462320a346637383135343666653636326439386138303536396562353232663439
- 63356232643334373836376131656334626661383363376132626461663130376233343038326138
- 32373966363261303935383465323834386131626138326235623363623661323430666238623730
- 62376330336330653737353036346237306539623233333731363463313531336433623338373332
- 63353162313034363131376130366565646565613533626631383337663834626331396235626131
- 3263
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
+afdian_access_token: !vault |
+ $ANSIBLE_VAULT;1.1;AES256
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
\ No newline at end of file
diff --git a/deploy/group_vars/gameservers.yml b/deploy/group_vars/gameservers.yml
index 5c95733b9..3e218d08d 100644
--- a/deploy/group_vars/gameservers.yml
+++ b/deploy/group_vars/gameservers.yml
@@ -7,3 +7,11 @@ bannerlord_server_bin_path: "{{ bannerlord_server_path }}/bin/Linux64_Shipping_S
crpg_module_path: "{{ bannerlord_server_path }}/Modules/cRPG"
crpg_game_server_id: "{{ ansible_hostname | regex_replace('^crpg(\\d\\d)$', '\\1') }}"
+
+steam_user_name: !vault |
+ $ANSIBLE_VAULT;1.1;AES256
+ 61353865653665623739633563633933626564623130343662643336363134343166313135663863
+ 6565313738343862346631353435373432313062646330350a343836326233353239663739336331
+ 34393762366338333333343635316562633162653932663138306432376431643363323762633430
+ 6465316635666365610a336564363433646161363632646665363236383936636134613164323462
+ 6434
diff --git a/deploy/host_vars/crpg02.c-rpg.eu.yml b/deploy/host_vars/crpg02.c-rpg.eu.yml
deleted file mode 100644
index 8803f559a..000000000
--- a/deploy/host_vars/crpg02.c-rpg.eu.yml
+++ /dev/null
@@ -1,41 +0,0 @@
----
-
-crpg_region: na
-crpg_happy_hours: "19:30-23:30,America/Chicago"
-
-crpg_game_server_instances:
- - name: a
- port: 7210
- game_type: battle
- - name: b
- port: 7211
- game_type: battle
- - name: c
- port: 7212
- game_type: duel
- - name: d
- port: 7213
- game_type: skirmish
- - name: e
- port: 7214
- game_type: skirmish
- - name: f
- port: 7215
- game_type: skirmish
-
-ansible_sudo_pass: !vault |
- $ANSIBLE_VAULT;1.1;AES256
- 62333333613761653062663061326461396431626235356262653734346265333031336238666264
- 6166643630396662373962613963373965386635656662620a343432633436616266616131316532
- 61616536336566363066303233306638646266623538393834346564353566326638336334663835
- 3838666464313333370a306435626662656163323831383738623832353362336439376137376661
- 63356532326236333130393766616661313539353633633866633732363065336136
-
-datadog_api_key: !vault |
- $ANSIBLE_VAULT;1.1;AES256
- 66383861316562376630646164613537323232633532383337313635323463396562383335646231
- 3938313162343134653331363665666334613737666466660a616663363638373664306663323465
- 36353231653932353366316461623864386265653164383739333666616534643761313365363464
- 3239333432643236640a396338393331313637653533343435313032386365373931306663386339
- 30636139363632643135353562303034373539396364323332363765366437613164633635323062
- 3966626430373534636463613933303037373331326133366134
diff --git a/deploy/hosts.ini b/deploy/hosts.ini
index c69222af6..34acf5b61 100644
--- a/deploy/hosts.ini
+++ b/deploy/hosts.ini
@@ -1,5 +1,5 @@
[centralservers]
-c-rpg.eu
+namidaka.fr
[gameservers]
-crpg03.c-rpg.eu
+crpg03.namidaka.fr
diff --git a/deploy/roles/geoip/vars/main.yml b/deploy/roles/geoip/vars/main.yml
index 81c7ab0df..3b6cd1843 100644
--- a/deploy/roles/geoip/vars/main.yml
+++ b/deploy/roles/geoip/vars/main.yml
@@ -4,8 +4,9 @@ geoip_path: /usr/share/geoip
maxmind_license_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
- 62613238363266343034336663373161383734336665303730663639373362306166623734303231
- 3636373937376336363763396635323562393861653939610a313565366163313731303562623430
- 63363564366133303330376462333361353164363731633261313931326162613238343635636266
- 3861643032326339660a646463363936333862303361626133333039303737306462366430316331
- 31373738653833333463373465333337646236393233623563336133616532396638
+ 34633832353166333731333634653537373538613431313135333839353633323235653434326131
+ 6165653032383033333839326365623731363663663765610a396265623834623331386438623330
+ 62373939383566356565393763373637663962363531346164306164663363343435623037303935
+ 3365333665343831660a383333353734653030383035613834353737623639336163616462306662
+ 38643836316462336435316466653333326361343833643865393061656466353963623364356638
+ 3664376239346334303934353962666161373731313965656632
diff --git a/deploy/roles/nginx/templates/nginx-api.c-rpg.eu.j2 b/deploy/roles/nginx/templates/nginx-api.namidaka.fr.j2
similarity index 100%
rename from deploy/roles/nginx/templates/nginx-api.c-rpg.eu.j2
rename to deploy/roles/nginx/templates/nginx-api.namidaka.fr.j2
diff --git a/deploy/roles/nginx/templates/nginx-c-rpg.eu.j2 b/deploy/roles/nginx/templates/nginx-namidaka.fr.j2
similarity index 100%
rename from deploy/roles/nginx/templates/nginx-c-rpg.eu.j2
rename to deploy/roles/nginx/templates/nginx-namidaka.fr.j2
diff --git a/src/Application/Captains/Commands/GetUserCaptainCommand.cs b/src/Application/Captains/Commands/GetUserCaptainCommand.cs
new file mode 100644
index 000000000..e04207957
--- /dev/null
+++ b/src/Application/Captains/Commands/GetUserCaptainCommand.cs
@@ -0,0 +1,75 @@
+using AutoMapper;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Crpg.Domain.Entities.Captains;
+using FluentValidation;
+using Microsoft.EntityFrameworkCore;
+
+namespace Crpg.Application.Captains.Commands;
+
+///
+/// Get or create a user with its character.
+///
+public record GetUserCaptainCommand : IMediatorRequest
+{
+ public int UserId { get; init; }
+ public class Validator : AbstractValidator
+ {
+ public Validator()
+ {
+ }
+ }
+
+ internal class Handler : IMediatorRequestHandler
+ {
+ private readonly ICrpgDbContext _db;
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task> Handle(GetUserCaptainCommand req, CancellationToken cancellationToken)
+ {
+ var user = await _db.Users
+ .Where(u => u.Id == req.UserId)
+ .Include(u => u.Captain)
+ .FirstOrDefaultAsync(cancellationToken);
+ if (user == null)
+ {
+ return new(CommonErrors.UserNotFound(req.UserId));
+ }
+
+ if (user.Captain == null)
+ {
+ user.Captain = CreateCaptain(req.UserId);
+ _db.Captains.Add(user.Captain);
+
+ await _db.SaveChangesAsync(cancellationToken);
+ }
+
+ var gameUser = _mapper.Map(user.Captain);
+ return new(gameUser);
+ }
+
+ private Captain CreateCaptain(int userId)
+ {
+ Captain captain = new()
+ {
+ UserId = userId,
+ Formations = new List()
+ {
+ new() { Number = 1, Weight = 33 },
+ new() { Number = 2, Weight = 33 },
+ new() { Number = 3, Weight = 33 },
+ },
+ };
+
+ return captain;
+ }
+ }
+}
diff --git a/src/Application/Captains/Commands/UpdateFormationCharacterCommand.cs b/src/Application/Captains/Commands/UpdateFormationCharacterCommand.cs
new file mode 100644
index 000000000..431a22105
--- /dev/null
+++ b/src/Application/Captains/Commands/UpdateFormationCharacterCommand.cs
@@ -0,0 +1,67 @@
+using AutoMapper;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Crpg.Application.Captains.Commands;
+
+public record UpdateFormationCharacterCommand : IMediatorRequest
+{
+ public int? CharacterId { get; init; }
+ public int UserId { get; init; }
+ public int Number { get; init; }
+
+ internal class Handler : IMediatorRequestHandler
+ {
+ private readonly ICrpgDbContext _db;
+
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task> Handle(UpdateFormationCharacterCommand req, CancellationToken cancellationToken)
+ {
+ var captain = await _db.Captains
+ .Where(c => c.UserId == req.UserId)
+ .Select(c => new
+ {
+ Formation = c.Formations.FirstOrDefault(f => f.Number == req.Number),
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (captain?.Formation == null)
+ {
+ return new(CommonErrors.CaptainFormationNotFound(req.Number, req.UserId));
+ }
+
+ if (req.CharacterId == null) {
+ captain.Formation.CharacterId = null;
+ await _db.SaveChangesAsync(cancellationToken);
+ return new(_mapper.Map(captain.Formation));
+ }
+
+ var character = await _db.Characters
+ .FirstOrDefaultAsync(c => c.UserId == req.UserId && c.Id == req.CharacterId.Value, cancellationToken);
+ if (character == null)
+ {
+ return new(CommonErrors.CharacterNotFound(req.CharacterId.Value, req.UserId));
+ }
+
+ if (character.ForTournament)
+ {
+ return new(CommonErrors.CharacterForTournament(req.CharacterId.Value));
+ }
+
+ captain.Formation.CharacterId = req.CharacterId.Value;
+
+ await _db.SaveChangesAsync(cancellationToken);
+ return new(_mapper.Map(captain.Formation));
+ }
+ }
+}
diff --git a/src/Application/Captains/Commands/UpdateFormationWeightCommand.cs b/src/Application/Captains/Commands/UpdateFormationWeightCommand.cs
new file mode 100644
index 000000000..27e2e94b3
--- /dev/null
+++ b/src/Application/Captains/Commands/UpdateFormationWeightCommand.cs
@@ -0,0 +1,53 @@
+using AutoMapper;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Crpg.Application.Captains.Commands;
+
+public record UpdateFormationWeightCommand : IMediatorRequest
+{
+ public int UserId { get; init; }
+ public int Weight { get; init; }
+ public int Number { get; init; }
+
+ internal class Handler : IMediatorRequestHandler
+ {
+ private readonly ICrpgDbContext _db;
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task> Handle(UpdateFormationWeightCommand req, CancellationToken cancellationToken)
+ {
+ if (req.Weight < -1 || req.Weight > 100)
+ {
+ return new(CommonErrors.CaptainFormationWeightNotInBounds(req.Number, req.UserId, req.Weight));
+ }
+
+ var captain = await _db.Captains
+ .Where(c => c.UserId == req.UserId)
+ .Select(c => new
+ {
+ Formation = c.Formations.FirstOrDefault(f => f.Number == req.Number),
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (captain?.Formation == null)
+ {
+ return new(CommonErrors.CaptainFormationNotFound(req.Number, req.UserId));
+ }
+
+ captain.Formation.Weight = req.Weight;
+
+ await _db.SaveChangesAsync(cancellationToken);
+ return new(_mapper.Map(captain.Formation));
+ }
+ }
+}
diff --git a/src/Application/Captains/Models/CaptainFormationViewModel.cs b/src/Application/Captains/Models/CaptainFormationViewModel.cs
new file mode 100644
index 000000000..0e4edda5c
--- /dev/null
+++ b/src/Application/Captains/Models/CaptainFormationViewModel.cs
@@ -0,0 +1,16 @@
+using AutoMapper;
+using Crpg.Application.Characters.Models;
+using Crpg.Application.Common.Mappings;
+using Crpg.Domain.Entities.Captains;
+using Crpg.Domain.Entities.Characters;
+
+namespace Crpg.Application.Captains.Models;
+
+public record CaptainFormationViewModel : IMapFrom
+{
+ public int Number { get; set; }
+ public int? CharacterId { get; set; }
+ public GameCharacterViewModel? Character { get; set; }
+ public int Weight { get; set; }
+
+}
diff --git a/src/Application/Captains/Models/CaptainViewModel.cs b/src/Application/Captains/Models/CaptainViewModel.cs
new file mode 100644
index 000000000..03657b956
--- /dev/null
+++ b/src/Application/Captains/Models/CaptainViewModel.cs
@@ -0,0 +1,17 @@
+using AutoMapper;
+using Crpg.Application.Common.Mappings;
+using Crpg.Application.Users.Models;
+using Crpg.Domain.Entities.Captains;
+
+namespace Crpg.Application.Captains.Models;
+
+public record CaptainViewModel : IMapFrom
+{
+ public IList Formations { get; set; } = new List();
+
+ public void Mapping(Profile profile)
+ {
+ profile.CreateMap()
+ .ForMember(dest => dest.Formations, opt => opt.MapFrom(src => src.Formations.OrderBy(f => f.Id)));
+ }
+}
diff --git a/src/Application/Captains/Queries/GetUserCaptainFormationQuery.cs b/src/Application/Captains/Queries/GetUserCaptainFormationQuery.cs
new file mode 100644
index 000000000..ab85c3a12
--- /dev/null
+++ b/src/Application/Captains/Queries/GetUserCaptainFormationQuery.cs
@@ -0,0 +1,41 @@
+using AutoMapper;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Crpg.Application.Captains.Queries;
+
+public record GetUserCaptainFormationQuery : IMediatorRequest
+{
+ public int UserId { get; init; }
+ public int Number { get; init; }
+
+ internal class Handler : IMediatorRequestHandler
+ {
+ private readonly ICrpgDbContext _db;
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task> Handle(GetUserCaptainFormationQuery req, CancellationToken cancellationToken)
+ {
+ var captain = await _db.Captains
+ .Where(c => c.UserId == req.UserId)
+ .Select(c => new
+ {
+ Formation = c.Formations.FirstOrDefault(f => f.Number == req.Number),
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return captain == null
+ ? new(CommonErrors.CaptainFormationNotFound(req.Number, req.UserId))
+ : new(_mapper.Map(captain.Formation));
+ }
+ }
+}
diff --git a/src/Application/Captains/Queries/GetUserCaptainFormationsQuery.cs b/src/Application/Captains/Queries/GetUserCaptainFormationsQuery.cs
new file mode 100644
index 000000000..7df1a4a55
--- /dev/null
+++ b/src/Application/Captains/Queries/GetUserCaptainFormationsQuery.cs
@@ -0,0 +1,41 @@
+using AutoMapper;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Crpg.Application.Captains.Queries;
+
+public record GetUserCaptainFormationsQuery : IMediatorRequest>
+{
+ public int UserId { get; init; }
+
+ internal class Handler : IMediatorRequestHandler>
+ {
+ private readonly ICrpgDbContext _db;
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task>> Handle(GetUserCaptainFormationsQuery req, CancellationToken cancellationToken)
+ {
+ var captain = await _db.Captains
+ .Where(c => c.UserId == req.UserId)
+ .Select(c => new
+ {
+ c.Formations,
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return captain == null
+ ? new(CommonErrors.CaptainNotFound(req.UserId))
+ : new(_mapper.Map>(captain.Formations));
+ }
+ }
+}
diff --git a/src/Application/Captains/Queries/GetUserCaptainQuery.cs b/src/Application/Captains/Queries/GetUserCaptainQuery.cs
new file mode 100644
index 000000000..ba018a765
--- /dev/null
+++ b/src/Application/Captains/Queries/GetUserCaptainQuery.cs
@@ -0,0 +1,37 @@
+using AutoMapper;
+using AutoMapper.QueryableExtensions;
+using Crpg.Application.Captains.Models;
+using Crpg.Application.Common.Interfaces;
+using Crpg.Application.Common.Mediator;
+using Crpg.Application.Common.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Crpg.Application.Captains.Queries;
+
+public record GetUserCaptainQuery : IMediatorRequest
+{
+ public int UserId { get; init; }
+
+ internal class Handler : IMediatorRequestHandler
+ {
+ private readonly ICrpgDbContext _db;
+ private readonly IMapper _mapper;
+
+ public Handler(ICrpgDbContext db, IMapper mapper)
+ {
+ _db = db;
+ _mapper = mapper;
+ }
+
+ public async Task> Handle(GetUserCaptainQuery req, CancellationToken cancellationToken)
+ {
+ var captain = await _db.Captains
+ .Where(c => c.UserId == req.UserId)
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .FirstOrDefaultAsync(cancellationToken);
+ return captain == null
+ ? new(CommonErrors.CaptainNotFound(req.UserId))
+ : new(captain);
+ }
+ }
+}
diff --git a/src/Application/Common/Interfaces/ICrpgDbContext.cs b/src/Application/Common/Interfaces/ICrpgDbContext.cs
index 6c50bbeb3..57b97cd0c 100644
--- a/src/Application/Common/Interfaces/ICrpgDbContext.cs
+++ b/src/Application/Common/Interfaces/ICrpgDbContext.cs
@@ -1,5 +1,6 @@
using Crpg.Domain.Entities.ActivityLogs;
using Crpg.Domain.Entities.Battles;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Characters;
using Crpg.Domain.Entities.Clans;
using Crpg.Domain.Entities.GameServers;
@@ -29,6 +30,7 @@ public interface ICrpgDbContext
DbSet ClanArmoryItems { get; }
DbSet ClanArmoryBorrowedItems { get; }
DbSet ClanInvitations { get; }
+ DbSet Captains { get; }
DbSet Parties { get; }
DbSet Settlements { get; }
DbSet SettlementItems { get; }
diff --git a/src/Application/Common/Results/CommonErrors.cs b/src/Application/Common/Results/CommonErrors.cs
index 6afcb7e6b..68ca3c88b 100644
--- a/src/Application/Common/Results/CommonErrors.cs
+++ b/src/Application/Common/Results/CommonErrors.cs
@@ -37,6 +37,24 @@ internal static class CommonErrors
Detail = $"Battle with id '{battleId}' is too far to perform the requested action",
};
+ public static Error CaptainFormationNotFound(int captainFormationId, int userId) => new(ErrorType.NotFound, ErrorCode.CaptainFormationNotFound)
+ {
+ Title = "Formation was not found",
+ Detail = $"Formation with id '{captainFormationId}' for user with id '{userId}' was not found",
+ };
+
+ public static Error CaptainFormationWeightNotInBounds(int captainFormationId, int userId, int weight) => new(ErrorType.NotFound, ErrorCode.CaptainFormationWeightNotInBounds)
+ {
+ Title = "Weight was invalid",
+ Detail = $"Formation with id '{captainFormationId}' for user with id '{userId}' with weight '{weight}' was outside of bounds.",
+ };
+
+ public static Error CaptainNotFound(int userId) => new(ErrorType.NotFound, ErrorCode.CaptainNotFound)
+ {
+ Title = "Captain was not found",
+ Detail = $"Captain for user with id '{userId}' was not found",
+ };
+
public static Error CharacterForTournament(int characterId) => new(ErrorType.Validation, ErrorCode.CharacterForTournament)
{
Title = "Character is for tournament",
diff --git a/src/Application/Common/Results/ErrorCode.cs b/src/Application/Common/Results/ErrorCode.cs
index 36ecb291d..cea79b774 100644
--- a/src/Application/Common/Results/ErrorCode.cs
+++ b/src/Application/Common/Results/ErrorCode.cs
@@ -10,6 +10,9 @@ public enum ErrorCode
BattleInvalidPhase,
BattleNotFound,
BattleTooFar,
+ CaptainFormationNotFound,
+ CaptainFormationWeightNotInBounds,
+ CaptainNotFound,
CharacterForTournament,
CharacterForTournamentNotFound,
CharacterGenerationRequirement,
diff --git a/src/Application/Games/Commands/GetGameUserCommand.cs b/src/Application/Games/Commands/GetGameUserCommand.cs
index 18c21fce6..50f8e7de9 100644
--- a/src/Application/Games/Commands/GetGameUserCommand.cs
+++ b/src/Application/Games/Commands/GetGameUserCommand.cs
@@ -1,10 +1,13 @@
-using AutoMapper;
+using System.Threading;
+using AutoMapper;
+using Crpg.Application.Captains.Models;
using Crpg.Application.Common.Interfaces;
using Crpg.Application.Common.Mediator;
using Crpg.Application.Common.Results;
using Crpg.Application.Common.Services;
using Crpg.Application.Games.Models;
using Crpg.Domain.Entities;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Characters;
using Crpg.Domain.Entities.Items;
using Crpg.Domain.Entities.Limitations;
@@ -158,6 +161,8 @@ public async Task> Handle(GetGameUserCommand req, Canc
var user = await _db.Users
.Include(u => u.ActiveCharacter)
.Include(u => u.ClanMembership)
+ .Include(u => u.Captain)
+ .ThenInclude(c => c!.Formations)
.FirstOrDefaultAsync(u => u.Platform == req.Platform && u.PlatformUserId == req.PlatformUserId,
cancellationToken);
@@ -246,6 +251,30 @@ await _db.Entry(user.ActiveCharacter)
user.ActiveCharacter.Statistics = new List { statistic };
}
+ if (user.Captain == null)
+ {
+ user.Captain = CreateCaptain(user.Id);
+
+ _db.Captains.Add(user.Captain);
+ await _db.SaveChangesAsync(cancellationToken);
+ }
+ else
+ {
+ // Iterate over the formations to load each Character's EquippedItems separately
+/* foreach (var formation in user.Captain.Formations)
+ {
+ if (formation.Character != null)
+ {
+ var equippedItems = await _db.EquippedItems
+ .Where(ei => ei.CharacterId == formation.Character.Id)
+ .Include(ei => ei.UserItem)
+ .ToListAsync(cancellationToken);
+
+ formation.Character.EquippedItems = equippedItems;
+ }
+ }*/
+ }
+
var gameUser = _mapper.Map(user);
gameUser.Restrictions = gameUser.Restrictions
.Where(r => _dateTime.UtcNow < r.CreatedAt + r.Duration)
@@ -371,5 +400,23 @@ private async Task> GiveUserRandomItemSet(User user, (string
return equippedItems;
}
+
+ private Captain CreateCaptain(int userId)
+ {
+ Captain captain = new()
+ {
+ UserId = userId,
+ Formations = new List()
+ {
+ new() { Number = 1, Weight = 33 },
+ new() { Number = 2, Weight = 33 },
+ new() { Number = 3, Weight = 33 },
+ },
+ };
+
+
+
+ return captain;
+ }
}
}
diff --git a/src/Application/Games/Commands/UpdateGameUsersCommand.cs b/src/Application/Games/Commands/UpdateGameUsersCommand.cs
index ba022a98b..4d80c5826 100644
--- a/src/Application/Games/Commands/UpdateGameUsersCommand.cs
+++ b/src/Application/Games/Commands/UpdateGameUsersCommand.cs
@@ -112,13 +112,27 @@ private async Task> LoadCharacters(IList u.CharacterId).ToArray();
var charactersById = await _db.Characters
.Include(c => c.User!.ClanMembership)
+ .Include(c => c.User!.Captain!.Formations)
+ .ThenInclude(f => f!.Character)
+
.Where(c => characterIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken);
- // Load items in a separate query to avoid cartesian explosion. The items will be automatically set
- // to their respective character.
+ int[] primaryCharacterIds = charactersById.Keys.ToArray();
+
+ // Extract character IDs from formations
+ int[] formationCharacterIds = charactersById.Values
+ .Where(c => c.User!.Captain != null)
+ .SelectMany(c => c.User!.Captain!.Formations)
+ .Where(f => f.CharacterId.HasValue)
+ .Select(f => f.CharacterId!.Value)
+ .Distinct()
+ .ToArray();
+
+ int[] allCharacterIds = primaryCharacterIds.Concat(formationCharacterIds).Distinct().ToArray();
+
await _db.EquippedItems
- .Where(ei => characterIds.Contains(ei.CharacterId))
+ .Where(ei => allCharacterIds.Contains(ei.CharacterId))
.Include(ei => ei.UserItem)
.LoadAsync(cancellationToken);
diff --git a/src/Application/Games/Models/GameUserViewModel.cs b/src/Application/Games/Models/GameUserViewModel.cs
index ca9876dfe..2ffda1f2f 100644
--- a/src/Application/Games/Models/GameUserViewModel.cs
+++ b/src/Application/Games/Models/GameUserViewModel.cs
@@ -1,9 +1,11 @@
using AutoMapper;
using Crpg.Application.Characters.Models;
using Crpg.Application.Clans.Models;
+using Crpg.Application.Captains.Models;
using Crpg.Application.Common.Mappings;
using Crpg.Application.Restrictions.Models;
using Crpg.Domain.Entities;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Users;
namespace Crpg.Application.Games.Models;
@@ -23,6 +25,7 @@ public record GameUserViewModel : IMapFrom
public GameCharacterViewModel Character { get; init; } = default!;
public IList Restrictions { get; set; } = Array.Empty();
public GameClanMemberViewModel? ClanMembership { get; set; }
+ public CaptainViewModel? Captain { get; set; } = default!;
public void Mapping(Profile profile)
{
diff --git a/src/Application/System/Commands/SeedDataCommand.cs b/src/Application/System/Commands/SeedDataCommand.cs
index 44bf45a25..402b7bb16 100644
--- a/src/Application/System/Commands/SeedDataCommand.cs
+++ b/src/Application/System/Commands/SeedDataCommand.cs
@@ -6,6 +6,7 @@
using Crpg.Domain.Entities;
using Crpg.Domain.Entities.ActivityLogs;
using Crpg.Domain.Entities.Battles;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Characters;
using Crpg.Domain.Entities.Clans;
using Crpg.Domain.Entities.Items;
@@ -941,7 +942,8 @@ private async Task AddDevelopmentData()
Character[] newCharacters =
{
takeoCharacter0, takeoCharacter1, takeoCharacter2, namidakaCharacter0, orleCharacter0, orleCharacter1, orleCharacter2, droobCharacter0,
- falcomCharacter0, victorhh888Character0, sellkaCharacter0, krogCharacter0, kadseCharacter0, noobAmphetamine0, baronCyborg0,
+ droobCharacter1, droobCharacter2, falcomCharacter0, victorhh888Character0, sellkaCharacter0, krogCharacter0, kadseCharacter0,
+ noobAmphetamine0, baronCyborg0,
};
var existingCharacters = await _db.Characters.ToDictionaryAsync(c => c.Name);
diff --git a/src/Domain/Entities/Captains/Captain.cs b/src/Domain/Entities/Captains/Captain.cs
new file mode 100644
index 000000000..52a8d8555
--- /dev/null
+++ b/src/Domain/Entities/Captains/Captain.cs
@@ -0,0 +1,15 @@
+using Crpg.Domain.Common;
+using Crpg.Domain.Entities.Users;
+
+namespace Crpg.Domain.Entities.Captains;
+
+///
+/// Represents a cRPG captain.
+///
+public class Captain : AuditableEntity
+{
+ public int Id { get; set; }
+ public int UserId { get; set; }
+ public IList Formations { get; set; } = new List();
+ public User User { get; set; } = default!;
+}
diff --git a/src/Domain/Entities/Captains/CaptainFormation.cs b/src/Domain/Entities/Captains/CaptainFormation.cs
new file mode 100644
index 000000000..5cb48364c
--- /dev/null
+++ b/src/Domain/Entities/Captains/CaptainFormation.cs
@@ -0,0 +1,29 @@
+using Crpg.Domain.Common;
+using Crpg.Domain.Entities.Characters;
+
+namespace Crpg.Domain.Entities.Captains;
+
+///
+/// Represents a cRPG captain formation.
+///
+public class CaptainFormation : AuditableEntity
+{
+ public int Id { get; set; }
+ ///
+ /// The number indentifies the formation slot of the captain.
+ ///
+ public int Number { get; set; }
+ public int CaptainId { get; set; }
+ ///
+ /// The characterId of the troops to spawn in a formation.
+ ///
+ public int? CharacterId { get; set; }
+
+ ///
+ /// The weight is compared to other formations to determine the composition of an army.
+ ///
+ public int Weight { get; set; }
+
+ public Captain Captain { get; set; } = default!;
+ public Character? Character { get; set; }
+}
diff --git a/src/Domain/Entities/GameServers/GameMode.cs b/src/Domain/Entities/GameServers/GameMode.cs
index 20b3a25ca..1bdd38825 100644
--- a/src/Domain/Entities/GameServers/GameMode.cs
+++ b/src/Domain/Entities/GameServers/GameMode.cs
@@ -10,6 +10,7 @@ public enum GameMode
CRPGSkirmish,
CRPGTeamDeathmatch,
CRPGUnknownGameMode,
+ CRPGCaptain,
}
public enum GameModeAlias
@@ -20,5 +21,6 @@ public enum GameModeAlias
D,
E,
F,
+ G,
Z,
}
diff --git a/src/Domain/Entities/Users/User.cs b/src/Domain/Entities/Users/User.cs
index da2a4ed3f..42e0b74c7 100644
--- a/src/Domain/Entities/Users/User.cs
+++ b/src/Domain/Entities/Users/User.cs
@@ -1,4 +1,5 @@
using Crpg.Domain.Common;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Characters;
using Crpg.Domain.Entities.Clans;
using Crpg.Domain.Entities.Items;
@@ -52,4 +53,5 @@ public class User : AuditableEntity
public IList Restrictions { get; set; } = new List();
public ClanMember? ClanMembership { get; set; }
public Party? Party { get; set; }
+ public Captain? Captain { get; set; } = default!;
}
diff --git a/src/Module.Server/Api/Models/Captains/CrpgCaptain.cs b/src/Module.Server/Api/Models/Captains/CrpgCaptain.cs
new file mode 100644
index 000000000..ca35877f6
--- /dev/null
+++ b/src/Module.Server/Api/Models/Captains/CrpgCaptain.cs
@@ -0,0 +1,7 @@
+namespace Crpg.Module.Api.Models.Captains;
+
+internal class CrpgCaptain
+{
+ public int Id { get; set; }
+ public IList Formations { get; set; } = new List();
+}
diff --git a/src/Module.Server/Api/Models/Captains/CrpgCaptainFormation.cs b/src/Module.Server/Api/Models/Captains/CrpgCaptainFormation.cs
new file mode 100644
index 000000000..11301637c
--- /dev/null
+++ b/src/Module.Server/Api/Models/Captains/CrpgCaptainFormation.cs
@@ -0,0 +1,10 @@
+using Crpg.Module.Api.Models.Characters;
+
+namespace Crpg.Module.Api.Models.Captains;
+internal class CrpgCaptainFormation
+{
+ public int Id { get; set; }
+ public int Number { get; set; }
+ public CrpgCharacter? Character { get; set; } = default!;
+ public int Weight { get; set; }
+}
diff --git a/src/Module.Server/Api/Models/Users/CrpgUser.cs b/src/Module.Server/Api/Models/Users/CrpgUser.cs
index 59d92db86..5d78bd15b 100644
--- a/src/Module.Server/Api/Models/Users/CrpgUser.cs
+++ b/src/Module.Server/Api/Models/Users/CrpgUser.cs
@@ -1,4 +1,5 @@
-using Crpg.Module.Api.Models.Characters;
+using Crpg.Module.Api.Models.Captains;
+using Crpg.Module.Api.Models.Characters;
using Crpg.Module.Api.Models.Clans;
using Crpg.Module.Api.Models.Restrictions;
@@ -18,4 +19,5 @@ internal class CrpgUser
public CrpgCharacter Character { get; set; } = default!;
public IList Restrictions { get; set; } = Array.Empty();
public CrpgClanMember? ClanMembership { get; set; }
+ public CrpgCaptain Captain { get; set; } = default!;
}
diff --git a/src/Module.Server/Common/CrpgCharacterBuilder.cs b/src/Module.Server/Common/CrpgCharacterBuilder.cs
index 1986fbd92..f60c680ac 100644
--- a/src/Module.Server/Common/CrpgCharacterBuilder.cs
+++ b/src/Module.Server/Common/CrpgCharacterBuilder.cs
@@ -62,6 +62,18 @@ public static Equipment CreateCharacterEquipment(IList equippe
return equipment;
}
+ public static Equipment CreateBotCharacterEquipment(IList equippedItems)
+ {
+ Equipment equipment = new();
+ foreach (var equippedItem in equippedItems)
+ {
+ var index = ItemSlotToIndex[equippedItem.Slot];
+ AddBotEquipment(equipment, index, equippedItem.UserItem.ItemId);
+ }
+
+ return equipment;
+ }
+
public static void AssignArmorsToTroopOrigin(CrpgBattleAgentOrigin origin, List items)
{
if (items == null)
@@ -115,4 +127,33 @@ private static void AddEquipment(Equipment equipments, EquipmentIndex idx, strin
EquipmentElement equipmentElement = new(itemObject);
equipments.AddEquipmentToSlotWithoutAgent(idx, equipmentElement);
}
+
+ private static void AddBotEquipment(Equipment equipments, EquipmentIndex idx, string itemId)
+ {
+ var itemObject = MBObjectManager.Instance.GetObject(itemId);
+ if (itemObject == null)
+ {
+ Debug.Print($"Cannot equip unknown item '{itemId}'");
+ return;
+ }
+
+ if (!Equipment.IsItemFitsToSlot(idx, itemObject))
+ {
+ Debug.Print($"Cannot equip item '{itemId} on slot {idx}");
+ return;
+ }
+
+ if (itemObject.ItemType == ItemObject.ItemTypeEnum.Bow)
+ {
+ itemObject = MBObjectManager.Instance.GetObject(itemId.Replace("crpg", "dtv"));
+ if (itemObject == null)
+ {
+ Debug.Print($"Cannot find appropriate bot item for '{itemId}'");
+ return;
+ }
+ }
+
+ EquipmentElement equipmentElement = new(itemObject);
+ equipments.AddEquipmentToSlotWithoutAgent(idx, equipmentElement);
+ }
}
diff --git a/src/Module.Server/Common/CrpgServerConfiguration.cs b/src/Module.Server/Common/CrpgServerConfiguration.cs
index 412fcb982..6678383ec 100644
--- a/src/Module.Server/Common/CrpgServerConfiguration.cs
+++ b/src/Module.Server/Common/CrpgServerConfiguration.cs
@@ -28,6 +28,7 @@ public static void Init()
public static float TeamBalancerClanGroupSizePenalty { get; private set; } = 0f;
public static float ServerExperienceMultiplier { get; private set; } = 1.0f;
public static int RewardTick { get; private set; } = 60;
+ public static int CaptainTotalBotCount { get; private set; } = 40;
public static bool TeamBalanceOnce { get; private set; }
public static bool FrozenBots { get; private set; } = false;
public static Tuple? HappyHours { get; private set; }
@@ -81,6 +82,23 @@ private static void SetRewardTick(string? rewardTickStr)
Debug.Print($"Set reward tick to {rewardTick}");
}
+ [UsedImplicitly]
+ [ConsoleCommandMethod("crpg_captain_bot_count", "Sets the total number of bots to spawn in Captain gamemode.")]
+ private static void SetCaptainBotCount(string? botCountStr)
+ {
+ if (botCountStr == null
+ || !int.TryParse(botCountStr, out int botCount)
+ || botCount < 0
+ || botCount > 1000)
+ {
+ Debug.Print($"Invalid bot count: {botCountStr}");
+ return;
+ }
+
+ CaptainTotalBotCount = botCount;
+ Debug.Print($"Set total captain bot count to {botCount}");
+ }
+
[UsedImplicitly]
[ConsoleCommandMethod("crpg_team_balance_once", "Sets if the team balancer should balance only after warmup.")]
private static void SetTeamBalanceOnce(string? teamBalanceOnceStr)
diff --git a/src/Module.Server/Common/CrpgSpawningBehaviorBase.cs b/src/Module.Server/Common/CrpgSpawningBehaviorBase.cs
index b9cd07c5d..ef61f8a15 100644
--- a/src/Module.Server/Common/CrpgSpawningBehaviorBase.cs
+++ b/src/Module.Server/Common/CrpgSpawningBehaviorBase.cs
@@ -1,6 +1,8 @@
-using System.ComponentModel;
+using Crpg.Module.Api.Models.Captains;
using Crpg.Module.Api.Models.Characters;
using Crpg.Module.Api.Models.Users;
+using Microsoft.VisualBasic;
+using NetworkMessages.FromServer;
using TaleWorlds.Core;
using TaleWorlds.Library;
using TaleWorlds.MountAndBlade;
@@ -31,7 +33,9 @@ internal abstract class CrpgSpawningBehaviorBase : SpawningBehaviorBase
WeaponClass.ThrowingAxe,
WeaponClass.ThrowingKnife,
};
+ private MultiplayerGameType gameMode = MultiplayerGameType.Battle;
+ public virtual MultiplayerGameType GameMode { get => gameMode; protected set => gameMode = value; }
public CrpgSpawningBehaviorBase(CrpgConstants constants)
{
_constants = constants;
@@ -42,6 +46,12 @@ public override bool AllowEarlyAgentVisualsDespawning(MissionPeer missionPeer)
return false;
}
+ public override void Initialize(SpawnComponent spawnComponent)
+ {
+ base.Initialize(spawnComponent);
+ base.OnAllAgentsFromPeerSpawnedFromVisuals += OnAllAgentsFromPeerSpawnedFromVisuals;
+ }
+
public override void RequestStartSpawnSession()
{
base.RequestStartSpawnSession();
@@ -62,7 +72,7 @@ protected override void SpawnAgents()
{
BasicCultureObject cultureTeam1 = MBObjectManager.Instance.GetObject(MultiplayerOptions.OptionType.CultureTeam1.GetStrValue());
BasicCultureObject cultureTeam2 = MBObjectManager.Instance.GetObject(MultiplayerOptions.OptionType.CultureTeam2.GetStrValue());
-
+ int p = 1;
foreach (NetworkCommunicator networkPeer in GameNetwork.NetworkPeers)
{
MissionPeer missionPeer = networkPeer.GetComponent();
@@ -81,7 +91,7 @@ protected override void SpawnAgents()
}
BasicCultureObject teamCulture = missionPeer.Team == Mission.AttackerTeam ? cultureTeam1 : cultureTeam2;
- var peerClass = MBObjectManager.Instance.GetObject("crpg_class_division");
+ var peerClass = MBObjectManager.Instance.GetObject($"crpg_class_division_{p}");
// var character = CreateCharacter(crpgPeer.User.Character, _constants);
var characterSkills = CrpgCharacterBuilder.CreateCharacterSkills(crpgPeer.User!.Character.Characteristics);
var characterXml = peerClass.HeroCharacter;
@@ -95,8 +105,6 @@ protected override void SpawnAgents()
Expecting that a character always exist in xmls
*/
- //
-
bool hasMount = characterEquipment[EquipmentIndex.Horse].Item != null;
bool firstSpawn = missionPeer.SpawnCountThisRound == 0;
@@ -106,6 +114,19 @@ Expecting that a character always exist in xmls
initialDirection.RotateCCW(MBRandom.RandomFloatRanged(-MathF.PI / 3f, MathF.PI / 3f));
var troopOrigin = new CrpgBattleAgentOrigin(characterXml, characterSkills);
CrpgCharacterBuilder.AssignArmorsToTroopOrigin(troopOrigin, crpgPeer.User.Character.EquippedItems.ToList());
+ Formation formation = missionPeer.ControlledFormation;
+ if (formation == null)
+ {
+ formation = missionPeer.Team.FormationsIncludingEmpty.First((Formation x) => x.PlayerOwner == null && x.CountOfUnits == 0);
+ formation.ContainsAgentVisuals = true;
+ if (string.IsNullOrEmpty(formation.BannerCode))
+ {
+ formation.BannerCode = crpgPeer.Clan?.BannerKey ?? missionPeer.Peer.BannerCode;
+ }
+ }
+
+ missionPeer.ControlledFormation = formation;
+ missionPeer.HasSpawnedAgentVisuals = true;
AgentBuildData agentBuildData = new AgentBuildData(characterXml)
.MissionPeer(missionPeer)
.Equipment(characterEquipment)
@@ -118,7 +139,8 @@ Expecting that a character always exist in xmls
// Note that what is sent here doesn't matter since it's ignored by the client.
.BodyProperties(characterXml.GetBodyPropertiesMin())
.InitialPosition(in spawnFrame.origin)
- .InitialDirection(in initialDirection);
+ .InitialDirection(in initialDirection)
+ .Formation(formation);
if (crpgPeer.Clan != null)
{
@@ -150,8 +172,55 @@ Expecting that a character always exist in xmls
agent.WieldInitialWeapons();
}
- missionPeer.HasSpawnedAgentVisuals = true;
+ if (IsRoundInProgress() && gameMode == MultiplayerGameType.Captain)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == missionPeer.Team).ToList();
+ float sumOfTeamEquipment = _teamSumOfEquipment[missionPeer.Team];
+ float peerSumOfEquipment = ComputeEquipmentValue(crpgPeer);
+ int peerNumberOfBots = 0;
+ if (teamRelevantPeers.Count - 1 < 1)
+ {
+ peerNumberOfBots = _teamNumberOfBots[missionPeer.Team] - 1;
+ }
+ else
+ {
+ peerNumberOfBots = (int)(_teamNumberOfBots[missionPeer.Team] * (1 - peerSumOfEquipment / sumOfTeamEquipment) /
+ (float)(teamRelevantPeers.Count - 1));
+ }
+
+ Dictionary formationBotWeight = new();
+ var captainFormations = crpgPeer.User.Captain.Formations.Where(f => f.Character != null);
+ if (captainFormations.Any())
+ {
+ double totalWeight = captainFormations.Sum(cf => cf.Weight);
+
+ foreach (CrpgCaptainFormation captainFormation in captainFormations)
+ {
+ double proportion = (double)(captainFormation.Weight / totalWeight);
+ formationBotWeight.Add(captainFormation.Number, proportion);
+ }
+ }
+ else
+ {
+ for (int i = 0; i < peerNumberOfBots; i++)
+ {
+ SpawnBotAgent(peerClass.StringId, agent.Team, missionPeer, p);
+ }
+ }
+
+ foreach (KeyValuePair captainFormation in formationBotWeight)
+ {
+ for (int i = 0; i < (int)(captainFormation.Value * peerNumberOfBots); i++)
+ {
+ SpawnBotAgent(peerClass.StringId, agent.Team, missionPeer, p, captainFormation.Key);
+ }
+ }
+
+ }
+ p++;
// AgentVisualSpawnComponent.RemoveAgentVisuals(missionPeer, sync: true);
}
}
@@ -165,7 +234,7 @@ private CrpgCharacterObject CreateCharacter(CrpgCharacter character, CrpgConstan
return characterObject;
}
- protected Agent SpawnBotAgent(string classDivisionId, Team team)
+ protected Agent SpawnBotAgent(string classDivisionId, Team team, MissionPeer? peer = null, int peerId = 0, int formationId = 0)
{
var teamCulture = team.Side == BattleSideEnum.Attacker
? MBObjectManager.Instance.GetObject(MultiplayerOptions.OptionType.CultureTeam1.GetStrValue())
@@ -195,6 +264,52 @@ protected Agent SpawnBotAgent(string classDivisionId, Team team)
? teamCulture.Color2
: teamCulture.ClothAlternativeColor2);
+ if (peer != null)
+ {
+ var crpgPeer = peer.GetComponent();
+ if (crpgPeer != null && crpgPeer?.User != null)
+ {
+ var formation = GetFormationFromPeer(peer, formationId);
+
+ var formationCharacter = formation?.Character ?? crpgPeer.User.Character;
+ string characterClass = formation != null ? $"crpg_class_division_{peerId}_{formation.Number}" : $"crpg_class_division_{peerId}";
+ botClass = MultiplayerClassDivisions
+ .GetMPHeroClasses()
+ .First(h => h.StringId == characterClass);
+ character = botClass.HeroCharacter;
+ agentBuildData = new AgentBuildData(character)
+ .Equipment(character.AllEquipments[MBRandom.RandomInt(character.AllEquipments.Count)])
+ .TroopOrigin(new BasicBattleAgentOrigin(character))
+ .EquipmentSeed(MissionLobbyComponent.GetRandomFaceSeedForCharacter(character))
+ .Team(team)
+ .VisualsIndex(0)
+ .InitialPosition(in spawnFrame.origin)
+ .InitialDirection(in initialDirection)
+ .IsFemale(character.IsFemale)
+ .ClothingColor1(
+ team.Side == BattleSideEnum.Attacker ? teamCulture.Color : teamCulture.ClothAlternativeColor)
+ .ClothingColor2(team.Side == BattleSideEnum.Attacker
+ ? teamCulture.Color2
+ : teamCulture.ClothAlternativeColor2);
+ var characterEquipment = CrpgCharacterBuilder.CreateBotCharacterEquipment(formationCharacter.EquippedItems);
+ var peerClass = MBObjectManager.Instance.GetObject(characterClass);
+ var characterSkills = CrpgCharacterBuilder.CreateCharacterSkills(formationCharacter.Characteristics);
+ var characterXml = peerClass.HeroCharacter;
+ var troopOrigin = new CrpgBattleAgentOrigin(characterXml, characterSkills);
+ agentBuildData.OwningMissionPeer(peer);
+ agentBuildData.Formation(peer.ControlledFormation);
+ agentBuildData.Equipment(characterEquipment);
+ agentBuildData.TroopOrigin(troopOrigin);
+ agentBuildData.Banner(new Banner(peer.Peer.BannerCode));
+
+ if (crpgPeer.Clan != null)
+ {
+ agentBuildData.ClothingColor1(crpgPeer.Clan.PrimaryColor);
+ agentBuildData.ClothingColor2(crpgPeer.Clan.SecondaryColor);
+ }
+ }
+ }
+
var bodyProperties = BodyProperties.GetRandomBodyProperties(
character.Race,
character.IsFemale,
@@ -287,4 +402,44 @@ private void ResetSpawnTeams()
}
}
}
+
+ private new void OnAllAgentsFromPeerSpawnedFromVisuals(MissionPeer peer)
+ {
+ if (peer.ControlledFormation != null)
+ {
+ peer.ControlledFormation.OnFormationDispersed();
+ peer.ControlledFormation.SetMovementOrder(MovementOrder.MovementOrderFollow(peer.ControlledAgent));
+ NetworkCommunicator networkPeer = peer.GetNetworkPeer();
+ if (peer.BotsUnderControlAlive != 0 || peer.BotsUnderControlTotal != 0)
+ {
+ GameNetwork.BeginBroadcastModuleEvent();
+ GameNetwork.WriteMessage(new BotsControlledChange(networkPeer, peer.BotsUnderControlAlive, peer.BotsUnderControlTotal));
+ GameNetwork.EndBroadcastModuleEvent(GameNetwork.EventBroadcastFlags.None, null);
+ Mission.GetMissionBehavior().OnBotsControlledChanged(peer, peer.BotsUnderControlAlive, peer.BotsUnderControlTotal);
+ }
+
+ if (peer.Team == Mission.AttackerTeam)
+ {
+ Mission.NumOfFormationsSpawnedTeamOne++;
+ }
+ else
+ {
+ Mission.NumOfFormationsSpawnedTeamTwo++;
+ }
+
+ GameNetwork.BeginBroadcastModuleEvent();
+ GameNetwork.WriteMessage(new SetSpawnedFormationCount(Mission.NumOfFormationsSpawnedTeamOne, Mission.NumOfFormationsSpawnedTeamTwo));
+ GameNetwork.EndBroadcastModuleEvent(GameNetwork.EventBroadcastFlags.None, null);
+ }
+ }
+ private CrpgCaptainFormation? GetFormationFromPeer(MissionPeer peer, int formationId)
+ {
+ var crpgPeer = peer.GetComponent();
+ if (crpgPeer != null && crpgPeer.User != null)
+ {
+ return crpgPeer.User.Captain.Formations.Where(f => f.Number == formationId).FirstOrDefault();
+ }
+
+ return null;
+ }
}
diff --git a/src/Module.Server/Common/Models/CrpgAgentStatCalculateModel.cs b/src/Module.Server/Common/Models/CrpgAgentStatCalculateModel.cs
index 824a825f3..11b0288d8 100644
--- a/src/Module.Server/Common/Models/CrpgAgentStatCalculateModel.cs
+++ b/src/Module.Server/Common/Models/CrpgAgentStatCalculateModel.cs
@@ -258,12 +258,12 @@ private void UpdateMountAgentStats(Agent agent, AgentDrivenProperties props)
// if you're dividing by (str -3)
private void UpdateHumanAgentStats(Agent agent, AgentDrivenProperties props)
{
-
// Dirty hack, part of the work-around to have skills without spawning custom characters. This hack should be
// be performed in InitializeHumanAgentStats but the MissionPeer is null there.
if (GameNetwork.IsClientOrReplay) // Server-side the hacky AgentOrigin is directly passed to the AgentBuildData.
{
var crpgUser = agent.MissionPeer?.GetComponent()?.User;
+ var agentCharacterId = agent.Character.StringId;
if (crpgUser != null && agent.Origin is not CrpgBattleAgentOrigin)
{
var characteristics = crpgUser.Character.Characteristics;
@@ -271,6 +271,41 @@ private void UpdateHumanAgentStats(Agent agent, AgentDrivenProperties props)
agent.Origin = new CrpgBattleAgentOrigin(agent.Origin?.Troop, mbSkills);
InitializeAgentStats(agent, agent.SpawnEquipment, props, null!);
}
+ else if (agentCharacterId.StartsWith("crpg_character"))
+ {
+ string[] parts = agentCharacterId.Split('_'); // Split the agentCharacterId to get the base ID and the formation ID
+ string baseId = string.Join("_", parts.Take(3)); // This reassembles the base ID (e.g., "crpg_character_1")
+ int? formationId = parts.Length > 3 ? int.Parse(parts[3]) : default(int?); // Extracts the formation ID
+
+ var crpgNetworkPeers = GameNetwork.NetworkPeers.Where(p =>
+ p.GetComponent() != null);
+ var ownerNetworkPeer =
+ crpgNetworkPeers.FirstOrDefault(p => p.ControlledAgent?.Character.StringId.Contains(baseId) ?? false);
+ if (ownerNetworkPeer?.GetComponent()?.User != null && agent.Origin is not CrpgBattleAgentOrigin)
+ {
+ var formation = formationId != null ? ownerNetworkPeer.GetComponent().User!.Captain?.Formations?
+ .FirstOrDefault(f => f?.Number == formationId) : null;
+
+ if (formation != null)
+ {
+ var characteristics = formation.Character!.Characteristics;
+
+ var mbSkills = CrpgCharacterBuilder.CreateCharacterSkills(characteristics);
+ agent.Origin = new CrpgBattleAgentOrigin(agent.Origin?.Troop, mbSkills);
+ InitializeAgentStats(agent, agent.SpawnEquipment, props, null!);
+ }
+ else
+ {
+ var characteristics = ownerNetworkPeer.GetComponent().User!.Character.Characteristics;
+
+ var mbSkills = CrpgCharacterBuilder.CreateCharacterSkills(characteristics);
+ agent.Origin = new CrpgBattleAgentOrigin(agent.Origin?.Troop, mbSkills);
+ InitializeAgentStats(agent, agent.SpawnEquipment, props, null!);
+ }
+
+ }
+ }
+
}
MissionEquipment equipment = agent.Equipment;
diff --git a/src/Module.Server/Common/Network/UpdateCrpgUser.cs b/src/Module.Server/Common/Network/UpdateCrpgUser.cs
index 8a7296e07..24ab05862 100644
--- a/src/Module.Server/Common/Network/UpdateCrpgUser.cs
+++ b/src/Module.Server/Common/Network/UpdateCrpgUser.cs
@@ -1,4 +1,5 @@
using System.IO.Compression;
+using Crpg.Module.Api.Models.Captains;
using Crpg.Module.Api.Models.Characters;
using Crpg.Module.Api.Models.Clans;
using Crpg.Module.Api.Models.Items;
@@ -50,6 +51,7 @@ private void WriteUserToPacket(CrpgUser user)
{
WriteCharacterToPacket(writer, user.Character);
WriteClanMemberToPacket(writer, user.ClanMembership);
+ WriteCaptainToPacket(writer, user.Captain);
}
WriteByteArrayToPacket(stream.ToArray(), 0, (int)stream.Length);
@@ -57,7 +59,7 @@ private void WriteUserToPacket(CrpgUser user)
private CrpgUser ReadUserFromPacket(ref bool bufferReadValid)
{
- byte[] buffer = new byte[1024];
+ byte[] buffer = new byte[10240];
int bufferLength = ReadByteArrayFromPacket(buffer, 0, buffer.Length, ref bufferReadValid);
using MemoryStream stream = new(buffer, 0, bufferLength);
@@ -66,15 +68,22 @@ private CrpgUser ReadUserFromPacket(ref bool bufferReadValid)
var character = ReadCharacterFromPacket(reader);
var clanMember = ReadClanMemberFromPacket(reader);
+ var captain = ReadCaptainFromPacket(reader);
return new CrpgUser
{
Character = character,
ClanMembership = clanMember,
+ Captain = captain,
};
}
- private void WriteCharacterToPacket(BinaryWriter writer, CrpgCharacter character)
+ private void WriteCharacterToPacket(BinaryWriter writer, CrpgCharacter? character)
{
+ if (character == null)
+ {
+ return;
+ }
+
writer.Write(character.Generation);
writer.Write(character.Level);
writer.Write(character.Experience);
@@ -209,4 +218,54 @@ private void WriteClanMemberToPacket(BinaryWriter writer, CrpgClanMember? clanMe
int clanId = reader.ReadInt32();
return clanId != -1 ? new CrpgClanMember { ClanId = clanId } : null;
}
+
+ private void WriteCaptainToPacket(BinaryWriter writer, CrpgCaptain captain)
+ {
+ writer.Write(captain.Formations.Count);
+ foreach (CrpgCaptainFormation formation in captain.Formations)
+ {
+ WriteFormationToPacket(writer, formation);
+ }
+ }
+
+ private CrpgCaptain ReadCaptainFromPacket(BinaryReader reader)
+ {
+ var formations = ReadFormationFromPacket(reader);
+ return new CrpgCaptain
+ {
+ Formations = formations,
+ };
+ }
+
+ private void WriteFormationToPacket(BinaryWriter writer, CrpgCaptainFormation formation)
+ {
+ writer.Write(formation.Number);
+ writer.Write(formation.Character != null);
+ if (formation.Character != null)
+ {
+ WriteCharacterToPacket(writer, formation.Character);
+ }
+ }
+
+ private IList ReadFormationFromPacket(BinaryReader reader)
+ {
+
+ int formationLength = reader.ReadInt32();
+
+ List formations = new(formationLength);
+ for (int i = 0; i < formationLength; i += 1)
+ {
+ int number = reader.ReadInt32();
+ bool doesCharacterExist = reader.ReadBoolean();
+ var character = doesCharacterExist ? ReadCharacterFromPacket(reader) : null;
+
+ formations.Add(new CrpgCaptainFormation
+ {
+ Number = number,
+ Character = character,
+ });
+ }
+
+ return formations;
+ }
}
diff --git a/src/Module.Server/Common/TeamSelect/CrpgTeamSelectServerComponent.cs b/src/Module.Server/Common/TeamSelect/CrpgTeamSelectServerComponent.cs
index 53626e63c..4ed2a70df 100644
--- a/src/Module.Server/Common/TeamSelect/CrpgTeamSelectServerComponent.cs
+++ b/src/Module.Server/Common/TeamSelect/CrpgTeamSelectServerComponent.cs
@@ -26,6 +26,7 @@ internal class CrpgTeamSelectServerComponent : MultiplayerTeamSelectComponent
private readonly MultiplayerRoundController? _roundController;
private readonly MatchBalancer _balancer;
private readonly PeriodStatsHelper _periodStatsHelper;
+ private readonly MultiplayerGameType _gameType;
///
/// Players waiting to be assigned to a team when the cRPG balancer is enabled.
@@ -34,7 +35,7 @@ internal class CrpgTeamSelectServerComponent : MultiplayerTeamSelectComponent
private readonly Dictionary _playerTeamsBeforeJoiningSpectator;
- public CrpgTeamSelectServerComponent(MultiplayerWarmupComponent warmupComponent, MultiplayerRoundController? roundController)
+ public CrpgTeamSelectServerComponent(MultiplayerWarmupComponent warmupComponent, MultiplayerRoundController? roundController, MultiplayerGameType gameType)
{
_warmupComponent = warmupComponent;
_roundController = roundController;
@@ -42,6 +43,7 @@ public CrpgTeamSelectServerComponent(MultiplayerWarmupComponent warmupComponent,
_periodStatsHelper = new PeriodStatsHelper();
_playersWaitingForTeam = new HashSet();
_playerTeamsBeforeJoiningSpectator = new Dictionary();
+ _gameType = gameType;
}
public override void OnBehaviorInitialize()
@@ -106,13 +108,13 @@ private bool HandleTeamChange(NetworkCommunicator peer, TeamChange message)
else
{
var missionPeer = peer.GetComponent();
- if (missionPeer is { Team: null })
+ if (missionPeer is { Team: null } && _gameType != MultiplayerGameType.Captain)
{
// If the player just connected to the server, auto-assign their team so they have a chance
// to play the round.
AutoAssignTeam(peer);
}
- else if (_playerTeamsBeforeJoiningSpectator.TryGetValue(peer.VirtualPlayer.Id, out var teamBeforeSpectator))
+ else if (_playerTeamsBeforeJoiningSpectator.TryGetValue(peer.VirtualPlayer.Id, out var teamBeforeSpectator))// && _gameType != MultiplayerGameType.Captain)
{
ChangeTeamServer(peer, teamBeforeSpectator);
_playerTeamsBeforeJoiningSpectator.Remove(peer.VirtualPlayer.Id);
diff --git a/src/Module.Server/CrpgSubModule.cs b/src/Module.Server/CrpgSubModule.cs
index ad07fe747..f0b02cbaf 100644
--- a/src/Module.Server/CrpgSubModule.cs
+++ b/src/Module.Server/CrpgSubModule.cs
@@ -62,8 +62,9 @@ protected override void OnSubModuleLoad()
{
base.OnSubModuleLoad();
_constants = LoadCrpgConstants();
- TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgBattleGameMode(_constants, isSkirmish: true));
- TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgBattleGameMode(_constants, isSkirmish: false));
+ TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgBattleGameMode(_constants, MultiplayerGameType.Battle));
+ TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgBattleGameMode(_constants, MultiplayerGameType.Skirmish));
+ TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgBattleGameMode(_constants, MultiplayerGameType.Captain));
TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgConquestGameMode(_constants));
TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgSiegeGameMode(_constants));
TaleWorlds.MountAndBlade.Module.CurrentModule.AddMultiplayerGameMode(new CrpgTeamDeathmatchGameMode(_constants));
diff --git a/src/Module.Server/MapRotation/ds_config_crpg_battle.txt b/src/Module.Server/MapRotation/ds_config_crpg_battle.txt
index c620d0e04..a9f2a57b0 100644
--- a/src/Module.Server/MapRotation/ds_config_crpg_battle.txt
+++ b/src/Module.Server/MapRotation/ds_config_crpg_battle.txt
@@ -1,7 +1,5 @@
-ServerName cRPG - Test Battle - c-rpg.eu 55 lol
-GameType cRPGBattle
-GamePassword yoyo
-WelcomeMessage yoyoyooooo
+ServerName cRPG - Test Captain's Mode - c-rpg.eu
+GameType cRPGCaptain
AllowPollsToKickPlayers True
AllowPollsToChangeMaps True
disable_culture_voting
@@ -16,15 +14,17 @@ FriendlyFireDamageMeleeSelfPercent 0
FriendlyFireDamageRangedFriendPercent 50
FriendlyFireDamageRangedSelfPercent 0
MinNumberOfPlayersForMatchStart 0
-NumberOfBotsTeam1 20
-NumberOfBotsTeam2 20
+NumberOfBotsTeam1 0
+NumberOfBotsTeam2 0
+NumberOfBotsPerFormation 10
RoundTotal 10
MapTimeLimit 8
-RoundTimeLimit 60
+RoundTimeLimit 600
WarmupTimeLimit 1
crpg_happy_hours 19:00-23:00,Central European Standard Time
start_game
set_automated_battle_count -1
enable_automated_battle_switching
-crpg_frozen_bots True
+crpg_frozen_bots False
crpg_apply_harmony_patches
+crpg_captain_bot_count 40
diff --git a/src/Module.Server/MapRotation/ds_config_crpg_battle_maps.txt b/src/Module.Server/MapRotation/ds_config_crpg_battle_maps.txt
index f345ce18a..7ec4238f9 100644
--- a/src/Module.Server/MapRotation/ds_config_crpg_battle_maps.txt
+++ b/src/Module.Server/MapRotation/ds_config_crpg_battle_maps.txt
@@ -1,19 +1,2 @@
-crpg_battle_skolderby
-mp_battle_map_002
-crpg_battle_tolstabeach
-crpg_battle_drakenburg
-crpg_battle_frosthaven
-crpg_battle_thearena
-crpg_battle_snowytown
-crpg_battle_hadiiqa
-crpg_battle_killington
-mp_skirmish_map_003_skinc
-crpg_battle_whitebridge
-crpg_battle_sandpit
-crpg_battle_lighthouse
-crpg_battle_orionarena
-crpg_battle_sanctuary
-crpg_battle_verloren
-crpg_battle_hexenbrennen
-crpg_battle_icelake
-crpg_battle_persia
+crpg_battle_howlingmeadow
+crpg_battle_howlingmeadow
diff --git a/src/Module.Server/Modes/Battle/CrpgBattleClient.cs b/src/Module.Server/Modes/Battle/CrpgBattleClient.cs
index c2b489cde..52c18f178 100644
--- a/src/Module.Server/Modes/Battle/CrpgBattleClient.cs
+++ b/src/Module.Server/Modes/Battle/CrpgBattleClient.cs
@@ -1,10 +1,12 @@
-using NetworkMessages.FromServer;
+using System.Reflection;
+using NetworkMessages.FromServer;
using TaleWorlds.Core;
using TaleWorlds.Engine;
using TaleWorlds.Library;
using TaleWorlds.Localization;
using TaleWorlds.MountAndBlade;
using TaleWorlds.MountAndBlade.MissionRepresentatives;
+using TaleWorlds.MountAndBlade.Network.Messages;
using TaleWorlds.MountAndBlade.Objects;
using static TaleWorlds.MountAndBlade.MissionLobbyComponent;
using MathF = TaleWorlds.Library.MathF;
@@ -17,7 +19,7 @@ internal class CrpgBattleClient : MissionMultiplayerGameModeBaseClient, ICommand
private const int BattleFlagUnlockTime = 45;
private const int SkirmishFlagsRemovalTime = 120;
- private readonly bool _isSkirmish;
+ private readonly MultiplayerGameType _gameType;
private FlagCapturePoint[] _flags = Array.Empty();
private Team?[] _flagOwners = Array.Empty();
private bool _notifiedForFlagRemoval;
@@ -30,17 +32,14 @@ internal class CrpgBattleClient : MissionMultiplayerGameModeBaseClient, ICommand
public event Action? OnFlagNumberChangedEvent;
public event Action? OnCapturePointOwnerChangedEvent;
- public CrpgBattleClient(bool isSkirmish)
+ public CrpgBattleClient(MultiplayerGameType gameType)
{
- _isSkirmish = isSkirmish;
+ _gameType = gameType;
}
-
public override bool IsGameModeUsingGold => false;
public override bool IsGameModeTactical => _flags.Length != 0;
public override bool IsGameModeUsingRoundCountdown => true;
- public override MultiplayerGameType GameType => _isSkirmish
- ? MultiplayerGameType.Skirmish
- : MultiplayerGameType.Battle;
+ public override MultiplayerGameType GameType => _gameType;
public override bool IsGameModeUsingCasualGold => false;
public IEnumerable AllCapturePoints => _flags;
public bool AreMoralesIndependent => false;
@@ -49,6 +48,9 @@ public CrpgBattleClient(bool isSkirmish)
public override void OnBehaviorInitialize()
{
+ typeof(TaleWorlds.MountAndBlade.CompressionMission)
+ .GetField(nameof(TaleWorlds.MountAndBlade.CompressionMission.AgentOffsetCompressionInfo), BindingFlags.Public | BindingFlags.Static)?
+ .SetValue(null, new CompressionInfo.Integer(0, 16));
base.OnBehaviorInitialize();
RoundComponent.OnPreparationEnded += OnPreparationEnded;
MissionNetworkComponent.OnMyClientSynchronized += OnMyClientSynchronized;
@@ -203,9 +205,11 @@ protected override void AddRemoveMessageHandlers(
base.AddRemoveMessageHandlers(registerer);
if (GameNetwork.IsClientOrReplay)
{
+ registerer.Register(HandleServerEventBotsControlledChangeEvent);
registerer.Register(OnMoraleChange);
registerer.Register(OnCapturePoint);
- if (_isSkirmish)
+ registerer.Register(HandleServerEventFormationWipedMessage);
+ if (_gameType == MultiplayerGameType.Skirmish)
{
registerer.Register(OnFlagsRemovedSkirmish);
}
@@ -243,7 +247,7 @@ protected override int GetWarningTimer()
private void NotifyForFlagManipulation()
{
- if (!_isSkirmish)
+ if (!(_gameType == MultiplayerGameType.Skirmish))
{
TextObject textObject = new("{=nbOZ9BNQ}A flag will spawn in {TIMER} seconds.",
new Dictionary { ["TIMER"] = 30 });
@@ -326,4 +330,17 @@ private void ResetFlags()
_flags = Mission.Current.MissionObjects.FindAllWithType().ToArray();
_flagOwners = new Team[_flags.Length];
}
+ private void HandleServerEventBotsControlledChangeEvent(GameNetworkMessage baseMessage)
+ {
+ BotsControlledChange botsControlledChange = (BotsControlledChange)baseMessage;
+ MissionPeer component = botsControlledChange.Peer.GetComponent();
+ this.OnBotsControlledChanged(component, botsControlledChange.AliveCount, botsControlledChange.TotalCount);
+ }
+ public void OnBotsControlledChanged(MissionPeer missionPeer, int botAliveCount, int botTotalCount)
+ {
+ missionPeer.BotsUnderControlAlive = botAliveCount;
+ }
+ private void HandleServerEventFormationWipedMessage(GameNetworkMessage baseMessage)
+ {
+ }
}
diff --git a/src/Module.Server/Modes/Battle/CrpgBattleGameMode.cs b/src/Module.Server/Modes/Battle/CrpgBattleGameMode.cs
index e5edd38d2..4307c703a 100644
--- a/src/Module.Server/Modes/Battle/CrpgBattleGameMode.cs
+++ b/src/Module.Server/Modes/Battle/CrpgBattleGameMode.cs
@@ -2,6 +2,7 @@
using Crpg.Module.Common.Commander;
using Crpg.Module.Common.HotConstants;
using Crpg.Module.Common.TeamSelect;
+using Crpg.Module.Modes.Captain;
using Crpg.Module.Modes.Skirmish;
using Crpg.Module.Modes.Warmup;
using Crpg.Module.Notifications;
@@ -18,7 +19,8 @@
using Crpg.Module.Api;
using Crpg.Module.Common.ChatCommands;
#else
-
+using TaleWorlds.MountAndBlade.Multiplayer.GauntletUI.Mission;
+using TaleWorlds.MountAndBlade.View.MissionViews.Order;
using Crpg.Module.GUI;
using Crpg.Module.GUI.Commander;
using Crpg.Module.GUI.EndOfRound;
@@ -38,16 +40,20 @@ internal class CrpgBattleGameMode : MissionBasedMultiplayerGameMode
{
private const string BattleGameName = "cRPGBattle";
private const string SkirmishGameName = "cRPGSkirmish";
-
+ private const string CaptainGameName = "cRPGCaptain";
private static CrpgConstants _constants = default!; // Static so it's accessible from the views.
-
- private readonly bool _isSkirmish;
-
- public CrpgBattleGameMode(CrpgConstants constants, bool isSkirmish)
- : base(isSkirmish ? SkirmishGameName : BattleGameName)
+ private MultiplayerGameType _gameType;
+ public CrpgBattleGameMode(CrpgConstants constants, MultiplayerGameType gameType)
+ : base(gameType switch
+ {
+ MultiplayerGameType.Battle => BattleGameName,
+ MultiplayerGameType.Skirmish => SkirmishGameName,
+ MultiplayerGameType.Captain => CaptainGameName,
+ _ => throw new ArgumentException(message: "Invalid game type", paramName: nameof(gameType)),
+ })
{
+ _gameType = gameType;
_constants = constants;
- _isSkirmish = isSkirmish;
}
#if CRPG_CLIENT
@@ -57,6 +63,8 @@ public CrpgBattleGameMode(CrpgConstants constants, bool isSkirmish)
public static MissionView[] OpenCrpgBattle(Mission mission) => OpenCrpgBattleOrSkirmish(mission);
[ViewMethod(SkirmishGameName)]
public static MissionView[] OpenCrpgSkirmish(Mission mission) => OpenCrpgBattleOrSkirmish(mission);
+ [ViewMethod(CaptainGameName)]
+ public static MissionView[] OpenCrpgCaptain(Mission mission) => OpenCrpgBattleOrSkirmish(mission);
[ViewMethod("")] // All static instances in ViewCreatorModule classes are expected to have a ViewMethod attribute.
private static MissionView[] OpenCrpgBattleOrSkirmish(Mission mission)
@@ -70,6 +78,8 @@ private static MissionView[] OpenCrpgBattleOrSkirmish(Mission mission)
MultiplayerViewCreator.CreateMultiplayerFactionBanVoteUIHandler(),
ViewCreator.CreateMissionAgentStatusUIHandler(mission),
ViewCreator.CreateMissionMainAgentEquipmentController(mission), // Pick/drop items.
+ new MissionGauntletMultiplayerOrderUIHandler(),
+ new OrderTroopPlacer(),
new CrpgMissionGauntletMainAgentCheerControllerView(),
crpgEscapeMenu,
ViewCreator.CreateMissionAgentLabelUIHandler(mission),
@@ -96,6 +106,7 @@ private static MissionView[] OpenCrpgBattleOrSkirmish(Mission mission)
}
#endif
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1118:Parameter should not span multiple lines", Justification = "")]
public override void StartMultiplayerGame(string scene)
{
// Inherits the MultiplayerGameNotificationsComponent component.
@@ -109,16 +120,21 @@ public override void StartMultiplayerGame(string scene)
MultiplayerRoundController roundController = new(); // starts/stops round, ends match
CrpgWarmupComponent warmupComponent = new(_constants, notificationsComponent, () =>
- (new FlagDominationSpawnFrameBehavior(), _isSkirmish
- ? new CrpgSkirmishSpawningBehavior(_constants, roundController)
- : new CrpgBattleSpawningBehavior(_constants, roundController)));
- CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, roundController);
+ (new FlagDominationSpawnFrameBehavior(),
+ _gameType switch
+ {
+ MultiplayerGameType.Battle => new CrpgBattleSpawningBehavior(_constants, roundController, _gameType),
+ MultiplayerGameType.Skirmish => new CrpgSkirmishSpawningBehavior(_constants, roundController),
+ MultiplayerGameType.Captain => new CrpgCaptainSpawningBehavior(_constants, roundController, _gameType),
+ _ => throw new ArgumentException(message: "Invalid game type", paramName: nameof(_gameType)),
+ }));
+ CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, roundController, _gameType);
CrpgRewardServer rewardServer = new(crpgClient, _constants, warmupComponent, enableTeamHitCompensations: true, enableRating: true);
#else
CrpgWarmupComponent warmupComponent = new(_constants, notificationsComponent, null);
CrpgTeamSelectClientComponent teamSelectComponent = new();
#endif
- CrpgBattleClient battleClient = new(_isSkirmish);
+ CrpgBattleClient battleClient = new(_gameType);
MissionState.OpenNew(
Name,
@@ -143,7 +159,13 @@ public override void StartMultiplayerGame(string scene)
new MultiplayerPollComponent(), // poll logic to kick player, ban player, change game
new CrpgCommanderPollComponent(),
new MissionOptionsComponent(),
- new CrpgScoreboardComponent(_isSkirmish ? new CrpgSkirmishScoreboardData() : new CrpgBattleScoreboardData()),
+ new CrpgScoreboardComponent(_gameType switch
+ {
+ MultiplayerGameType.Battle => new CrpgBattleScoreboardData(),
+ MultiplayerGameType.Skirmish => new CrpgSkirmishScoreboardData(),
+ MultiplayerGameType.Captain => new CrpgBattleScoreboardData(),
+ _ => throw new ArgumentException(message: "Invalid game type", paramName: nameof(_gameType)),
+ }),
new MissionAgentPanicHandler(),
new EquipmentControllerLeaveLogic(),
new MultiplayerPreloadHelper(),
@@ -153,11 +175,18 @@ public override void StartMultiplayerGame(string scene)
#if CRPG_SERVER
roundController,
- new CrpgBattleServer(battleClient, _isSkirmish, rewardServer),
+ new CrpgBattleServer(battleClient, _gameType, rewardServer),
rewardServer,
// SpawnFrameBehaviour: where to spawn, SpawningBehaviour: when to spawn
new SpawnComponent(new BattleSpawnFrameBehavior(),
- _isSkirmish ? new CrpgSkirmishSpawningBehavior(_constants, roundController) : new CrpgBattleSpawningBehavior(_constants, roundController)),
+ _gameType switch
+ {
+ MultiplayerGameType.Battle => new CrpgBattleSpawningBehavior(_constants, roundController, _gameType),
+ MultiplayerGameType.Skirmish => new CrpgSkirmishSpawningBehavior(_constants, roundController),
+ MultiplayerGameType.Captain => new CrpgCaptainSpawningBehavior(_constants, roundController, _gameType),
+ _ => throw new ArgumentException(message: "Invalid game type", paramName: nameof(_gameType)),
+ }),
+
new AgentHumanAILogic(), // bot intelligence
new MultiplayerAdminComponent(), // admin UI to kick player or restart game
new CrpgUserManagerServer(crpgClient, _constants),
diff --git a/src/Module.Server/Modes/Battle/CrpgBattleServer.cs b/src/Module.Server/Modes/Battle/CrpgBattleServer.cs
index 6c7446daf..bb9d054ec 100644
--- a/src/Module.Server/Modes/Battle/CrpgBattleServer.cs
+++ b/src/Module.Server/Modes/Battle/CrpgBattleServer.cs
@@ -1,4 +1,6 @@
-using Crpg.Module.Modes.Battle.FlagSystems;
+using System.Reflection;
+using Crpg.Module.Modes.Battle.FlagSystems;
+using Crpg.Module.Modes.Captain;
using Crpg.Module.Modes.Skirmish;
using Crpg.Module.Rewards;
using NetworkMessages.FromServer;
@@ -20,7 +22,7 @@ internal class CrpgBattleServer : MissionMultiplayerGameModeBase
private const float SkirmishMoraleGainMultiplierLastFlag = 2f;
private readonly CrpgBattleClient _battleClient;
- private readonly bool _isSkirmish;
+ private readonly MultiplayerGameType _gametype;
private readonly CrpgRewardServer _rewardServer;
private AbstractFlagSystem _flagSystem = default!;
@@ -35,22 +37,26 @@ internal class CrpgBattleServer : MissionMultiplayerGameModeBase
public override bool AllowCustomPlayerBanners() => false;
public override bool UseRoundController() => true;
- public CrpgBattleServer(CrpgBattleClient battleClient, bool isSkirmish,
+ public CrpgBattleServer(CrpgBattleClient battleClient, MultiplayerGameType gametype,
CrpgRewardServer rewardServer)
{
_battleClient = battleClient;
- _isSkirmish = isSkirmish;
+ _gametype = gametype;
_rewardServer = rewardServer;
+ typeof(TaleWorlds.MountAndBlade.CompressionMission)
+ .GetField(nameof(TaleWorlds.MountAndBlade.CompressionMission.AgentOffsetCompressionInfo), BindingFlags.Public | BindingFlags.Static)?
+ .SetValue(null, new CompressionInfo.Integer(0, 16));
}
public override MultiplayerGameType GetMissionType()
{
- return MultiplayerGameType.Battle;
+ return _gametype;
}
public override void AfterStart()
{
base.AfterStart();
+ MissionPeer.OnPreTeamChanged += this.OnPreTeamChanged;
RoundController.OnPreRoundEnding += OnPreRoundEnding;
AddTeams();
@@ -58,7 +64,7 @@ public override void AfterStart()
public override void OnAgentRemoved(Agent affectedAgent, Agent affectorAgent, AgentState agentState, KillingBlow blow)
{
- if (_isSkirmish || !affectedAgent.IsHuman)
+ if (_gametype == MultiplayerGameType.Skirmish || !affectedAgent.IsHuman)
{
return;
}
@@ -66,12 +72,21 @@ public override void OnAgentRemoved(Agent affectedAgent, Agent affectorAgent, Ag
((CrpgBattleFlagSystem)_flagSystem).CheckForDeadPlayerFlagSpawnThreshold(_attackersSpawned, _defendersSpawned);
}
+ protected override void HandleEarlyPlayerDisconnect(NetworkCommunicator networkPeer)
+ {
+ if (this.RoundController.IsRoundInProgress && MultiplayerOptions.OptionType.NumberOfBotsPerFormation.GetIntValue(MultiplayerOptions.MultiplayerOptionsAccessMode.CurrentMapOptions) > 0)
+ {
+ this.MakePlayerFormationCharge(networkPeer);
+ }
+ }
+
public override void OnBehaviorInitialize()
{
base.OnBehaviorInitialize();
- _flagSystem = _isSkirmish
- ? new CrpgSkirmishFlagSystem(Mission, NotificationsComponent, _battleClient)
- : new CrpgBattleFlagSystem(Mission, NotificationsComponent, _battleClient);
+ _flagSystem = _gametype switch {
+ MultiplayerGameType.Skirmish => new CrpgSkirmishFlagSystem(Mission, NotificationsComponent, _battleClient),
+ _ => new CrpgBattleFlagSystem(Mission, NotificationsComponent, _battleClient),
+ };
_flagSystem.ResetFlags();
_morale = 0f;
@@ -81,6 +96,7 @@ public override void OnBehaviorInitialize()
public override void OnRemoveBehavior()
{
RoundController.OnPreRoundEnding -= OnPreRoundEnding;
+ MissionPeer.OnPreTeamChanged -= this.OnPreTeamChanged;
base.OnRemoveBehavior();
}
@@ -115,12 +131,12 @@ public override void OnMissionTick(float dt)
base.OnMissionTick(dt);
if (MissionLobbyComponent.CurrentMultiplayerState != MissionLobbyComponent.MultiplayerGameState.Playing
|| !RoundController.IsRoundInProgress
- || !CanGameModeSystemsTickThisFrame
- || _flagSystem.HasNoFlags()) // Protection against scene with no flags.
+ || !CanGameModeSystemsTickThisFrame)
{
return;
}
+
if (SpawnComponent.SpawningBehavior is CrpgBattleSpawningBehavior s && s.SpawnDelayEnded() && !_hasSpawnDelayEnded)
{
_hasSpawnDelayEnded = true;
@@ -128,6 +144,14 @@ public override void OnMissionTick(float dt)
_defendersSpawned = Mission.DefenderTeam.ActiveAgents.Count;
}
+ CheckForPlayersSpawningAsBots();
+
+
+ if (_flagSystem.HasNoFlags()) // Protection against scene with no flags.
+ {
+ return;
+ }
+
_flagSystem.CheckForManipulationOfFlags();
CheckMorales();
_flagSystem.TickFlags();
@@ -162,9 +186,14 @@ public override bool CheckForRoundEnd()
return false;
}
+ if (SpawnComponent.SpawningBehavior is CrpgCaptainSpawningBehavior c && !c.SpawnDelayEnded())
+ {
+ return false;
+ }
+
bool defenderTeamDepleted = Mission.DefenderTeam.ActiveAgents.Count == 0;
bool attackerTeamDepleted = Mission.AttackerTeam.ActiveAgents.Count == 0;
- if (!_isSkirmish)
+ if (!(_gametype == MultiplayerGameType.Skirmish))
{
return defenderTeamDepleted || attackerTeamDepleted;
}
@@ -238,9 +267,20 @@ private void AddTeams()
BasicCultureObject cultureTeam1 = MBObjectManager.Instance.GetObject(MultiplayerOptions.OptionType.CultureTeam1.GetStrValue());
Banner bannerTeam1 = new(cultureTeam1.BannerKey, cultureTeam1.BackgroundColor1, cultureTeam1.ForegroundColor1);
Mission.Teams.Add(BattleSideEnum.Attacker, cultureTeam1.BackgroundColor1, cultureTeam1.ForegroundColor1, bannerTeam1, false, true);
+ for (int i = 0; i < 42; i++)
+ {
+ Formation f = new(Mission.Current.Teams.Attacker, 1);
+ Mission.Teams.Attacker.FormationsIncludingEmpty.Add(f);
+ }
+
BasicCultureObject cultureTeam2 = MBObjectManager.Instance.GetObject(MultiplayerOptions.OptionType.CultureTeam2.GetStrValue());
Banner bannerTeam2 = new(cultureTeam2.BannerKey, cultureTeam2.BackgroundColor2, cultureTeam2.ForegroundColor2);
Mission.Teams.Add(BattleSideEnum.Defender, cultureTeam2.BackgroundColor2, cultureTeam2.ForegroundColor2, bannerTeam2, false, true);
+ for (int i = 0; i < 42; i++)
+ {
+ Formation f = new(Mission.Current.Teams.Defender, 1);
+ Mission.Teams.Defender.FormationsIncludingEmpty.Add(f);
+ }
}
private void CheckMorales()
@@ -271,8 +311,8 @@ private float GetMoraleGain()
return 0f;
}
- float moraleGainOnTick = _isSkirmish ? SkirmishMoraleGainOnTick : BattleMoraleGainOnTick;
- float moraleGainMultiplierLastFlag = _isSkirmish ? SkirmishMoraleGainMultiplierLastFlag : BattleMoraleGainMultiplierLastFlag;
+ float moraleGainOnTick = _gametype == MultiplayerGameType.Skirmish ? SkirmishMoraleGainOnTick : BattleMoraleGainOnTick;
+ float moraleGainMultiplierLastFlag = _gametype == MultiplayerGameType.Skirmish ? SkirmishMoraleGainMultiplierLastFlag : BattleMoraleGainMultiplierLastFlag;
float moraleMultiplier = moraleGainOnTick * Math.Abs(teamFlagsDelta);
float moraleGain = teamFlagsDelta <= 0
@@ -386,4 +426,71 @@ private void CheerForRoundEnd(CaptureTheFlagCaptureResultEnum roundResult)
missionBehavior.SetTimersOfVictoryReactionsOnBattleEnd(BattleSideEnum.Defender);
}
}
+
+ private void CheckForPlayersSpawningAsBots()
+ {
+ foreach (NetworkCommunicator networkCommunicator in GameNetwork.NetworkPeers)
+ {
+ if (networkCommunicator.IsSynchronized)
+ {
+ MissionPeer component = networkCommunicator.GetComponent();
+ if (component != null && component.ControlledAgent == null && component.Team != null && component.ControlledFormation != null && component.SpawnCountThisRound > 0)
+ {
+ if (!component.HasSpawnTimerExpired && component.SpawnTimer.Check(base.Mission.CurrentTime))
+ {
+ component.HasSpawnTimerExpired = true;
+ }
+
+ if (component.HasSpawnTimerExpired)
+ {
+ if (component.ControlledFormation.HasUnitsWithCondition((Agent agent) => agent.IsActive() && agent.IsAIControlled))
+ {
+ Agent? newAgent = null;
+ Agent followingAgent = component.FollowedAgent;
+ if (followingAgent != null && followingAgent.IsActive() && followingAgent.IsAIControlled && component.ControlledFormation.HasUnitsWithCondition((Agent agent) => agent == followingAgent))
+ {
+ newAgent = followingAgent;
+ }
+ else
+ {
+ float maxHealth = 0f;
+ component.ControlledFormation.ApplyActionOnEachUnit(delegate (Agent agent)
+ {
+ if (agent.Health > maxHealth)
+ {
+ maxHealth = agent.Health;
+ newAgent = agent;
+ }
+ }, null);
+ }
+
+ Mission.Current.ReplaceBotWithPlayer(newAgent, component);
+ component.WantsToSpawnAsBot = false;
+ component.HasSpawnTimerExpired = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void MakePlayerFormationCharge(NetworkCommunicator peer)
+ {
+ if (peer.IsSynchronized)
+ {
+ MissionPeer component = peer.GetComponent();
+ if (component.ControlledFormation != null)
+ {
+ component.ControlledFormation.SetMovementOrder(MovementOrder.MovementOrderCharge);
+ }
+ }
+ }
+
+ private void OnPreTeamChanged(NetworkCommunicator peer, Team currentTeam, Team newTeam)
+ {
+ if (peer.IsSynchronized && peer.GetComponent().ControlledAgent != null)
+ {
+ this.MakePlayerFormationCharge(peer);
+ }
+ }
}
diff --git a/src/Module.Server/Modes/Battle/CrpgBattleSpawningBehavior.cs b/src/Module.Server/Modes/Battle/CrpgBattleSpawningBehavior.cs
index 23b958acd..65631b127 100644
--- a/src/Module.Server/Modes/Battle/CrpgBattleSpawningBehavior.cs
+++ b/src/Module.Server/Modes/Battle/CrpgBattleSpawningBehavior.cs
@@ -1,4 +1,5 @@
using Crpg.Module.Common;
+using Crpg.Module.Modes.Warmup;
using Crpg.Module.Notifications;
using TaleWorlds.Core;
using TaleWorlds.MountAndBlade;
@@ -14,12 +15,12 @@ internal class CrpgBattleSpawningBehavior : CrpgSpawningBehaviorBase
private MissionTimer? _spawnTimer;
private MissionTimer? _cavalrySpawnDelayTimer;
private bool _botsSpawned;
-
- public CrpgBattleSpawningBehavior(CrpgConstants constants, MultiplayerRoundController roundController)
+ public CrpgBattleSpawningBehavior(CrpgConstants constants, MultiplayerRoundController roundController, MultiplayerGameType gameType)
: base(constants)
{
_roundController = roundController;
_notifiedPlayersAboutSpawnRestriction = new HashSet();
+ GameMode = gameType;
}
public override void Initialize(SpawnComponent spawnComponent)
@@ -60,7 +61,6 @@ public override void OnTick(float dt)
public override void RequestStartSpawnSession()
{
base.RequestStartSpawnSession();
- _botsSpawned = false;
_spawnTimer = new MissionTimer(TotalSpawnDuration); // Limit spawning for 30 seconds.
_cavalrySpawnDelayTimer = new MissionTimer(GetCavalrySpawnDelay()); // Cav will spawn X seconds later.
_notifiedPlayersAboutSpawnRestriction.Clear();
diff --git a/src/Module.Server/Modes/Captain/CrpgCaptainSpawningBehavior.cs b/src/Module.Server/Modes/Captain/CrpgCaptainSpawningBehavior.cs
new file mode 100644
index 000000000..6df9d3633
--- /dev/null
+++ b/src/Module.Server/Modes/Captain/CrpgCaptainSpawningBehavior.cs
@@ -0,0 +1,412 @@
+using Crpg.Module.Api.Models.Captains;
+using Crpg.Module.Api.Models.Users;
+using Crpg.Module.Common;
+using Crpg.Module.Modes.Warmup;
+using Crpg.Module.Notifications;
+using TaleWorlds.Core;
+using TaleWorlds.MountAndBlade;
+using TaleWorlds.ObjectSystem;
+using TaleWorlds.PlayerServices;
+
+namespace Crpg.Module.Modes.Captain;
+
+internal class CrpgCaptainSpawningBehavior : CrpgSpawningBehaviorBase
+{
+ private const float TotalSpawnDuration = 30f;
+ private readonly MultiplayerRoundController _roundController;
+ private readonly HashSet _notifiedPlayersAboutSpawnRestriction;
+ private MissionTimer? _spawnTimer;
+ private MissionTimer? _cavalrySpawnDelayTimer;
+ private bool _botsSpawned = false;
+
+ private readonly Dictionary _teamSumOfEquipment = new();
+ private readonly Dictionary _teamAverageEquipment = new();
+ private readonly Dictionary _teamNumberOfBots = new();
+
+ private readonly int _totalNumberOfBots;
+ private bool _isSinglePlayer;
+
+ public CrpgCaptainSpawningBehavior(CrpgConstants constants, MultiplayerRoundController roundController, MultiplayerGameType gameType)
+ : base(constants)
+ {
+ _roundController = roundController;
+ _notifiedPlayersAboutSpawnRestriction = new HashSet();
+ GameMode = gameType;
+#if CRPG_SERVER
+ _totalNumberOfBots = CrpgServerConfiguration.CaptainTotalBotCount;
+#endif
+ }
+
+ public override void Initialize(SpawnComponent spawnComponent)
+ {
+ base.Initialize(spawnComponent);
+ _roundController.OnPreparationEnded += RequestStartSpawnSession;
+ _roundController.OnRoundEnding += RequestStopSpawnSession;
+ }
+
+ public override void Clear()
+ {
+ base.Clear();
+ _roundController.OnPreparationEnded -= RequestStartSpawnSession;
+ _roundController.OnRoundEnding -= RequestStopSpawnSession;
+ }
+
+ public override void OnTick(float dt)
+ {
+ if (!IsSpawningEnabled || !IsRoundInProgress())
+ {
+ return;
+ }
+
+ if (_spawnTimer!.Check())
+ {
+ return;
+ }
+
+ if (!_botsSpawned)
+ {
+ SpawnCaptainBots();
+ _botsSpawned = true;
+ }
+
+ SpawnAgents();
+ }
+
+ public override void RequestStartSpawnSession()
+ {
+ _isSinglePlayer = IsSinglePlayer();
+
+ foreach (Team team in Mission.Current.Teams)
+ {
+ _teamSumOfEquipment[team] = ComputeTeamSumOfEquipmentValue(team);
+ _teamAverageEquipment[team] = ComputeTeamAverageUnitValue(team, _teamSumOfEquipment[team]);
+ }
+
+ if (!_isSinglePlayer)
+ {
+ foreach (Team team in Mission.Current.Teams)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == team).ToList();
+
+ float numerator = _totalNumberOfBots * _teamAverageEquipment.Where(kvp => kvp.Key != team).Sum(kvp => kvp.Value);
+ float denominator = (Mission.Current.Teams.Count - 2) * _teamAverageEquipment.Sum(kvp => kvp.Value); // -2 because we also remove spectator
+ _teamNumberOfBots[team] = (int)(numerator / denominator) - teamRelevantPeers.Count;
+ }
+ }
+ else
+ {
+ foreach (Team team in Mission.Current.Teams)
+ {
+ if (team.Side != BattleSideEnum.None)
+ {
+ _teamNumberOfBots[team] = _totalNumberOfBots / (Mission.Current.Teams.Count - 1);
+ }
+ }
+ }
+
+ base.RequestStartSpawnSession();
+ _botsSpawned = false;
+ _spawnTimer = new MissionTimer(TotalSpawnDuration); // Limit spawning for 30 seconds.
+ _cavalrySpawnDelayTimer = new MissionTimer(GetCavalrySpawnDelay()); // Cav will spawn X seconds later.
+ _notifiedPlayersAboutSpawnRestriction.Clear();
+ }
+
+ public bool SpawnDelayEnded()
+ {
+ return _cavalrySpawnDelayTimer != null && _cavalrySpawnDelayTimer!.Check();
+ }
+
+ protected override bool IsRoundInProgress()
+ {
+ return _roundController.IsRoundInProgress;
+ }
+
+ protected override bool IsPlayerAllowedToSpawn(NetworkCommunicator networkPeer)
+ {
+ var crpgPeer = networkPeer.GetComponent();
+ var missionPeer = networkPeer.GetComponent();
+ if (crpgPeer?.User == null
+ || crpgPeer.LastSpawnInfo != null
+ || missionPeer == null)
+ {
+ return false;
+ }
+
+ var characterEquipment = CrpgCharacterBuilder.CreateCharacterEquipment(crpgPeer.User.Character.EquippedItems);
+ if (!DoesEquipmentContainWeapon(characterEquipment)) // Disallow spawning without weapons.
+ {
+ if (_notifiedPlayersAboutSpawnRestriction.Add(networkPeer.VirtualPlayer.Id))
+ {
+ GameNetwork.BeginModuleEventAsServer(networkPeer);
+ GameNetwork.WriteMessage(new CrpgNotificationId
+ {
+ Type = CrpgNotificationType.Announcement,
+ TextId = "str_kick_reason",
+ TextVariation = "no_weapon",
+ SoundEvent = string.Empty,
+ });
+ GameNetwork.EndModuleEventAsServer();
+ }
+
+ return false;
+ }
+
+ bool hasMount = characterEquipment[EquipmentIndex.Horse].Item != null;
+ // Disallow spawning cavalry before the cav spawn delay ended.
+ if (hasMount && _cavalrySpawnDelayTimer != null && !_cavalrySpawnDelayTimer.Check())
+ {
+ if (_notifiedPlayersAboutSpawnRestriction.Add(networkPeer.VirtualPlayer.Id))
+ {
+ GameNetwork.BeginModuleEventAsServer(networkPeer);
+ GameNetwork.WriteMessage(new CrpgNotificationId
+ {
+ Type = CrpgNotificationType.Notification,
+ TextId = "str_notification",
+ TextVariation = "cavalry_spawn_delay",
+ SoundEvent = string.Empty,
+ Variables = { ["SECONDS"] = ((int)_cavalrySpawnDelayTimer.GetTimerDuration()).ToString() },
+ });
+ GameNetwork.EndModuleEventAsServer();
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ protected override void OnPeerSpawned(Agent agent)
+ {
+ MissionPeer? missionPeer = agent.MissionPeer;
+
+ if (missionPeer == null)
+ {
+ return;
+ }
+
+ CrpgPeer? crpgPeer = missionPeer.Peer.GetComponent();
+
+ if (crpgPeer == null)
+ {
+ return;
+ }
+
+ if (agent.MissionPeer.ControlledFormation != null)
+ {
+ agent.Team.AssignPlayerAsSergeantOfFormation(agent.MissionPeer, agent.MissionPeer.ControlledFormation.FormationIndex);
+ }
+
+ base.OnPeerSpawned(agent);
+ agent.MissionPeer.SpawnCountThisRound += 1;
+
+ if (IsRoundInProgress())
+ {
+ int p = int.Parse(agent.Character.StringId.Split('_').Last());
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == missionPeer.Team).ToList();
+ float sumOfTeamEquipment = _teamSumOfEquipment[missionPeer.Team];
+ float peerSumOfEquipment = ComputeEquipmentValue(crpgPeer);
+ int peerNumberOfBots = 0;
+ if (teamRelevantPeers.Count - 1 < 1)
+ {
+ peerNumberOfBots = _teamNumberOfBots[missionPeer.Team] - 1;
+ }
+ else
+ {
+ peerNumberOfBots = (int)(_teamNumberOfBots[missionPeer.Team] * (1 - peerSumOfEquipment / sumOfTeamEquipment) /
+ (float)(teamRelevantPeers.Count - 1));
+ }
+
+ Dictionary formationBotWeight = new();
+ var captainFormations = crpgPeer.User!.Captain.Formations.Where(f => f.Character != null);
+ if (captainFormations.Any())
+ {
+ double totalWeight = captainFormations.Sum(cf => cf.Weight);
+
+ foreach (CrpgCaptainFormation captainFormation in captainFormations)
+ {
+ double proportion = (double)(captainFormation.Weight / totalWeight);
+ formationBotWeight.Add(captainFormation.Number, proportion);
+ }
+ }
+ else
+ {
+ for (int i = 0; i < peerNumberOfBots; i++)
+ {
+ SpawnBotAgent($"crpg_class_division_{p}", agent.Team, missionPeer, p);
+ }
+ }
+
+ foreach (KeyValuePair captainFormation in formationBotWeight)
+ {
+ for (int i = 0; i < (int)(captainFormation.Value * peerNumberOfBots); i++)
+ {
+ SpawnBotAgent($"crpg_class_division_{p}", agent.Team, missionPeer, p, captainFormation.Key);
+ }
+ }
+ }
+
+ }
+
+ ///
+ /// Cav spawn delay values
+ /// 10 => 7sec
+ /// 30 => 9sec
+ /// 60 => 13sec
+ /// 90 => 17sec
+ /// 120 => 22sec
+ /// 150 => 26sec
+ /// 165+ => 28sec.
+ ///
+ private int GetCavalrySpawnDelay()
+ {
+ int currentPlayers = Math.Max(GetCurrentPlayerCount(), 1);
+ return Math.Min(28, 5 + currentPlayers / 7);
+ }
+
+ private int GetCurrentPlayerCount()
+ {
+ int counter = 0;
+ foreach (NetworkCommunicator networkPeer in GameNetwork.NetworkPeers)
+ {
+ var missionPeer = networkPeer.GetComponent();
+ if (!networkPeer.IsSynchronized
+ || missionPeer == null
+ || missionPeer.Team == null
+ || missionPeer.Team.Side == BattleSideEnum.None)
+ {
+ continue;
+ }
+
+ counter++;
+ }
+
+ return counter;
+ }
+
+ private int ComputeTeamSumOfEquipmentValue(Team team)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == team).ToList();
+ int valueToReturn = teamRelevantPeers.Sum(p => ComputeEquipmentValue(p.GetComponent()));
+ return (int)Math.Max(valueToReturn, 1);
+ }
+
+ private int ComputeTeamAverageUnitValue(Team team, int teamSumOfEquipment)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == team).ToList();
+ if (teamRelevantPeers.Count < 2)
+ {
+ return teamRelevantPeers.Sum(p => ComputeEquipmentValue(p.GetComponent()));
+ }
+
+ double sumOfEachSquared = teamRelevantPeers.Sum(p => Math.Pow(ComputeEquipmentValue(p.GetComponent()), 2f));
+ int valueToReturn = (int)((teamSumOfEquipment - sumOfEachSquared / teamSumOfEquipment) / (float)(teamRelevantPeers.Count - 1));
+ return (int)Math.Max(valueToReturn, 1);
+ }
+
+ private int ComputeEquipmentValue(CrpgPeer peer)
+ {
+ int totalValue = 0;
+
+ Dictionary formationBotWeight = new();
+ var captainFormations = peer?.User?.Captain.Formations.Where(f => f.Character != null);
+ if (captainFormations.Any())
+ {
+ double totalWeight = captainFormations.Sum(cf => cf.Weight);
+
+ foreach (CrpgCaptainFormation captainFormation in captainFormations!)
+ {
+ double proportion = (double)(captainFormation.Weight / totalWeight);
+ formationBotWeight.Add(captainFormation.Number, proportion);
+
+ int characterValue = captainFormation.Character!.EquippedItems
+ .Select(i => MBObjectManager.Instance.GetObject(i.UserItem.ItemId))
+ .Where(io => io != null)
+ .Sum(io => io.Value);
+
+ totalValue += (int)(characterValue * formationBotWeight[captainFormation.Number]);
+ }
+ }
+ else
+ {
+ totalValue = peer?.User?.Character.EquippedItems.Select(i => MBObjectManager.Instance.GetObject(i.UserItem.ItemId)).Sum(io => io.Value) ?? 0;
+ }
+
+ return totalValue + 10000; // protection against naked
+ }
+
+ private int ComputeSingleEquipmentValue(ItemObject item)
+ {
+ return 0;
+ }
+
+ private bool IsNetworkPeerRelevant(NetworkCommunicator networkPeer)
+ {
+ MissionPeer missionPeer = networkPeer.GetComponent();
+ CrpgPeer crpgPeer = networkPeer.GetComponent();
+ bool isRelevant = !(!networkPeer.IsSynchronized
+ || missionPeer == null
+ || missionPeer.Team == null
+ || missionPeer.Team == Mission.SpectatorTeam
+ || crpgPeer == null
+ || crpgPeer.UserLoading
+ || crpgPeer.User == null);
+ return isRelevant;
+ }
+
+ private bool IsSinglePlayer()
+ {
+ bool isATeamEmpty = false;
+
+ foreach (Team team in Mission.Current.Teams)
+ {
+ if (team.Side != BattleSideEnum.None)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == team).ToList();
+
+ if (teamRelevantPeers.Count < 1)
+ {
+ isATeamEmpty = true;
+ }
+ }
+ }
+
+ return isATeamEmpty;
+ }
+
+ private void SpawnCaptainBots()
+ {
+ if (IsRoundInProgress())
+ {
+ foreach (Team team in Mission.Current.Teams)
+ {
+ if (team.Side != BattleSideEnum.None)
+ {
+ var peers = GameNetwork.NetworkPeers;
+ var teamRelevantPeers =
+ peers.Where(p => IsNetworkPeerRelevant(p) && p.GetComponent().Team == team).ToList();
+
+ if (teamRelevantPeers.Count == 0)
+ {
+ for (int i = 0; i < _teamNumberOfBots[team]; i++)
+ {
+ MultiplayerClassDivisions.MPHeroClass botClass = MultiplayerClassDivisions
+ .GetMPHeroClasses()
+ .GetRandomElementWithPredicate(x => x.StringId.StartsWith("crpg_bot_"));
+ SpawnBotAgent(botClass.StringId, team);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Module.Server/Modes/Conquest/CrpgConquestGameMode.cs b/src/Module.Server/Modes/Conquest/CrpgConquestGameMode.cs
index 015bf3c28..df8e72d3f 100644
--- a/src/Module.Server/Modes/Conquest/CrpgConquestGameMode.cs
+++ b/src/Module.Server/Modes/Conquest/CrpgConquestGameMode.cs
@@ -91,7 +91,7 @@ public override void StartMultiplayerGame(string scene)
ChatBox chatBox = Game.Current.GetGameHandler();
CrpgWarmupComponent warmupComponent = new(_constants, notificationsComponent,
() => (new SiegeSpawnFrameBehavior(), new CrpgSiegeSpawningBehavior(_constants)));
- CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, null);
+ CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, null, MultiplayerGameType.Siege);
CrpgRewardServer rewardServer = new(crpgClient, _constants, warmupComponent, enableTeamHitCompensations: false, enableRating: false);
#else
CrpgWarmupComponent warmupComponent = new(_constants, notificationsComponent, null);
diff --git a/src/Module.Server/Modes/Dtv/CrpgDtvGameMode.cs b/src/Module.Server/Modes/Dtv/CrpgDtvGameMode.cs
index 2537e746e..69c164c12 100644
--- a/src/Module.Server/Modes/Dtv/CrpgDtvGameMode.cs
+++ b/src/Module.Server/Modes/Dtv/CrpgDtvGameMode.cs
@@ -95,7 +95,7 @@ public override void StartMultiplayerGame(string scene)
CrpgWarmupComponent warmupComponent = new(_constants, notificationsComponent, () =>
(new FlagDominationSpawnFrameBehavior(),
new CrpgDtvSpawningBehavior(_constants)));
- CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, null);
+ CrpgTeamSelectServerComponent teamSelectComponent = new(warmupComponent, null, MultiplayerGameType.Siege);
CrpgRewardServer rewardServer = new(crpgClient, _constants, warmupComponent, enableTeamHitCompensations: true, enableRating: false, enableLowPopulationUpkeep: true);
CrpgDtvSpawningBehavior spawnBehaviour = new(_constants);
#else
diff --git a/src/Module.Server/ModuleData/crpg_captain_characters.xml b/src/Module.Server/ModuleData/crpg_captain_characters.xml
new file mode 100644
index 000000000..d7aef07fa
--- /dev/null
+++ b/src/Module.Server/ModuleData/crpg_captain_characters.xml
@@ -0,0 +1,1104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Module.Server/ModuleData/crpg_captain_class_divisions.xml b/src/Module.Server/ModuleData/crpg_captain_class_divisions.xml
new file mode 100644
index 000000000..65c13b165
--- /dev/null
+++ b/src/Module.Server/ModuleData/crpg_captain_class_divisions.xml
@@ -0,0 +1,5064 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Module.Server/ModuleData/multiplayer_strings.xml b/src/Module.Server/ModuleData/multiplayer_strings.xml
index e077ea5c0..0f2f9262f 100644
--- a/src/Module.Server/ModuleData/multiplayer_strings.xml
+++ b/src/Module.Server/ModuleData/multiplayer_strings.xml
@@ -4,7 +4,10 @@
-
+
+
+
+
diff --git a/src/Module.Server/Properties/launchSettings.json b/src/Module.Server/Properties/launchSettings.json
index 3e7d15ee1..00559c318 100644
--- a/src/Module.Server/Properties/launchSettings.json
+++ b/src/Module.Server/Properties/launchSettings.json
@@ -72,4 +72,4 @@
}
},
"$schema": "http://json.schemastore.org/launchsettings.json"
-}
+}
\ No newline at end of file
diff --git a/src/Module.Server/SubModule.xml b/src/Module.Server/SubModule.xml
index 71c89c05c..1c679505c 100644
--- a/src/Module.Server/SubModule.xml
+++ b/src/Module.Server/SubModule.xml
@@ -65,6 +65,12 @@
+
+
+
+
+
+
diff --git a/src/Persistence/Configurations/CaptainConfiguration.cs b/src/Persistence/Configurations/CaptainConfiguration.cs
new file mode 100644
index 000000000..05ee1a509
--- /dev/null
+++ b/src/Persistence/Configurations/CaptainConfiguration.cs
@@ -0,0 +1,18 @@
+using Crpg.Domain.Entities.Captains;
+using Crpg.Domain.Entities.Users;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Crpg.Persistence.Configurations;
+
+public class CaptainConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(c => c.Id);
+
+ builder.HasMany(c => c.Formations)
+ .WithOne(f => f.Captain)
+ .HasForeignKey(f => f.CaptainId);
+ }
+}
diff --git a/src/Persistence/Configurations/CaptainFormationConfiguration.cs b/src/Persistence/Configurations/CaptainFormationConfiguration.cs
new file mode 100644
index 000000000..f7938bcc0
--- /dev/null
+++ b/src/Persistence/Configurations/CaptainFormationConfiguration.cs
@@ -0,0 +1,20 @@
+using Crpg.Domain.Entities.Captains;
+using Crpg.Domain.Entities.Users;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Crpg.Persistence.Configurations;
+
+public class CaptainFormationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(f => f.Id);
+
+ builder.Property(f => f.Weight)
+ .IsRequired();
+ builder.HasOne(f => f.Character)
+ .WithMany()
+ .HasForeignKey(f => f.CharacterId);
+ }
+}
diff --git a/src/Persistence/Configurations/UserConfiguration.cs b/src/Persistence/Configurations/UserConfiguration.cs
index 4bb87f2f8..518877511 100644
--- a/src/Persistence/Configurations/UserConfiguration.cs
+++ b/src/Persistence/Configurations/UserConfiguration.cs
@@ -1,4 +1,5 @@
using Crpg.Domain.Entities.Users;
+using Crpg.Domain.Entities.Captains;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
diff --git a/src/Persistence/CrpgDbContext.cs b/src/Persistence/CrpgDbContext.cs
index d8b4fb242..66f98a91a 100644
--- a/src/Persistence/CrpgDbContext.cs
+++ b/src/Persistence/CrpgDbContext.cs
@@ -4,6 +4,7 @@
using Crpg.Domain.Entities;
using Crpg.Domain.Entities.ActivityLogs;
using Crpg.Domain.Entities.Battles;
+using Crpg.Domain.Entities.Captains;
using Crpg.Domain.Entities.Characters;
using Crpg.Domain.Entities.Clans;
using Crpg.Domain.Entities.GameServers;
@@ -79,6 +80,7 @@ public CrpgDbContext(
public DbSet ClanArmoryItems { get; set; } = default!;
public DbSet ClanArmoryBorrowedItems { get; set; } = default!;
public DbSet ClanInvitations { get; set; } = default!;
+ public DbSet Captains { get; set; } = default!;
public DbSet Parties { get; set; } = default!;
public DbSet Settlements { get; set; } = default!;
public DbSet SettlementItems { get; set; } = default!;
diff --git a/src/Persistence/Migrations/20240307120749_Captain.Designer.cs b/src/Persistence/Migrations/20240307120749_Captain.Designer.cs
new file mode 100644
index 000000000..c22fa341d
--- /dev/null
+++ b/src/Persistence/Migrations/20240307120749_Captain.Designer.cs
@@ -0,0 +1,2301 @@
+//
+using System;
+using Crpg.Domain.Entities;
+using Crpg.Domain.Entities.ActivityLogs;
+using Crpg.Domain.Entities.Battles;
+using Crpg.Domain.Entities.Characters;
+using Crpg.Domain.Entities.Clans;
+using Crpg.Domain.Entities.Items;
+using Crpg.Domain.Entities.Parties;
+using Crpg.Domain.Entities.Restrictions;
+using Crpg.Domain.Entities.Settlements;
+using Crpg.Domain.Entities.Users;
+using Crpg.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NetTopologySuite.Geometries;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Crpg.Persistence.Migrations
+{
+ [DbContext(typeof(CrpgDbContext))]
+ [Migration("20240307120749_Captain")]
+ partial class Captain
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "activity_log_type", new[] { "user_created", "user_deleted", "user_renamed", "user_rewarded", "item_bought", "item_sold", "item_broke", "item_reforged", "item_repaired", "item_upgraded", "character_created", "character_deleted", "character_rating_reset", "character_respecialized", "character_retired", "character_rewarded", "character_earned", "server_joined", "chat_message_sent", "team_hit", "clan_armory_add_item", "clan_armory_remove_item", "clan_armory_return_item", "clan_armory_borrow_item" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "battle_fighter_application_status", new[] { "pending", "declined", "accepted" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "battle_mercenary_application_status", new[] { "pending", "declined", "accepted" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "battle_phase", new[] { "preparation", "hiring", "scheduled", "live", "end" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "battle_side", new[] { "attacker", "defender" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "character_class", new[] { "peasant", "infantry", "shock_infantry", "skirmisher", "crossbowman", "archer", "cavalry", "mounted_archer" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_invitation_status", new[] { "pending", "declined", "accepted" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_invitation_type", new[] { "request", "offer" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_member_role", new[] { "member", "officer", "leader" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "culture", new[] { "neutral", "aserai", "battania", "empire", "khuzait", "looters", "sturgia", "vlandia" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "damage_type", new[] { "undefined", "cut", "pierce", "blunt" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "item_slot", new[] { "head", "shoulder", "body", "hand", "leg", "mount_harness", "mount", "weapon0", "weapon1", "weapon2", "weapon3", "weapon_extra" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "item_type", new[] { "undefined", "head_armor", "shoulder_armor", "body_armor", "hand_armor", "leg_armor", "mount_harness", "mount", "shield", "bow", "crossbow", "one_handed_weapon", "two_handed_weapon", "polearm", "thrown", "arrows", "bolts", "pistol", "musket", "bullets", "banner" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "party_status", new[] { "idle", "idle_in_settlement", "recruiting_in_settlement", "moving_to_point", "following_party", "moving_to_settlement", "moving_to_attack_party", "moving_to_attack_settlement", "in_battle" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "platform", new[] { "steam", "epic_games", "microsoft" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "region", new[] { "eu", "na", "as", "oc" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "restriction_type", new[] { "all", "join", "chat" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role", new[] { "user", "moderator", "game_admin", "admin" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "settlement_type", new[] { "village", "castle", "town" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "weapon_class", new[] { "undefined", "dagger", "one_handed_sword", "two_handed_sword", "one_handed_axe", "two_handed_axe", "mace", "pick", "two_handed_mace", "one_handed_polearm", "two_handed_polearm", "low_grip_polearm", "arrow", "bolt", "cartridge", "bow", "crossbow", "stone", "boulder", "throwing_axe", "throwing_knife", "javelin", "pistol", "musket", "small_shield", "large_shield", "banner" });
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Crpg.Domain.Entities.ActivityLogs.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Type")
+ .HasColumnType("activity_log_type")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_activity_logs");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_activity_logs_user_id");
+
+ b.HasIndex("CreatedAt", "UserId")
+ .HasDatabaseName("ix_activity_logs_created_at_user_id");
+
+ b.ToTable("activity_logs", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.ActivityLogs.ActivityLogMetadata", b =>
+ {
+ b.Property("ActivityLogId")
+ .HasColumnType("integer")
+ .HasColumnName("activity_log_id");
+
+ b.Property("Key")
+ .HasColumnType("text")
+ .HasColumnName("key");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("ActivityLogId", "Key")
+ .HasName("pk_activity_log_metadata");
+
+ b.ToTable("activity_log_metadata", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Battles.Battle", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Phase")
+ .HasColumnType("battle_phase")
+ .HasColumnName("phase");
+
+ b.Property("Position")
+ .IsRequired()
+ .HasColumnType("geometry")
+ .HasColumnName("position");
+
+ b.Property("Region")
+ .HasColumnType("region")
+ .HasColumnName("region");
+
+ b.Property("ScheduledFor")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("scheduled_for");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_battles");
+
+ b.ToTable("battles", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Battles.BattleFighter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BattleId")
+ .HasColumnType("integer")
+ .HasColumnName("battle_id");
+
+ b.Property("Commander")
+ .HasColumnType("boolean")
+ .HasColumnName("commander");
+
+ b.Property("MercenarySlots")
+ .HasColumnType("integer")
+ .HasColumnName("mercenary_slots");
+
+ b.Property("PartyId")
+ .HasColumnType("integer")
+ .HasColumnName("party_id");
+
+ b.Property("SettlementId")
+ .HasColumnType("integer")
+ .HasColumnName("settlement_id");
+
+ b.Property("Side")
+ .HasColumnType("battle_side")
+ .HasColumnName("side");
+
+ b.HasKey("Id")
+ .HasName("pk_battle_fighters");
+
+ b.HasIndex("BattleId")
+ .HasDatabaseName("ix_battle_fighters_battle_id");
+
+ b.HasIndex("PartyId")
+ .HasDatabaseName("ix_battle_fighters_party_id");
+
+ b.HasIndex("SettlementId")
+ .HasDatabaseName("ix_battle_fighters_settlement_id");
+
+ b.ToTable("battle_fighters", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Battles.BattleFighterApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BattleId")
+ .HasColumnType("integer")
+ .HasColumnName("battle_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("PartyId")
+ .HasColumnType("integer")
+ .HasColumnName("party_id");
+
+ b.Property("Side")
+ .HasColumnType("battle_side")
+ .HasColumnName("side");
+
+ b.Property("Status")
+ .HasColumnType("battle_fighter_application_status")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_battle_fighter_applications");
+
+ b.HasIndex("BattleId")
+ .HasDatabaseName("ix_battle_fighter_applications_battle_id");
+
+ b.HasIndex("PartyId")
+ .HasDatabaseName("ix_battle_fighter_applications_party_id");
+
+ b.ToTable("battle_fighter_applications", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Battles.BattleMercenary", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApplicationId")
+ .HasColumnType("integer")
+ .HasColumnName("application_id");
+
+ b.Property("BattleId")
+ .HasColumnType("integer")
+ .HasColumnName("battle_id");
+
+ b.Property("CaptainFighterId")
+ .HasColumnType("integer")
+ .HasColumnName("captain_fighter_id");
+
+ b.Property("CharacterId")
+ .HasColumnType("integer")
+ .HasColumnName("character_id");
+
+ b.Property("Side")
+ .HasColumnType("battle_side")
+ .HasColumnName("side");
+
+ b.HasKey("Id")
+ .HasName("pk_battle_mercenaries");
+
+ b.HasIndex("ApplicationId")
+ .HasDatabaseName("ix_battle_mercenaries_application_id");
+
+ b.HasIndex("BattleId")
+ .HasDatabaseName("ix_battle_mercenaries_battle_id");
+
+ b.HasIndex("CaptainFighterId")
+ .HasDatabaseName("ix_battle_mercenaries_captain_fighter_id");
+
+ b.HasIndex("CharacterId")
+ .HasDatabaseName("ix_battle_mercenaries_character_id");
+
+ b.ToTable("battle_mercenaries", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Battles.BattleMercenaryApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BattleId")
+ .HasColumnType("integer")
+ .HasColumnName("battle_id");
+
+ b.Property("CharacterId")
+ .HasColumnType("integer")
+ .HasColumnName("character_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("note");
+
+ b.Property("Side")
+ .HasColumnType("battle_side")
+ .HasColumnName("side");
+
+ b.Property("Status")
+ .HasColumnType("battle_mercenary_application_status")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Wage")
+ .HasColumnType("integer")
+ .HasColumnName("wage");
+
+ b.HasKey("Id")
+ .HasName("pk_battle_mercenary_applications");
+
+ b.HasIndex("BattleId")
+ .HasDatabaseName("ix_battle_mercenary_applications_battle_id");
+
+ b.HasIndex("CharacterId")
+ .HasDatabaseName("ix_battle_mercenary_applications_character_id");
+
+ b.ToTable("battle_mercenary_applications", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Captains.Captain", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_captains");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_captains_user_id");
+
+ b.ToTable("captains", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Captains.CaptainFormation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CaptainId")
+ .HasColumnType("integer")
+ .HasColumnName("captain_id");
+
+ b.Property("CharacterId")
+ .HasColumnType("integer")
+ .HasColumnName("character_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Number")
+ .HasColumnType("integer")
+ .HasColumnName("number");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Weight")
+ .HasColumnType("integer")
+ .HasColumnName("weight");
+
+ b.HasKey("Id")
+ .HasName("pk_captain_formation");
+
+ b.HasIndex("CaptainId")
+ .HasDatabaseName("ix_captain_formation_captain_id");
+
+ b.HasIndex("CharacterId")
+ .HasDatabaseName("ix_captain_formation_character_id");
+
+ b.ToTable("captain_formation", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Characters.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Class")
+ .HasColumnType("character_class")
+ .HasColumnName("class");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("Experience")
+ .HasColumnType("integer")
+ .HasColumnName("experience");
+
+ b.Property("ForTournament")
+ .HasColumnType("boolean")
+ .HasColumnName("for_tournament");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("Level")
+ .HasColumnType("integer")
+ .HasColumnName("level");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_characters");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_characters_user_id");
+
+ b.ToTable("characters", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Clans.Clan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ArmoryTimeout")
+ .HasColumnType("interval")
+ .HasColumnName("armory_timeout");
+
+ b.Property("BannerKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("banner_key");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Discord")
+ .HasColumnType("text")
+ .HasColumnName("discord");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("bigint")
+ .HasColumnName("primary_color");
+
+ b.Property("Region")
+ .HasColumnType("region")
+ .HasColumnName("region");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("bigint")
+ .HasColumnName("secondary_color");
+
+ b.Property("Tag")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("tag");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_clans");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_clans_name");
+
+ b.HasIndex("Tag")
+ .IsUnique()
+ .HasDatabaseName("ix_clans_tag");
+
+ b.ToTable("clans", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Clans.ClanArmoryBorrowedItem", b =>
+ {
+ b.Property("UserItemId")
+ .HasColumnType("integer")
+ .HasColumnName("user_item_id");
+
+ b.Property("BorrowerClanId")
+ .HasColumnType("integer")
+ .HasColumnName("borrower_clan_id");
+
+ b.Property("BorrowerUserId")
+ .HasColumnType("integer")
+ .HasColumnName("borrower_user_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("UserItemId")
+ .HasName("pk_clan_armory_borrowed_items");
+
+ b.HasIndex("BorrowerClanId")
+ .HasDatabaseName("ix_clan_armory_borrowed_items_borrower_clan_id");
+
+ b.HasIndex("BorrowerUserId")
+ .HasDatabaseName("ix_clan_armory_borrowed_items_borrower_user_id");
+
+ b.ToTable("clan_armory_borrowed_items", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Clans.ClanInvitation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClanId")
+ .HasColumnType("integer")
+ .HasColumnName("clan_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("InviteeId")
+ .HasColumnType("integer")
+ .HasColumnName("invitee_id");
+
+ b.Property("InviterId")
+ .HasColumnType("integer")
+ .HasColumnName("inviter_id");
+
+ b.Property("Status")
+ .HasColumnType("clan_invitation_status")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .HasColumnType("clan_invitation_type")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_clan_invitations");
+
+ b.HasIndex("ClanId")
+ .HasDatabaseName("ix_clan_invitations_clan_id");
+
+ b.HasIndex("InviteeId")
+ .HasDatabaseName("ix_clan_invitations_invitee_id");
+
+ b.HasIndex("InviterId")
+ .HasDatabaseName("ix_clan_invitations_inviter_id");
+
+ b.ToTable("clan_invitations", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Clans.ClanMember", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("ClanId")
+ .HasColumnType("integer")
+ .HasColumnName("clan_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Role")
+ .HasColumnType("clan_member_role")
+ .HasColumnName("role");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("UserId")
+ .HasName("pk_clan_members");
+
+ b.HasIndex("ClanId")
+ .HasDatabaseName("ix_clan_members_clan_id");
+
+ b.ToTable("clan_members", (string)null);
+ });
+
+ modelBuilder.Entity("Crpg.Domain.Entities.Items.ClanArmoryItem", b =>
+ {
+ b.Property("UserItemId")
+ .HasColumnType("integer")
+ .HasColumnName("user_item_id");
+
+ b.Property