107 Commits

Author SHA1 Message Date
Acentech Dev
df338578d1 Pin the production image to the OpenMU upstream Dalga A release (d60aad7d2f)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-08-13 09:27:10 +03:00
Acentech Dev
d60aad7d2f Load the log viewer script from the path it is actually served on
The admin panel sets StaticWebAssetBasePath to _content/<assembly>, so the
colocated module is served as
/_content/MUnique.OpenMU.Web.AdminPanel/Pages/LogFiles.razor.js. The import
asked for "./Pages/LogFiles.razor.js", which resolves against the page url
/logfiles and answers 404. The failure is caught and logged, so the viewer
still renders - it just never scrolls itself.

Build the path from the assembly name the way ThemeSelector and MapEditor
already do.
2026-08-13 09:13:56 +03:00
Acentech Dev
233ad41f6b Merge branch 'integrate/openmu-20260813a'
OpenMU upstream Dalga A: d4ca915c4..a7412572c aralığındaki düşük riskli
sekiz PR. #858, #854, #845 ve #864 uygulandı; #754 ve #860'ın gövdesi zaten
ağaçtaydı; #859 boş çıktığı için atlandı.

Ayrıca #860'ın eksik kalan testi ve #845'in düşürdüğü chat command kaynak
dizeleri tamamlandı.

1.295 test başarılı, 6 atlandı, 0 hata.
2026-08-13 09:03:28 +03:00
Eduardo
72874567c6 replace Bazored.Toast with custom Toast component 2026-08-13 08:53:09 +03:00
Acentech Dev
23bc36fd65 Restore the chat command resource strings the log viewer merge dropped
The admin panel log viewer (#845) reordered Resources.resx around the
chat command entries. AdaMu never took the admin panel page which
displays them (upstream PR #851), so the conflict resolution left the
entries out - but Resources.Designer.cs still declares the twelve
properties, and each would have returned null at runtime.

Add the entries back, unchanged from upstream, so resx and the generated
designer agree again.
2026-08-13 08:53:09 +03:00
Claude
79922204c5 Fix build and behavior issues in the admin panel log viewer
- Resources.resx: close the unterminated DownloadFile data element, which
  made the file invalid XML, and remove the duplicated Actions and Refresh
  entries which already exist.
- Resources.Designer.cs: restore the UTF-8 BOM and put the new properties
  into the alphabetical order the strongly typed resource builder produces,
  so the file matches its generated form again.
- LogFiles.razor: import the collocated script from ./Pages/LogFiles.razor.js.
  The _content/{PackageId} prefix only applies to razor class libraries, so
  the import failed for this web application and the module was never loaded.
- LogFiles.razor: follow the new entries in live mode again by using the
  isScrolledToBottom helper, so the terminal scrolls along unless the user
  scrolled up to read the history.
- LogFiles.razor: only catch the expected javascript interop exceptions and
  log a failing module import instead of swallowing it silently.
- LogFiles.razor: restore the BOM and the trailing newline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vjs6n29WzQx8pGg3KJPGXk
(cherry picked from commit 7be969ea7b2a9b7441a8c5a65cc8a0928009d595)
2026-08-13 08:52:04 +03:00
Rhefew
f5b4292af1 fix(admin): address code review feedback on log viewer localization, CSP JS module, timer disposal, and scroll UX 2026-08-13 08:51:08 +03:00
Rhefew
074f91bd47 style(admin): apply code formatting, static helper functions and ConfigureWait to LogFiles.razor 2026-08-13 08:51:08 +03:00
Rhefew
f050e56e91 feat(admin): auto-scroll terminal to bottom on log refreshes 2026-08-13 08:51:08 +03:00
Rhefew
f7f6526c16 feat(admin): layout log files page side-by-side when viewing 2026-08-13 08:51:08 +03:00
Rhefew
31a5e64f40 feat(admin): implement live log viewer and searcher in log files page 2026-08-13 08:51:07 +03:00
Acentech Dev
790a101f23 Cover the configuration change publishing filter with its test
The Castle Siege persistence import (#860) brought
EntityFrameworkContextBase.PublishesConfigurationChanges into the tree,
but not the test which pins its behaviour, and not the guard which keeps
the two initialization test fixtures from configuring the connection
twice.

Add both, unchanged from upstream.
2026-08-13 08:50:53 +03:00
nolt
52950362ff Document the cross-player persistence lock-ordering invariant
The packet handler funnel now holds each player's persistence lock for the
whole handler. Acquiring a second player's lock from inside a handler is
therefore a lock-ordering hazard; note the invariant on the guard method so a
future cross-player save cannot silently open an AB-BA cycle. Documentation
only, no behavioural change.
2026-08-13 08:46:48 +03:00
nolt
d3dd57f620 Absorb the remaining off-funnel mutation races against the periodic save
The per-player persistence lock serializes the packet handler funnel and the
save, but a few structural mutations happen off that funnel: the offline/bot
MuHelper loots and maintains its inventory on a 500ms timer, and combat
destroys depleted ammunition or a dead pet on the attacker's or a monster's
thread. Those can still run while the periodic save enumerates the change
tracker and corrupt it.

Two additions:

- Run the whole offline MuHelper tick under the player's persistence lock.
  Bots are in the saved player list and loot continuously, so this was the
  most likely remaining reproducer. The tick has no internal delays, so the
  lock is held only briefly, and it is the bot's own lock (no cross-player
  deadlock).

- Retry the save a bounded number of times on the transient exceptions a
  concurrent change-tracker mutation produces. The corruption surfaces as
  several types depending on where change detection was (a modified
  collection, a transiently-null key, an out-of-range index), so the retry
  covers that family rather than a single type. A genuinely persistent error
  rethrows once the attempts are exhausted. This absorbs the rare, bursty
  combat sources that no lock is held for.
2026-08-13 08:46:47 +03:00
nolt
7671fe2d94 Serialize player context mutations against the periodic save
A player's progress is persisted from two unrelated flows: action
handlers triggered by incoming packets (sequential per connection) and
the periodic save, which runs on an independent timer. Action handlers
mutate tracked entities with plain field and collection writes - for
example crafting toggles item.ItemOptions, and item stacking and NPC
selling delete item rows - which bypass the persistence context's own
lock. When such a mutation runs while SaveChangesAsync enumerates the
change tracker, the save throws (collection-modified, or a
DbUpdateConcurrency "affected 0 rows" since the context has no
concurrency tokens). SaveChanges is atomic, so every following save
fails too and the whole session never persists: on relog the player
rolls back, losing progress and items.

Add a per-player re-entrant persistence lock and acquire it around both
the packet-handling funnel and SaveProgressAsync, so a player's
mutations and saves can never overlap. The lock is re-entrant per
asynchronous flow (an instance AsyncLocal), so an inline save inside an
already-serialized handler does not deadlock; and it is per player, so a
trade still acquires the trading partner's lock separately.

Add regression tests covering mutual exclusion and re-entrancy of the
lock.
2026-08-13 08:45:38 +03:00
nolt
ebf6958068 bots: accept party invitations regardless of level gap
The reset-aware level gate in BotPartyHandler rejected nearly every party
invitation on servers with resets, because it folded reset count into the level
scale (one reset ~= 400 points against a 500 cap). A veteran player inviting a
freshly generated bot was always over the limit, so the invitation was silently
declined. Remove the gate so a bot accepts any inviter who is alive and in the
world, matching OpenMU's own party action; the situational safeguards (shopping,
revenge, mini game, pending invite, human companion) stay in place.
2026-08-13 08:45:29 +03:00
Acentech Dev
0ff0707212 Pin the production image to the TvT NPC rename release (ede52f4176)
Some checks failed
.NET Core / build (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:24:57 +03:00
Acentech Dev
ede52f4176 feat(tvt): rename the Lorencia event NPC to "TvT Guard"
Some checks failed
.NET Core / build (push) Has been cancelled
The NPC (560) that opens the TvT registration stands in Lorencia and was seeded as
"TvT Event Gorevlisi". Renaming the seed only helps fresh databases, so update 106
renames it on existing ones too - that name is what the admin panel lists.

The name players see over its head comes from the client's own table
(Data\Local\<lang>\Npcname(<lang>).txt), not from this designation, and is changed
there separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:23:02 +03:00
Acentech Dev
797e17b666 Pin the production image to the Castle Siege release (d249fca935)
Some checks failed
.NET Core / build (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:53:09 +03:00
Acentech Dev
d249fca935 Merge branch 'feature/castle-siege-upstream-model'
Castle Siege on the upstream data model: guild ownership by id, click-and-hold
Crown Switches, click-to-capture crown, and the build fix that kept the generated
persistence model in sync with the data model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:45:31 +03:00
Acentech Dev
f8e856c7c6 feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled
The switches used to be held by simply standing near them, and the crown captured
by standing near it - clicking a switch only produced the client's "not implemented
yet" message. This drives both from the original interaction instead:

- Clicking a Crown Switch starts an operation which completes after
  CastleSiegeSettings.SwitchPushSeconds (15) and keeps the switch for the guild
  until its operator leaves the switch's area. One player per switch; anybody else
  clicking it is told another team is on it (C1 B2 14 state 2).
- While one guild holds both switches the crown's shield drops for it, and its
  guild master captures the throne by CLICKING the crown and holding it for
  CrownHoldTimeSeconds - seeded to 60 now, to match the countdown the client's
  registration panel hardcodes. The throne stays contestable until the siege ends.
- The shield now depends on the switches alone, as in the original; the gates and
  statues remain what they always were, the obstacle in the way.

The switch info packet (C1 B2 20) is broadcast before any switch-state packet
because the client's "switch released" handler reads its switch table without
checking that it exists - that table is only allocated when the info packet
arrives, so the wrong order crashes the client.

Also fixed while in here:
- The crown registration panel could never be closed: the cancel was only sent
  while the master still stood on the crown, which is precisely when the hold does
  NOT break. The panel is now closed for the player it was opened for.
- A contested switch was decided by enumeration order.
- Panels opened by the siege are closed when it ends.
- TryCaptureThrone was dead code carrying a second, diverged rule set.
- /csphase advertised the pre-refactor phase names to the client.
- The periodic broadcasts keyed off "UtcNow.Second % n", which silently skips when
  a tick runs late; they count ticks now.

The unit tests never compiled against the refactored model - they are migrated to
the state machine and guild ids, and cover the new switch and crown rules. A new
test proves update 105 writes the configuration into an existing database.

ApplyPendingUpdatesTool applies pending configuration updates without the admin
panel; it is [Explicit], so it never runs in a normal test pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:44:12 +03:00
Acentech Dev
af46499279 fix(build): run the persistence generator against the current data model
The PreBuild targets ran the generator with "--no-build", so it used whatever
assemblies happened to sit in its output folder. When that copy of the data model
was older than a newly added type, the generator regenerated the checked-in
*.Generated.cs files WITHOUT that type and overwrote them in the source tree.

Nothing failed at build time: the C# compile stayed green and docker builds
(-p:ci=true) skip the generator entirely, so they compiled whatever was in the
tree. The damage only surfaced at runtime, when EF validated the model and found
the inherited GameConfiguration.CastleSiegeConfiguration navigation pointing at a
keyless type - the server died on startup with "The entity type
'CastleSiegeConfiguration' requires a primary key to be defined".

Dropping the switch makes the generator build first, so its output always matches
the data model. The regenerated files here are that missing output: the Castle
Siege mappings, and the packet tests for packets whose XML was already committed.

TypedContextModelTests builds the typed context the startup reads its plugin
configurations through - the first one to touch the model - so this class of
breakage fails in seconds without a database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:43:39 +03:00
Acentech Dev
6f7e58ff35 refactor(castle-siege): drive the cycle on the client's state numbers and persist guilds by id
Moves AdaMu's working Castle Siege onto the upstream data model that the
previous commit introduced, without changing how the siege plays.

State model
- CastleSiegePhase is replaced by DataModel's CastleSiegeState, whose values are
  exactly what the game client's CASTLESIEGE_STATE enum expects. The cycle now
  runs Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) ->
  EndCycle(9) -> Idle1(0).
- Idle2(2), RegisterMark(3), Idle3(4) and Notify(5) keep their numbers for client
  compatibility but are never entered: AdaMu registers guilds directly and has no
  Mark of Lord step.

Guild identity
- Guilds are now identified by their persistent Guid instead of by name, so a
  rename (or a delete and re-create under the same name) can no longer hand
  castle ownership to the wrong guild. Names are carried alongside only for
  display and for the packets that send a name to the client.
- Interfaces.Guild deliberately has no id and the guild server's short ids are
  in-memory only, so the persistent id is resolved through the guild name once
  and cached per process. This avoids adding a method to IGuildServer, which
  upstream keeps changing.

Persistence
- The castle owner is stored in the CastleSiegeData row and the registrations in
  CastleSiegeGuildRegistration rows, replacing the previous plugin-configuration
  JSON blob. Only the current state and when it started still ride on the plugin
  configuration, because they have no column in the upstream schema.

Castle NPCs
- The hard-coded gate, catapult, crown and switch coordinates are gone. They are
  read from GameConfiguration.CastleSiegeConfiguration, seeded by
  CastleSiegeInitializer. Definitions flagged IsPersistedToDatabase are the
  breakable defenses and count towards the throne, which additionally brings in
  the 4 guardian statues the previous implementation did not spawn.
- The crown hold time now comes from the seeded configuration instead of the
  plugin settings.

The AdaMu operational settings (cycle durations, registration fee, designated
server id, auto-open schedule) moved to a renamed CastleSiegeSettings class, so
they no longer collide with upstream's CastleSiegeConfiguration entity.

Verified: full server build succeeds with 0 errors.
Not yet done: the 0xB2 0x00 CastleSiegeState request handler, and the docker /
local run.
2026-08-04 03:37:37 +03:00
Acentech Dev
3aa9815b10 feat(castle-siege): adopt the upstream Castle Siege data model and persistence
Brings in the database layer of upstream OpenMU PRs #754 and #860 without
touching AdaMu's working Castle Siege gameplay. This is purely additive: the
existing 5-phase implementation still runs exactly as before.

What is included:

- DataModel: CastleSiegeState (the original Season 6 values 0-9, which are
  exactly what the game client's CASTLESIEGE_STATE enum expects),
  CastleSiegeJoinSide, and the zone/NPC/upgrade definition types.
- Entities: CastleSiegeData, CastleSiegeGuildRegistration, CastleSiegeNpcState.
  These identify a guild by its persistent Guid rather than by name.
- Generated persistence: 8 BasicModel + 8 EntityFramework model classes,
  CastleSiegeExtensions, and the regenerated ExtendedTypeContext,
  MapsterConfigurator and GameConfiguration partials.
- Migrations: 20260730194321_AddCastleSiege and
  20260801162427_ConfigureCastleSiegePersistence, plus the model snapshot.
- EntityDataContext gains the two DbSets and the five model registrations.
- EntityFrameworkContextBase only publishes configuration changes for entities
  in the configuration schema, so siege state writes are no longer broadcast as
  configuration changes.

AdaMu-specific adaptations:

- UpdateVersion.AddCastleSiegeData is 105, not upstream's 100. AdaMu already
  ships 95-104, and the applied-update bookkeeping is keyed on this value, so a
  collision would skip or re-run updates on live databases.
- CastleSiegeInitializer does not seed a weekly StateSchedule. Upstream drives
  the cycle from a fixed Saturday schedule; AdaMu drives it manually from
  CastleSiegeEventPlugIn and the AdminPanel, so the schedule is left empty and
  nothing reads it.

The seeded NPC definitions match AdaMu's existing hard-coded coordinates
exactly (6 gates, the two crown switches and the crown), and additionally
provide 4 guardian statues, 6 guardsmen and real gate/statue hit point tables
that the current implementation does not have yet.

Two pre-existing migrations were restyled by upstream (copyright header, using
placement, trailing comma). No functional change.

Verified: full server build succeeds with 0 errors.
2026-08-04 03:28:10 +03:00
Acentech Dev
0fdb455cec Pin the production image to a commit-SHA tag instead of :latest
Some checks failed
.NET Core / build (push) Has been cancelled
Coolify does not re-pull a tag it already knows locally, so :latest silently
kept the server on a stale layer. On 2026-07-31 production was still running
the 26 July image while DockerHub :latest pointed at a newer digest, which is
why update 104 never appeared in the admin panel.

A commit-SHA tag has never been seen by the host, so the pull is unavoidable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:37:42 +03:00
Claude
452f0bb139 Describe chat commands in a machine readable way
Some checks failed
.NET Core / build (push) Has been cancelled
The only description of a chat command was its usage string, which is
meant to be read by a human. A user interface which wants to offer the
commands to a player needs the parts separately: the command, what it
does, and one entry per parameter.

Add ChatCommandInfo and ChatCommandParameterInfo, built from the
metadata which is already there - ChatCommandHelpAttribute for the
command and its required character status, ArgumentAttribute for the
short names and whether a parameter is required, ValidValuesAttribute
for the accepted values. Name and description come from the display
attribute, so they are returned in the language of the player.

The description which is passed to ChatCommandHelpAttribute was never
stored anywhere. Keep it as a fallback for commands which have no
display attribute, instead of discarding it.

The reset info command had its texts hard coded in English. It now
refers to resources like every other chat command does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
2026-07-31 15:09:32 +03:00
Acentech Dev
38fd435ff6 Add RepairImportedMapWarpsSeason6 update (warp indexes 83-87)
Databases whose imported season 6 maps (Arkania 82, Acheron 83, Debenter 84,
Uruk 85, Ferea 86) were created by a fresh initialization end up with the map
rows and exit gates but without the /move WarpInfo entries, so /move answers
"Unknown warp index" for those maps.

AddArkaniaMapUpdateSeason6 and AddImportedMapsUpdateSeason6 return early when
the map already exists, which also skips the spawn gate, the WarpInfo and the
game-server assignment that follow. Update 104 repairs each of those pieces
independently and is safe to run repeatedly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:02:56 +03:00
Acentech Dev
30b0fd1bc9 Regenerate packet code from merged XML (TvT + chat-command packets)
The ClientToServer/ServerToClient packet .cs/.md are XSL-generated from the .xml
(Network.Packets PreBuild). Regenerated after hand-merging the packet XML so the
generated structs contain BOTH the AdaMu custom TvT (HeykelSavasi FA/FB/FC/FD +
TeamSelect) packets AND the upstream chat-command packets (F5/00 request, F5/01
AvailableChatCommand).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 01:19:53 +03:00
sven-n
4f63b5f4f3 Increased version to 0.9.10
(cherry picked from commit 644dfcce6631dcde4967c7e577c25d35d9a5d37c)
2026-07-27 01:16:10 +03:00
sven-n
cb810e8b0c Updated generated files
(cherry picked from commit 709ac8b5642c543c7b70c0423adac000b488f316)
2026-07-27 01:16:10 +03:00
Claude
0aedeb9ce8 Rename the message so it doesn't collide with the description class
The message and the class which describes a chat command in the game
logic were both named ChatCommandInfo. A view plugin needs both, so the
name was ambiguous there and its method didn't match the interface any
more.

Name the message AvailableChatCommand: it carries one command which is
available to the player, which also reads well next to the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
(cherry picked from commit a945634ba683a465b68f5ee8aed06293c1bb1187)
2026-07-27 01:14:07 +03:00
Claude
7a7e63b40e Declare the parameter type enum where both packet structs see it
An enum which is declared inside a packet is generated as a nested type
of its struct. The ref struct of the same packet is generated into
another file and refers to the enum by its plain name, so it didn't
compile.

Declare it next to the other shared enums instead, which puts it into
the namespace - that's also where CharacterStatus lives, which is used
by a structure of another packet in the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
(cherry picked from commit 33703647e97d9fb13680086d0366f8200fe2865a)
2026-07-27 01:14:05 +03:00
Claude
066894c4f6 Send the available chat commands to clients which can show them
Adds the server side of the chat command list: a view plugin which
sends one ChatCommandInfo per available command, and the handler for
the request of a client which supports it.

Both are limited to the extended protocol, so classic clients don't
receive messages they can't understand. The commands come from
GetAvailableChatCommandInfos, so a player only learns about the ones
he may execute, and deactivated ones stay out.

Executing a command needs nothing new: the client sends the composed
command line as an ordinary chat message, which the chat message
processor already routes to the command without broadcasting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
(cherry picked from commit f3a9cb132c295863942bb0f06f40cde231e57907)
2026-07-27 01:14:05 +03:00
Claude
4f83b72de8 Define the packets for the chat command list
Adds the two message definitions which let a client ask for the chat
commands of its player and receive them in a machine readable form:

- ChatCommandListRequest (C1, F5/00), sent by the client after it
  entered the game world.
- ChatCommandInfo (C2, F5/01), one message per available command. It
  carries the command, its localized name and description, the required
  character status and one entry per parameter with its name, short
  name, kind and accepted values.

F5 is unused in both directions and in the client, and F3 - the other
candidate - is character scoped, so it doesn't fit.

The generated code was produced with the same transformations the
project uses, so the diff only adds the new types and leaves the
existing ones untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
(cherry picked from commit 2cd46516f6136cfc7f96f4d3b7db4c8329559d3a)
2026-07-27 01:14:05 +03:00
ze-dom
960664c1c3 Reverted parenthesis, updated update plugin date
(cherry picked from commit b7df2defd31d3ee5eeca13490fd47956f1c06bb4)
2026-07-27 01:03:46 +03:00
ze-dom
6d473a4cf6 Updated update plugin
(cherry picked from commit e56c5c57f02af48d2d8c324282398c06e4ff931d)
2026-07-27 01:03:46 +03:00
ze-dom
5e33f16cdd Fixed frustum dynamic projectile index
(cherry picked from commit 2b30d22c1f49ac3e8ec42815a48cef6fa646e119)
2026-07-27 01:03:46 +03:00
ze-dom
e3c2012ddb Updated updateplugin
(cherry picked from commit 681b0e70e2d22204adba493c0b38369994e8afcc)
2026-07-27 01:03:46 +03:00
ze-dom
ddc9482809 Fixed durations and extra projectiles logic
(cherry picked from commit 2f552938d7057b09210e8fc77ff641b304b7f3f7)
2026-07-27 01:03:45 +03:00
ze-dom
00be181d5c Improved plugin description
(cherry picked from commit 60fc09689d498c6d2fca94a0ef00bdbfa3027042)
2026-07-27 01:03:45 +03:00
ze-dom
b884474fa6 Added updateplugin
(cherry picked from commit daf72d784757ca60a05fc1c897f182a5b82f7665)
2026-07-27 01:03:45 +03:00
ze-dom
7166513681 Fixed elf master skill s and added extra projectile attribute
(cherry picked from commit 983db566e6ac315acdcda5d1ac35cdf12d49c8bf)
2026-07-27 01:02:02 +03:00
nolt
7325e1a1d6 Let strangers pick up party money and split it equally
Money has no owner - unlike a dropped item it can be picked up by anyone
standing on it. Deriving the drop's shares from the killer's party made a
stranger's pick up pay nothing and leave the money on the ground, destroying
zen that was previously taken and split by the picking party.

The recorded shares now apply only when the party picking the money up is the
one which earned it; otherwise there is no experience of theirs to follow and
the money is split equally between the picking party, as it was before and as
share-less money (e.g. from an item box) already is.

Covered by DroppedMoneyTest.StrangerPartySplitsPickedUpMoneyEquallyAsync and
EarningPartyReceivesMoneyByExperienceShareAsync.

(cherry picked from commit ae7cad81da6893cfbd1c28bb29811b7f0a10d540)
2026-07-27 00:54:17 +03:00
nolt
d7c589fc45 Keep a money basis for party members at max level without master quest
AwardExperienceAsync returned 0 for a character at the maximum level which has
not completed the master quest: it gains neither normal nor master experience.
Since the money of a kill is now derived from those returned values, such a
member got no zen at all in a party, while the same character still earns zen
when killing solo - AddExpAfterKillAsync returns the calculated amount there,
regardless of whether it could be applied.

The normal experience is now always calculated and returned, and only awarded
when the character can still gain it.

(cherry picked from commit 7cdb34b7c58f5924427b97ea17f26c3fe0f37719)
2026-07-27 00:54:16 +03:00
nolt
6307ecfc24 Derive each player's zen from the experience they actually gained
The money amount of a monster drop is computed as "gained experience + 7", but
in a party it was neither derived from, nor distributed like, that experience.

DistributeExperienceAfterKillAsync returned a party total without the game rate
and the personal experience rates, while AddExpAfterKillAsync returned a value
which had them applied. Both landed in the same variable in OnDeathAsync, so the
party number was orders of magnitude smaller. That mismatch was worked around by
recalculating the killer's solo experience for money purposes, which pinned the
party pool to a solo-sized amount: with the pool then split by member count, a
party of three received about a third of the solo zen each, while its experience
pool grows with the party size.

AwardExperienceAsync already computes the per member experience with all rates
applied, it just discarded it. It now returns that value, the distribution
returns the per member breakdown, and the money is split proportionally to it.
The workaround is gone, the pool follows the experience, and each member's zen
matches their own level, their own rates and their own master/normal branch. The
units lost to the integer division are handed to the shares which were cut the
most, one each, so no member is systematically favoured over many kills.

Two defects around the money rate are fixed on the way:

- MoneyAmountRate was applied for the killer when the drop was created and again
  for the receiver when a party picked it up, so a rate of 3.0 paid a party 9x.
  On the "money straight into the inventory" path for a solo killer it was not
  applied at all. It is now applied exactly once, for the receiver.
- ClampMoneyOnPickup was only honoured for solo pick ups. A party member at
  MaximumInventoryMoney silently lost their share, because the drop was consumed
  as soon as any other member could take one. The clamp now runs on the shared
  payout path, after the money rate, so it clamps the amount actually credited.

Money without a per player breakdown, such as the fixed amount of an item box,
keeps being split equally. Shares of players who are no longer eligible are
redistributed among the remaining ones instead of being lost.

(cherry picked from commit 53f25aca08e690255571f2bb6c796bb7c10934a2)
2026-07-27 00:54:16 +03:00
nolt
9e0bcede2f Cascade delete mini game ranking entries
A character which has participated in a mini game with enabled ranking
statistics could not be deleted. The database rejected it with

  Npgsql.PostgresException 23503: update or delete on table "Character"
  violates foreign key constraint "FK_MiniGameRankingEntry_Character_CharacterId"

MiniGameRankingEntry is a member of neither the character nor the mini game
definition aggregate, so the model generator doesn't emit a delete behavior
for its references. The entity was registered without any additional
configuration, so Entity Framework fell back to no action for these optional
relationships, which the database translates into a restricting foreign key.

Both references are now configured with a cascading delete, matching the
behavior of the other references to a character. With that, all seven
relationships pointing to Character delete their dependents.

This affects both deletion paths: the one of the player
(DeleteCharacterAction) and the one of an administrator, who can remove a
character from the account in the admin panel.

The cascade on the mini game definition is deliberate and part of the same
defect: deleting a definition in the admin panel ran into the very same
restriction. It does mean that the ranking history of a mini game is dropped
together with its definition.

Reported in issue 796.

(cherry picked from commit a391bdedbbd3a36ccb37124125bd7ede4f851dca)
2026-07-27 00:53:27 +03:00
Acentech Dev
d03518deac fix(heykel-savasi): allow /starths restart + accept unencrypted team-select
Some checks failed
.NET Core / build (push) Has been cancelled
Two TvT (Heykel Savasi) event fixes found during mobile+PC client work:

- PeriodicTaskBasePlugIn: a forced start (GM /starths) no longer blocked while a
  previous run's TaskDuration window is still open. When an event finished early
  (team won / empty / before the 20-min duration) players are warped out but the
  periodic state stayed Started with a future NextRunUtc, so the guard returned on
  every tick and a re-start only worked after the full duration or a server
  restart. Force-finish the stale run when _isStartForced so the same execution
  falls through and prepares a fresh one; the NextRunUtc wait now applies to
  scheduled runs only.

- HeykelSavasiTeamSelectHandlerPlugIn: IsEncryptionExpected => false. The AdaMu
  client sends client-to-server packets unencrypted (like walk / animation /
  talk-to-npc), so the server was dropping the team-select (JOIN) packet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:59:38 +03:00
sven-n
67cc57cf31 Merge pull request #840 from nolt/fix-admin-panel-account-search
Search accounts in the database instead of loading all of them

(cherry picked from commit 4e806cbe64f855a765e864c0c2ff45238f487e36)
2026-07-23 11:38:09 +03:00
sven-n
e910845383 Merge pull request #841 from nolt/bot-behaviour
Make the server-side bots hold up as a population

(cherry picked from commit 88535b63a958fee9803d5fc3a4bdac5a3318d221)
2026-07-23 11:38:09 +03:00
sven-n
eb4c05eda2 Merge pull request #839 from vanvonlj/upstream-pr/assume-externally-provisioned-database
Support externally-provisioned databases (opt-in, no drop/create)

(cherry picked from commit 2e117c26764483fb4ee3de9540c9bf7a647d8067)
2026-07-23 11:38:09 +03:00
sven-n
b854565586 Merge pull request #834 from eduardosmaniotto/bugfix/duplicate-rows
fix: duplicate rows in deserialized aggregate collections
(cherry picked from commit f95498770ea1af30530a164f868f571ec30052b5)
2026-07-23 11:38:09 +03:00
sven-n
0e2c94b84b Merge pull request #836 from nolt/speedhack-exempt-offline-players
Don't check offline players for speedhacking

(cherry picked from commit 6380b269770f0c949b03794ca701876c13636513)
2026-07-23 11:38:09 +03:00
sven-n
215f48bfc9 Merge pull request #837 from nolt/fix-money-drop-lock
Release a money drop again when nobody could take it

(cherry picked from commit 83e942e4c778a14eeade8433632a6180ef286e3f)
2026-07-23 11:38:09 +03:00
sven-n
0159849335 Merge pull request #838 from nolt/fix-bot-log-spam
Log the disabled auto-repair once per session

(cherry picked from commit 4636dfaf0832b1c06eee9a2bc4b3b624eb5985e9)
2026-07-23 11:38:09 +03:00
sven-n
93050d5276 Merge pull request #835 from nolt/fix-item-row-leak
Delete item rows when items leave the game

(cherry picked from commit c5eb47f03e726fc300fae352487622b79337911d)
2026-07-23 11:38:08 +03:00
sven-n
dee0e014b1 Merge pull request #832 from eduardosmaniotto/bugfix/pathfind-overflow
bugfix: IndexOutOfRangeException in pathfinding due to byte overflow
(cherry picked from commit 26fc908ba8fccae04c5f7bee0b9a5784d89ae830)
2026-07-23 11:38:08 +03:00
sven-n
54bdf901b8 Merge pull request #833 from eduardosmaniotto/bugfix/account-search
fix: account search typeahead not working
(cherry picked from commit 8f2b32d5d3654d7ef3f271ba3e6a2ad19a2ff212)
2026-07-23 11:38:08 +03:00
sven-n
d04cbecae3 Merge pull request #820 from nolt/feature-bots
(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
2026-07-23 11:38:08 +03:00
sven-n
8baf329654 Merge pull request #786 from ze-dom/DW_master_tree_finishup
(cherry picked from commit 06cc3489b92cc4b83a3a743ac9e29d00c144a303)
2026-07-23 11:38:08 +03:00
sven-n
30feae5a26 Merge pull request #819 from eduardosmaniotto/bugfix/merchant-and-loading
bugfix: standardize loading states and add save spinner to Create pages
(cherry picked from commit 7fb61714feea55d4c765b094c2e5f513ee5da3ba)
2026-07-23 11:37:22 +03:00
sven-n
d22ec20a04 Merge pull request #825 from eduardosmaniotto/feature/configurable-npc-buffs
feature: configurable npc buffs
(cherry picked from commit 6a061fcad756e644dadf3721e84e3cce2a25bceb)
2026-07-23 11:37:22 +03:00
sven-n
ed32ebdfd3 Merge pull request #827 from Rhefew/feature/ip-connection-limit
feat(plugins): implement customizable maximum concurrent connections

(cherry picked from commit 0e30df9ae22a265c3486dea1eba259567631eb87)
2026-07-23 11:35:43 +03:00
sven-n
77df5d1145 Merge pull request #828 from Rhefew/feature/admin-account-search
feat(admin): add account & character search to accounts page

(cherry picked from commit 75a435d7d3ac424f32a271c5275eac102881a80a)
2026-07-23 11:35:43 +03:00
sven-n
e9aec9540f Merge pull request #830 from valentinoConti/fix/modals-crashing-app
Fix modals making app crash

(cherry picked from commit 55ac67312448f4d851c44a1c77734703c9b1d3dd)
2026-07-23 11:35:43 +03:00
Acentech Dev
a76647265c docs(packets): add TvT scoreboard packet documentation
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-23 10:54:13 +03:00
Acentech Dev
107513aeb0 feat(tvt): scoreboard shows up to 10 players (was 8) 2026-07-21 22:42:25 +03:00
Acentech Dev
49ffa5bd89 fix(tvt): count event kills, respawn on the game's own map, end-ceremony countdown
- Kills now credited from OnDeathAsync (the AfterKilledPlayerAsync/PK path is skipped when
  AllowPlayerKilling is true, so event kills never reached the scoreboard).
- Respawn after death now stays on the mini-game's OWN map instance (older-client path fetched the
  shared world map of the same number -> respawned player saw no statues/participants).
- On game end: announce the winner + run a 60s ceremony broadcast loop sending phase-3 HUD countdown
  and the final scoreboard until the Lorencia teleport.
2026-07-21 16:57:12 +03:00
Acentech Dev
538189ea7c feat(tvt): per-character scoreboard (kills, statues, statue damage)
Server: new 0xFD HeykelSavasiScoreboard packet + view plugin; HeykelSavasiContext tracks per-player
kills (Player.AfterKilledPlayerAsync hook), statues broken (OnDestructibleDied), and statue damage
(AttackableNpcBase.AttackByAsync hook); broadcast top-8 by damage (desc) every ~1s, omitting zero-damage
characters.
2026-07-21 16:34:15 +03:00
Acentech Dev
586590e06f feat(tvt): test-phase statue HP override, stationary guards, in-game rename
- Statue HP overridden to 100k at spawn (MaximumHealthOverride) for the test phase - no DB change.
- Guards made fully stationary by overriding their definition's IntelligenceTypeName to
  NullMonsterIntelligence at runtime (no wander, no chase); no DB change.
- Statue/guard in-game names rebranded to 'TvT Statue' / 'TvT Guard' (runtime designation override).
2026-07-21 16:19:07 +03:00
Acentech Dev
a2119b272e feat(tvt): 4 arena bosses at 3 statues, robust guard/boss spawn, reliable GM restart, English strings
- Bosses: 306/309/357/459 spawn once at (55,42)/(32,42)/(31,59)/(56,58) after 3 total statues broken.
- Guards/bosses spawn in a walkable BOX (retry) instead of a fixed point; several statue-guard
  offsets landed on non-walkable terrain and silently failed, leaving statues unbreakable.
- Guard-alive gate uses the ACTUAL spawned count so a statue can always reach 0 -> breakable.
- HeykelSavasiStartPlugIn.OnStartedAsync force-disposes a lingering finished context so GM /starths
  restarts the event immediately (no server restart).
- Statue-break + join messages translated to English.
2026-07-21 15:40:45 +03:00
Acentech Dev
bd5cda12dd fix(minigame): GM ForceStart bypasses the previous-run TaskDuration guard
A forced start (e.g. /starths) was blocked for the whole TaskDuration after a
run even though the event had already ended, so re-starting needed a server
restart. ForceStart now skips the 'previous event still running' guard;
scheduled auto-starts still respect it.
2026-07-21 15:02:45 +03:00
Acentech Dev
0f9db37a92 feat(hs): all statues visible at fixed coords, guard-gated, any-order break with counter+killer message
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:54:43 +03:00
Acentech Dev
a76708c899 chore(hs): statue spawn coordinate message in English 2026-07-21 13:27:37 +03:00
Acentech Dev
ae68e15f77 feat(hs): announce statue spawn coordinates as yellow center message to event players 2026-07-21 13:25:02 +03:00
Acentech Dev
d41d46f24f chore(hs): rename event display name 'Heykel Savasi' -> 'TvT Event' (strings only)
Display strings/messages/designations changed everywhere (Name, entrance
messages, NPC designation, HUD title). Code identifiers/namespaces/commands
unchanged. DB rows updated separately.
2026-07-21 13:01:36 +03:00
Acentech Dev
7312b0e20d fix(minigame): announce entrance-opened for sub-minute registration windows
SendOpenedNotificationsAsync only looped per full minute, so a <1min entrance
(e.g. a 30s test window) sent no golden 'event opened' notification. Now sends
one up front when TotalMinutes==0. >=1min windows unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:55:00 +03:00
Acentech Dev
e45ad4f8d7 feat(hs): spawn statues+guards at random walkable spots (hunt-the-statue)
Instead of a fixed lane, each statue (and its 4 guards) now spawns at a random
walkable coordinate anywhere on the map; breaking one reveals the next at a new
random spot, so players must search for it. Falls back to the fixed positions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:35:17 +03:00
Acentech Dev
fcf2e80570 feat(hs): disable monster transform (EventFormSkin=0); team look via client cape override 2026-07-21 11:16:50 +03:00
Acentech Dev
1e8478fe70 feat(hs): raise statue HP, transform players in-battle, per-player team roster packet, drop wing-unequip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:25:07 +03:00
Acentech Dev
7aae9dff0c feat(hs): disable wings in-event + periodic HUD-state packet/broadcast
- HeykelSavasiContext.IsItemAllowedToEquip disallows wings/capes during
  Playing only (so entry is never rejected for wearing wings); auto-unequips
  any equipped wing/cape into a free inventory slot in OnGameStartAsync.
- New S2C packet HeykelSavasiHudState (C1, code FB, length 11) carrying
  phase, team, counts, statue progress and remaining seconds, plus its view
  plugin/interface, broadcast once per second across registration/prep/
  battle and one-shot on join/statue-break/game-end.
2026-07-21 03:16:29 +03:00
Acentech Dev
5750ab6511 fix(hs): no PvP before battle + clean team assignment on leave
#1: block all player-vs-player damage while State != Playing (registration/
prep), keep same-team friendly-fire off during battle. Prevents pre-battle
kills (which also caused deaths to route to Lorencia instead of a base).
#3: remove a player from their team on OnObjectRemovedFromMapAsync (real
leave to another map), not on same-map death-respawn, so team counts stay
accurate and re-joining works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:18:49 +03:00
Acentech Dev
a174c1a29f fix(hs): robust team-base gate selection by Y-position (not exact coords)
GetTeamSpawnGate matched gates by exact X1/Y1 anchors, which threw
'Sequence contains no matching element' when the DB gate coords drifted a
tile from the code anchors. Now selects the two spawn gates by Y-order
(red=top, blue=bottom) so it works regardless of exact coordinates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:53:58 +03:00
Acentech Dev
583df657fb feat(hs): web-applicable update plugin for the Heykel Savasi event
AddHeykelSavasiEventUpdateSeason6 (version 100): adds map 92 + terrain,
statue/guard monsters, red/blue base gates, event NPC 560 in Lorencia, and
the mini-game definition to an EXISTING config via the admin Updates page
(no DB wipe). Reuses HeykelSavasiMap/HeykelSavasiInitializer; idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:22:51 +03:00
Acentech Dev
917831e198 feat(hs): import LoL map (server 92 / World93) + real base & statue coords
- Embed Terrain93.att (server map No 92 -> loads Terrain93); map Number 90->92.
- Real walkable coords from the LoL arena diamond (center lane x=42):
  Red base (42,10) top tip, Blue base (42,92) bottom tip; 7 statues per team along x=42.
- Client World93 + Object93 deployed to PC client separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:18:54 +03:00
Acentech Dev
790d944757 docs(hs): generated packet docs for HeykelSavasi FA packets
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:58:44 +03:00
Acentech Dev
e6e93450b7 fix(hs): make statues immune to friendly (same-team) damage
A player could destroy their own team's Heykel Savasi statue: the win-condition
attribution (OnDestructibleDied) trusts geometry alone (attacker = Opponent of
defender), and the old friendly-fire guard only covered Player targets, not
Destructible statues. Block same-team damage at the NPC damage intake instead
so only the enemy team can ever land a hit on a statue.
2026-07-21 00:57:15 +03:00
Acentech Dev
e0fafe7e03 feat(hs): /starths force-start command + /hsteam GM test-join command 2026-07-21 00:44:00 +03:00
Acentech Dev
b674e5a2cb feat(hs): periodic scheduled start plugin + config + state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:38:57 +03:00
Acentech Dev
5f03a011db feat(hs): grant fixed Zen to winning team at game end 2026-07-21 00:34:08 +03:00
Acentech Dev
83cb9b9fe4 feat(hs): team-base respawn + reapply earned buffs on respawn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:25:44 +03:00
Acentech Dev
89aef08bed feat(hs): block friendly-fire within the same team 2026-07-21 00:19:43 +03:00
Acentech Dev
ceacc11446 feat(hs): apply mapped class buffs to team on statue break 2026-07-21 00:15:52 +03:00
Acentech Dev
712773c332 feat(hs): sequential statue spawn + guard mobs + win detection
Adds HeykelSavasiContext runtime logic for Task 4.2: per-team statue
break progress/winner tracking (extracted into a pure, testable
StatueProgressState), OnDestructibleDied wiring against the base
MiniGameContext's existing Destructible->Died auto-subscription, and
SpawnStatueAsync which spawns the next statue (561) + 4 guards (580)
via the map initializer using placeholder line-of-statues coordinates.
OnGameStartAsync now spawns each team's first statue after the warp.
2026-07-21 00:07:00 +03:00
Acentech Dev
32e05e2bc1 feat(hs): define statue (destructible) + guard monster definitions 2026-07-20 23:52:34 +03:00
Acentech Dev
372929a8d1 fix(hs): roll back team reservation if TryEnterAsync throws 2026-07-20 23:48:36 +03:00
Acentech Dev
cd55e726f1 feat(hs): team-select handler + join action with atomic balance reserve and base warp 2026-07-20 23:42:07 +03:00
Acentech Dev
7e954b610f feat(hs): NPC talk plugin opens team panel when registration is open 2026-07-20 23:33:55 +03:00
Acentech Dev
2efe1ef84d feat(hs): view plugin to open team-select panel 2026-07-20 23:28:04 +03:00
Acentech Dev
f5e1f46444 feat(hs): add open-team-panel (S2C) and team-select (C2S) packets 2026-07-20 23:22:33 +03:00
Acentech Dev
cc0b7abb44 feat(hs): add event NPC 560 and place it in Lorencia
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:15:52 +03:00
Acentech Dev
5bc5f461e5 feat(hs): warp teams to base on start + cancel if a team is empty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:09:39 +03:00
Acentech Dev
8295c30b71 feat(hs): wire HeykelSavasiContext in GetMiniGameAsync + team gate resolver 2026-07-20 23:04:21 +03:00
Acentech Dev
968eb0c155 feat(hs): HeykelSavasiContext team tracking + balance rule
Adds the HeykelSavasiTeam enum (None/Red/Blue) and the HeykelSavasiContext
skeleton (subclass of MiniGameContext) with team registration
(AssignTeam/GetTeam/PlayersOf/TeamCount) and a join-balance rule
(|Red-Blue| <= 2 after the prospective join), exposed as a pure static
IsJoinAllowed for fast unit testing without constructing the full context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:59:15 +03:00
Acentech Dev
5d0f475472 feat(hs): add HeykelSavasi MiniGameDefinition initializer 2026-07-20 22:44:38 +03:00
Acentech Dev
b90013c4bb feat(hs): add MiniGameType.HeykelSavasi 2026-07-20 22:41:00 +03:00
Acentech Dev
732196f623 feat(hs): add red/blue base exit gates on Heykel Savasi map 2026-07-20 22:38:20 +03:00
Acentech Dev
5a354fb419 feat(hs): add Heykel Savasi event map skeleton (map 90)
Adds HeykelSavasiMap (BaseMapInitializer, Number=90/World91) as an empty
terrain-only skeleton and registers it in GameMapsInitializer. Statue/guard
monster definitions and spawns are added by later Heykel Savasi tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:31:46 +03:00
312 changed files with 54526 additions and 1382 deletions

View File

@@ -4,7 +4,12 @@
# set edilirse onu, edilmezse aşağıdaki default'u kullanır.
services:
openmu-startup:
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:latest} # <-- kendi DockerHub image'imiz (Task 3)
# Production'da DAIMA commit-SHA etiketi kullanilir, asla :latest. :latest degisken
# bir isim oldugu icin Coolify onu yerelde tanidiginda yeniden cekmez ve eski katman
# sessizce calismaya devam eder (2026-07-31'de tam olarak bu oldu: sunucu 26 Temmuz
# image'ini calistiriyordu). SHA etiketi daha once gorulmedigi icin pull zorunlu olur.
# Yeni surum yayinlarken bu satiri yeni SHA ile guncelle.
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:d60aad7d2f55}
container_name: openmu-startup
networks:
- coolify

327
docs/Bots.md Normal file
View File

@@ -0,0 +1,327 @@
# Server-side AI Bots
Bots are persistent, autonomous characters which populate a server like real
players: they hunt with the skills of their class, level up, spend their points,
keep their buffs up, pick up and wear better gear, restock in town, group up,
defend themselves, and come and go over the day. A player who meets one should
not be able to tell it apart from a quiet human player.
They are driven entirely by the server. No game client is involved and no
packets are exchanged: a bot is a connection-less `OfflinePlayer` — the same
class which keeps a character playing after its owner logs out — with a
navigator on top which gives it a life of its own.
The feature is disabled by default. Enabling it is always a deliberate act of
the server admin.
## How it works
**Bots are ordinary accounts.** Each one is a regular `Account` with the `IsBot`
flag, holding up to five characters with generated names, levels, classes,
stats, skills and starter gear. They are created once, saved like any other
character, and reloaded on every start — a bot's progress belongs to the
server's data, not to a process. Every bot animates one character in its own
persistence context, so the characters of one account can play at the same time.
**Two ticks make up the mind of a bot.** The offline MU Helper AI runs twice a
second and does what it does for a human's offline session: attack, heal, buff,
pick items up. On top of it, a bot navigator runs every second and decides the
things an offline session never had to: where to hunt, when to travel or warp,
when to go shopping, whom to follow. Everything a bot changes about itself —
equipping, jewels, resets, master points — is queued into the AI tick, so it
never runs while the combat handler is working on the same character.
**Bots act through the regular player actions.** Moving an item, talking to a
merchant, consuming a jewel, entering an event: a bot goes through the same
actions with the same validations a client's packet would trigger. It cannot do
anything a player could not do, and rule changes apply to bots for free.
**The population is split over the game servers.** Bots count towards the player
count of their server exactly like players do, and a server which reached its
maximum player count turns new clients away — so a population large enough to
fill a server would lock the players out of it. `Bot capacity %` (60 by default)
is the share of a server's player limit its bots may occupy; the rest stays
reserved for the players. Which accounts a server animates is a pure function of
the account index and the set of configured game servers, so every server
computes the same split without asking the others — which also holds when each
game server runs as its own process. Exactly one server generates the
population, so accounts and character names are never created twice. Accounts
which do not fit stay offline until the deployment offers the room for them.
## Configuration
The *Bots* feature plugin, in the "Feature Plugins" section of the admin panel:
- **`Enabled`** — spawns the bots after the server has started. Off by default.
- **`Number of accounts`** — how many bot accounts to maintain.
- **`Characters per account`** — how many characters each account animates at
once (at most five).
- **`Bot capacity %`** — the share of a game server's maximum player count its
bots may occupy; the rest is reserved for the players.
- **`Presence rotation`** — bots log in and out over the day instead of all
being online around the clock.
- **`Min. online share %`** — how much of the population stays online at the
quietest hour.
- **`Bots pay reset costs`** — whether bots pay the configured zen and item
costs for their resets. Off by default: they take no part in the economy those
costs are balanced for.
- **`Jewel stock per kind`** — how many Jewels of Bless, Soul and Life a bot
keeps of each kind. Above it, it stops picking them up and sells what it
already carries. Depends on the server's drop rates: on high rates a small
stock keeps the backpack free, on low rates a larger one is never reached
anyway.
- **`Potion stock charges`** — how many charges of healing and of mana potions a
bot restocks to at a merchant. Depends on what the shops sell: a server whose
potions come in stacks of 255 fills the target in a single purchase.
- **`Reset bots`** — deletes the whole population and generates it again, then
clears itself. With the number of accounts set to zero it deletes without
generating anything.
- **`Purge bots`** — deletes the whole population WITHOUT generating a new one,
and switches the feature off. It works with the feature enabled or disabled;
the switch-off is what makes it a purge, since the same pass would otherwise
create the population again right after deleting it. Clears itself afterwards.
## What a bot does
### Hunting and travelling
A bot hunts where the monsters actually are: it scans its surroundings for live
monsters instead of walking to a spawn point which may be empty. Long distances
are covered with a cached route over the whole map, walked a few steps at a
time, so the bot can stop and fight on the way.
It only engages what it can survive. The decision is made against the monster's
real damage, defense and attack rate versus the bot's own defense, health and
chance to be hit — a monster's nominal level says little about its punch on the
high-end maps. An agility build's dodge therefore counts as the defense it
really is, and better gear opens tougher maps, exactly like for a player.
Map access follows the game's warp list, and a bot travels on a player's terms:
it enters a map only if its level may legally warp there, it meets the map's own
requirements, and it pays the warp's fare out of its own Zen. A bot which finds
itself on a map it may not be on (after a reset, for instance) leaves for the
best map it may use. The map it reached is persisted, so a restarted bot wakes up
where it stopped.
Which map it goes to is drawn rather than maximized. The maps of a level band
differ by a few monster levels, so always taking the strongest one made it every
bot's answer and left the rest of the band deserted. The best map still wins
about a third of the picks and the runners-up split the rest, so the population
spreads over the maps a player of that band would choose between. Every map in
the draw is one the bot may legally reach, can afford, and which is better than
where it stands - the draw only decides between improvements.
A map can pass every check and still pay nothing: the monsters the bot may fight
are a rare kind among ones it must refuse, or other hunters empty the grounds
first. The bot notices the way a player would, by having landed no hit in
minutes, and steps down to easier ground one notch at a time until it finds
something it can farm; the regular map choice carries it back up as its level and
gear recover. A bot which cannot afford any trip at all walks home to its class
town for free, so it is never stranded on ground it cannot earn on.
### Fighting and progressing
A bot fights with the strongest skill of its class it has learned and can pay
for; casters keep their distance and drink mana. Between skills worth about the
same it takes the one with the longer reach - the flat bonus of a spell is a
rounding error next to a high-level character's own damage, while three tiles of
range are three tiles at any level. Skills the game only activates during a
castle siege are left out, and so are a pet's skills unless the pet is actually
equipped: Plasma Storm draws its damage from the Fenrir, but the attribute behind
it is derived from the character's own stats, so nothing but the pet slot tells a
mounted character from one riding nothing. Skills are learned against the
game's own requirements — total energy, leadership, character level — at
generation and again on every level-up, and the class buffs are kept up on their
own.
A skill the character cannot currently cast is passed over, in the attack
rotation and in the buffs alike. That is not the same as not having learned it: a
reset keeps every skill but takes back the level which unlocked it, so a veteran
back at level 12 still owns Swell Life, which asks for level 120. The game
refuses such a cast silently, so a character which kept trying would simply stand
there — buffing something that never takes effect, and never getting as far as
attacking.
Level-up points follow a per-class build modelled on what players actually play:
an agility/shield meta on reset servers, guide-style builds on classic ones,
chosen automatically by whether the reset feature is configured. Classes with
two viable archetypes (a warrior or a wizard Magic Gladiator, a pure or an
energy Blade Knight) roll one per bot, and a stat which hits a server's maximum
overflows into the rest of the build.
Bots evolve like players do. The second-generation class change happens at level
200 — the same assignment the class-change quest performs — and the master class
at the game's maximum level, followed by a relog, because the master attributes
only mount when a character enters the world. Master points go into the master
skill tree through the regular action, with its rank gates and skill
requirements, preferring passives which boost a stat and strengtheners of skills
the bot actually uses; a bonus tied to a weapon type the bot does not fight with
is never bought. On a server with the reset feature, a bot only masters once its
reset limit is exhausted — while resets remain, resetting is what players do, so
the bots do it too.
A mastered bot changes what it hunts. Master experience is only granted for
monsters of at least `Minimum monster level for master experience` (95 in the
default configuration), and a character at the maximum level earns nothing
else — so below that line a kill pays a mastered bot nothing at all. It
therefore looks for maps which hold such monsters, and takes the weakest ones
above the line rather than the strongest: master experience hardly grows with
the monster's level, so the cheapest kill above it is the best one. Those
monsters carry 40.000+ health, well beyond the hit budget a bot's usual gear
affords, so the budget is stretched for them — a slow fight it survives beats a
quick one worth nothing. What is not stretched is its survivability: a monster
whose hits the bot cannot take is refused, mastered or not.
### Items and money
Dropped gear is judged before it is picked up: a bot collects what it can wear
and what is worth money, and leaves the rest lying. An upgrade is put on through
the regular move-item action, with the whole swap planned first — which slot,
and which pieces have to come off, including the other hand for a two-handed
weapon. If the engine refuses the equip after all, the old gear goes straight
back on. The replaced piece stays in the backpack and is sold on the next trip
to town, rather than being dropped where the next bot would pick it up again.
A merchant trip is the only moment a bot can turn loot into anything, so it goes
whenever it has something to gain there: the backpack is filling with junk, the
potions are running low, a jewel is waiting to be used, or a surplus is waiting
to be sold. Restocking needs the means to pay for it, though - Zen, or loot to
sell once it is there. A broke bot buys nothing, so the trip would leave it just
as short as it set out, and it would set out again instead of hunting, which is
the only way it could have earned the money. It picks the merchant which sells what it needs right now, and on a
map whose merchants sell no potions while it needs some, it warps home to a real
town instead. While the shop dialog visibly occupies it, the bot sells its junk,
repairs its gear — which is what earns the NPC's discount — and buys potions and,
where a shop offers them, jewels.
Only Jewels of Bless, Soul and Life are collected, and only up to the configured
stock: a bot cannot trade or craft, so any other kind would be a backpack slot it
never gets back. They are spent on its own equipment through the regular consume
action, with the same success rates and failure penalties a player faces, and
with the caution a player shows: a Soul is only risked where a failure cannot
destroy the item's level.
A bot which reaches the server's maximum inventory money can no longer sell
anything — the money simply does not fit. What it cannot sell it keeps, and
destroys only what has no other way out: jewels beyond its stock, and, while the
backpack is genuinely full, junk gear. The repair bill is what normally keeps it
away from that limit in the first place.
Wings do not drop, so bots earn them at the classic milestones instead — the
first pair at level 180, the second at 280 and the third, master-only pair at
400. Which class wears which pair comes from the item data, and the outgrown
pair is destroyed rather than dropped.
### Mini game events
A bot never enters Blood Castle, Devil Square or Chaos Castle on its own — it
has no ticket and does not farm for one. It enters when a player who leads a
party with bots enters with their own ticket: the leader's entry legitimizes the
visit for the whole group.
Each bot is checked against the entry restrictions a player faces (the level
bracket, including the separate one for the special characters, the master-class
requirement, the player-killer rule). A bot which does not qualify leaves the
party and goes back to its own life instead of blocking the entry.
Inside, its open-world routine is suspended: no shopping, no map changes, no
boredom, no grudges. It fights what the event throws at it and keeps up with the
leader. Chaos Castle is a free-for-all, so there the other participants are
targets like everyone else — and a fight inside leaves no grudge outside. A bot
which dies respawns in the safezone like a player, which takes it out of the
event; the survivors are warped out when the event ends.
### Company and rhythm
Bots hunt in parties of two to five, grouped by level so the whole party can
hunt the leader's maps. The elf heals, the buffs are shared, the party
experience bonus applies. Parties re-form every hour.
A player may invite a bot into their own party: it accepts after a human-like
pause of a few seconds, as long as it is not in the middle of an errand. There is
no level gate — just like OpenMU's own party action, a bot accepts an inviter of
any level, since it is the player who invites and the bot leaves once it gets
bored. A living player takes precedence over the bot's own company
— a bot hunting with other bots leaves them for the inviter, and breaks that bot
party up if it was leading it, so a player never has to guess which bot happens
to be free. In a party the bot follows its leader, defers a due reset, and
eventually leaves politely: when the leader enters a map it may not access,
before its own logout, or simply when it gets bored.
A bot fights back when a player attacks it, but only as far as the game's own
PvP rules allow: inside the active self-defense window, or against a player
already flagged as a killer. It can therefore never be provoked into becoming an
outlaw that players could farm for free. It remembers who hit it, and a killed
bot walks back to its killer — waiting for a legal opening rather than taking
one.
Over the day, the presence rotation logs bots in and out: fewest in the early
morning, most in the evening, and never more than one at a time, so the
population ebbs and flows instead of appearing and vanishing in blocks.
### Keeping itself alive
The engine's attribute system is not thread-safe, and a lost race can corrupt a
character's attribute graph for good. A bot which hits it stops playing and
throws on every following tick. Rather than leave it lying there, a bot counts
the ticks which fail in a row and, after twenty of them, has itself restarted: a
fresh login rebuilds the attribute graph and heals it — the same thing a player
would do. A single failing tick is skipped, as before.
## What it costs
Measured on a 12-core host, as a rough guide for capacity planning:
| Population | CPU | Memory |
| --- | --- | --- |
| 250 bots | ~0.35 core | ~760 MB |
| 1100 bots | ~1.7 cores | ~1.2 GiB |
Generating a fresh population costs about a second per account (the password
hash dominates); starting an existing one of 1100 bots takes some 15 seconds.
## Known limitations
- **The engine's races are hit more often.** Neither `MagicEffectsList` nor
`ComposableAttribute` is thread-safe, and a thousand bots run into them more
often than human players do: a few caught exceptions per minute. A bot whose
attribute graph gets corrupted restarts itself (see above); a real fix belongs
into the engine, not into the bots.
- **Master skills which cost ten points at once are never learned.** A bot
invests every point as it earns it, so it never holds ten of them, and the
branches of the tree behind such a skill stay untouched.
- **The Summoner's enemy debuffs (Sleep, Weakness, Innovation) are unused.**
Deliberate: they would be cast through the buff rotation, which would have the
bot put itself to sleep. A cast-on-enemy path in the combat handler would be
needed.
- **Bots never buy equipment.** They wear what they find, so their gear lags
behind their level, and a bot at the maximum level is weaker than a player of
the same level would be. It is the reason a mastered bot needs a stretched hit
budget to reach the monsters which pay master experience at all. Letting bots
spend their money on gear would close the loop; they earn plenty of it.
- **Bots do no quests and do not trade with players.** Deliberate scope. The
quests which matter for progression (the class changes) are performed
directly, and trading would be an abuse surface.
- **Adding a game server to a running deployment does not spread the bots onto
it before a restart.** Deliberate: moving a bot between two running servers
would animate one account from two persistence contexts, which corrupts the
character.
## Enabling it on a server
1. Enable the *Bots* plugin and set the number of accounts. Each account
animates up to five characters, so 50 accounts × 5 = 250 bots.
2. Check that the population fits. The bots of a game server may occupy
`Bot capacity %` of its player limit — with the default of 60 %, a server for
1000 players hosts up to 600 bots. What does not fit stays offline, and the
plugin says so in the log: raise the player limit, raise the share, or add a
game server, over which the population then spreads by itself.
3. Restart the server. The population is generated on the first start and
reloaded afterwards.
4. To build a fresh population, set `Reset bots`: it deletes the old one,
generates a new one, and clears the flag again.
5. To stop the bots without losing them, uncheck `Enabled`: they log out within
a few seconds and nothing is deleted, so checking it again brings the same
characters back. To get rid of them for good, set `Purge bots` — it deletes
every bot account with its characters, items and storages, and leaves the
feature switched off.

View File

@@ -2,7 +2,7 @@
## Is sent when
A player cancels a specific magic effect of a skill, usually 'Infinity Arrow' and 'Wizardy Enhance'.
A player cancels a specific magic effect of a skill, usually 'Infinity Arrow' and 'Wizardry Enhance'.
## Causes the following actions on the server side

View File

@@ -0,0 +1,18 @@
# C1 F5 00 - ChatCommandListRequest (by client)
## Is sent when
A client which supports a user interface for chat commands requests the list of commands which are available to the player. It's usually sent after the character entered the game world.
## Causes the following actions on the server side
The server sends an AvailableChatCommand message for each available chat command.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 4 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xF5 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x00 | Packet header - sub packet type identifier |

View File

@@ -0,0 +1,19 @@
# C1 FA - HeykelSavasiOpenTeamPanel (by server)
## Is sent when
The Heykel Savasi (Statue War) event is available and the team selection panel should be shown to the player.
## Causes the following actions on the client side
The client opens the team selection panel, showing the current balance of red and blue team members.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 5 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xFA | Packet header - packet type identifier |
| 3 | 1 | Byte | | RedCount |
| 4 | 1 | Byte | | BlueCount |

View File

@@ -0,0 +1,18 @@
# C1 FA - HeykelSavasiTeamSelect (by client)
## Is sent when
A player selects a team (Red or Blue) in the Heykel Savasi (Statue War) team selection panel.
## Causes the following actions on the server side
The server assigns the player to the requested team, if possible, and updates the event state accordingly.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 4 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xFA | Packet header - packet type identifier |
| 3 | 1 | Byte | | Team; 1 = Red team, 2 = Blue team. |

View File

@@ -0,0 +1,24 @@
# C1 FB - HeykelSavasiHudState (by server)
## Is sent when
Periodically (about once per second) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played.
## Causes the following actions on the client side
The client updates its Heykel Savasi HUD panel (team counts, statue progress, and remaining time).
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 11 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xFB | Packet header - packet type identifier |
| 3 | 1 | Byte | | Phase; 0 = registration open, 1 = preparation countdown, 2 = battle, 3 = ended. |
| 4 | 1 | Byte | | MyTeam; 0 = none, 1 = red, 2 = blue. |
| 5 | 1 | Byte | | RedCount |
| 6 | 1 | Byte | | BlueCount |
| 7 | 1 | Byte | | RedProgress; The number (0-7) of statues the red team has destroyed of the blue team's line. |
| 8 | 1 | Byte | | BlueProgress; The number (0-7) of statues the blue team has destroyed of the red team's line. |
| 9 | 2 | ShortBigEndian | | RemainingSeconds; The number of seconds left in the current phase. |

View File

@@ -0,0 +1,30 @@
# C1 FC - HeykelSavasiTeamRoster (by server)
## Is sent when
Periodically (about once every one to two seconds) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played.
## Causes the following actions on the client side
The client tints each listed nearby player red or blue according to its team.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | | Packet header - length of the packet |
| 2 | 1 | Byte | 0xFC | Packet header - packet type identifier |
| 3 | 1 | Byte | | Count; The number of player-team entries which follow. |
| 4 | PlayerTeam.Length * Count | Array of PlayerTeam | | Players |
### PlayerTeam Structure
Maps a player's network id to its Heykel Savasi team.
Length: 3 Bytes
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 2 | ShortBigEndian | | PlayerId; The network id of the player (same id used in the viewport/appearance packets). |
| 2 | 1 | Byte | | Team; 1 = red, 2 = blue. |

View File

@@ -0,0 +1,32 @@
# C1 FD - HeykelSavasiScoreboard (by server)
## Is sent when
Periodically (about once every one to two seconds) while the TvT Event (Statue War) is being played.
## Causes the following actions on the client side
The client updates its TvT Event scoreboard panel, listing the top contributing characters ordered by statue damage.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | | Packet header - length of the packet |
| 2 | 1 | Byte | 0xFD | Packet header - packet type identifier |
| 3 | 1 | Byte | | Count; The number of scoreboard entries which follow. |
| 4 | ScoreEntry.Length * Count | Array of ScoreEntry | | Entries |
### ScoreEntry Structure
A character's TvT Event contribution: kills, statues broken, and total statue damage.
Length: 17 Bytes
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 10 | String | | Name; The character name. |
| 10 | 2 | ShortBigEndian | | Kills; Number of enemy-team players this character killed. |
| 12 | 1 | Byte | | Statues; Number of statues this character broke (dealt the killing blow to). |
| 13 | 4 | IntegerBigEndian | | Damage; Total damage this character dealt to statues. |

View File

@@ -0,0 +1,60 @@
# C2 F5 01 - AvailableChatCommand (by server)
## Is sent when
After the client requested the list of available chat commands. One message is sent for each command which is available to the player.
## Causes the following actions on the client side
The client adds the command to its list of known commands, so that it can offer them to the player without requiring him to know or type them.
## Structure
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) |
| 1 | 2 | Short | | Packet header - length of the packet |
| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier |
| 4 | 1 | Byte | 0x01 | Packet header - sub packet type identifier |
| 5 | 1 | Byte | | Index; The index of this command within the list, starting at 0. |
| 6 | 1 | Byte | | Count; The total number of commands which are available to the player, so that the client knows when it received all of them. |
| 7 | 1 | CharacterStatus | | MinimumCharacterStatus; The character status which is required to execute the command. |
| 8 | 1 | Byte | | ParameterCount |
| 9 | 32 | String | | Command; The command including its slash, e.g. '/item'. |
| 41 | 48 | String | | Name; The name of the command, in the language of the player. |
| 89 | 256 | String | | Description; The description of the command, in the language of the player. |
| 345 | ChatCommandParameter.Length * ParameterCount | Array of ChatCommandParameter | | Parameters; The parameters of the command, in the order in which they are expected when they are entered without their short names. |
### ChatCommandParameter Structure
Describes one parameter of a chat command, so that a user interface can offer an input for it.
Length: 102 Bytes
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Boolean | | IsRequired; Defines if the parameter has to be specified to execute the command. |
| 1 | 1 | ChatCommandParameterType | | Type; The kind of value which is expected, so that a fitting input can be shown. |
| 2 | 32 | String | | Name |
| 34 | 20 | String | | ShortName; The short name which is used in the 'shortName=value' notation. It's empty when the parameter can only be passed by its position. |
| 54 | 48 | String | | ValidValues; The accepted values, separated by a pipe. It's empty when the parameter isn't limited to a set of values. |
### ChatCommandParameterType Enum
The kind of value which a chat command parameter expects.
| Value | Name | Description |
|-------|------|-------------|
| 0 | Text | The parameter expects a text. |
| 1 | Number | The parameter expects a number. |
| 2 | Boolean | The parameter expects a 0 or a 1. |
### CharacterStatus Enum
The status of a character.
| Value | Name | Description |
|-------|------|-------------|
| 0 | Normal | The state of the character is normal. |
| 1 | Banned | The character is banned from the game. |
| 32 | GameMaster | The character is a game master. |

View File

@@ -179,6 +179,7 @@
* [C1 F3 15 - FocusCharacter (by client)](C1-F3-15-FocusCharacter_by-client.md)
* [C1 F3 30 - SaveKeyConfiguration (by client)](C1-F3-30-SaveKeyConfiguration_by-client.md)
* [C1 F3 52 - AddMasterSkillPoint (by client)](C1-F3-52-AddMasterSkillPoint_by-client.md)
* [C1 F5 00 - ChatCommandListRequest (by client)](C1-F5-00-ChatCommandListRequest_by-client.md)
* [C1 F6 0A - QuestSelectRequest (by client)](C1-F6-0A-QuestSelectRequest_by-client.md)
* [C1 F6 0B - QuestProceedRequest (by client)](C1-F6-0B-QuestProceedRequest_by-client.md)
* [C1 F6 0D - QuestCompletionRequest (by client)](C1-F6-0D-QuestCompletionRequest_by-client.md)
@@ -194,3 +195,4 @@
* [C1 F8 03 - GensLeaveRequest (by client)](C1-F8-03-GensLeaveRequest_by-client.md)
* [C1 F8 09 - GensRewardRequest (by client)](C1-F8-09-GensRewardRequest_by-client.md)
* [C1 F8 0B - GensRankingRequest (by client)](C1-F8-0B-GensRankingRequest_by-client.md)
* [C1 FA - HeykelSavasiTeamSelect (by client)](C1-FA-HeykelSavasiTeamSelect_by-client.md)

View File

@@ -230,6 +230,7 @@
* [C1 F3 51 - MasterCharacterLevelUpdateExtended (by server)](C1-F3-51-MasterCharacterLevelUpdateExtended_by-server.md)
* [C1 F3 52 - MasterSkillLevelUpdate (by server)](C1-F3-52-MasterSkillLevelUpdate_by-server.md)
* [C2 F3 53 - MasterSkillList (by server)](C2-F3-53-MasterSkillList_by-server.md)
* [C2 F5 01 - AvailableChatCommand (by server)](C2-F5-01-AvailableChatCommand_by-server.md)
* [C1 F6 03 - QuestEventResponse (by server)](C1-F6-03-QuestEventResponse_by-server.md)
* [C1 F6 0A - AvailableQuests (by server)](C1-F6-0A-AvailableQuests_by-server.md)
* [C1 F6 0B - QuestStepInfo (by server)](C1-F6-0B-QuestStepInfo_by-server.md)
@@ -241,3 +242,7 @@
* [C1 F6 1B - QuestState (by server)](C1-F6-1B-QuestState_by-server.md)
* [C2 F6 1B - QuestStateExtended (by server)](C2-F6-1B-QuestStateExtended_by-server.md)
* [C3 F9 01 - OpenNpcDialog (by server)](C3-F9-01-OpenNpcDialog_by-server.md)
* [C1 FA - HeykelSavasiOpenTeamPanel (by server)](C1-FA-HeykelSavasiOpenTeamPanel_by-server.md)
* [C1 FB - HeykelSavasiHudState (by server)](C1-FB-HeykelSavasiHudState_by-server.md)
* [C1 FC - HeykelSavasiTeamRoster (by server)](C1-FC-HeykelSavasiTeamRoster_by-server.md)
* [C1 FD - HeykelSavasiScoreboard (by server)](C1-FD-HeykelSavasiScoreboard_by-server.md)

View File

@@ -140,6 +140,9 @@ database (e.g. RavenDB).
* [Master Skill System](MasterSystem.md): Description about the master skill system
* [Server-side AI Bots](Bots.md): Description about the bots which populate
the server
* [GameMap](GameMap.md): Description about the GameMap implementation
* [Progress](Progress.md): Information about the feature implementation

View File

@@ -1,12 +1,6 @@
@inherits LayoutComponentBase
<div class="page">
<!--
<div class="sidebar">
<NavMenu />
</div>
<BlazoredToasts />
-->
<main>
<div class="top-row px-4">
<BreadcrumbNavigation />

View File

@@ -9,9 +9,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.Shared

View File

@@ -0,0 +1,30 @@
// <copyright file="Buff.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// A buff which can be granted by an NPC.
/// </summary>
[Cloneable]
public partial class Buff
{
/// <summary>
/// Gets or sets the magic effect definition which defines the buff and its duration.
/// </summary>
[MemberOfAggregate]
public virtual MagicEffectDefinition? MagicEffectDefinition { get; set; }
/// <summary>
/// Gets or sets the minimum character level to be allowed to receive this buff. Optional.
/// </summary>
public int? MinimumLevel { get; set; }
/// <summary>
/// Gets or sets the maximum character level to be allowed to receive this buff. Optional.
/// </summary>
public int? MaximumLevel { get; set; }
}

View File

@@ -0,0 +1,152 @@
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// Main configuration for the castle siege event.
/// </summary>
[Cloneable]
public partial class CastleSiegeConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the castle siege feature is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the number of seconds a guild must hold the crown to capture the castle.
/// </summary>
public int CrownHoldTimeSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the minimum combined level of a guild master required to register for the siege.
/// </summary>
public int RegisterMinLevel { get; set; } = 200;
/// <summary>
/// Gets or sets the minimum number of guild members required to register for the siege.
/// </summary>
public int RegisterMinMembers { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum number of seconds a participant must be present in the battle to be eligible for a reward.
/// </summary>
public int ParticipantRewardMinSeconds { get; set; }
/// <summary>
/// Gets or sets the maximum number of attacking alliance slots.
/// </summary>
public int MaxAttackingGuilds { get; set; } = 3;
/// <summary>
/// Gets or sets the guild score awarded to the guild that wins the siege.
/// </summary>
public int GuildScoreCastleSiege { get; set; }
/// <summary>
/// Gets or sets the guild score awarded to alliance member guilds of the winning side.
/// </summary>
public int GuildScoreCastleSiegeMembers { get; set; }
/// <summary>
/// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed gate.
/// </summary>
public int GateBuyPrice { get; set; }
/// <summary>
/// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed statue.
/// </summary>
public int StatueBuyPrice { get; set; }
/// <summary>
/// Gets or sets the map definition for the Valley of Loren (map 30), where the siege takes place.
/// </summary>
public virtual GameMapDefinition? CastleSiegeMapDefinition { get; set; }
/// <summary>
/// Gets or sets the map definition for the Land of Trials (map 31), the castle-owner's exclusive zone.
/// </summary>
public virtual GameMapDefinition? LandOfTrialsMapDefinition { get; set; }
/// <summary>
/// Gets or sets the item definition for the participation reward item.
/// </summary>
public virtual ItemDefinition? RewardItemDefinition { get; set; }
/// <summary>
/// Gets or sets the schedule entries that define when each siege state begins.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeStateScheduleEntry> StateSchedule { get; protected set; } = null!;
/// <summary>
/// Gets or sets the definitions for all castle siege NPCs (gates, statues, etc.).
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeNpcDefinition> NpcDefinitions { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for gate defense.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> GateDefenseUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for gate maximum HP.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> GateLifeUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue defense.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueDefenseUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue maximum HP.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueLifeUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the upgrade levels for statue HP regeneration.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeUpgradeDefinition> StatueRegenUpgrades { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zones on the siege map where attacking siege machines may be placed.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeZoneDefinition> AttackMachineZones { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zones on the siege map where defensive siege machines may be placed.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeZoneDefinition> DefenseMachineZones { get; protected set; } = null!;
/// <summary>
/// Gets or sets the zone where defending players respawn during the siege.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeZoneDefinition? DefenseRespawnArea { get; set; }
/// <summary>
/// Gets or sets the zone where attacking players respawn during the siege.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeZoneDefinition? AttackRespawnArea { get; set; }
/// <inheritdoc />
public override string ToString()
{
return "Castle Siege Configuration";
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="CastleSiegeJoinSide.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Defines the side (defending or attacking) a guild or NPC belongs to in the castle siege.
/// </summary>
public enum CastleSiegeJoinSide : byte
{
/// <summary>
/// No side assigned.
/// </summary>
None = 0,
/// <summary>
/// The defending guild side.
/// </summary>
Defense = 1,
/// <summary>
/// The first attacking alliance slot.
/// </summary>
Attack1 = 2,
/// <summary>
/// The second attacking alliance slot.
/// </summary>
Attack2 = 3,
/// <summary>
/// The third attacking alliance slot.
/// </summary>
Attack3 = 4,
}

View File

@@ -0,0 +1,55 @@
// <copyright file="CastleSiegeNpcDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a castle siege NPC instance, including its spawn location, side, and persistence settings.
/// </summary>
[Cloneable]
public partial class CastleSiegeNpcDefinition
{
/// <summary>
/// Gets or sets the monster definition template for this NPC.
/// </summary>
public virtual MonsterDefinition? MonsterDefinition { get; set; }
/// <summary>
/// Gets or sets the unique instance identifier within its NPC type.
/// </summary>
public byte InstanceId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this NPC's state is persisted to the database between sieges.
/// </summary>
public bool IsPersistedToDatabase { get; set; }
/// <summary>
/// Gets or sets the default join side this NPC belongs to.
/// </summary>
public CastleSiegeJoinSide DefaultSide { get; set; }
/// <summary>
/// Gets or sets the X coordinate of the NPC's spawn position.
/// </summary>
public byte SpawnX { get; set; }
/// <summary>
/// Gets or sets the Y coordinate of the NPC's spawn position.
/// </summary>
public byte SpawnY { get; set; }
/// <summary>
/// Gets or sets the facing direction of the NPC at spawn.
/// </summary>
public Direction Direction { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.MonsterDefinition} #{this.InstanceId} at ({this.SpawnX},{this.SpawnY})";
}
}

View File

@@ -0,0 +1,61 @@
// <copyright file="CastleSiegeState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The state of the castle siege event cycle.
/// </summary>
public enum CastleSiegeState : byte
{
/// <summary>
/// Idle state before guild registration opens.
/// </summary>
Idle1 = 0,
/// <summary>
/// Guilds may register for the siege.
/// </summary>
RegisterGuild = 1,
/// <summary>
/// Idle state after guild registration.
/// </summary>
Idle2 = 2,
/// <summary>
/// Guilds may register emblems (Marks of Lord) to determine the attacking guilds.
/// </summary>
RegisterMark = 3,
/// <summary>
/// Idle state after mark registration.
/// </summary>
Idle3 = 4,
/// <summary>
/// Players are notified that the siege is about to start.
/// </summary>
Notify = 5,
/// <summary>
/// The siege map is prepared and entry is allowed.
/// </summary>
Ready = 6,
/// <summary>
/// The siege battle is in progress.
/// </summary>
Start = 7,
/// <summary>
/// The siege battle has ended and results are being processed.
/// </summary>
End = 8,
/// <summary>
/// The full siege cycle has completed.
/// </summary>
EndCycle = 9,
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeStateScheduleEntry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a scheduled transition to a specific <see cref="CastleSiegeState"/> at a given day and time.
/// </summary>
[Cloneable]
public partial class CastleSiegeStateScheduleEntry
{
/// <summary>
/// Gets or sets the siege state that becomes active at the scheduled time.
/// </summary>
public CastleSiegeState State { get; set; }
/// <summary>
/// Gets or sets the day of the week on which this state transition occurs.
/// </summary>
public DayOfWeek DayOfWeek { get; set; }
/// <summary>
/// Gets or sets the hour (023) at which this state transition occurs.
/// </summary>
public byte Hour { get; set; }
/// <summary>
/// Gets or sets the minute (059) at which this state transition occurs.
/// </summary>
public byte Minute { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.State} on {this.DayOfWeek} at {this.Hour:D2}:{this.Minute:D2}";
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeUpgradeDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines one level of an upgrade that the castle owner can apply to a gate or statue NPC.
/// </summary>
[Cloneable]
public partial class CastleSiegeUpgradeDefinition
{
/// <summary>
/// Gets or sets the upgrade level (03), where 0 represents the base/unupgraded state.
/// </summary>
public byte Level { get; set; }
/// <summary>
/// Gets or sets the number of Jewels of Guardian required to perform this upgrade.
/// </summary>
public int RequiredJewelOfGuardianCount { get; set; }
/// <summary>
/// Gets or sets the amount of Zen required to perform this upgrade.
/// </summary>
public int RequiredZen { get; set; }
/// <summary>
/// Gets or sets the resulting stat value granted by this upgrade level (defense or max HP).
/// </summary>
public int Value { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"Level {this.Level}: Value={this.Value}, Jewels={this.RequiredJewelOfGuardianCount}, Zen={this.RequiredZen}";
}
}

View File

@@ -0,0 +1,31 @@
// <copyright file="CastleSiegeUpgradeType.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The type of upgrade applied to a castle siege NPC (gate or statue).
/// </summary>
public enum CastleSiegeUpgradeType : byte
{
/// <summary>
/// No upgrade type assigned.
/// </summary>
Undefined = 0,
/// <summary>
/// Increases the defense stat of the NPC.
/// </summary>
Defense = 1,
/// <summary>
/// Increases the HP regeneration rate of the NPC.
/// </summary>
Regen = 2,
/// <summary>
/// Increases the maximum HP of the NPC.
/// </summary>
Life = 3,
}

View File

@@ -0,0 +1,40 @@
// <copyright file="CastleSiegeZoneDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Annotations;
/// <summary>
/// Defines a rectangular zone on the castle siege map, used for spawn areas and machine zones.
/// </summary>
[Cloneable]
public partial class CastleSiegeZoneDefinition
{
/// <summary>
/// Gets or sets the top-left X coordinate of the zone.
/// </summary>
public byte X1 { get; set; }
/// <summary>
/// Gets or sets the top-left Y coordinate of the zone.
/// </summary>
public byte Y1 { get; set; }
/// <summary>
/// Gets or sets the bottom-right X coordinate of the zone.
/// </summary>
public byte X2 { get; set; }
/// <summary>
/// Gets or sets the bottom-right Y coordinate of the zone.
/// </summary>
public byte Y2 { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.X1} / {this.Y1} to {this.X2} / {this.Y2}";
}
}

View File

@@ -300,6 +300,12 @@ public partial class GameConfiguration
[MemberOfAggregate]
public virtual ICollection<MiniGameDefinition> MiniGameDefinitions { get; protected set; } = null!;
/// <summary>
/// Gets or sets the castle siege configuration.
/// </summary>
[MemberOfAggregate]
public virtual CastleSiegeConfiguration? CastleSiegeConfiguration { get; set; }
/// <inheritdoc />
public override string ToString()
{

View File

@@ -38,4 +38,9 @@ public enum MiniGameType
/// The doppelganger event.
/// </summary>
Doppelganger,
/// <summary>
/// The heykel savasi event.
/// </summary>
HeykelSavasi,
}

View File

@@ -165,6 +165,16 @@ public enum NpcWindow
/// The dialog for the legacy quest system.
/// </summary>
LegacyQuest,
/// <summary>
/// The castle siege gate NPC interaction window.
/// </summary>
CastleSiegeGateNpc,
/// <summary>
/// The castle siege lever NPC interaction window.
/// </summary>
CastleSiegeLeverNpc,
}
/// <summary>
@@ -328,6 +338,12 @@ public partial class MonsterDefinition
[MemberOfAggregate]
public virtual ICollection<QuestDefinition> Quests { get; protected set; } = null!;
/// <summary>
/// Gets or sets the buffs which can be granted by this npc.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<Buff> Buffs { get; protected set; } = null!;
/// <summary>
/// Attribute default accessor.
/// </summary>

View File

@@ -130,6 +130,13 @@ public class Account
/// </summary>
public bool IsTemplate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this account is a server-side bot account.
/// Bot accounts are generated and maintained by the bot feature; this flag is the reliable
/// marker used to load them on startup (instead of regenerating) and to purge them.
/// </summary>
public bool IsBot { get; set; }
/// <summary>
/// Gets or sets the characters.
/// </summary>

View File

@@ -0,0 +1,67 @@
// <copyright file="CastleSiegeData.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Persistent state of the castle siege, stored as a single row across siege cycles.
/// </summary>
[AggregateRoot]
public class CastleSiegeData
{
/// <summary>
/// Gets or sets the unique identifier of this record.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the persistent identifier of the guild that currently owns the castle.
/// <see langword="null"/> when no guild owns the castle.
/// </summary>
public Guid? OwnerGuildId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether any guild currently occupies the castle.
/// </summary>
public bool IsOccupied { get; set; }
/// <summary>
/// Gets or sets the Chaos Machine tax rate applied by the castle owner (03).
/// </summary>
public byte TaxChaos { get; set; }
/// <summary>
/// Gets or sets the personal store tax rate applied by the castle owner (03).
/// </summary>
public byte TaxStore { get; set; }
/// <summary>
/// Gets or sets the entry fee (in Zen) for the castle owner's hunt zone (0300000).
/// </summary>
public int TaxHunt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the hunt zone (Land of Trials) is currently open to the public.
/// </summary>
public bool IsHuntZoneEnabled { get; set; }
/// <summary>
/// Gets or sets the accumulated tribute money collected from the hunt zone and taxes.
/// </summary>
public long TributeMoney { get; set; }
/// <summary>
/// Gets or sets the persisted states of all castle NPCs.
/// </summary>
[MemberOfAggregate]
public virtual ICollection<CastleSiegeNpcState> NpcStates { get; protected set; } = null!;
/// <inheritdoc />
public override string ToString()
{
return this.IsOccupied
? $"Castle owned by guild {this.OwnerGuildId}"
: "Castle unoccupied";
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CastleSiegeGuildRegistration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Stores a guild's registration data for the current castle siege cycle,
/// including the number of emblems submitted to determine attacking guilds.
/// </summary>
[AggregateRoot]
public class CastleSiegeGuildRegistration
{
/// <summary>
/// Gets or sets the unique identifier of this registration record.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the persistent identifier of the registered guild.
/// </summary>
public Guid GuildId { get; set; }
/// <summary>
/// Gets or sets the guild name, denormalized for convenience to avoid extra lookups during siege processing.
/// </summary>
public string GuildName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of Emblems of Lord registered by this guild.
/// </summary>
public int Marks { get; set; }
/// <summary>
/// Gets or sets the insertion order of this registration, used for tie-breaking when guilds have equal marks.
/// </summary>
public int RegistrationOrder { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"{this.GuildName} (Marks={this.Marks}, Order={this.RegistrationOrder})";
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="CastleSiegeNpcState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Persistent state of a single castle siege NPC between siege cycles.
/// </summary>
public class CastleSiegeNpcState
{
/// <summary>
/// Gets or sets the unique identifier of this NPC state.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the monster definition number that identifies the NPC template.
/// </summary>
public short MonsterNumber { get; set; }
/// <summary>
/// Gets or sets the instance identifier matching <see cref="MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition.InstanceId"/>.
/// </summary>
public byte InstanceId { get; set; }
/// <summary>
/// Gets or sets the current defense upgrade level (03).
/// </summary>
public byte DefenseLevel { get; set; }
/// <summary>
/// Gets or sets the current HP regeneration upgrade level (03).
/// </summary>
public byte RegenLevel { get; set; }
/// <summary>
/// Gets or sets the current maximum HP upgrade level (03).
/// </summary>
public byte LifeLevel { get; set; }
/// <summary>
/// Gets or sets the current HP of the NPC. A value of 0 means the NPC is destroyed.
/// </summary>
public int CurrentHp { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"NPC {this.MonsterNumber} #{this.InstanceId} (HP={this.CurrentHp})";
}
}

View File

@@ -6,7 +6,6 @@
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BlazorInputFile" Version="0.2.0" />
<PackageVersion Include="Blazored.Toast" Version="4.2.1" />
<PackageVersion Include="BuildWebCompiler2022" Version="1.14.15" />
<PackageVersion Include="DG.AdvancedDataGridView" Version="1.2.30115.18" />
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.1" />

View File

@@ -91,7 +91,7 @@ public static class AttackableExtensions
else
{
var defenseAttribute = defender.GetDefenseAttribute(attacker);
defense = (int)(defender.Attributes[defenseAttribute] * defender.Attributes[Stats.DefenseDecrement]);
defense = (int)((defender.Attributes[defenseAttribute] + defender.Attributes[Stats.GreaterDefenseBonus]) * defender.Attributes[Stats.DefenseDecrement]);
if (defense < 0)
{
defense = 0;
@@ -378,7 +378,7 @@ public static class AttackableExtensions
if (regeneration != null)
{
var regenerationValue = player.Attributes.CreateElement(powerUpDefinition);
var value = skillEntry.Level == 0 ? regenerationValue.Value : regenerationValue.Value + skillEntry.CalculateValue();
var value = regenerationValue.Value + (skillEntry.Level == 0 ? 0 : regenerationValue.Value * skillEntry.CalculateValue() / 100);
target.Attributes[regeneration.CurrentAttribute] = Math.Min(
target.Attributes[regeneration.CurrentAttribute] + value,
target.Attributes[regeneration.MaximumAttribute]);

View File

@@ -17,8 +17,8 @@ public class MonsterAttributeHolder : IAttributeSystem
new Dictionary<AttributeDefinition, Func<AttackableNpcBase, float>>
{
{ Stats.CurrentHealth, m => m.Health },
{ Stats.DefensePvm, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
{ Stats.DefensePvp, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
{ Stats.DefensePvm, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) * (1 + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0)) },
{ Stats.DefensePvp, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) * (1 + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0)) },
{ Stats.DamageReceiveDecrement, m => 1.0f },
{ Stats.AttackDamageIncrease, m => 1.0f },
{ Stats.MovementSpeedFactor, m => 1.0f },

View File

@@ -1,4 +1,4 @@
// <copyright file="Stats.cs" company="MUnique">
// <copyright file="Stats.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
@@ -524,6 +524,12 @@ public class Stats
/// </summary>
public static AttributeDefinition CrossBowMasteryBonusDamage { get; } = new(new Guid("B3AF7F51-6D6B-42FB-B8FF-689D03890F3E"), "Cross Bow Mastery PvP Bonus Damage (MST)", string.Empty);
/// <summary>
/// Gets the extra projectiles attribute definition.
/// </summary>
/// <remarks>Can be increased by equipping certain (cross)bows and/or unlocking triple shot mastery.</remarks>
public static AttributeDefinition ExtraProjectiles { get; } = new(new Guid("B9E4F3C2-A1D5-4B8E-7F2C-6D3A9E1F5C8B"), "Extra Projectiles", "The number of extra projectiles shot with triple shot skill.");
/// <summary>
/// Gets elf's melee attack mode attribute definition.
/// </summary>
@@ -554,6 +560,11 @@ public class Stats
/// </summary>
public static AttributeDefinition ArcheryMaxDmg { get; } = new(new Guid("EC807B7C-4004-4D13-BED2-326E13F8EFEB"), "Archery Maximum Damage", "The elf's maximum archery damage, which is added to projectile weapon attacks.");
/// <summary>
/// Gets the elf's greater defense buff bonus defense attribute definition.
/// </summary>
public static AttributeDefinition GreaterDefenseBonus { get; } = new(new Guid("5A7C3E9B-D1F4-4B82-8A6D-2F3E1C9B7A4D"), "Greater Defense Bonus", string.Empty);
/// <summary>
/// Gets the elf's greater damage buff bonus damage attribute definition.
/// </summary>
@@ -729,7 +740,7 @@ public class Stats
public static AttributeDefinition SummonedMonsterHealthIncrease { get; } = new(new Guid("7B0625C8-DA1A-4A5D-BCA5-26AACDA0BDC6"), "Summoned Monster Health Increase %", string.Empty);
/// <summary>
/// Gets the summoned monster defense increase, absolute.
/// Gets the summoned monster defense increase, percentage.
/// </summary>
public static AttributeDefinition SummonedMonsterDefenseIncrease { get; } = new(new Guid("0D55CFCA-751F-4E66-B327-635576A9A0B3"), "Summoned Monster Defense Increase", string.Empty);
@@ -775,7 +786,7 @@ public class Stats
/// <see cref="AggregateType.Multiplicate"/> values include:
/// <see cref="DefenseIncreaseWithEquippedShield"/>.
/// <see cref="AggregateType.AddFinal"/> values include:
/// Greater defense buff; MST bonus defense with shield (shield strengthener); MST dark horse strengthener; Jack O'Lantern Cry bonus (halved); Berserker defense reduction.
/// MST bonus defense with shield (shield strengthener); MST dark horse strengthener; Jack O'Lantern Cry bonus (halved); Berserker defense reduction.
/// </remarks>
public static AttributeDefinition DefenseFinal { get; } = new(new Guid("0888AD48-0CC8-47CA-B6A3-99F3771AA5FC"), "Final Defense", string.Empty);

View File

@@ -0,0 +1,167 @@
// <copyright file="BotConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// The admin-panel editable configuration of the <see cref="BotFeaturePlugIn"/>.
/// </summary>
public class BotConfiguration
{
/// <summary>
/// The hard limit of characters a single account can hold in the game.
/// </summary>
public const int MaxCharactersPerAccountLimit = 5;
/// <summary>
/// Gets or sets a value indicating whether the bot feature is enabled.
/// Disabled by default so that enabling bots is always an explicit, deliberate action.
/// </summary>
[Display(Name = "Enabled", Description = "If enabled, bots are spawned after the server has started.")]
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all bot accounts and characters should be deleted.
/// When set, the feature purges every bot account on the next startup before generating fresh
/// ones, and then automatically clears this flag again. Use it to reset the bot population.
/// </summary>
[Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")]
public bool ResetBots { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all bot accounts and characters should be deleted
/// WITHOUT being regenerated. Unlike <see cref="ResetBots"/> this also turns <see cref="Enabled"/>
/// off - otherwise the very same pass would generate the population again - so it is the single
/// switch for "I do not want bots on this server anymore". Clears itself afterwards.
/// </summary>
[Display(Name = "Purge bots", Description = "Deletes all bot accounts and characters and turns the bot feature off, without generating new ones. Clears itself afterwards.")]
public bool PurgeBots { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot population rotates its presence over the day:
/// fewer bots are online at night, most in the evening, with bots smoothly logging in and out -
/// like a real player base, instead of the same characters being online 24/7.
/// </summary>
[Display(Name = "Presence rotation", Description = "Bots log in and out over the day (fewest at night, most in the evening) instead of all being online 24/7.")]
public bool PresenceRotation { get; set; } = true;
/// <summary>
/// Gets or sets the share (in percent) of bots which stays online at the quietest time of day.
/// 100 effectively disables the rotation effect.
/// </summary>
[Display(Name = "Min. online share %", Description = "Percentage of the bot population which stays online at the quietest hour (100 = no rotation effect).")]
public int MinOnlineSharePercent { get; set; } = 60;
/// <summary>
/// Gets or sets the number of bot accounts. Together with <see cref="MaxCharactersPerAccount"/>
/// this defines the generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters.
/// </summary>
[Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")]
[Range(0, 1000)]
public int NumberOfAccounts { get; set; } = 10;
/// <summary>
/// Gets or sets the number of characters per bot account. An account can hold at most
/// <see cref="MaxCharactersPerAccountLimit"/> (5) characters, so this value is clamped on use.
/// </summary>
[Display(Name = "Characters per account", Description = "How many characters each bot account holds (max 5).")]
[Range(1, MaxCharactersPerAccountLimit)]
public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit;
/// <summary>
/// Gets or sets the share (in percent) of a game server's maximum player count which its bots may
/// occupy. Bots count towards that limit like players do, and a full server turns new clients away -
/// so the rest of the capacity stays reserved for real players, who must never be denied a slot by a
/// bot. The population is split over all configured game servers accordingly (see
/// <see cref="BotServerPartition"/>); accounts which do not fit stay offline until the servers offer
/// the room for them.
/// </summary>
[Display(Name = "Bot capacity %", Description = "Share of a game server's maximum player count which its bots may occupy; the rest stays reserved for real players.")]
[Range(1, 100)]
public int BotCapacityPercent { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether bots pay the configured reset costs (zen, reset items)
/// when they reset their character on a server with the reset feature enabled. Off by default:
/// bots don't take part in the player economy the costs are balanced for, so charging them only
/// stalls their progression (a bot can't farm zen for a billion-zen reset the way players trade).
/// </summary>
[Display(Name = "Bots pay reset costs", Description = "If enabled, bots consume the configured zen/item costs for their resets like human players (default: free bot resets).")]
public bool BotsPayResetCosts { get; set; }
/// <summary>
/// Gets or sets how many Jewels of Bless, Soul and Life a bot keeps of each kind. Bots only pick up
/// the jewels they can actually spend on their own gear (see <c>BotJewelHandler</c>) and stop
/// collecting a kind once they hold this many; whatever they carry above it is sold on the next
/// merchant visit. The sensible value depends entirely on the server's drop rates - on a high rate
/// server a bot refills a big stock within hours, so a low limit keeps its backpack usable.
/// </summary>
[Display(Name = "Jewel stock per kind", Description = "How many Jewels of Bless/Soul/Life a bot keeps of each kind; above this it stops picking them up and sells the surplus.")]
[Range(0, 100)]
public int JewelStockPerKind { get; set; } = 10;
/// <summary>
/// Gets or sets the number of potion charges (per healing and per mana potions) a bot stocks up to
/// at a merchant. Merchants sell potions in stacks of different sizes, so this is the target the bot
/// buys towards, not a stack count.
/// </summary>
[Display(Name = "Potion stock (charges)", Description = "How many healing and mana potion charges a bot buys up to at a merchant.")]
[Range(10, 255)]
public int PotionStockCharges { get; set; } = 60;
/// <summary>
/// Gets or sets a comma separated list of login names of existing accounts to animate as bots.
/// This is an optional extra hook alongside the generated population (see
/// <see cref="NumberOfAccounts"/>): every listed account gets a bot driving its first character.
/// These accounts are animated as-is and are not part of the partitioned, capacity-limited
/// population, so leave it empty unless you specifically want to drive existing accounts.
/// </summary>
[Display(Name = "Extra accounts to animate", Description = "Comma separated login names of existing accounts to animate as bots, in addition to the generated population.")]
public string ProofOfConceptAccounts { get; set; } = string.Empty;
/// <summary>
/// Gets the effective, clamped number of characters per account.
/// </summary>
/// <returns>A value between 1 and <see cref="MaxCharactersPerAccountLimit"/>.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public int GetEffectiveCharactersPerAccount()
=> Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit);
/// <summary>
/// Gets the effective, clamped share of a server's player capacity which its bots may occupy.
/// </summary>
/// <returns>A value between 1 and 100.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectiveBotCapacityPercent()
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
/// <summary>
/// Gets the effective, clamped jewel stock a bot keeps of each usable kind.
/// </summary>
/// <returns>A value between 0 and 100.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectiveJewelStockPerKind()
=> Math.Clamp(this.JewelStockPerKind, 0, 100);
/// <summary>
/// Gets the effective, clamped potion charges a bot stocks up to.
/// </summary>
/// <returns>A value between 10 and 255.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectivePotionStockCharges()
=> Math.Clamp(this.PotionStockCharges, 10, 255);
/// <summary>
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
/// </summary>
/// <returns>The list of login names.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public IReadOnlyList<string> ParseProofOfConceptAccounts()
=> this.ProofOfConceptAccounts
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}

View File

@@ -0,0 +1,283 @@
// <copyright file="BotEquipmentHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// <summary>
/// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup
/// (see <see cref="IsUpgradeFor"/>, used by the offline <see cref="ItemPickupHandler"/>), and looted
/// upgrades are periodically equipped; the replaced piece goes into the backpack and is sold on the next
/// shopping trip, so the backpack does not silt up with junk. All moves go through the regular
/// <see cref="MoveItemAction"/>, which enforces the same class- and stat-requirements as for a human
/// player - including which hand a weapon needs (see <see cref="ItemExtensions.ConflictsWithEquippedHands"/>).
/// </summary>
internal static class BotEquipmentHandler
{
/// <summary>A candidate must beat the equipped piece by at least this score margin to be worth swapping.</summary>
private const int UpgradeScoreMargin = 1;
/// <summary>The highest item group which is a weapon (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff).</summary>
private const byte LastWeaponGroup = 5;
/// <summary>The item group of the shields.</summary>
private const byte ShieldGroup = 6;
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear,
/// so the pickup handler only collects items worth carrying.
/// </summary>
/// <param name="player">The bot player which would wear the item.</param>
/// <param name="item">The dropped item to evaluate.</param>
public static bool IsUpgradeFor(Player player, Item item)
{
return TryPlanSwap(player, item) is not null;
}
/// <summary>
/// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece
/// stays in the backpack, where the next shopping trip sells it.
/// </summary>
/// <param name="player">The bot player whose backpack is scanned.</param>
public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return;
}
// Snapshot, because equipping mutates the item collection while we iterate.
var backpackItems = inventory.Items
.Where(i => i.ItemSlot >= InventoryConstants.EquippableSlotsCount)
.ToList();
foreach (var item in backpackItems)
{
if (TryPlanSwap(player, item) is not { } plan)
{
continue;
}
if (await TryApplySwapAsync(player, inventory, item, plan).ConfigureAwait(false))
{
// One swap per pass keeps the work per tick small; the next pass picks up the rest.
return;
}
}
}
/// <summary>
/// Puts the planned piece on: the gear it replaces goes to the backpack first (an equip only works
/// into a free slot), then the candidate is equipped through the regular <see cref="MoveItemAction"/>.
/// If the engine refuses the equip after all - the requirements are checked against the TOTAL stats,
/// which drop as soon as the old piece with its bonuses comes off - everything moved so far is put
/// back on. Without that rollback the bot ended up with an empty slot, re-equipped the old piece on
/// its next pass and started over: hundreds of swaps per hour, fighting without a weapon half of the
/// time.
/// </summary>
/// <returns><c>true</c> if the candidate is now equipped.</returns>
private static async ValueTask<bool> TryApplySwapAsync(OfflinePlayer player, IStorage inventory, Item item, EquipSwap plan)
{
var undo = new List<(Item Item, byte EquipSlot)>(2);
foreach (var removed in plan.Removed)
{
if (inventory.CheckInvSpace(removed) is not { } freeSlot)
{
// No room of the item's SIZE in the backpack (a 2x3 armor needs a 2x3 hole).
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
var equipSlot = removed.ItemSlot;
await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(equipSlot) is not null)
{
player.Logger.LogDebug("Bot '{Name}' could not take off '{Item}' for a swap.", player.Name, removed);
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
undo.Add((removed, equipSlot));
}
await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, plan.Slot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(plan.Slot) != item)
{
player.Logger.LogDebug("Bot '{Name}' could not equip '{Item}' - putting its old gear back on.", player.Name, item);
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
player.Logger.LogInformation(
"Bot '{Name}' equipped '{New}'{Replaced}.",
player.Name,
item,
plan.Removed.Count == 0 ? string.Empty : $" (replacing {string.Join(", ", plan.Removed.Select(r => $"'{r}'"))})");
// The outgrown gear stays in the backpack and is SOLD on the next shopping trip (see
// BotShoppingHandler.IsSellableJunk): dropping it on the ground littered the hunting grounds
// with the bots' hand-me-downs, which the bots then picked up again - including the bot which
// had just dropped the piece, whose persistence context still tracked it (an entity conflict on
// the pickup). Selling it also feeds the Zen the bot restocks its potions with.
return true;
}
private static async ValueTask RollbackAsync(OfflinePlayer player, List<(Item Item, byte EquipSlot)> undo)
{
foreach (var (item, equipSlot) in undo)
{
await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
if (player.Inventory?.GetItem(equipSlot) != item)
{
player.Logger.LogWarning("Bot '{Name}' could not put '{Item}' back on into slot {Slot}.", player.Name, item, equipSlot);
}
}
}
/// <summary>
/// Plans how the item could be worn: into which slot, and which equipped pieces it would replace.
/// Returns <c>null</c> when the bot would not (or could not) wear it - which is exactly what makes an
/// item worth picking up from the ground, so the pickup handler asks the same question through
/// <see cref="IsUpgradeFor"/>. Beside the piece in the target slot, a two-handed weapon also replaces
/// whatever blocks the other hand: the engine refuses to equip it otherwise (see
/// <see cref="ItemExtensions.ConflictsWithEquippedHands"/>), and a bot which does not plan for that
/// keeps trying (and failing) to put it on forever.
/// </summary>
private static EquipSwap? TryPlanSwap(Player player, Item item)
{
if (item.Definition is not { } definition
|| player.SelectedCharacter?.CharacterClass is not { } characterClass
|| player.Inventory is not { } inventory
|| !IsWearableCandidate(player, definition, characterClass)
|| !player.CompliesRequirements(item))
{
return null;
}
var candidateScore = Score(item);
EquipSwap? best = null;
var bestReplacedScore = 0;
foreach (var slot in GetTargetSlots(definition))
{
var equipped = inventory.GetItem(slot);
if (equipped?.Definition?.IsAmmunition == true)
{
// Never displace the ammunition (an archer's arrows) - the bow would stop working.
continue;
}
var removed = new List<Item>(2);
if (equipped is not null)
{
removed.Add(equipped);
}
if (definition.ConflictsWithEquippedHands(inventory, slot))
{
// Only the main hand resolves such a conflict: taking the two-handed weapon off to fit a
// shield into the other hand would disarm the bot.
if (slot != InventoryConstants.LeftHandSlot
|| inventory.GetItem(InventoryConstants.RightHandSlot) is not { } blocking)
{
continue;
}
removed.Add(blocking);
}
var replacedScore = removed.Sum(Score);
if (removed.Count > 0 && replacedScore + UpgradeScoreMargin > candidateScore)
{
continue;
}
// Prefer the cheapest swap: an empty slot beats replacing gear, and among occupied slots the
// weakest gear goes first (this is what spreads rings over both ring slots).
if (best is null || replacedScore < bestReplacedScore)
{
best = new EquipSwap(slot, removed);
bestReplacedScore = replacedScore;
}
}
return best;
}
/// <summary>
/// The slots the bot considers for this piece: a weapon only goes into the main hand and a shield
/// only into the off-hand - a bot filling its off-hand with a second (junk) weapon it happens to be
/// qualified for is neither useful nor a sight any real character offers. Everything else may go into
/// any of its qualified slots (rings have two).
/// </summary>
private static IEnumerable<byte> GetTargetSlots(ItemDefinition definition)
{
var slots = definition.ItemSlot!.ItemSlots.Select(s => (byte)s).ToList();
if (definition.Group <= LastWeaponGroup && slots.Contains(InventoryConstants.LeftHandSlot))
{
return [InventoryConstants.LeftHandSlot];
}
if (definition.Group == ShieldGroup && slots.Contains(InventoryConstants.RightHandSlot))
{
return [InventoryConstants.RightHandSlot];
}
return slots;
}
/// <summary>
/// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which -
/// if it is a weapon - matches the fighting style of the bot's build (an elf only considers bows, a
/// caster only staves), so bots don't fill their hands with random qualified junk like a Small Axe.
/// </summary>
private static bool IsWearableCandidate(Player player, ItemDefinition definition, CharacterClass characterClass)
{
if (definition.ItemSlot is not { ItemSlots.Count: > 0 }
|| definition.IsAmmunition
|| !definition.QualifiedCharacters.Contains(characterClass))
{
return false;
}
if (definition.Group > LastWeaponGroup)
{
return true;
}
var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
return BotProgression.IsPreferredWeaponGroup(characterClass, player.SelectedCharacter!.Name, resetMeta, (byte)definition.Group);
}
/// <summary>
/// A rough, monotonic quality score of an equippable item: the definition's drop level tracks the
/// gear tier, the item level its upgrades, and excellent/ancient options add their extra worth.
/// </summary>
private static int Score(Item item)
{
if (item.Definition is not { } definition)
{
return 0;
}
var score = definition.DropLevel + (item.Level * 3);
score += 12 * item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
if (item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
{
score += 15;
}
return score;
}
/// <summary>
/// A planned equip: the slot the candidate goes into, and the equipped pieces which have to come off
/// for it (the gear in the target slot, plus the other hand's item when a two-handed weapon needs it).
/// </summary>
private sealed record EquipSwap(byte Slot, IReadOnlyList<Item> Removed);
}

View File

@@ -0,0 +1,709 @@
// <copyright file="BotFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Collections.Concurrent;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Feature plugin which spawns and maintains server-side bots.
/// Appears in the "Feature Plugins" section of the admin panel next to the MU Helper and reset features.
/// </summary>
[PlugIn]
[Display(Name = "Bots", Description = "Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature.")]
[Guid("6F3A2B91-7C4E-4D88-9A1F-2E5C0B7A4D63")]
public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration<BotConfiguration>, ISupportDefaultCustomConfiguration
{
/// <summary>
/// Delay before the first spawn attempt, giving the server time to finish starting up
/// (maps, configuration and plugins fully initialized).
/// </summary>
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60);
/// <summary>
/// The typical activity of a player base by local hour (0..1): quietest in the early morning,
/// busiest in the evening. Scales between <see cref="BotConfiguration.MinOnlineSharePercent"/>
/// and 100% of the bot population.
/// </summary>
private static readonly double[] ActivityByHour =
[
0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45,
0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50,
];
/// <summary>
/// The state of the feature, per game server: the plugin instance is shared by all game servers of
/// the process, while <see cref="ExecuteTaskAsync"/> is called by each of them separately. One shared
/// state would mean the server whose timer fires first animates the whole population (which is how
/// the bots used to end up on a single server), and the other servers doing nothing at all.
/// </summary>
private readonly ConcurrentDictionary<IGameContext, ServerState> _states = new();
/// <summary>
/// The phase of a game server's single startup pass. The periodic task timer fires every second
/// WITHOUT awaiting the previous invocation, so during the minutes-long generation/spawn further
/// ticks arrive concurrently - they must neither re-enter the startup nor run the maintenance
/// (e.g. the presence rotation) against a half-spawned population.
/// </summary>
private enum StartupPhase
{
/// <summary>The startup has not run yet.</summary>
NotStarted = 0,
/// <summary>The startup (generation and spawn) is in progress.</summary>
InProgress = 1,
/// <summary>The startup is done; the server is in maintenance mode.</summary>
Done = 2,
}
/// <inheritdoc />
public BotConfiguration? Configuration { get; set; }
/// <summary>
/// Gets the bot configuration of the given game context, so the bot handlers can read their
/// admin-panel editable settings without being handed the plugin around (same shape as
/// <see cref="BotResetHandler.GetResetConfiguration"/>).
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The configuration, or <c>null</c> when the bot feature is not configured.</returns>
public static BotConfiguration? GetConfiguration(IGameContext gameContext)
=> gameContext.FeaturePlugIns.GetPlugIn<BotFeaturePlugIn>()?.Configuration;
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
var configuration = this.Configuration ??= CreateDefaultConfiguration();
if (configuration.PurgeBots)
{
await this.PurgeAsync(gameContext, state, configuration).ConfigureAwait(false);
return;
}
if (!configuration.Enabled)
{
// Switching the feature off takes effect right away instead of at the next restart: the
// bots log out, and nothing is deleted. Re-checked on the following ticks, the feature may
// get enabled again later - then the population is spawned from scratch.
await this.StopBotsAsync(gameContext, state, "the bot feature was switched off").ConfigureAwait(false);
return;
}
if (state.StartupState == (int)StartupPhase.Done)
{
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
return;
}
if (DateTime.UtcNow < state.NextRunUtc
|| Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
return;
}
try
{
await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false);
}
finally
{
// Like before: the startup runs once, even when parts of it failed (the errors are
// logged); the maintenance pass takes over from here.
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.Done);
}
}
/// <summary>
/// Stops every bot this server animates and puts it back to the state before the startup pass, so
/// the population is spawned from scratch if the feature is switched on again. Nothing is deleted.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="state">The state of this server.</param>
/// <param name="reason">The reason to log, if there was anything to stop.</param>
private async ValueTask StopBotsAsync(GameContext gameContext, ServerState state, string reason)
{
if (state.Manager.BotCount == 0 && state.StartupState == (int)StartupPhase.NotStarted)
{
// The usual case of a server without bots - this runs on every tick, so it stays cheap.
return;
}
// Take over the startup state machine, so the bots are not stopped while a startup pass is
// still spawning them (it would spawn into the emptied manager afterwards).
if (Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.Done) != (int)StartupPhase.Done
&& Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
// A startup pass is running; retried on one of the next ticks.
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
try
{
var stopped = state.Manager.BotCount;
await state.Manager.StopAllAsync().ConfigureAwait(false);
state.PendingRespawns.Clear();
if (stopped > 0)
{
logger.LogInformation("Stopped {Stopped} bot(s): {Reason}.", stopped, reason);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to stop the bots.");
}
finally
{
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
}
}
/// <summary>
/// Carries out a requested purge: every bot account is deleted and the feature switches itself off,
/// so the population is NOT generated again - the difference to <see cref="BotConfiguration.ResetBots"/>.
/// Works with the feature enabled or disabled, and whatever number of accounts is configured.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="state">The state of this server.</param>
/// <param name="configuration">The bot configuration.</param>
private async ValueTask PurgeAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
{
if (DateTime.UtcNow < state.NextRunUtc)
{
// The same head start the startup pass gets - and where a failed purge backs off to.
return;
}
// The bots have to be gone before their accounts are: one which is still online would go on
// saving a character whose row is about to be deleted.
await this.StopBotsAsync(gameContext, state, "the bot population is being purged").ConfigureAwait(false);
if (state.Manager.BotCount > 0
|| Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
// A startup pass still holds the state machine; retried on one of the next ticks.
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
try
{
var partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
if (!partition.IsGenerator)
{
// Another server deletes the accounts and clears the flags. This one only had to stop
// its own bots; the switched-off feature keeps it from starting them again.
return;
}
var generator = new BotGenerator(gameContext, logger);
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
// Switching the feature off is what makes this a purge instead of a reset: the generation
// below would otherwise create the whole population again, right after it was deleted.
configuration.Enabled = false;
configuration.PurgeBots = false;
configuration.ResetBots = false;
await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
logger.LogInformation("Purge requested: deleted {Deleted} bot account(s) and switched the bot feature off.", deleted);
}
catch (Exception ex)
{
// The flag stays set, so the purge is retried - backed off, to not repeat it every second.
state.NextRunUtc = DateTime.UtcNow + MaintenanceInterval;
logger.LogError(ex, "Failed to purge the bot population.");
}
finally
{
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
}
}
private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
{
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
var generator = new BotGenerator(gameContext, logger);
var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
if (configuration.ResetBots && !partition.IsGenerator)
{
// Not silent: the flag is set, but this server is not the one which acts on it.
logger.LogInformation("Reset requested: another game server of the deployment carries it out.");
}
if (configuration.ResetBots && partition.IsGenerator)
{
try
{
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
logger.LogInformation("Reset requested: deleted {Deleted} bot account(s); {Requested} account(s) are generated again.", deleted, Math.Max(configuration.NumberOfAccounts, 0));
// Clear the flag (in memory and persisted) so the next restart does not purge again.
configuration.ResetBots = false;
await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reset the bot population.");
}
}
if (partition.IsGenerator)
{
try
{
// Generate the persistent bot population if it is not there yet (idempotent). Only this
// server does it - see BotServerPartition.IsGenerator; the others find the accounts once
// they exist and retry the spawns of their own share meanwhile.
var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false);
if (created > 0)
{
logger.LogInformation("Generated {Created} new bot account(s).", created);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate the bot population.");
}
}
var charactersPerAccount = Math.Clamp(
Math.Min(configuration.MaxCharactersPerAccount, gameContext.Configuration.MaximumCharactersPerAccount),
1,
BotConfiguration.MaxCharactersPerAccountLimit);
var started = 0;
var total = 0;
for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
{
var loginName = BotGenerator.GetLoginName(i);
for (byte slot = 0; slot < charactersPerAccount; slot++)
{
total++;
try
{
if (await state.Manager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false))
{
started++;
}
else
{
// The account may just not be generated yet (another server is generating the
// population right now) - the maintenance pass retries it.
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, slot);
}
}
}
// The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot)
// accounts. They are not part of the partitioned population, so only one server animates them.
if (partition.IsGenerator)
{
foreach (var loginName in configuration.ParseProofOfConceptAccounts())
{
try
{
if (await state.Manager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false))
{
started++;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName);
}
}
}
logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total);
try
{
await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to form bot parties.");
}
}
/// <inheritdoc />
public void ForceStart()
{
foreach (var state in this._states.Values)
{
state.NextRunUtc = DateTime.UtcNow;
}
}
/// <inheritdoc />
public object CreateDefaultConfig()
{
return CreateDefaultConfiguration();
}
private static BotConfiguration CreateDefaultConfiguration()
{
return new BotConfiguration();
}
/// <summary>
/// Runs the periodic post-spawn maintenance: the presence rotation (one bot in or out per pass, so
/// the population ebbs and flows smoothly over the day) and an hourly party re-formation which
/// groups bots that lost or never had a party (e.g. after rotating back in).
/// </summary>
private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState state)
{
if (DateTime.UtcNow < state.NextMaintenanceUtc)
{
return;
}
// The engine fires the periodic tasks every second WITHOUT awaiting the previous run, so a pass
// which takes longer than its interval (spawning a bot loads a whole account from the database)
// would otherwise overlap with itself: two passes restarting the same bot, rotating the presence
// twice, forming parties in parallel. One pass at a time - like the startup below.
if (Interlocked.CompareExchange(ref state.MaintenanceRunning, 1, 0) != 0)
{
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
try
{
state.NextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval;
var configuration = this.Configuration;
if (configuration?.Enabled != true)
{
return;
}
await this.RespawnPendingAsync(gameContext, state).ConfigureAwait(false);
await this.RestartFaultedBotsAsync(gameContext, state, logger).ConfigureAwait(false);
await this.EvolveDueMastersAsync(gameContext, state, logger).ConfigureAwait(false);
if (configuration.PresenceRotation)
{
await this.RotatePresenceAsync(gameContext, state, configuration, logger).ConfigureAwait(false);
}
if (DateTime.UtcNow >= state.NextPartyReformUtc)
{
state.NextPartyReformUtc = DateTime.UtcNow + PartyReformInterval;
await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Bot maintenance failed.");
}
finally
{
Interlocked.Exchange(ref state.MaintenanceRunning, 0);
}
}
/// <summary>
/// Restarts bots whose AI keeps throwing (see <see cref="BotPlayer.AwaitsFaultRestart"/>). The
/// engine's attribute system is not thread-safe, and a lost race can corrupt a character's attribute
/// graph for good: the bot stops playing and every following tick throws the same exception, up to a
/// flood of them per second. A fresh login rebuilds the graph and heals it - the same thing a player
/// would do, and the only cure available from outside the engine. Runs from the maintenance pass,
/// which is the only place allowed to restart a bot.
/// </summary>
private async ValueTask RestartFaultedBotsAsync(GameContext gameContext, ServerState state, ILogger logger)
{
foreach (var bot in state.Manager.Bots)
{
if (!bot.AwaitsFaultRestart)
{
continue;
}
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
bot.AwaitsFaultRestart = false;
try
{
if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
&& loginName is not null
&& characterSlot is { } slot)
{
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restart the faulted bot '{Name}'.", bot.Name);
}
}
}
/// <summary>
/// Evolves bots which reached the game's maximum level into their master class (see
/// <see cref="BotMasterHandler"/> for the rules, including the iron rule of reset servers).
/// Runs from the maintenance pass - outside the bot's own AI tick - because the evolved bot is
/// restarted right away (see <see cref="BotManager.RestartBotAsync"/>), which must not happen
/// from within one of its own timer callbacks.
/// </summary>
private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ServerState state, ILogger logger)
{
foreach (var bot in state.Manager.Bots)
{
// Not while it hunts with a human (the restart would desert the group), sits in an NPC
// dialog or lies dead - like a due reset, the evolution simply happens on a later pass.
if (BotPartyHandler.HasHumanCompanion(bot)
|| bot.PlayerState.CurrentState != PlayerState.EnteredWorld
|| !bot.IsAlive)
{
continue;
}
if (bot.AwaitsMasterRestart)
{
await this.RestartEvolvedBotAsync(gameContext, state, bot, logger).ConfigureAwait(false);
continue;
}
if (BotMasterHandler.IsMasterEvolutionDue(bot))
{
// The class change and its save go through the bot's own tick, like every other
// bot-initiated mutation (see OfflinePlayer.PendingBotActions): running them from the
// maintenance pass would write the character through the same persistence context the
// combat tick is using at that very moment. The restart follows on the next pass -
// it must NOT happen from within one of the bot's own timer callbacks.
bot.PendingBotActions.Enqueue(async () =>
{
if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false))
{
bot.AwaitsMasterRestart = true;
}
});
}
}
}
/// <summary>
/// Gives the freshly evolved bot the "relog" it needs: the master class's base attributes (master
/// experience rate, master points per level) and the master level stat are only mounted when a
/// character enters the world (see <see cref="BotManager.RestartBotAsync"/>).
/// </summary>
private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, ServerState state, BotPlayer bot, ILogger logger)
{
// Captured before the restart - the disposed bot loses its account and character.
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
bot.AwaitsMasterRestart = false;
try
{
if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
&& loginName is not null
&& characterSlot is { } slot)
{
// The evolution is persisted; only the presence is at risk. RespawnPendingAsync
// drops the entry when the bot is (still or again) online, so a kept-alive old
// instance or a rotation comeback doesn't get doubled.
logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name);
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restart bot '{Name}' after its master evolution.", bot.Name);
}
}
/// <summary>
/// Retries bringing back bots whose respawn after the master evolution failed.
/// </summary>
private async ValueTask RespawnPendingAsync(GameContext gameContext, ServerState state)
{
var count = state.PendingRespawns.Count;
for (var i = 0; i < count && state.PendingRespawns.TryDequeue(out var entry); i++)
{
if (state.Manager.IsActive(entry.Login, entry.Slot))
{
continue;
}
if (!await state.Manager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false))
{
state.PendingRespawns.Enqueue(entry);
}
}
}
/// <summary>
/// Rotates the presence of the bots THIS server animates (see <see cref="BotServerPartition"/>):
/// each server keeps the daily curve within its own share of the population.
/// </summary>
private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState state, BotConfiguration configuration, ILogger logger)
{
if (state.Partition is not { AccountCount: > 0 } partition)
{
return;
}
var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
var totalPopulation = partition.AccountCount * charactersPerAccount;
if (totalPopulation <= 0)
{
return;
}
var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0;
// Local wall-clock time on purpose (the rest of the class uses UtcNow for durations): this curve
// models human presence - fewest in the early morning, most in the evening - so it has to follow
// the players' day, which is the host's local time, not UTC. On a UTC-configured host the two
// coincide; on a host set to the player base's zone, local time keeps the peak in their evening.
var activity = ActivityByHour[DateTime.Now.Hour];
var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity)));
var online = state.Manager.Bots.Count;
if (online < targetOnline)
{
// Bring one bot online: pick a random character which is currently offline.
var offline = new List<(string Login, byte Slot)>();
for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
{
var loginName = BotGenerator.GetLoginName(i);
for (byte slot = 0; slot < charactersPerAccount; slot++)
{
if (!state.Manager.IsActive(loginName, slot))
{
offline.Add((loginName, slot));
}
}
}
if (offline.SelectRandom() is { Login: not null } candidate
&& await state.Manager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false))
{
logger.LogInformation("Bot presence rotation: +1 (online {Online}/{Target} of {Total}).", online + 1, targetOnline, totalPopulation);
}
}
else if (online > targetOnline)
{
var stopped = await state.Manager.StopRandomBotAsync().ConfigureAwait(false);
if (stopped is not null)
{
logger.LogInformation("Bot presence rotation: -1 '{Name}' (online {Online}/{Target} of {Total}).", stopped, online - 1, targetOnline, totalPopulation);
}
}
}
/// <summary>
/// Persists the current configuration back to its <see cref="PlugInConfiguration"/> row, so a
/// programmatic change (e.g. clearing the reset flag) survives a restart.
/// </summary>
private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger)
{
try
{
// Load the configuration through a fresh context so the PlugInConfiguration entity is tracked
// and the change is actually persisted - the in-memory cached config graph is not tracked.
using var context = gameContext.PersistenceContextProvider.CreateNewContext();
var typeId = typeof(BotFeaturePlugIn).GUID;
var gameConfiguration = (await context.GetAsync<GameConfiguration>().ConfigureAwait(false)).FirstOrDefault();
var entity = gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId);
if (entity is null)
{
logger.LogWarning("Could not find the bot plugin configuration row to persist.");
return;
}
entity.SetConfiguration(configuration, gameContext.PlugInManager.CustomConfigReferenceHandler);
await context.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to persist the bot plugin configuration.");
}
}
/// <summary>
/// The bot feature's state of ONE game server: its own share of the population, its own bots, and
/// its own startup and maintenance schedule (see <see cref="_states"/>).
/// </summary>
private sealed class ServerState
{
/// <summary>
/// The <see cref="StartupPhase"/> of this server, as a raw <see cref="int"/>: it is transitioned
/// with <see cref="Interlocked"/>, which has no overload for arbitrary enum types, so the field
/// stays an <see cref="int"/> and the phase constants are cast at the call sites.
/// </summary>
private int _startupState;
/// <summary>
/// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in
/// parallel. Interlocked-updated, hence a field.
/// </summary>
private int _maintenanceRunning;
/// <summary>
/// Gets the bots this server animates.
/// </summary>
public BotManager Manager { get; } = new();
/// <summary>
/// Gets the bots whose spawn failed - the account may not be generated yet (another game server
/// is generating the population), or a respawn after the master evolution did not go through.
/// Retried on the following maintenance passes.
/// </summary>
public ConcurrentQueue<(string Login, byte Slot)> PendingRespawns { get; } = new();
/// <summary>
/// Gets or sets the share of the bot population which this server animates.
/// </summary>
public BotServerPartition? Partition { get; set; }
/// <summary>
/// Gets or sets the time of this server's (single) startup pass.
/// </summary>
public DateTime NextRunUtc { get; set; } = DateTime.UtcNow + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next maintenance pass.
/// </summary>
public DateTime NextMaintenanceUtc { get; set; } = DateTime.UtcNow + StartupDelay + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next bot party re-formation.
/// </summary>
public DateTime NextPartyReformUtc { get; set; } = DateTime.UtcNow + PartyReformInterval;
/// <summary>
/// Gets a reference to the startup state, for the interlocked transitions of the startup pass.
/// </summary>
public ref int StartupState => ref this._startupState;
/// <summary>
/// Gets a reference to the maintenance flag, for the interlocked guard of the maintenance pass.
/// </summary>
public ref int MaintenanceRunning => ref this._maintenanceRunning;
}
}

View File

@@ -0,0 +1,629 @@
// <copyright file="BotGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates and maintains the persistent population of bot accounts and their characters.
/// </summary>
/// <remarks>
/// Accounts are flagged with <see cref="Account.IsBot"/> so they can be reliably reloaded on
/// startup (instead of being regenerated) and purged on request. The account login names follow
/// a deterministic, internal scheme (<see cref="GetLoginName"/>) which is never shown to other
/// players; the player-visible character names are realistic and unique (see <see cref="BotNameGenerator"/>).
/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start.
/// </remarks>
internal sealed class BotGenerator
{
private const string LoginPrefix = "bot";
/// <summary>
/// BCrypt work factor for a bot account's password. The password is a random <see cref="Guid"/>
/// which is discarded immediately and never used to log in - a bot is a connection-less
/// <c>OfflinePlayer</c>, so no client ever authenticates against it. A minimal factor is therefore
/// safe (a 128-bit random secret is infeasible to brute-force regardless of the factor) and keeps
/// generating a large population from becoming a multi-minute BCrypt bottleneck, while still storing
/// a valid BCrypt hash. The default factor is kept for real accounts.
/// </summary>
private const int BotPasswordWorkFactor = 4;
private const int MinLevel = 10;
/// <summary>
/// The highest generated level. High enough that the upper maps (Tarkan, Aida, Kanturu, ...) get a
/// resident bot population and that some bots start beyond the class evolution level
/// (<see cref="BotProgression.ClassEvolutionLevel"/>) - those are created as their second-generation
/// class right away, like a player who did the class quest long ago.
/// </summary>
private const int MaxLevel = 250;
/// <summary>
/// Skew of the level distribution: values above 1 make low and mid levels more common than high
/// ones, like a real server's population pyramid (an even spread would feel top-heavy).
/// </summary>
private const double LevelSkew = 1.6;
private const int StartMoney = 100000;
/// <summary>Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp.</summary>
private const byte StarterItemLevel = 6;
/// <summary>Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.</summary>
private const int BotInventoryExtensions = 4;
/// <summary>Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).</summary>
private const byte MaxMeleeGroup = 3;
/// <summary>Item group of bows (need ammunition).</summary>
private const byte BowGroup = 4;
/// <summary>Item group of staves/sticks (casters).</summary>
private const byte StaffGroup = 5;
/// <summary>Item group of body armor; its item number identifies the armor set.</summary>
private const byte ArmorGroup = 8;
/// <summary>
/// Armor set numbers tried in thematic order; the first the class is qualified for (by its chest piece)
/// is used: 5 Leather (warriors), 2 Pad (wizards), 10 Vine (elves), 39 Mistery (summoners), then fallbacks.
/// </summary>
private static readonly byte[] ArmorSetCandidates = { 5, 2, 10, 39, 6, 0, 4, 8 };
private readonly IGameContext _gameContext;
private readonly ILogger _logger;
private readonly BotNameGenerator _nameGenerator = new();
/// <summary>
/// Initializes a new instance of the <see cref="BotGenerator"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="logger">The logger.</param>
public BotGenerator(IGameContext gameContext, ILogger logger)
{
this._gameContext = gameContext;
this._logger = logger;
}
/// <summary>
/// Gets the deterministic, internal login name of the bot account with the given one-based index.
/// </summary>
/// <param name="index">The one-based account index.</param>
/// <returns>The login name, e.g. <c>bot0001</c> (kept within the 10 character account name limit).</returns>
public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}";
/// <summary>
/// Ensures that the configured number of bot accounts (each with the configured number of
/// characters) exists. Only missing accounts are created.
/// </summary>
/// <param name="numberOfAccounts">The desired number of bot accounts.</param>
/// <param name="charactersPerAccount">The desired number of characters per account.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of accounts that were newly created.</returns>
public async ValueTask<int> EnsureBotsAsync(int numberOfAccounts, int charactersPerAccount, CancellationToken cancellationToken = default)
{
var creatableClasses = this._gameContext.Configuration.CharacterClasses
.Where(c => c is { CanGetCreated: true, HomeMap: not null })
.ToList();
if (creatableClasses.Count == 0)
{
this._logger.LogWarning("No creatable character classes found - cannot generate bots.");
return 0;
}
var perAccount = Math.Clamp(
Math.Min(charactersPerAccount, this._gameContext.Configuration.MaximumCharactersPerAccount),
1,
BotConfiguration.MaxCharactersPerAccountLimit);
var experienceTable = this._gameContext.ExperienceTable;
var maxLevel = Math.Min(MaxLevel, experienceTable.Length - 1);
var minLevel = Math.Clamp(MinLevel, 1, maxLevel);
// On servers with the reset feature the existing population has resets, so freshly generated
// bots get a random reset history too - a visitor should meet believable veterans (even TOP,
// max-reset characters), not a population uniformly starting from zero. Only possible when the
// configuration bounds the resets; unlimited-reset servers keep unseeded bots.
var resetConfiguration = BotResetHandler.GetResetConfiguration(this._gameContext);
var maxSeededResets = resetConfiguration?.ResetLimit is > 0 ? resetConfiguration.ResetLimit.Value : 0;
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
var reservedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var created = 0;
// Build a balanced, shuffled queue of classes so the whole population is evenly split across
// all creatable classes. Independent random draws leave visible skew at this scale (e.g. 11
// Summoners vs 4 Elves for 50 bots); the quota queue guarantees ~even counts, drawn per character.
var classQueue = BuildBalancedClassQueue(creatableClasses, numberOfAccounts * perAccount);
for (var i = 1; i <= numberOfAccounts; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var loginName = GetLoginName(i);
var existing = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
continue;
}
var account = context.CreateNew<Account>();
account.LoginName = loginName;
account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor);
account.IsBot = true;
account.Vault = context.CreateNew<ItemStorage>();
for (byte slot = 0; slot < perAccount; slot++)
{
var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!;
var level = minLevel + (int)((maxLevel - minLevel) * Math.Pow(Rand.NextInt(0, 1001) / 1000.0, LevelSkew));
var seededResets = maxSeededResets > 0 ? Rand.NextInt(0, maxSeededResets + 1) : 0;
var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false);
this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, resetConfiguration);
}
// Save per account so a single failure does not roll back already generated accounts,
// and re-runs simply resume where they left off (idempotent).
if (await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false))
{
created++;
this._logger.LogInformation("Generated bot account '{LoginName}' with {Count} character(s).", loginName, perAccount);
}
}
return created;
}
/// <summary>
/// Deletes all bot accounts with their characters, item storages and items.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of deleted bot accounts.</returns>
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
{
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
// Collect first, delete afterwards: the paging query orders by login name, so deleting while
// paging would shift the accounts which are not visited yet into the pages already passed.
var loginNames = new List<string>();
const int pageSize = 100;
var skip = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var page = (await context.GetAccountsOrderedByLoginNameAsync(skip, pageSize, cancellationToken).ConfigureAwait(false)).ToList();
if (page.Count == 0)
{
break;
}
loginNames.AddRange(page.Where(account => account.IsBot).Select(account => account.LoginName));
skip += page.Count;
}
var deleted = 0;
foreach (var loginName in loginNames)
{
cancellationToken.ThrowIfCancellationRequested();
// Load the account again, this time with its whole graph: the paging query returns the
// accounts untracked and without their characters, and deleting such a shallow account
// leaves its item storages behind. A character's inventory is referenced BY the character,
// so no delete cascade ever reaches it - those storages, and every item lying in them, would
// stay in the database forever as unreachable rows.
var account = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (account is null)
{
continue;
}
foreach (var character in account.Characters)
{
if (character.Inventory is { } inventory)
{
await context.DeleteAsync(inventory).ConfigureAwait(false);
}
}
if (account.Vault is { } vault)
{
await context.DeleteAsync(vault).ConfigureAwait(false);
}
if (await context.DeleteAsync(account).ConfigureAwait(false))
{
deleted++;
}
else
{
// Not silent: a bot account which survives the purge is spawned again right after it.
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted.", loginName);
}
// Save per account, so a single failure does not roll back the accounts already deleted.
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return deleted;
}
private static byte[] CreateDefaultKeyConfiguration()
{
// Mirrors CreateCharacterAction: bind Q to the healing potion and W to the mana potion,
// leave E and R unbound. An all-zero blob would otherwise bind the apple (a heal) to all slots.
const byte healingPotion = 1;
const byte manaPotion = 4;
const byte unbound = 0xFF;
var keyConfiguration = new byte[30];
keyConfiguration[21] = healingPotion; // Q
keyConfiguration[22] = manaPotion; // W
keyConfiguration[23] = unbound; // E
keyConfiguration[25] = unbound; // R
return keyConfiguration;
}
/// <summary>
/// Builds a shuffled queue of character classes with even quotas across <paramref name="classes"/>,
/// so the generated population is balanced instead of relying on the variance of independent random
/// draws. The order is randomized so accounts do not get a predictable class pattern.
/// </summary>
private static Queue<CharacterClass> BuildBalancedClassQueue(IList<CharacterClass> classes, int total)
{
var pool = new List<CharacterClass>(total);
for (var n = 0; n < total; n++)
{
// Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes).
pool.Add(classes[n % classes.Count]);
}
// Fisher-Yates shuffle so the balanced pool is handed out in random order.
for (var n = pool.Count - 1; n > 0; n--)
{
var j = Rand.NextInt(0, n + 1);
(pool[n], pool[j]) = (pool[j], pool[n]);
}
return new Queue<CharacterClass>(pool);
}
/// <summary>
/// Spends the character's level-up points, so a high-level bot actually has high-level stats.
/// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and
/// damage) and die instantly. The split follows the class build in <see cref="BotProgression"/>
/// for the server's meta profile (reset vs classic) - the same split the bot keeps using for
/// points it earns at runtime - and respects each stat's configured maximum (fun servers) as
/// well as the bot's personal vitality target on reset-meta servers.
/// </summary>
private static void DistributeStatPoints(Character character, CharacterClass characterClass, bool resetMeta)
{
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
long CapacityOf(AttributeDefinition stat)
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is null)
{
return 0;
}
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - (long)attribute.Value;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = (long)attribute.Value - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is not null)
{
attribute.Value += amount;
character.LevelUpPoints -= amount;
}
}
}
/// <summary>
/// Calculates the level-up points a character with the given reset history would have available,
/// so a seeded bot invests the same total a player of that history would: the points granted by
/// the resets themselves (looped through <see cref="ResetProgressionCalculator"/>, so tiers,
/// multipliers and the replace/add mode of the server's configuration all apply) plus the points
/// earned by leveling. With <see cref="ResetConfiguration.ResetStats"/> the level points of the
/// finished cycles were invested and wiped again at each reset, so only the current cycle's count;
/// without it every cycle's investment survived and still counts.
/// </summary>
private static int CalculateLevelUpPoints(CharacterClass characterClass, int level, int seededResets, ResetConfiguration? resetConfiguration)
{
var pointsPerLevel = (int)characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue;
if (seededResets <= 0 || resetConfiguration is null)
{
return (level - 1) * pointsPerLevel;
}
var pointsPerResetOverride = (int)(characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == Stats.PointsPerReset)?.BaseValue ?? 0f);
var resetPoints = 0;
for (var reset = 0; reset < seededResets; reset++)
{
var progression = ResetProgressionCalculator.Calculate(reset, pointsPerResetOverride, resetConfiguration);
resetPoints = resetConfiguration.ReplacePointsPerReset
? progression.TotalPointsAfterReset
: resetPoints + progression.PointsForReset;
}
var currentCyclePoints = Math.Max(0, level - resetConfiguration.LevelAfterReset) * pointsPerLevel;
if (resetConfiguration.ResetStats)
{
return resetPoints + currentCyclePoints;
}
var firstCyclePoints = Math.Max(0, resetConfiguration.RequiredLevel - 1) * pointsPerLevel;
var laterCyclesPoints = (seededResets - 1) * Math.Max(0, resetConfiguration.RequiredLevel - resetConfiguration.LevelAfterReset) * pointsPerLevel;
return resetPoints + firstCyclePoints + laterCyclesPoints + currentCyclePoints;
}
private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, ResetConfiguration? resetConfiguration)
{
// A character generated beyond the class evolution level was created as its second-generation
// class right away - like a player who completed the class quest long ago. Everything downstream
// (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset
// history evolved in its first cycle at the latest - provided the reset's required level lies
// beyond the evolution level (the check below), which makes it pass the evolution on the way
// to its first reset regardless of its current in-cycle level.
var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel;
if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle)
&& BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass)
{
characterClass = evolvedClass;
}
var character = context.CreateNew<Character>();
character.CharacterClass = characterClass;
character.Name = name;
character.CharacterSlot = slot;
character.CreateDate = DateTime.UtcNow;
character.KeyConfiguration = CreateDefaultKeyConfiguration();
foreach (var attribute in characterClass.StatAttributes.Select(a => context.CreateNew<StatAttribute>(a.Attribute, a.BaseValue)))
{
character.Attributes.Add(attribute);
}
character.CurrentMap = characterClass.HomeMap;
var spawnGate = character.CurrentMap!.ExitGates.Where(g => g.IsSpawnGate).SelectRandom();
if (spawnGate is not null)
{
character.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
character.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
}
var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level);
levelAttribute.Value = level;
if (seededResets > 0
&& character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets) is { } resetsAttribute)
{
// Persisted exactly like a real player's resets (a per-character stat attribute), so the
// reset counter, the effective level and the reset limit all see the seeded history.
resetsAttribute.Value = seededResets;
}
character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)];
character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration);
character.InventoryExtensions = BotInventoryExtensions;
DistributeStatPoints(character, characterClass, resetConfiguration is not null);
// Skills survive resets, so a seeded veteran knows everything the highest level of its past
// cycles unlocked - level-gated skills are checked against that level, not the current one.
var highestLevelReached = seededResets > 0 && resetConfiguration is not null
? Math.Max(level, resetConfiguration.RequiredLevel)
: level;
this.LearnClassSkills(context, character, characterClass, highestLevelReached);
character.Inventory = context.CreateNew<ItemStorage>();
character.Inventory.Money = StartMoney;
this.EquipStarterGear(context, character, resetConfiguration is not null);
account.Characters.Add(character);
}
/// <summary>
/// Teaches the character the class skills appropriate to its level and stats - attack skills as well
/// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the
/// class is qualified for are ever learned, gated by the skills' real learn requirements from the game
/// configuration (total energy, leadership, character level, ...) evaluated against the stats the bot
/// was just given - exactly the requirements a human player has to meet for the same skill.
/// </summary>
private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level)
{
float? GetValue(AttributeDefinition attribute)
{
if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat)
{
return null;
}
return baseStat == Stats.Level
? level
: character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value;
}
var learnedNumbers = new HashSet<short>(character.LearnedSkills.Select(s => s.Skill!.Number));
foreach (var skill in this._gameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| !BotProgression.MeetsRequirements(skill, GetValue)
|| !learnedNumbers.Add(skill.Number))
{
continue;
}
var entry = context.CreateNew<SkillEntry>();
entry.Skill = skill;
entry.Level = 0;
character.LearnedSkills.Add(entry);
}
}
/// <summary>
/// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test
/// account gear), so it is not naked and punching with its fists. The item level scales modestly
/// with the bot level for a bit more defense/damage without raising the equip requirements too high.
/// </summary>
private void EquipStarterGear(IPlayerContext context, Character character, bool resetMeta)
{
var inventory = character.Inventory!;
var characterClass = character.CharacterClass!;
// Data-driven so every class gets gear it is actually QUALIFIED to wear (a Dark Lord must never
// end up in a Pad/wizard set). We pick the most basic options (lowest DropLevel) the class can use:
// - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff),
// - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set,
// and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots).
// The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the
// later upgrades use), so an energy-specced Magic Gladiator starts with a staff instead of a blade.
// The Small Axe is qualified for almost every class, so without this filter casters and archers would
// all end up with one.
bool IsPreferredWeapon(ItemDefinition definition)
=> BotProgression.IsPreferredWeaponGroup(characterClass, character.Name, resetMeta, (byte)definition.Group);
// Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every
// archer would get a bolt stack as its "weapon" and end up punching with its fists.
var weapon = this._gameContext.Configuration.Items
.Where(d => IsPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel)
?? this._gameContext.Configuration.Items
.Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel);
if (weapon is not null)
{
if (weapon.Group == BowGroup)
{
// Bows need ammunition; the arrows go into the left hand.
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.RightHandSlot, weapon);
this.AddAmmunition(context, inventory);
}
else
{
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon);
}
}
// Choose a thematically appropriate armor set the class can wear, tried in order (warriors -> Leather,
// wizards -> Pad, elves -> Vine, summoners -> Mistery, then fallbacks). Each piece is added only if the
// class is qualified for it, so e.g. the Magic Gladiator keeps the set but skips the helm it can't wear.
foreach (var set in ArmorSetCandidates)
{
if (this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == ArmorGroup && d.Number == set) is not { } chest
|| !chest.QualifiedCharacters.Contains(characterClass))
{
continue;
}
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.HelmSlot, 7, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set);
break;
}
this.AddPotions(context, inventory);
}
private void AddPotions(IPlayerContext context, ItemStorage inventory)
{
// A stack of Large Healing Potions so the offline HealingHandler has something to drink, and a
// stack of Large Mana Potions so casters can keep casting instead of degrading to weak melee once
// their mana runs dry. The BotNavigator tops both up at runtime, so the bot never runs out.
// Durability holds the stack count.
this.AddPotionStack(context, inventory, 3, InventoryConstants.EquippableSlotsCount); // Large Healing Potion, first backpack slot
this.AddPotionStack(context, inventory, 6, (byte)(InventoryConstants.EquippableSlotsCount + 1)); // Large Mana Potion, second backpack slot
}
private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte potionNumber, byte slot)
{
var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == potionNumber);
if (potion is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = potion;
// Only a handful of charges to start with: fresh bots head to the merchant right away and buy
// their supplies with their starting Zen, kicking off the shopping economy from minute one
// (kept just above the emergency top-up threshold, so the economy path - not the fallback - runs).
item.Durability = Rand.NextInt(10, 16);
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number)
{
var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number);
if (definition is null || !definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
this.AddEquippedItem(context, inventory, characterClass, slot, definition);
}
private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition)
{
if (!definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = definition;
item.Level = StarterItemLevel;
item.Durability = definition.Durability;
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void AddAmmunition(IPlayerContext context, ItemStorage inventory)
{
var arrows = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 4 && d.Number == 15);
if (arrows is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = arrows;
item.Durability = 255;
item.ItemSlot = InventoryConstants.LeftHandSlot;
inventory.Items.Add(item);
}
}

View File

@@ -0,0 +1,383 @@
// <copyright file="BotJewelHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// <summary>
/// Lets a bot invest its looted jewels into its own gear like a real player would: after a shopping
/// trip (a rare, safe moment in town), it takes a piece of equipment off, applies a Jewel of Bless,
/// Soul or Life through the regular <see cref="ItemConsumeAction"/> - the same validations, success
/// rates and failure penalties as for a human - and puts the piece back on.
/// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped
/// piece towards +6, whether it has luck or not; Soul (50%, +25% with luck; a failure at +6 drops
/// the item to +5, from +7 on it resets it to +0) is only risked with a spare in stock, on plain
/// items only at +6 and up to +9 only on lucky ones; Life (50%, removes the option on failure) is
/// used sparingly on already upgraded gear. Only a couple of jewels are spent per trip, so the
/// upgrades trickle in over many visits like for a player instead of the whole hoard being burned
/// at once.
/// </summary>
internal static class BotJewelHandler
{
/// <summary>
/// The jewels a bot has a use for: it upgrades its own gear with them (see the policy above).
/// Everything else - Chaos, Creation, Guardian, Gemstone, Harmony, the refine stones - is only
/// spendable by trading or crafting, which a bot does neither of.
/// </summary>
internal static readonly ItemIdentifier[] UsableJewels =
[
ItemConstants.JewelOfBless,
ItemConstants.JewelOfSoul,
ItemConstants.JewelOfLife,
];
/// <summary>
/// Jewels a bot may still be carrying from before but can never spend: it neither trades nor crafts.
/// They are not picked up anymore, so this is about clearing out what is already in the backpack.
/// </summary>
internal static readonly ItemIdentifier[] UnusableJewels =
[
ItemConstants.JewelOfChaos,
ItemConstants.JewelOfCreation,
ItemConstants.JewelOfGuardian,
ItemConstants.Gemstone,
ItemConstants.JewelOfHarmony,
ItemConstants.LowerRefineStone,
ItemConstants.HigherRefineStone,
];
/// <summary>
/// Jewels of Bless per shopping trip. Each kind has its OWN budget on purpose: with one shared
/// budget the Bless rule - which has a target whenever any equipped piece is below +6, and a swapped
/// in piece arrives at the level it dropped with - consumed every use, every trip, and the Soul and
/// Life rules below were never reached at all.
/// </summary>
private const int MaxBlessPerTrip = 2;
/// <summary>Jewels of Soul per shopping trip.</summary>
private const int MaxSoulPerTrip = 1;
/// <summary>Jewels of Life per shopping trip.</summary>
private const int MaxLifePerTrip = 1;
/// <summary>Safety net for the planning loop; the per-kind budgets are the real limit.</summary>
private const int MaxUsesPerTrip = MaxBlessPerTrip + MaxSoulPerTrip + MaxLifePerTrip;
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
private const byte BlessMaxTargetLevel = 5;
/// <summary>Souls are never spent below +6 - that range is Bless territory (safe and cheap).</summary>
private const byte SoulMinTargetLevel = 6;
/// <summary>
/// Without luck a Soul is only risked at +6, where a failure merely drops the item to +5 (a Bless
/// restores that): from +7 on, a failed Soul resets the item to +0
/// (<c>ResetToLevel0WhenFailMinLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>), and at the base
/// 50% success rate that gamble wipes gear more often than not.
/// </summary>
private const byte SoulMaxTargetLevelPlain = 6;
/// <summary>
/// With luck (+25% success) the Soul may be risked up to +8, so lucky items can reach the jewel
/// ceiling of +9 (<c>MaximumLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>).
/// </summary>
private const byte SoulMaxTargetLevelLucky = 8;
/// <summary>Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve.</summary>
private const int MinSoulStock = 2;
/// <summary>Life is only worth risking on gear which already proved worth upgrading.</summary>
private const byte LifeMinTargetLevel = 6;
/// <summary>Only risk a Life with at least this many in stock.</summary>
private const int MinLifeStock = 2;
/// <summary>Fallback stock per kind when the bot feature has no configuration at hand.</summary>
private const int DefaultJewelStockPerKind = 10;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// Spends up to <see cref="MaxUsesPerTrip"/> looted jewels on the bot's own equipment. Call this
/// right after a finished merchant trade: the bot stands in the safezone, the NPC dialog is closed
/// (player state is back at <c>EnteredWorld</c>, which the consume handlers require), and the
/// navigator's shopping cooldown provides the rare, player-like cadence.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TryUpgradeGearAsync(OfflinePlayer player)
{
if (player.Inventory is null)
{
return;
}
var uses = 0;
var blessLeft = MaxBlessPerTrip;
var soulLeft = MaxSoulPerTrip;
var lifeLeft = MaxLifePerTrip;
while (uses < MaxUsesPerTrip && PlanNextUse(player, blessLeft, soulLeft, lifeLeft) is { } plan)
{
if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
{
break;
}
uses++;
if (IsJewel(plan.Jewel, ItemConstants.JewelOfBless))
{
blessLeft--;
}
else if (IsJewel(plan.Jewel, ItemConstants.JewelOfSoul))
{
soulLeft--;
}
else
{
lifeLeft--;
}
}
if (uses > 0)
{
try
{
// Persist right away like after a bot reset - a rolled Soul/Life outcome shouldn't be
// replayable by losing it to a crash before the next periodic save.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after using jewels; the periodic save will retry.", player.Name);
}
}
}
/// <summary>
/// Picks the next (jewel, equipped target item) pair according to the player-like policy, or
/// <c>null</c> when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="blessLeft">How many Jewels of Bless may still be used this trip.</param>
/// <param name="soulLeft">How many Jewels of Soul may still be used this trip.</param>
/// <param name="lifeLeft">How many Jewels of Life may still be used this trip.</param>
internal static (Item Jewel, Item Target)? PlanNextUse(Player player, int blessLeft, int soulLeft, int lifeLeft)
{
if (player.Inventory is not { } inventory)
{
return null;
}
var backpack = inventory.Items.Where(i => i.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex).ToList();
var equipped = inventory.Items
.Where(i => i.ItemSlot <= InventoryConstants.LastEquippableItemSlotIndex
&& i.Definition?.IsAmmunition == false)
.ToList();
var blessStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfBless)).ToList();
var soulStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfSoul)).ToList();
var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
// 1. Bless - free progress: push the weakest equipped piece towards +6.
if (blessLeft > 0
&& blessStock.Count > 0
&& equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
.OrderBy(i => i.Level)
.FirstOrDefault() is { } blessTarget)
{
return (blessStock[0], blessTarget);
}
// 2. Soul - risky: only with a spare in stock, and only where the possible loss is bearable -
// items without luck stop at +6 -> +7 (see SoulMaxTargetLevelPlain), lucky ones may go for +9.
if (soulLeft > 0
&& soulStock.Count >= MinSoulStock
&& equipped.Where(i => i.CanLevelBeUpgraded()
&& i.Level >= SoulMinTargetLevel
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
.OrderByDescending(HasLuck)
.ThenBy(i => i.Level)
.FirstOrDefault() is { } soulTarget)
{
return (soulStock[0], soulTarget);
}
// 3. Life - sparingly: at most one per trip, only on gear that is already +6 or better. Whether
// the item can actually carry the option is the consume handler's call; a rejected consume
// keeps the jewel.
if (lifeLeft > 0
&& lifeStock.Count >= MinLifeStock
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
.OrderByDescending(i => i.Level)
.FirstOrDefault() is { } lifeTarget)
{
return (lifeStock[0], lifeTarget);
}
return null;
}
/// <summary>
/// Whether the bot carries jewels it should get rid of at a merchant: any it cannot use at all, or
/// more of a usable kind than its stock limit. Deliberately cheap - the trip planner asks this on
/// every check, so unlike the full junk scan it must not plan equipment swaps.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns><c>True</c>, if there is jewel surplus to sell.</returns>
internal static bool HasSurplus(Player player)
{
if (player.Inventory is not { } inventory)
{
return false;
}
var limit = GetStockLimit(player);
var counts = new Dictionary<ItemIdentifier, int>();
foreach (var item in inventory.Items)
{
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition)
{
continue;
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
if (UnusableJewels.Contains(identifier))
{
return true;
}
if (!UsableJewels.Contains(identifier))
{
continue;
}
var count = counts.GetValueOrDefault(identifier) + 1;
if (count > limit)
{
return true;
}
counts[identifier] = count;
}
return false;
}
/// <summary>
/// Whether the bot has a jewel it could spend on its gear right now. Jewels are only used at the
/// end of a merchant trip - the rare, safe, player-like moment for it - so the trip planner asks
/// this to decide whether a visit is worth making at all.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns><c>True</c>, if an upgrade is pending.</returns>
internal static bool HasPendingUpgrade(Player player)
=> PlanNextUse(player, MaxBlessPerTrip, MaxSoulPerTrip, MaxLifePerTrip) is not null;
/// <summary>
/// Whether the bot still has room in its stock for this jewel - the pickup handler asks before
/// taking one from the ground, and the merchant trade asks to tell a working stock from surplus.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="item">The jewel to judge.</param>
/// <returns><c>True</c>, if it is a usable jewel and the stock is not full yet.</returns>
internal static bool WantsMoreOf(Player player, Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
return UsableJewels.Contains(identifier)
&& CountInStock(player, identifier) < GetStockLimit(player);
}
/// <summary>
/// Gets the configured number of jewels a bot keeps of each usable kind.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>The stock limit per kind.</returns>
internal static int GetStockLimit(Player player)
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectiveJewelStockPerKind()
?? DefaultJewelStockPerKind;
/// <summary>
/// Counts how many jewels of the given kind the bot carries.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="identifier">The jewel kind.</param>
/// <returns>The number of jewels of that kind in the inventory.</returns>
internal static int CountInStock(Player player, ItemIdentifier identifier)
=> player.Inventory?.Items.Count(i => IsJewel(i, identifier)) ?? 0;
/// <summary>
/// Applies one jewel to one equipped item the way a player does it: take the piece off into a free
/// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it
/// through the regular action, and wear the piece again - whatever the outcome, even a Soul
/// failure's downgraded item goes back on.
/// </summary>
/// <returns><c>true</c>, if the jewel was actually consumed.</returns>
private static async ValueTask<bool> ApplyJewelAsync(OfflinePlayer player, Item jewel, Item target)
{
var inventory = player.Inventory!;
var equipSlot = target.ItemSlot;
// A free slot is not enough - the piece needs a hole of its SIZE (a 2x3 armor does not fit into
// the 1x1 gap a jewel left behind), which is exactly what CheckInvSpace answers.
if (inventory.CheckInvSpace(target) is not { } freeSlot
|| freeSlot <= InventoryConstants.LastEquippableItemSlotIndex)
{
return false;
}
await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(freeSlot) != target)
{
// The unequip was rejected - don't force it.
return false;
}
var jewelSlot = jewel.ItemSlot;
var levelBefore = target.Level;
try
{
await ConsumeAction.HandleConsumeRequestAsync(player, jewelSlot, freeSlot, FruitUsage.Undefined).ConfigureAwait(false);
}
finally
{
// Wear the piece again in any case; the move action re-checks the requirements itself.
await MoveAction.MoveItemAsync(player, freeSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
}
var consumed = inventory.GetItem(jewelSlot) != jewel;
if (consumed)
{
player.Logger.LogInformation(
"Bot '{Name}' used '{Jewel}' on '{Item}': level {Before} -> {After}.",
player.Name,
jewel.Definition?.Name,
target,
levelBefore,
target.Level);
}
return consumed;
}
private static bool IsJewel(Item item, ItemIdentifier identifier)
{
return item.Definition is { } definition
&& identifier == new ItemIdentifier(definition.Number, definition.Group);
}
private static bool HasLuck(Item item)
{
return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck);
}
}

View File

@@ -0,0 +1,316 @@
// <copyright file="BotManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Collections.Concurrent;
using System.Linq;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Manages the lifecycle of server-side bots.
/// </summary>
/// <remarks>
/// A bot reuses the connection-less <see cref="OfflinePlayer"/> together with its MU Helper AI,
/// but is spawned in a fully standalone way: the account is loaded fresh in the bot's own
/// persistence context (see <see cref="OfflinePlayer.InitializeAsync"/>), so there is no
/// cross-context attach of entities owned by another player - which is the root cause of the
/// known data-corruption issue of the <c>/offlevel</c> handover. Because each bot drives a
/// distinct character in its own context, several bots can animate different characters of the
/// same account at once (the shared account row is only attached, never modified).
/// </remarks>
public sealed class BotManager
{
private readonly ConcurrentDictionary<string, BotPlayer> _bots = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets a snapshot of the currently active bots.
/// </summary>
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
/// <summary>
/// Gets the number of currently active bots, without taking a snapshot of them - the periodic task
/// asks every second whether there is anything to stop.
/// </summary>
public int BotCount => this._bots.Count;
/// <summary>
/// Spawns a bot which drives a specific character of the given account.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="loginName">The login name of an existing account.</param>
/// <param name="characterSlot">The character slot to drive; <c>null</c> drives the first character by slot.</param>
/// <returns><c>true</c> if a bot was started; <c>false</c> if it could not be started or was already active.</returns>
public async ValueTask<bool> SpawnBotAsync(IGameContext gameContext, string loginName, byte? characterSlot = null)
{
if (string.IsNullOrWhiteSpace(loginName))
{
return false;
}
var bot = new BotPlayer(gameContext);
var added = false;
string? key = null;
try
{
// Load the account through the bot's OWN persistence context (no cross-context attach).
var account = await bot.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false);
var character = characterSlot is { } slot
? account?.Characters.FirstOrDefault(c => c.CharacterSlot == slot)
: account?.Characters.OrderBy(c => c.CharacterSlot).FirstOrDefault();
if (account is null || character is null)
{
bot.Logger.LogWarning("Bot account '{LoginName}' (slot {Slot}) could not be loaded or has no character.", loginName, characterSlot);
await bot.DisposeAsync().ConfigureAwait(false);
return false;
}
key = GetKey(loginName, character.CharacterSlot);
if (!this._bots.TryAdd(key, bot))
{
// Already animating this character.
await bot.DisposeAsync().ConfigureAwait(false);
return false;
}
added = true;
// Provide AI settings so the bot actually hunts (a missing config means a single-tile range).
bot.MuHelperSettings = new BotMuHelperSettings();
if (!await bot.InitializeAsync(loginName, character.Name).ConfigureAwait(false))
{
await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
return false;
}
BotSkillProgressionPlugIn.CatchUpPendingProgress(bot);
bot.Logger.LogInformation("Bot started for account '{LoginName}', character '{Character}'.", loginName, character.Name);
return true;
}
catch (Exception ex)
{
bot.Logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, characterSlot);
if (added && key is not null)
{
await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
}
else
{
await bot.DisposeAsync().ConfigureAwait(false);
}
return false;
}
}
/// <summary>
/// Stops and removes all currently active bots.
/// </summary>
/// <returns>The task.</returns>
public async ValueTask StopAllAsync()
{
foreach (var key in this._bots.Keys.ToList())
{
if (this._bots.TryRemove(key, out var bot))
{
await StopAndDisposeAsync(bot, key, "shutdown").ConfigureAwait(false);
}
}
}
/// <summary>
/// Determines whether the given character of the given account is currently animated by a bot.
/// </summary>
/// <param name="loginName">The account login name.</param>
/// <param name="slot">The character slot.</param>
public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot));
/// <summary>
/// Stops one randomly chosen bot (used by the presence rotation, so the population ebbs and flows
/// like a real player base). The bot leaves its party cleanly first, then disconnects - which also
/// saves its progress, like a regular logout.
/// </summary>
/// <returns>The name of the stopped bot's character, or null if no bot was active.</returns>
public async ValueTask<string?> StopRandomBotAsync()
{
var key = this._bots.Keys.ToList().SelectRandom();
if (key is null || !this._bots.TryRemove(key, out var bot))
{
return null;
}
var name = bot.Name;
try
{
if (bot.Party is { } party)
{
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// A failed party goodbye must not skip the stop below - the party cleans up a
// disconnected member itself.
bot.Logger.LogWarning(ex, "Bot '{Key}' couldn't leave its party for the presence rotation.", key);
}
await StopAndDisposeAsync(bot, key, "presence rotation").ConfigureAwait(false);
return name;
}
/// <summary>
/// Stops the given bot like a regular logout and immediately brings the same character back
/// online - the ghost equivalent of a player relogging. Used after the master evolution: the
/// master class's base attributes (master experience rate, master points per level) and the
/// master level stat are only mounted when the character enters the world, so the class change
/// must be followed by a fresh world entry before master experience can flow.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="bot">The bot to restart.</param>
/// <returns><c>true</c> if the bot came back online.</returns>
public async ValueTask<bool> RestartBotAsync(IGameContext gameContext, BotPlayer bot)
{
// Captured before the stop - the disposed bot loses its account and character.
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
if (loginName is null
|| characterSlot is not { } slot
|| !this._bots.TryRemove(GetKey(loginName, slot), out var removed))
{
return false;
}
if (removed.Party is { } party)
{
try
{
await party.KickMySelfAsync(removed).ConfigureAwait(false);
}
catch (Exception ex)
{
// A failed party goodbye must not skip the stop below - the party cleans up a
// disconnected member itself.
removed.Logger.LogWarning(ex, "Bot '{Login}/{Slot}' couldn't leave its party for the restart.", loginName, slot);
}
}
try
{
await removed.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
// The old instance may still be (partially) alive - put it back under management and
// don't spawn a second player driving the same character (two persistence contexts
// saving one character would be last-write-wins data loss). Retried on a later pass.
removed.Logger.LogError(ex, "Error while stopping bot '{Login}/{Slot}' for a restart; keeping the old instance.", loginName, slot);
this._bots.TryAdd(GetKey(loginName, slot), removed);
return false;
}
// The stopped instance is done for good - release its persistence context and the tracked
// account graph before the fresh one loads them again.
await removed.DisposeAsync().ConfigureAwait(false);
return await this.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false);
}
/// <summary>
/// Groups a share of the active bots into small hunting parties of level-wise similar characters,
/// like real players do: the party members follow their leader (see the follow logic in
/// <see cref="BotNavigator"/>), the elf heals the group, buffs are shared and the party experience
/// bonus applies. The rest of the bots keep hunting solo, so the population stays varied.
/// </summary>
/// <param name="gameContext">The game context (provides the party manager).</param>
public async ValueTask FormPartiesAsync(IGameContext gameContext)
{
const int minPartySize = 2;
const int maxPartySize = 5;
const int maxLevelGap = 12;
const int partiedSharePercent = 60;
// Matched by the reset-aware effective level (see BotResetHandler.GetEffectiveLevel), so on a
// reset server a freshly reset veteran groups with its peers instead of with real newbies.
var candidates = this._bots.Values
.Where(b => b.Party is null && b.Attributes is not null)
.OrderBy(BotResetHandler.GetEffectiveLevel)
.ToList();
var index = 0;
while (index < candidates.Count - 1)
{
if (Rand.NextInt(0, 100) >= partiedSharePercent)
{
index++; // this bot stays solo
continue;
}
var leader = candidates[index];
var leaderLevel = BotResetHandler.GetEffectiveLevel(leader);
var targetSize = Rand.NextInt(minPartySize, maxPartySize + 1);
var members = new List<BotPlayer> { leader };
var next = index + 1;
while (next < candidates.Count
&& members.Count < targetSize
&& BotResetHandler.GetEffectiveLevel(candidates[next]) - leaderLevel <= maxLevelGap)
{
members.Add(candidates[next]);
next++;
}
if (members.Count >= minPartySize)
{
var party = gameContext.PartyManager.CreateParty();
foreach (var member in members)
{
if (!await party.AddAsync(member).ConfigureAwait(false))
{
break;
}
}
leader.Logger.LogInformation(
"Formed bot party of {Count} around '{Leader}' (level {Level}).",
members.Count,
leader.Name,
leaderLevel);
}
index = next;
}
}
private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}";
/// <summary>
/// Stops the bot like a regular logout (which saves its progress) and releases its resources.
/// A stopped-but-not-disposed player keeps its persistence context - and with it the whole tracked
/// account graph - alive; with the presence rotation stopping bots around the clock, that adds up
/// to a leak. Mirrors the teardown of the <see cref="Offline.OfflinePlayerManager"/>; the removal
/// from the game context happens through the disconnect event.
/// </summary>
private static async ValueTask StopAndDisposeAsync(BotPlayer bot, string key, string reason)
{
try
{
await bot.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
bot.Logger.LogError(ex, "Error while stopping bot '{Key}' ({Reason}).", key, reason);
}
finally
{
await bot.DisposeAsync().ConfigureAwait(false);
}
}
private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot)
{
this._bots.TryRemove(key, out _);
await bot.DisposeAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,351 @@
// <copyright file="BotMasterHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Character;
/// <summary>
/// Handles the third-generation ("master") stage of a bot's career: the evolution into the master
/// class at the game's maximum level - the same class assignment the level-400 master quests perform
/// for a human player - and the investment of the master points earned per master level, through the
/// regular <see cref="AddMasterPointAction"/> with all its validations.
/// </summary>
/// <remarks>
/// On servers with the reset feature the evolution follows one iron rule: a bot only becomes a master
/// when no reset can ever follow, i.e. the configured reset limit is exhausted. While resets remain -
/// or when no limit is configured at all, so resetting forever is the endgame - the bot keeps resetting
/// and never masters, like the players of such servers. Without the reset feature it evolves as soon as
/// it reaches the maximum level.
/// The master class's base attributes (master experience rate, master points per level) and the master
/// level stat are only mounted into the attribute system when the character enters the world, so the
/// caller must restart the bot after the evolution (see <c>BotManager.RestartBotAsync</c>) - the ghost
/// equivalent of a player relogging; master experience only flows after that fresh world entry.
/// </remarks>
internal static class BotMasterHandler
{
/// <summary>
/// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this
/// level, see <see cref="AddMasterPointAction"/>.
/// </summary>
private const int RankUnlockLevel = 10;
/// <summary>The item groups of the weapons a master bonus can be tied to (see <see cref="WeaponGroupOfBonus"/>).</summary>
private const byte SwordGroup = 0;
/// <summary>The item group of the maces - the scepters live here as well.</summary>
private const byte MaceGroup = 2;
/// <summary>The item group of the spears.</summary>
private const byte SpearGroup = 3;
/// <summary>The item group of the bows and crossbows.</summary>
private const byte BowGroup = 4;
/// <summary>The item group of the staffs - the sticks and books live here as well.</summary>
private const byte StaffGroup = 5;
private static readonly AddMasterPointAction AddPointAction = new();
/// <summary>
/// Determines whether the bot is due for its master evolution: it has a master class to evolve
/// into, stands at the game's maximum level, and - on reset servers - exhausted the reset limit
/// (see the remarks of <see cref="BotMasterHandler"/> for the rationale).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the bot should evolve into its master class now.</returns>
public static bool IsMasterEvolutionDue(Player player)
{
if (player.SelectedCharacter is not { CharacterClass: { } currentClass }
|| player.Attributes is not { } attributes
|| BotProgression.GetMasterEvolutionTarget(currentClass) is null)
{
return false;
}
if ((int)attributes[Stats.Level] < player.GameContext.Configuration.MaximumLevel)
{
return false;
}
if (BotResetHandler.GetResetConfiguration(player.GameContext) is { } resetConfiguration
&& (resetConfiguration.ResetLimit is not > 0
|| (int)attributes[Stats.Resets] < resetConfiguration.ResetLimit))
{
return false;
}
return true;
}
/// <summary>
/// Evolves the bot into its master class when due. The caller must restart the bot afterwards
/// (see the remarks of <see cref="BotMasterHandler"/>).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the evolution was performed.</returns>
public static async ValueTask<bool> TryEvolveAsync(OfflinePlayer player)
{
if (!IsMasterEvolutionDue(player) || player.SelectedCharacter is not { } character)
{
return false;
}
var masterClass = BotProgression.GetMasterEvolutionTarget(character.CharacterClass!)!;
character.CharacterClass = masterClass;
player.Logger.LogInformation(
"Bot '{Name}' evolved into master class {Class} at level {Level}.",
player.Name,
masterClass.Name,
player.Level);
try
{
// Persist right away like a performed reset - the following restart reloads the character
// from the database, so the class change must be down there before it.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its master evolution; the logout save will retry.", player.Name);
}
return true;
}
/// <summary>
/// Determines whether the bot is a master with points to invest - the cheap per-tick guard for
/// queueing <see cref="TrySpendMasterPointsAsync"/>.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if there are master points to spend.</returns>
public static bool HasMasterPointsToSpend(Player player)
=> player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 };
/// <summary>
/// Invests the bot's available master points (earned one per master level) into its master skill
/// tree through the regular <see cref="AddMasterPointAction"/>. Learning a skill mutates the
/// skill list, so this must run inside the bot's AI tick - queue it via
/// <see cref="OfflinePlayer.PendingBotActions"/> (see the call site in <c>BotNavigator</c>);
/// with no points available it is a cheap no-op.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player)
{
if (player.SelectedCharacter is not { CharacterClass.IsMasterClass: true } character
|| character.MasterLevelUpPoints < 1)
{
return;
}
while (character.MasterLevelUpPoints > 0 && PickNextMasterSkill(player) is { } skill)
{
var pointsBefore = character.MasterLevelUpPoints;
await AddPointAction.AddMasterPointAsync(player, (ushort)skill.Number).ConfigureAwait(false);
if (character.MasterLevelUpPoints >= pointsBefore)
{
// The action refused - its own checks are authoritative, don't loop on the same pick.
break;
}
player.Logger.LogInformation(
"Bot '{Name}' invested {Points} master point(s) into '{Skill}'.",
player.Name,
pointsBefore - character.MasterLevelUpPoints,
skill.Name);
}
}
/// <summary>
/// Picks the master skill the bot invests its next point into, or <c>null</c> when nothing is
/// eligible. The policy fills the tree like a player: first push a started skill to the rank-unlock
/// level of 10, then learn a new eligible skill - preferring "useful" ones, i.e. passives boosting a
/// stat or strengtheners of a skill the bot actually has - and finally pump the learned skills
/// towards their maximum. Deterministic, so a bot builds the same tree across sessions.
/// Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
internal static Skill? PickNextMasterSkill(Player player)
{
if (player.SelectedCharacter is not { CharacterClass: { } characterClass } character
|| player.SkillList is not { } skillList)
{
return null;
}
var learned = character.LearnedSkills
.Where(l => l.Skill?.MasterDefinition?.Root is not null)
.ToList();
if (learned
.Where(l => l.Level < RankUnlockLevel && l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
.OrderBy(l => l.Skill!.MasterDefinition!.Rank)
.ThenBy(l => l.Skill!.Number)
.FirstOrDefault() is { } gate)
{
return gate.Skill;
}
if (player.GameContext.Configuration.Skills
.Where(s => s.MasterDefinition?.Root is not null
&& s.QualifiedCharacters.Contains(characterClass)
&& character.LearnedSkills.All(l => l.Skill != s)
&& CanLearn(player, s, character.MasterLevelUpPoints))
.OrderBy(s => IsUsefulPick(player, s, skillList) ? 0 : 1)
.ThenBy(s => s.MasterDefinition!.Rank)
.ThenBy(s => s.Number)
.FirstOrDefault() is { } newSkill)
{
return newSkill;
}
return learned
.Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
.OrderBy(l => IsUsefulPick(player, l.Skill!, skillList) ? 0 : 1)
.ThenBy(l => l.Skill!.MasterDefinition!.Rank)
.ThenBy(l => l.Skill!.Number)
.FirstOrDefault()?.Skill;
}
/// <summary>
/// Mirrors the private requisition checks of <see cref="AddMasterPointAction"/> (minimum points,
/// previous rank of the same root at 10+, required skills), so the picker only proposes skills the
/// action will accept. A mismatch is harmless: the action refuses and the spend loop stops.
/// </summary>
private static bool CanLearn(Player player, Skill skill, int availablePoints)
{
var definition = skill.MasterDefinition!;
if (availablePoints < definition.MinimumLevel)
{
return false;
}
if (definition.Rank > 1
&& !player.SelectedCharacter!.LearnedSkills.Any(l =>
l.Skill?.MasterDefinition?.Root is { } root
&& root.Id == definition.Root?.Id
&& l.Skill.MasterDefinition.Rank == definition.Rank - 1
&& l.Level >= RankUnlockLevel))
{
return false;
}
if (definition.RequiredMasterSkills?.Any() == true
&& !definition.RequiredMasterSkills.All(s =>
player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill == s && l.Level >= RankUnlockLevel)
|| (s.MasterDefinition is null && player.SkillList?.ContainsSkill((ushort)s.Number) == true)))
{
return false;
}
return true;
}
/// <summary>
/// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat,
/// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON
/// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a
/// sword), and neither is one which only applies against other PLAYERS: a bot spends its life
/// hunting monsters, and it may not even attack a player unless attacked first (see
/// <see cref="BotPvpRules"/>). Both get filled last, after everything which actually helps.
/// </summary>
private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList)
{
var definition = skill.MasterDefinition!;
if (definition.TargetAttribute is { } target)
{
return !IsPvpOnlyBonus(target)
&& (WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup));
}
return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number);
}
/// <summary>
/// Whether the master bonus only ever applies in a fight against another player, which is not what a
/// bot's points are for.
/// </summary>
/// <param name="attribute">The bonus attribute.</param>
private static bool IsPvpOnlyBonus(AttributeDefinition attribute)
{
return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp;
}
/// <summary>
/// The item group of the weapon a master bonus attribute belongs to, or <c>null</c> when the bonus
/// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from
/// the item data: scepters live in the mace group, the Rage Fighter's gloves in the sword group, and
/// sticks and books next to the staffs.
/// </summary>
private static byte? WeaponGroupOfBonus(AttributeDefinition attribute)
{
if (attribute == Stats.OneHandedSwordBonusDamage
|| attribute == Stats.TwoHandedSwordStrBonusDamage
|| attribute == Stats.TwoHandedSwordMasteryBonusDamage
|| attribute == Stats.GloveWeaponBonusDamage)
{
return SwordGroup;
}
if (attribute == Stats.MaceBonusDamage
|| attribute == Stats.MaceMasteryStunChance
|| attribute == Stats.ScepterStrBonusDamage
|| attribute == Stats.ScepterMasteryBonusDamage
|| attribute == Stats.ScepterPetBonusDamage
|| attribute == Stats.BonusDamageWithScepterCmdDiv)
{
return MaceGroup;
}
if (attribute == Stats.SpearBonusDamage
|| attribute == Stats.SpearMasteryDoubleDamageChance)
{
return SpearGroup;
}
if (attribute == Stats.BowStrBonusDamage
|| attribute == Stats.CrossBowStrBonusDamage
|| attribute == Stats.CrossBowMasteryBonusDamage)
{
return BowGroup;
}
if (attribute == Stats.OneHandedStaffBonusBaseDamage
|| attribute == Stats.TwoHandedStaffBonusBaseDamage
|| attribute == Stats.TwoHandedStaffMasteryBonusDamage
|| attribute == Stats.StickBonusBaseDamage
|| attribute == Stats.StickMasteryBonusDamage
|| attribute == Stats.BookBonusBaseDamage)
{
return StaffGroup;
}
return null;
}
/// <summary>
/// Whether the bot fights with a weapon of this item group - what it carries right now, and what its
/// build makes it pick up in the future (see <see cref="BotProgression.IsPreferredWeaponGroup"/>), so
/// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon.
/// </summary>
private static bool CarriesWeaponOfGroup(Player player, byte weaponGroup)
{
if (player.Inventory?.GetItem(InventoryConstants.LeftHandSlot)?.Definition is { } weapon
&& weapon.Group == weaponGroup)
{
return true;
}
return player.SelectedCharacter is { CharacterClass: { } characterClass } character
&& BotProgression.IsPreferredWeaponGroup(
characterClass,
character.Name,
BotResetHandler.GetResetConfiguration(player.GameContext) is not null,
weaponGroup);
}
}

View File

@@ -0,0 +1,177 @@
// <copyright file="BotMiniGameHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
/// <summary>
/// Lets server-side bots take part in the mini game events (Blood Castle, Devil Square, Chaos
/// Castle) - but only ever in the wake of a human: when a real player who leads a party with bots
/// enters an event with their own ticket, the party's bots follow them in. Bots never enter on
/// their own, and they don't need tickets - the leader's entry is what legitimizes the visit.
/// Each bot is checked against the same entry rules a player faces (the event level bracket,
/// the master class requirement, the player killer restriction); a bot which does not qualify
/// says goodbye and leaves the party to go back to its own hunting life, like a player who cannot
/// join the run. Inside, the bot's routine switches to the event mode of the
/// <see cref="BotNavigator"/>; death and the event's end need no special handling, because the
/// engine respawns a dead bot at the map's safezone (exactly like a player, which removes it from
/// the event) and the event itself warps the remaining participants out when it ends.
/// </summary>
internal static class BotMiniGameHandler
{
/// <summary>
/// Takes the snapshot of the bots which would follow the given player into a mini game.
/// Must be called BEFORE the player actually enters: entering an event which disallows
/// parties (Chaos Castle) kicks the entering player out of its party, and with it the
/// knowledge of who was going to follow.
/// </summary>
/// <param name="player">The player about to enter a mini game.</param>
/// <returns>The party bots to bring along; empty when the player is a bot itself, has no party or is not its master.</returns>
internal static IReadOnlyList<OfflinePlayer> SnapshotPartyBots(Player player)
{
if (player is OfflinePlayer
|| player.Party is not { } party
|| !ReferenceEquals(party.PartyMaster, player))
{
return [];
}
return party.PartyList
.OfType<OfflinePlayer>()
.Where(bot => bot.Account?.IsBot == true)
.ToList();
}
/// <summary>
/// Brings the party bots of a player who just successfully entered a mini game along into it.
/// Each bot's entry is queued into its own MuHelper tick (see <see cref="OfflinePlayer.PendingBotActions"/>),
/// because warping and effect-clearing mutate the bot's state.
/// </summary>
/// <param name="leader">The party leader who entered the mini game.</param>
/// <param name="bots">The snapshot taken by <see cref="SnapshotPartyBots"/> before the entry.</param>
/// <param name="definition">The definition of the entered mini game.</param>
/// <param name="miniGame">The mini game instance the leader entered.</param>
internal static void BringPartyBotsAlong(Player leader, IReadOnlyList<OfflinePlayer> bots, MiniGameDefinition definition, MiniGameContext miniGame)
{
foreach (var bot in bots)
{
bot.PendingBotActions.Enqueue(() => TryEnterAsync(bot, leader, definition, miniGame));
}
}
/// <summary>
/// Determines whether the bot passes the same entry restrictions <c>EnterMiniGameAction</c>
/// checks for a player: the event's character level bracket, the master class requirement and
/// the player killer restriction. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="bot">The bot which wants to follow its leader in.</param>
/// <param name="definition">The mini game definition.</param>
/// <param name="reason">The human-readable reason when the bot does not qualify.</param>
internal static bool IsEligible(Player bot, MiniGameDefinition definition, out string reason)
{
var level = (int)(bot.Attributes?[Stats.Level] ?? 0);
// The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter the events in
// their own level bracket - the same distinction EnterMiniGameAction makes for a player. Judging
// them by the regular bracket kicked a qualified Magic Gladiator out of its leader's party as
// "below the minimum" (and would have let it in past its own maximum).
var isSpecialCharacter = bot.SelectedCharacter?.IsSpecialCharacter() == true;
var minimumLevel = isSpecialCharacter ? definition.MinimumSpecialCharacterLevel : definition.MinimumCharacterLevel;
var maximumLevel = isSpecialCharacter ? definition.MaximumSpecialCharacterLevel : definition.MaximumCharacterLevel;
if (level < minimumLevel)
{
reason = $"level {level} is below the minimum of {minimumLevel}";
return false;
}
if (level > maximumLevel)
{
reason = $"level {level} is above the maximum of {maximumLevel}";
return false;
}
if (definition.RequiresMasterClass && bot.SelectedCharacter?.CharacterClass?.IsMasterClass is not true)
{
reason = "it has not evolved into a master class yet";
return false;
}
if (!definition.ArePlayerKillersAllowedToEnter && bot.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
{
reason = "player killers cannot enter";
return false;
}
reason = string.Empty;
return true;
}
private static async ValueTask TryEnterAsync(OfflinePlayer bot, Player leader, MiniGameDefinition definition, MiniGameContext miniGame)
{
if (bot.PlayerState.CurrentState != PlayerState.EnteredWorld
|| !bot.IsAlive
|| bot.CurrentMiniGame is not null)
{
return;
}
if (!IsEligible(bot, definition, out var reason))
{
// Like a player who cannot join the run: the bot says goodbye and goes back to its
// own hunting life instead of waiting at the gate.
bot.Logger.LogInformation(
"Bot '{Name}' cannot follow '{Leader}' into {Event} ({Reason}) and leaves the party.",
bot.Name,
leader.Name,
definition.Name,
reason);
if (bot.Party is { } party)
{
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
return;
}
if (definition.Entrance is not { } entrance)
{
return;
}
var enterResult = await miniGame.TryEnterAsync(bot).ConfigureAwait(false);
if (enterResult != EnterResult.Success)
{
// Full or already closed - not the bot's fault; it stays in the party and waits for
// the leader outside, hunting normally.
bot.Logger.LogInformation(
"Bot '{Name}' could not follow '{Leader}' into {Event}: {Result}.",
bot.Name,
leader.Name,
definition.Name,
enterResult);
return;
}
// Mirror of the player entry flow in EnterMiniGameAction, without ticket and entrance fee.
if (!definition.AllowParty && bot.Party is { } noPartyEventParty)
{
await noPartyEventParty.KickMySelfAsync(bot).ConfigureAwait(false);
}
await bot.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
await bot.RemoveSummonAsync().ConfigureAwait(false);
await bot.WarpToAsync(entrance).ConfigureAwait(false);
bot.Logger.LogInformation(
"Bot '{Name}' follows '{Leader}' into {Event}.",
bot.Name,
leader.Name,
definition.Name);
}
}

View File

@@ -0,0 +1,212 @@
// <copyright file="BotMuHelperSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// Default MU Helper settings used to drive a bot's combat AI.
/// A bot never sends a client-side MU Helper configuration, so without this the player would
/// fall back to a hunting range of a single tile (see <see cref="Offline.CombatHandler"/>).
/// These defaults make the bot hunt nearby monsters, pick up the valuable drops and use
/// potions, while staying close to its spawn origin.
/// </summary>
internal sealed class BotMuHelperSettings : IMuHelperSettings
{
/// <inheritdoc />
public int BasicSkillId => 0;
/// <inheritdoc />
public int ActivationSkill1Id => 0;
/// <inheritdoc />
public int ActivationSkill2Id => 0;
/// <inheritdoc />
public int DelayMinSkill1 => 0;
/// <inheritdoc />
public int DelayMinSkill2 => 0;
/// <inheritdoc />
public bool Skill1UseTimer => false;
/// <inheritdoc />
public bool Skill1UseCondition => false;
/// <inheritdoc />
public bool Skill1ConditionAttacking => false;
/// <inheritdoc />
public int Skill1SubCondition => 0;
/// <inheritdoc />
public bool Skill2UseTimer => false;
/// <inheritdoc />
public bool Skill2UseCondition => false;
/// <inheritdoc />
public bool Skill2ConditionAttacking => false;
/// <inheritdoc />
public int Skill2SubCondition => 0;
/// <inheritdoc />
public bool UseCombo => false;
/// <inheritdoc />
public int HuntingRange => 6;
/// <inheritdoc />
public int MaxSecondsAway => 30;
/// <inheritdoc />
public bool LongRangeCounterAttack => false;
/// <inheritdoc />
/// <remarks>
/// Disabled for bots: the <see cref="BotNavigator"/> is the sole driver of travel between hunting
/// grounds, so the offline movement handler must not try to walk the bot back to its origin in parallel.
/// </remarks>
public bool ReturnToOriginalPosition => false;
/// <inheritdoc />
public int BuffSkill0Id => 0;
/// <inheritdoc />
public int BuffSkill1Id => 0;
/// <inheritdoc />
public int BuffSkill2Id => 0;
/// <inheritdoc />
public bool BuffOnDuration => false;
/// <inheritdoc />
public bool BuffDurationForParty => false;
/// <inheritdoc />
public int BuffCastIntervalSeconds => 0;
// With the class heal skill learned (e.g. elf Heal), the HealingHandler casts it below the threshold
// before falling back to potions - the same order a real player follows.
/// <inheritdoc />
public bool AutoHeal => true;
/// <inheritdoc />
public int HealThresholdPercent => 60;
/// <inheritdoc />
public bool UseDrainLife => false;
/// <inheritdoc />
public bool UseHealPotion => true;
/// <inheritdoc />
public int PotionThresholdPercent => 60;
/// <inheritdoc />
// Bots hunt in small parties (see BotManager.FormParties): the elf heals the group, buffs are
// shared, and the party experience bonus applies - like a real group of players.
public bool SupportParty => true;
/// <inheritdoc />
public bool AutoHealParty => true;
/// <inheritdoc />
public int HealPartyThresholdPercent => 60;
/// <inheritdoc />
public bool UseDarkRaven => false;
/// <inheritdoc />
public int DarkRavenMode => 0;
/// <inheritdoc />
public int ObtainRange => 6;
/// <inheritdoc />
public bool PickAllItems => false;
/// <inheritdoc />
// Must be true: the pickup handler bails out early unless PickAllItems or PickSelectItems is set,
// so with this off the selective PickZen/PickJewel/PickAncient flags below never take effect.
public bool PickSelectItems => true;
/// <inheritdoc />
public bool PickJewel => true;
/// <inheritdoc />
public bool PickZen => true;
/// <inheritdoc />
public bool PickAncient => true;
/// <inheritdoc />
public bool PickExcellent => true;
/// <inheritdoc />
public bool PickExtraItems => false;
/// <inheritdoc />
public IReadOnlyList<string> ExtraItemNames => Array.Empty<string>();
/// <inheritdoc />
/// <remarks>
/// Disabled on purpose: offline auto-repair has no NPC discount and drains Zen at an
/// increased rate. Bots should not burn their balance on repairs during the proof of concept.
/// </remarks>
public bool RepairItem => false;
/// <inheritdoc />
/// <remarks>Bots fight back when a player attacks them (see <see cref="BotSelfDefensePlugIn"/>).</remarks>
public bool UseSelfDefense => true;
/// <inheritdoc />
public bool AutoAcceptFriend => false;
/// <inheritdoc />
public bool AutoAcceptGuild => false;
/// <inheritdoc />
/// <remarks>
/// Bots accept party invitations from any player, like a friendly stranger would - within the
/// safeguards applied by <see cref="BotPartyHandler"/> (level gap, not while busy, limited time).
/// </remarks>
public bool AutoAcceptAnyone => true;
/// <inheritdoc />
public bool FallbackBasicAttack => true;
/// <inheritdoc />
/// <remarks>
/// Enabled for bots: they have no client-side skill configuration, so the combat AI auto-selects the
/// strongest learned attack skill the character can currently afford. Combined with the level-gated
/// skills granted at generation (see <see cref="BotGenerator"/>), this makes bots cast class- and
/// level-appropriate magic/skills instead of only swinging their weapon.
/// </remarks>
public bool AutoSelectBestSkill => true;
/// <inheritdoc />
/// <remarks>Bots keep their class's learned buffs up automatically (e.g. elf Greater Defense/Greater Damage).</remarks>
public bool AutoSelectBuffs => true;
/// <inheritdoc />
/// <remarks>Casters drink mana potions, so they keep casting instead of degrading to weak melee.</remarks>
public bool UseManaPotion => true;
/// <inheritdoc />
/// <remarks>
/// Bots only engage monsters they can handle (the navigator's safe-monster cap). Without this, a bot
/// travelling through hostile territory picks fights with monsters far above its level and dies.
/// </remarks>
public bool OnlyHuntSafeMonsters => true;
/// <inheritdoc />
/// <remarks>Bots evaluate dropped gear and pick up upgrades for their own class (see <see cref="BotEquipmentHandler"/>).</remarks>
public bool PickUpgradeItems => true;
}

View File

@@ -0,0 +1,92 @@
// <copyright file="BotNameGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates pronounceable, realistic-looking character names for bots.
/// </summary>
/// <remarks>
/// Names are built procedurally from syllables so the generator scales to thousands of unique
/// names while still looking like names a real player might pick. Every generated name satisfies
/// the default character name rules (3-10 alphanumeric characters), so bots blend in with players;
/// the bot nature is tracked by <see cref="DataModel.Entities.Account.IsBot"/>, never by the name.
/// </remarks>
internal sealed class BotNameGenerator
{
private static readonly string[] Starts =
{
"Dra", "Kar", "Mil", "Tho", "Zan", "Bel", "Gor", "Vyn", "Ael", "Mor",
"Run", "Syl", "Tor", "Kra", "Fen", "Lyr", "Nyx", "Ori", "Var", "Eld",
"Bro", "Cyn", "Dar", "Hal", "Ith", "Jor", "Kae", "Lor", "Mag", "Nor",
"Pyr", "Rha", "Ser", "Ulr", "Wyn", "Xan", "Yor", "Zel", "Ari", "Cae",
};
private static readonly string[] Middles =
{
string.Empty, string.Empty, string.Empty,
"a", "e", "i", "o", "ia", "ae", "an", "or", "el", "yn", "ar",
};
private static readonly string[] Ends =
{
"dor", "lin", "rik", "gar", "wyn", "ric", "mir", "dan", "eth", "ron",
"ana", "ella", "ix", "ael", "oth", "ara", "une", "is", "ius", "wen",
};
private readonly IRandomizer _randomizer = Rand.GetRandomizer();
/// <summary>
/// Generates a name which is not yet used, neither within this run nor in the database.
/// </summary>
/// <param name="context">The context used to check name availability.</param>
/// <param name="reserved">The set of names already handed out in this run; the returned name is added to it.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A unique, valid character name.</returns>
public async ValueTask<string> GenerateUniqueAsync(IPlayerContext context, ISet<string> reserved, CancellationToken cancellationToken = default)
{
for (var attempt = 0; attempt < 500; attempt++)
{
var name = this.BuildName(attempt);
if (!reserved.Add(name))
{
continue;
}
var existing = await context.GetAccountByCharacterNameAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return name;
}
}
throw new InvalidOperationException("Could not generate a unique bot character name after many attempts.");
}
private string BuildName(int attempt)
{
var start = Starts.SelectRandom(this._randomizer)!;
var middle = Middles.SelectRandom(this._randomizer)!;
var end = Ends.SelectRandom(this._randomizer)!;
var name = start + middle + end;
// Once the simple name space gets crowded, append a digit to keep finding free names
// without ever exceeding the 10 character limit.
if (attempt > 30)
{
var suffix = (attempt % 10).ToString(CultureInfo.InvariantCulture);
name = (name.Length >= 10 ? name[..9] : name) + suffix;
}
else if (name.Length > 10)
{
name = name[..10];
}
return char.ToUpperInvariant(name[0]) + name[1..].ToLowerInvariant();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
// <copyright file="BotPartyHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Lets a server-side bot party up with players who invite it (enabled by
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): the invitation is accepted after a short
/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
/// in <see cref="BotNavigator"/>) until it gets bored and politely leaves. Safeguards keep it
/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has
/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the
/// inviter may have joined another party or left. There is no level gate, matching OpenMU's own party
/// action: it is the player who invites, and the bot leaves again once it gets bored.
/// </summary>
internal static class BotPartyHandler
{
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
/// <summary>Upper bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5);
/// <summary>
/// Lower bound of the time the bot stays in a party with a human before it gets bored and leaves.
/// A player who groups a bot gets a companion for a decent hunting session, but not a permanent
/// follower - the bot has its own goals (its resets, its shopping, its own pace).
/// </summary>
private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10);
/// <summary>Upper bound of the time the bot stays in a party with a human, see <see cref="MinPartyDuration"/>.</summary>
private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20);
/// <summary>
/// Schedules the acceptance of a party invitation to a bot, if the bot is available for it.
/// Called from the auto-accept criteria of <see cref="MuHelper.PartyRequestHandler"/>.
/// </summary>
/// <param name="receiver">The invited player; only server-side bots schedule an accept.</param>
/// <param name="requester">The player who sent the party request.</param>
/// <param name="acceptDelay">Overrides the human-like random delay (used by tests).</param>
/// <returns>True, if the invitation was taken and will be answered; false, if no criteria matched.</returns>
internal static async ValueTask<bool> TryScheduleAcceptAsync(Player receiver, Player requester, TimeSpan? acceptDelay = null)
{
if (receiver is not OfflinePlayer bot
|| bot.Account?.IsBot != true
|| HasHumanCompanion(bot)
|| bot.PendingPartyInvite is not null)
{
return false;
}
if (bot.IsOnShoppingTrip || bot.HasRevengeIntent || bot.CurrentMiniGame is not null)
{
// Busy - a player in the middle of an errand, a grudge or an event would not group up either.
return false;
}
if (!IsRequesterEligible(requester))
{
return false;
}
var delay = acceptDelay
?? MinAcceptDelay + TimeSpan.FromMilliseconds(Rand.NextInt(0, (int)(MaxAcceptDelay - MinAcceptDelay).TotalMilliseconds + 1));
// Blocks a second concurrent inviter (the request action treats a set requester like a busy
// player) and is cleared again when the invitation is answered or dropped.
bot.LastPartyRequester = requester;
bot.PendingPartyInvite = new PendingPartyInvite(requester, DateTime.UtcNow + delay);
// The same feedback a human invitee's request flow gives, so the inviter knows it went out.
await requester.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RequestedPlayerForParty), bot.Name).ConfigureAwait(false);
bot.Logger.LogInformation("Bot '{Name}' accepts the party invitation of '{Requester}' in {Delay}.", bot.Name, requester.Name, delay);
return true;
}
/// <summary>
/// Drives the bot's party behavior; called from the bot's regular evaluation tick. Answers a
/// pending invitation once its delay passed, and leaves the party again when the bot got bored
/// of grouping with a human (bot-only parties are exempt - they are managed by the hourly
/// re-formation of <see cref="BotManager"/>).
/// </summary>
/// <param name="bot">The bot.</param>
internal static async ValueTask ProcessAsync(OfflinePlayer bot)
{
if (bot.PendingPartyInvite is { } invite && DateTime.UtcNow >= invite.AcceptAtUtc)
{
bot.PendingPartyInvite = null;
try
{
await AcceptInvitationAsync(bot, invite.Requester).ConfigureAwait(false);
}
finally
{
bot.LastPartyRequester = null;
}
}
if (bot.Party is { } party && HasHumanCompanion(bot))
{
bot.PartyBoredomAtUtc ??= DateTime.UtcNow + MinPartyDuration
+ TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxPartyDuration - MinPartyDuration).TotalSeconds + 1));
if (DateTime.UtcNow >= bot.PartyBoredomAtUtc)
{
bot.PartyBoredomAtUtc = null;
bot.Logger.LogInformation("Bot '{Name}' got bored and leaves its party.", bot.Name);
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
}
else
{
bot.PartyBoredomAtUtc = null;
}
}
/// <summary>
/// Determines whether the bot's party contains a human player (any live member which is not a
/// server-side <see cref="OfflinePlayer"/>).
/// </summary>
/// <param name="bot">The bot.</param>
/// <returns>True, if a human player is in the bot's party.</returns>
internal static bool HasHumanCompanion(Player bot)
{
return bot.Party is { } party
&& party.PartyList.OfType<Player>().Any(member => member is not OfflinePlayer);
}
private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player requester)
{
// Re-validate: between the invitation and this answer, the bot may have joined a human's party
// and the inviter may have died, left the game or joined another party.
if (HasHumanCompanion(bot) || !IsRequesterEligible(requester))
{
bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
return;
}
await LeaveBotPartyAsync(bot).ConfigureAwait(false);
if (bot.Party is not null)
{
bot.Logger.LogInformation("Bot '{Name}' could not leave its bot party for '{Requester}'.", bot.Name, requester.Name);
return;
}
bool success;
if (requester.Party is { } requesterParty)
{
if (!Equals(requesterParty.PartyMaster, requester))
{
// The inviter joined another party as a plain member in the meantime; it can no
// longer take the bot in.
return;
}
success = await requesterParty.AddAsync(bot).ConfigureAwait(false);
}
else
{
// Like the regular party response: the requester becomes the master of the new party.
var party = bot.GameContext.PartyManager.CreateParty();
success = await party.AddAsync(requester).ConfigureAwait(false)
&& await party.AddAsync(bot).ConfigureAwait(false);
}
if (success)
{
bot.Logger.LogInformation("Bot '{Name}' joined the party of '{Requester}'.", bot.Name, requester.Name);
}
}
/// <summary>
/// Lets the bot leave the bot-only party it hunts in, so it can join the player who invited it: a
/// living player takes precedence over the bot's own company. When the bot LEADS that party, the
/// group is broken up instead - the engine does not hand the mastership over to another member when
/// the master leaves (it only removes them from the member list), which would leave the remaining
/// bots following a leader who is not in their party anymore. Their next hourly re-formation groups
/// them again (see <see cref="BotManager"/>).
/// </summary>
private static async ValueTask LeaveBotPartyAsync(OfflinePlayer bot)
{
if (bot.Party is not { } party)
{
return;
}
if (Equals(party.PartyMaster, bot))
{
bot.Logger.LogInformation("Bot '{Name}' breaks up its bot party to join a player.", bot.Name);
foreach (var member in party.PartyList.ToList())
{
await party.KickMySelfAsync(member).ConfigureAwait(false);
}
return;
}
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
private static bool IsRequesterEligible(Player requester)
{
return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld;
}
}

View File

@@ -0,0 +1,115 @@
// <copyright file="BotPlayer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Threading;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// A connection-less bot player. It reuses the whole offline-player intelligence (combat, buffs,
/// healing, pickup) and adds a <see cref="BotNavigator"/> which makes it roam to level-appropriate
/// hunting grounds instead of standing on a fixed spawn position.
/// </summary>
public sealed class BotPlayer : OfflinePlayer
{
/// <summary>
/// After this many AI ticks failing in a row, the bot is considered broken and gets restarted.
/// The engine's attribute system is not thread-safe, and a lost race can corrupt a character's
/// attribute graph for good: every following tick throws, the bot stops playing and floods the log
/// with the same exception until the server restarts. A fresh login rebuilds the graph and heals it,
/// which is exactly what a player would do. The threshold is high enough that a single failing tick
/// (a transient race, a monster which just died) is simply skipped, like before.
/// </summary>
private const int ConsecutiveFailuresUntilRestart = 20;
private BotNavigator? _navigator;
private int _consecutiveTickFailures;
/// <summary>
/// Initializes a new instance of the <see cref="BotPlayer"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
public BotPlayer(IGameContext gameContext)
: base(gameContext)
{
}
/// <inheritdoc />
public override bool RespawnAndContinue => true;
/// <summary>
/// Gets or sets a value indicating whether this bot evolved into its master class and still needs
/// the "relog" which mounts the master attributes (see <see cref="BotManager.RestartBotAsync"/>).
/// Set from the bot's own tick, where the evolution runs; acted upon by the maintenance pass, which
/// is the only place allowed to restart a bot (a restart from within the bot's own timer callback
/// would tear down the very loop it runs in).
/// </summary>
public bool AwaitsMasterRestart { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this bot's AI keeps failing and it has to be restarted
/// (see <see cref="ConsecutiveFailuresUntilRestart"/>). Acted upon by the maintenance pass, which is
/// the only place allowed to restart a bot.
/// </summary>
public bool AwaitsFaultRestart { get; set; }
/// <inheritdoc />
public override async ValueTask StopAsync()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
await base.StopAsync().ConfigureAwait(false);
}
/// <inheritdoc />
internal override void OnAiTickSucceeded()
{
if (this._consecutiveTickFailures > 0)
{
Interlocked.Exchange(ref this._consecutiveTickFailures, 0);
}
}
/// <inheritdoc />
internal override void OnAiTickFailed()
{
if (Interlocked.Increment(ref this._consecutiveTickFailures) == ConsecutiveFailuresUntilRestart)
{
// Deliberately '==', not '>=': this arms the restart exactly once, at the tick that crosses
// the threshold, so the log gets one line and the flag is raised once. Further failures keep
// incrementing the counter (which stays above the threshold) but don't re-fire; the
// maintenance pass consumes AwaitsFaultRestart and the restart resets the counter to 0.
this.Logger.LogWarning(
"Bot '{Name}' failed {Count} AI ticks in a row and gets restarted to heal it.",
this.Name,
ConsecutiveFailuresUntilRestart);
this.AwaitsFaultRestart = true;
}
}
/// <inheritdoc />
protected override void StartIntelligence()
{
base.StartIntelligence();
this._navigator = new BotNavigator(this);
this._navigator.Start();
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private async ValueTask StopNavigatorAsync()
{
if (this._navigator is { } navigator)
{
this._navigator = null;
await navigator.DisposeAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,496 @@
// <copyright file="BotProgression.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// The shared progression rules of server-side bots: how a bot of a given class invests its stat
/// points, and which skills it may learn. Used by the <see cref="BotGenerator"/> when a bot is
/// created and by the <see cref="BotSkillProgressionPlugIn"/> when it levels up during play, so a
/// freshly generated bot and one that grew to the same level in-game end up with the same build.
/// </summary>
internal static class BotProgression
{
/// <summary>
/// The character level at which a bot changes into its second-generation class (e.g. Dark Knight
/// to Blade Knight), the way a player completes the class-change quest. The quest itself boils down
/// to exactly this assignment (see <c>QuestCompletionAction</c>), so bots take the direct route.
/// </summary>
public const int ClassEvolutionLevel = 200;
/// <summary>
/// The character class numbers from the game's data model (<c>CharacterClassNumber</c> lives in the
/// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here).
/// </summary>
private const byte DarkWizardNumber = 0;
private const byte SoulMasterNumber = 2;
private const byte GrandMasterNumber = 3;
private const byte DarkKnightNumber = 4;
private const byte BladeKnightNumber = 6;
private const byte BladeMasterNumber = 7;
private const byte FairyElfNumber = 8;
private const byte MuseElfNumber = 10;
private const byte HighElfNumber = 11;
private const byte MagicGladiatorNumber = 12;
private const byte DuelMasterNumber = 13;
private const byte DarkLordNumber = 16;
private const byte LordEmperorNumber = 17;
private const byte SummonerNumber = 20;
private const byte BloodySummonerNumber = 22;
private const byte DimensionMasterNumber = 23;
private const byte RageFighterNumber = 24;
private const byte FistMasterNumber = 25;
/// <summary>
/// The share of each invested batch that goes into vitality on reset-meta servers, until the bot's
/// personal <see cref="GetVitalityTarget"/> is reached (out of a nominal weight total of ~100).
/// </summary>
private const int ResetMetaVitalityWeight = 5;
/// <summary>
/// The base classes which evolve into a second-generation class at <see cref="ClassEvolutionLevel"/>:
/// Dark Wizard, Dark Knight, Fairy Elf and Summoner. The Magic Gladiator, Dark Lord and Rage Fighter
/// have no second generation - their next class is the level-400 master evolution, out of bot scope.
/// </summary>
private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20];
/// <summary>
/// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's
/// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the
/// bot would put itself to sleep), and Defense (18), which players get from equipping a shield
/// rather than learning it.
/// </summary>
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
/// <summary>
/// The skills which the game only activates on the castle siege map. Nothing in the skill data marks
/// them, but the client does not let a player cast them anywhere else, so a bot hunting with one is a
/// bot doing something no player can do. They are also the strongest numbers each class has - they are
/// meant to be - which is exactly why a "pick the strongest" rule walks straight into them: a Dark
/// Knight fought with Crescent Moon Slash, every elf with Starfall and every Rage Fighter with Charge.
/// (44 Crescent Moon Slash, 45 Lance, 46 Starfall, 57 Spiral Slash, 73 Mana Rays, 74 Fire Blast,
/// 269 Charge - the same set the client refuses outside a siege.)
/// The second group are the skills of the siege roles - the guild's battle masters and its master -
/// which are handed out for the siege and are not attacks at all: 67 Stun, 68 Cancel Stun,
/// 69 Swell Mana, 70 Invisibility, 71 Cancel Invisibility, 72 Abolish Magic. Stun in particular is
/// indistinguishable from a real attack skill by its data alone: like Twisting Slash and Power Slash
/// it is an area skill with no damage of its own and a single hit.
/// </summary>
private static readonly short[] CastleSiegeOnlySkillNumbers = [44, 45, 46, 57, 67, 68, 69, 70, 71, 72, 73, 74, 269];
/// <summary>
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
/// class has no (in-scope) evolution.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass)
{
return EvolvableClassNumbers.Contains(characterClass.Number)
? characterClass.NextGenerationClass
: null;
}
/// <summary>
/// Gets the master class the character evolves into at the game's maximum level (the
/// third-generation evolution the level-400 master quests perform), or null when the current class
/// has none. Unlike <see cref="GetEvolutionTarget"/> this applies to all classes: the
/// second-generation classes evolve into their masters (Blade Knight -> Blade Master, ...), and
/// Magic Gladiator, Dark Lord and Rage Fighter - which have no second generation - evolve directly
/// (-> Duel Master, Lord Emperor, Fist Master). When and whether a bot takes this step is decided
/// by <see cref="BotMasterHandler.IsMasterEvolutionDue"/>.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetMasterEvolutionTarget(CharacterClass characterClass)
{
return characterClass is { IsMasterClass: false, NextGenerationClass: { IsMasterClass: true } masterClass }
? masterClass
: null;
}
/// <summary>
/// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen
/// by the server type (see <see cref="BotResetHandler.GetResetConfiguration"/> at the call sites):
/// <list type="bullet">
/// <item><b>Reset meta</b> (reset feature enabled) - modeled on the actual endgame characters of a
/// reset server: everything goes into the class's combat stats (shield and agility-based defense do
/// the tanking there), vitality only receives a token share until the bot's personal
/// <see cref="GetVitalityTarget"/> is reached (enforce via <see cref="SplitPoints"/> capacities).</item>
/// <item><b>Classic meta</b> (no reset feature) - the builds of community stat guides for classic
/// servers, where point pools are small and vitality is a plain percentage of the build.</item>
/// </list>
/// Where a class has two established builds (knight agility/PK, gladiator warrior/mage, elf
/// archer/supporter), each bot picks one deterministically from its character name, so the
/// population is diverse but every bot keeps the same build across sessions and re-invests
/// consistently after each reset. The first entry is always the build's primary stat - it absorbs
/// rounding remainders and overflow from capped stats.
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant for two-build classes.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass, string characterName, bool resetMeta)
{
// Stable across processes (string.GetHashCode is randomized per run, which would re-spec
// the bot on every server restart).
var variant = characterName.Aggregate(0, (acc, c) => acc + c) % 2;
var vit = Stats.BaseVitality;
var str = Stats.BaseStrength;
var agi = Stats.BaseAgility;
var ene = Stats.BaseEnergy;
var cmd = Stats.BaseLeadership;
if (resetMeta)
{
const int v = ResetMetaVitalityWeight;
return characterClass.Number switch
{
DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
? new[] { (str, 59), (agi, 39), (ene, 2), (vit, v) }
: new[] { (str, 52), (agi, 35), (ene, 13), (vit, v) },
DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
new[] { (ene, 49), (agi, 47), (str, 4), (vit, v) },
FairyElfNumber or MuseElfNumber or HighElfNumber =>
new[] { (agi, 67), (ene, 27), (str, 6), (vit, v) },
MagicGladiatorNumber or DuelMasterNumber => variant == 0
? new[] { (str, 57), (agi, 26), (ene, 17), (vit, v) }
: new[] { (ene, 66), (agi, 18), (str, 16), (vit, v) },
DarkLordNumber or LordEmperorNumber =>
new[] { (str, 54), (cmd, 23), (agi, 18), (ene, 5), (vit, v) },
SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
new[] { (ene, 70), (agi, 18), (str, 12), (vit, v) },
RageFighterNumber or FistMasterNumber =>
new[] { (str, 64), (agi, 18), (ene, 18), (vit, v) },
_ when GetMainDamageStat(characterClass) == str =>
new[] { (str, 60), (agi, 35), (vit, v) },
_ => new[] { (GetMainDamageStat(characterClass), 65), (agi, 30), (vit, v) },
};
}
return characterClass.Number switch
{
DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
? new[] { (str, 62), (agi, 26), (vit, 8), (ene, 4) }
: new[] { (str, 50), (vit, 28), (agi, 18), (ene, 4) },
DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
new[] { (ene, 66), (vit, 22), (agi, 8), (str, 4) },
FairyElfNumber or MuseElfNumber or HighElfNumber => variant == 0
? new[] { (agi, 62), (vit, 23), (ene, 10), (str, 5) }
: new[] { (ene, 65), (vit, 22), (agi, 8), (str, 5) },
MagicGladiatorNumber or DuelMasterNumber => variant == 0
? new[] { (str, 57), (agi, 22), (vit, 15), (ene, 6) }
: new[] { (ene, 58), (vit, 26), (agi, 11), (str, 5) },
DarkLordNumber or LordEmperorNumber =>
new[] { (str, 38), (cmd, 30), (vit, 22), (agi, 8), (ene, 2) },
SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
new[] { (ene, 64), (vit, 24), (agi, 8), (str, 4) },
RageFighterNumber or FistMasterNumber =>
new[] { (str, 45), (vit, 35), (ene, 20) },
_ => new[] { (GetMainDamageStat(characterClass), 50), (vit, 50) },
};
}
/// <summary>
/// The bot's personal vitality target on reset-meta servers: how many points it invests into
/// vitality over its whole career (100..500, rolled deterministically from the character name,
/// so the population gets a natural spread from glassy to sturdy and every bot keeps its roll
/// across restarts and resets). The endgame players such servers breed leave vitality almost
/// untouched - shield and agility-based defense tank instead - so the target is intentionally low.
/// </summary>
/// <param name="characterName">The character name.</param>
public static int GetVitalityTarget(string characterName)
{
var sum = characterName.Aggregate(0, (acc, c) => acc + c);
return 100 + ((sum * 7919) % 401);
}
/// <summary>
/// Splits the given points proportionally to the class's stat weights, returning whole-point
/// amounts which sum up to <paramref name="points"/> - unless capacities cut it short. The
/// optional <paramref name="capacityOf"/> callback limits how many points a stat may still take
/// (its <see cref="AttributeDefinition.MaximumValue"/> on fun servers, the vitality target on
/// reset-meta servers); a filled stat drops out of the split and its share flows to the remaining
/// stats over subsequent rounds. When every stat is full, the rest of the points stay unassigned,
/// like for a maxed-out human character.
/// </summary>
/// <param name="points">The number of points to split.</param>
/// <param name="weights">The stat weights of the class.</param>
/// <param name="capacityOf">Optionally resolves how many more points a stat can take; null means unlimited.</param>
public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(
int points,
IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights,
Func<AttributeDefinition, long>? capacityOf = null)
{
if (points <= 0 || weights.Count == 0)
{
yield break;
}
var allocated = new int[weights.Count];
var capacity = new long[weights.Count];
for (var i = 0; i < weights.Count; i++)
{
capacity[i] = Math.Max(0, capacityOf?.Invoke(weights[i].Stat) ?? long.MaxValue);
}
var remaining = points;
while (remaining > 0)
{
var activeTotalWeight = 0;
var firstActive = -1;
for (var i = 0; i < weights.Count; i++)
{
if (weights[i].Weight > 0 && allocated[i] < capacity[i])
{
activeTotalWeight += weights[i].Weight;
if (firstActive < 0)
{
firstActive = i;
}
}
}
if (activeTotalWeight <= 0)
{
break; // every stat is at its capacity - the rest stays unspent.
}
var assignedThisRound = 0;
for (var i = 0; i < weights.Count; i++)
{
if (weights[i].Weight <= 0 || allocated[i] >= capacity[i])
{
continue;
}
var share = (int)Math.Min((long)remaining * weights[i].Weight / activeTotalWeight, capacity[i] - allocated[i]);
allocated[i] += share;
assignedThisRound += share;
}
if (assignedThisRound == 0)
{
// Rounding tail (fewer points left than active stats): the primary stat takes it.
var tail = (int)Math.Min(remaining, capacity[firstActive] - allocated[firstActive]);
allocated[firstActive] += tail;
assignedThisRound = tail;
if (assignedThisRound == 0)
{
break;
}
}
remaining -= assignedThisRound;
}
for (var i = 0; i < weights.Count; i++)
{
if (allocated[i] > 0)
{
yield return (weights[i].Stat, allocated[i]);
}
}
}
/// <summary>
/// Determines whether the skill is one a bot may learn: an actual attack skill, or a self/party
/// buff or heal with a magic effect (which the offline buff/heal handlers know how to cast).
/// Passive boosts, event skills, enemy debuffs and utility skills are left out.
/// </summary>
/// <param name="skill">The skill to check.</param>
public static bool IsBotLearnableSkill(Skill skill)
{
if (skill.MasterDefinition is not null)
{
// Master skills are never learned for free - they cost the master points earned per master
// level and go through the regular action (see BotMasterHandler), like for a human player.
return false;
}
if (CastleSiegeOnlySkillNumbers.Contains(skill.Number))
{
return false;
}
if (IsAttackSkill(skill))
{
// Worth learning if it adds damage of its own, hits more than once, or hits more than one
// target. Judging by AttackDamage alone locked a Rage Fighter out of its entire arsenal:
// Killing Blow, Chain Drive, Dragon Roar and Phoenix Shot all carry a flat bonus of zero and
// four hits instead, because their damage comes from the weapon - which is also how the
// server pays them out.
return skill.AttackDamage > 0
|| skill.NumberOfHitsPerAttack > 1
|| IsAreaSkill(skill);
}
return skill.SkillType is SkillType.Buff or SkillType.Regeneration
&& skill.MagicEffectDef is not null
&& !ExcludedBuffSkillNumbers.Contains(skill.Number);
}
/// <summary>
/// Determines whether the skill deals damage to a target, as opposed to buffing, summoning or the like.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsAttackSkill(Skill skill)
=> skill.SkillType is SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget;
/// <summary>
/// Determines whether the skill hits more than its primary target.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsAreaSkill(Skill skill)
=> skill.SkillType is SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget;
/// <summary>
/// Determines whether the skill is one the game only activates during a castle siege, which a bot
/// therefore never uses while hunting - not even when it already knows it, as a Dark Knight does:
/// Crescent Moon Slash is handed to every one of them when the character is created.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsCastleSiegeOnly(Skill skill) => CastleSiegeOnlySkillNumbers.Contains(skill.Number);
/// <summary>
/// Determines whether the skill belongs to a PET rather than to the character, and may therefore
/// only be used while that pet is actually equipped. Plasma Storm is the Fenrir's, and nothing in
/// the skill's own numbers gives the missing pet away: the attribute behind its damage
/// (<see cref="Attributes.Stats.FenrirBaseDmg"/>) is derived from the character's own strength,
/// agility, vitality and energy, so it is large for any high level character - with or without the
/// pet. Scoring it by that attribute alone handed Plasma Storm, the longest ranged skill most
/// classes own, to a whole population riding nothing.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool RequiresPet(Skill skill) => skill.DamageType == DamageType.Fenrir;
/// <summary>
/// Determines whether the character meets the skill's learn requirements (the same ones the game
/// enforces when casting, e.g. total energy for wizard spells or character level for knight skills).
/// <paramref name="getAttributeValue"/> resolves an attribute's current value; returning null means
/// the attribute is unknown in the caller's context, which conservatively fails the requirement.
/// </summary>
/// <param name="skill">The skill whose requirements are checked.</param>
/// <param name="getAttributeValue">Resolves an attribute's current value; null means the attribute is unknown.</param>
public static bool MeetsRequirements(Skill skill, Func<AttributeDefinition, float?> getAttributeValue)
{
foreach (var requirement in skill.Requirements)
{
if (requirement.Attribute is not { } attribute)
{
continue;
}
if (getAttributeValue(attribute) is not { } value || value < requirement.MinimumValue)
{
return false;
}
}
return true;
}
/// <summary>
/// Maps a "total" attribute (used by skill requirements) to the base stat a generated character
/// actually has, so requirements can be evaluated before the character was ever composed at runtime.
/// Returns null for attributes that have no base-stat counterpart.
/// </summary>
/// <param name="attribute">The "total" attribute to map.</param>
public static AttributeDefinition? TotalToBaseStat(AttributeDefinition attribute)
{
if (attribute == Stats.TotalEnergy)
{
return Stats.BaseEnergy;
}
if (attribute == Stats.TotalStrength)
{
return Stats.BaseStrength;
}
if (attribute == Stats.TotalAgility)
{
return Stats.BaseAgility;
}
if (attribute == Stats.TotalVitality)
{
return Stats.BaseVitality;
}
if (attribute == Stats.TotalLeadership)
{
return Stats.BaseLeadership;
}
if (attribute == Stats.Level)
{
return Stats.Level;
}
return null;
}
/// <summary>
/// Determines whether a weapon of the given item group fits the fighting style of this bot's BUILD:
/// archers use bows, casters staves, everyone else melee weapons. The build decides, not just the
/// class - a Magic Gladiator specced into energy (see the variants in <see cref="GetStatWeights"/>)
/// is a caster and must get a staff, while its strength-specced sibling wants a blade; deciding by
/// the class's base attributes alone handed both of them swords. Classes whose base attributes make
/// them archers (the elves) keep their bow in every build - it is the only weapon they can wield.
/// Used both for the starter gear (<see cref="BotGenerator"/>) and for later upgrades
/// (<see cref="BotEquipmentHandler"/>), so an elf never swaps its bow for a random axe it happens to
/// be qualified for (which would also displace its arrows).
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant, see <see cref="GetStatWeights"/>.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
/// <param name="itemGroup">The item group of the weapon.</param>
public static bool IsPreferredWeaponGroup(CharacterClass characterClass, string characterName, bool resetMeta, byte itemGroup)
{
const byte maxMeleeGroup = 3;
const byte bowGroup = 4;
const byte staffGroup = 5;
float ClassStat(AttributeDefinition attribute)
=> characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f;
var strength = ClassStat(Stats.BaseStrength);
var agility = ClassStat(Stats.BaseAgility);
var energy = ClassStat(Stats.BaseEnergy);
if (agility > strength && agility > energy)
{
return itemGroup == bowGroup;
}
// The build's primary stat (the first weight, see GetStatWeights) tells a caster from a fighter;
// the class fallback covers classes without an energy build of their own.
var primaryStat = GetStatWeights(characterClass, characterName, resetMeta)[0].Stat;
if (primaryStat == Stats.BaseEnergy || energy > strength)
{
return itemGroup == staffGroup;
}
return itemGroup <= maxMeleeGroup;
}
private static AttributeDefinition GetMainDamageStat(CharacterClass characterClass)
{
return characterClass.StatAttributes
.Where(a => a.Attribute == Stats.BaseStrength
|| a.Attribute == Stats.BaseAgility
|| a.Attribute == Stats.BaseEnergy
|| a.Attribute == Stats.BaseLeadership)
.OrderByDescending(a => a.BaseValue)
.Select(a => a.Attribute!)
.FirstOrDefault() ?? Stats.BaseStrength;
}
}

View File

@@ -0,0 +1,69 @@
// <copyright file="BotPvpRules.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// The single source of truth for when a server-side bot may attack a player.
/// </summary>
/// <remarks>
/// Invariant: a bot must never escalate its own <see cref="HeroState"/>. A bot which turns outlaw
/// is a broken toy - it can be killed penalty-free forever, loses the warp command, and visibly
/// marks itself as a misbehaving AI. The game escalates the killer's hero state (see
/// <c>Player.AfterKilledPlayerAsync</c>) unless the victim is already an outlaw or the kill happened
/// in active self-defense (duels and rival-guild wars are exempt as well, but bots have neither),
/// so those two cases are exactly what this rule allows.
/// The bot's grudge memory (<see cref="Offline.OfflinePlayer.RecentAggressor"/>, ~5 minutes, and the
/// revenge march after a death) is deliberately longer than the game's self-defense window: the
/// grudge only decides WHOM the bot prioritizes and where it walks; whether it may actually strike
/// is decided here, per attack, against the game's own rules.
/// </remarks>
public static class BotPvpRules
{
/// <summary>
/// How much of the self-defense window must still remain for an attack to count as safe. The
/// legality check runs when the attack is issued, but the kill (and with it the game's own
/// self-defense evaluation) can land moments later - without a margin, a final blow right at
/// the window's edge would escalate the bot's hero state after all. While the player keeps
/// attacking, every damaging hit renews the window, so the margin never interrupts an ongoing fight.
/// </summary>
private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3);
/// <summary>
/// Determines whether the bot may legally attack the given player, i.e. without any risk of
/// escalating the bot's own <see cref="HeroState"/>.
/// </summary>
/// <param name="bot">The bot (or offline player) which wants to attack.</param>
/// <param name="target">The player it wants to attack.</param>
/// <returns><c>true</c> if attacking is free of PK consequences; otherwise, <c>false</c>.</returns>
public static bool IsLegalPvpTarget(Player bot, Player target)
{
// A running mini game with free player killing (Chaos Castle): every fellow participant
// is fair game - such kills never escalate the hero state (see Player.OnDeathAsync), the
// game's self-defense bookkeeping doesn't even track them. Gated on the running state, so
// bots don't swing at players during the countdown before the event starts.
if (!ReferenceEquals(bot, target)
&& bot.CurrentMiniGame is { AllowPlayerKilling: true, IsEventRunning: true } miniGame
&& ReferenceEquals(target.CurrentMiniGame, miniGame))
{
return true;
}
// Outlaws are fair game for everyone - killing them never escalates the killer's state.
if (target.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
{
return true;
}
// Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed
// (attacker, defender) and renewed on every damaging hit by the SelfDefensePlugIn).
if (bot.GameContext.SelfDefenseState.TryGetValue((target, bot), out var timeout)
&& timeout > DateTime.UtcNow.Add(SelfDefenseSafetyMargin))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
// <copyright file="BotResetHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// Performs character resets for bots on servers where the <see cref="ResetFeaturePlugIn"/> is enabled,
/// and provides the reset-aware effective level used by the bot logic. Everything is driven by the
/// server's actual <see cref="ResetConfiguration"/> (and <see cref="ResetProgressionCalculator"/>), so
/// bots follow the same reset rules as the human players of that particular server; when the feature
/// is disabled, none of this changes bot behavior at all.
/// </summary>
/// <remarks>
/// The reset itself mirrors the effect of <see cref="ResetCharacterAction"/>, with two deliberate
/// differences for the connection-less bot ghosts: the costs (zen, reset items) are skipped by default,
/// because bots don't take part in the economy the costs are balanced for (see
/// <see cref="BotConfiguration.BotsPayResetCosts"/>), and the <see cref="ResetConfiguration.LogOut"/>
/// step is replaced by continuing in place - a bot has no client to log out.
/// </remarks>
internal static class BotResetHandler
{
/// <summary>
/// Gets the server's reset configuration, or null when the reset feature is not enabled.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The reset configuration, or null.</returns>
public static ResetConfiguration? GetResetConfiguration(IGameContext gameContext)
=> gameContext.FeaturePlugIns.GetPlugIn<ResetFeaturePlugIn>()?.Configuration;
/// <summary>
/// Gets the player's effective level for bot decisions: on a reset server a freshly reset
/// character is back at the configured <see cref="ResetConfiguration.LevelAfterReset"/> but
/// fights with the accumulated power of all its resets, so the
/// plain level would misjudge it everywhere (target safety, map choice, party matching). Each
/// reset counts as the level span it took (<see cref="ResetConfiguration.RequiredLevel"/>).
/// Master levels count on top (like the game's own total level), so an evolved master keeps
/// being judged stronger than a plain level-capped character.
/// Without the reset feature this is the character level plus the master level.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The effective level.</returns>
public static int GetEffectiveLevel(Player player)
{
var level = (int)(player.Attributes?[Stats.Level] ?? 1)
+ (int)(player.Attributes?[Stats.MasterLevel] ?? 0f);
if (GetResetConfiguration(player.GameContext) is not { } configuration)
{
return level;
}
var resets = (int)(player.Attributes?[Stats.Resets] ?? 0f);
return (resets * Math.Max(0, configuration.RequiredLevel)) + level;
}
/// <summary>
/// Determines whether the bot is currently eligible for a reset: the reset feature is enabled,
/// the required level is reached and the reset limit (if any) is not yet exhausted.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <returns>True, if the bot can reset now.</returns>
public static bool IsResetDue(Player player, ResetConfiguration configuration)
{
if (player.Attributes is not { } attributes)
{
return false;
}
if (player.Level < configuration.RequiredLevel)
{
return false;
}
var nextResetCount = (int)attributes[Stats.Resets] + 1;
return configuration.ResetLimit is not > 0 || nextResetCount <= configuration.ResetLimit;
}
/// <summary>
/// Resets the bot character, mirroring the effect of <see cref="ResetCharacterAction"/>: the reset
/// count goes up, the level drops to <see cref="ResetConfiguration.LevelAfterReset"/>, the stats and
/// level-up points follow the server's configuration, and with <see cref="ResetConfiguration.MoveHome"/>
/// the bot warps back to its class home town. Afterwards the regular level-up progression is fired,
/// so the bot immediately re-invests the granted point pool according to its class build - until that
/// runs, the damage-based target safety keeps the temporarily weak bot away from monsters it can no
/// longer handle.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <param name="payCosts">Whether the configured costs (zen, reset items) are consumed like for a human player.</param>
/// <returns>True, if the reset was performed.</returns>
public static async ValueTask<bool> TryResetAsync(OfflinePlayer player, ResetConfiguration configuration, bool payCosts)
{
if (player.Attributes is not { } attributes
|| player.SelectedCharacter is not { CharacterClass: not null } character
|| !IsResetDue(player, configuration))
{
return false;
}
var resetProgression = ResetProgressionCalculator.Calculate(
(int)attributes[Stats.Resets],
(int)attributes[Stats.PointsPerReset],
configuration);
if (payCosts && !await TryConsumeCostsAsync(player, configuration, resetProgression).ConfigureAwait(false))
{
return false;
}
attributes[Stats.Resets] = resetProgression.NextResetCount;
attributes[Stats.Level] = configuration.LevelAfterReset;
character.Experience = 0;
if (configuration.ResetStats)
{
character.CharacterClass!.StatAttributes
.Where(s => s.IncreasableByPlayer)
.ForEach(s => attributes[s.Attribute] = s.BaseValue);
}
if (configuration.ReplacePointsPerReset)
{
character.LevelUpPoints = resetProgression.TotalPointsAfterReset;
}
else
{
character.LevelUpPoints += resetProgression.PointsForReset;
}
player.Logger.LogInformation(
"Bot '{Name}' performed reset {ResetCount} and got {Points} points to invest.",
player.Name,
resetProgression.NextResetCount,
character.LevelUpPoints);
if (configuration.MoveHome)
{
await MoveHomeAsync(player).ConfigureAwait(false);
}
// No LogOut for the connection-less ghost - instead re-run the level-up progression, which
// invests the whole granted point pool and re-checks the learnable skills (queued into the
// bot's AI tick by BotSkillProgressionPlugIn, exactly like for an earned level-up).
player.GameContext.PlugInManager.GetPlugInPoint<ICharacterLevelUpPlugIn>()?.CharacterLeveledUp(player);
try
{
// Persist right away instead of waiting for the periodic save - losing a whole performed
// reset to a crash within that window would hurt far more than ordinary hunting progress.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its reset; the periodic save will retry.", player.Name);
}
return true;
}
/// <summary>
/// Consumes the configured reset costs like <see cref="ResetCharacterAction"/> does for a human
/// player: the required zen and the required amount of the configured reset item. Only used when
/// <see cref="BotConfiguration.BotsPayResetCosts"/> is enabled.
/// </summary>
private static async ValueTask<bool> TryConsumeCostsAsync(OfflinePlayer player, ResetConfiguration configuration, ResetProgression resetProgression)
{
IList<Item> requiredItems = [];
if (resetProgression.RequiredItemAmount > 0 && configuration.RequiredResetItem is { } requiredDefinition)
{
requiredItems = player.Inventory?.Items
.Where(item => item.Definition is { } definition
&& definition.Group == requiredDefinition.Group
&& definition.Number == requiredDefinition.Number)
.Take(resetProgression.RequiredItemAmount)
.ToList() ?? [];
if (requiredItems.Count < resetProgression.RequiredItemAmount)
{
return false;
}
}
if (player.Money < resetProgression.RequiredZen
|| (resetProgression.RequiredZen > 0 && !player.TryRemoveMoney(resetProgression.RequiredZen)))
{
return false;
}
foreach (var item in requiredItems)
{
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
}
return true;
}
/// <summary>
/// Warps the bot to a spawn gate of its class home map, the live-ghost equivalent of the
/// position rewrite <see cref="ResetCharacterAction"/> performs before logging a player out.
/// </summary>
private static async ValueTask MoveHomeAsync(OfflinePlayer player)
{
var homeMapDefinition = player.SelectedCharacter?.CharacterClass?.HomeMap;
if (homeMapDefinition is null
|| await player.GameContext.GetMapAsync((ushort)homeMapDefinition.Number).ConfigureAwait(false) is not { } homeMap
|| homeMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } spawnGate)
{
return;
}
await player.WarpToAsync(spawnGate).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="BotRevengePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Notes when a (human) player kills a server-side bot, so the bot can march back to the place of
/// its death after respawning and take revenge on the killer. A bot which shrugs off being killed
/// and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes
/// back angry. The return march happens in the <see cref="BotNavigator"/>; the counter-attack in
/// the offline <see cref="CombatHandler"/>, whose re-armed aggressor memory keeps the killer
/// prioritized - struck only once the game's own rules make it legal (see <see cref="BotPvpRules"/>).
/// </summary>
[PlugIn]
[Display(Name = "Bot revenge", Description = "Makes server-side bots return to their death site and take revenge on the player who killed them.")]
[Guid("29B871B0-FBCF-44D4-A677-8A9832AAC193")]
public class BotRevengePlugIn : IAttackableGotKilledPlugIn
{
/// <inheritdoc />
public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer)
{
if (killed is OfflinePlayer bot
&& bot.Account?.IsBot == true
&& bot.CurrentMiniGame is null // a death in an event (Chaos Castle) is part of the game, not a wrong to avenge
&& killer is Player killerPlayer
&& killerPlayer is not OfflinePlayer
&& !ReferenceEquals(killerPlayer, killed))
{
bot.RegisterDeathByPlayer(killerPlayer);
}
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="BotSelfDefensePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Notes when a (human) player attacks a server-side bot, so the bot's combat AI can defend itself.
/// Without this, a bot placidly keeps farming monsters while a player kills it - the most obviously
/// bot-like behavior an observer can trigger. The actual counter-attack happens in the offline
/// <see cref="CombatHandler"/>, which prioritizes a recent aggressor over its monster targets.
/// </summary>
[PlugIn]
[Display(Name = "Bot self defense", Description = "Makes server-side bots fight back when a player attacks them.")]
[Guid("7E2B9C41-5A8D-4F36-B190-3D6E84C7F215")]
public class BotSelfDefensePlugIn : IAttackableGotHitPlugIn
{
/// <inheritdoc />
public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo)
{
if (attackable is OfflinePlayer bot
&& bot.Account?.IsBot == true
&& bot.CurrentMiniGame is null // event fights (Chaos Castle) leave no grudge outside
&& attacker is Player aggressor
&& aggressor is not OfflinePlayer
&& !ReferenceEquals(aggressor, attackable))
{
bot.RegisterAggressor(aggressor);
}
}
}

View File

@@ -0,0 +1,195 @@
// <copyright file="BotServerPartition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Splits the bot population over the game servers of the deployment, so a server does not animate the
/// whole population by itself: bots count towards the player count of their server, and a server which
/// is full turns real clients away - the bots would lock the players out of the game.
/// <para>
/// Which accounts a server animates is a pure function of the account index and the SET of configured
/// game servers, so every server computes the same answer without asking the others - no coordination,
/// no shared state, and it holds in a deployment where each game server is its own process. The share
/// of a server is proportional to its capacity, and only a part of that capacity
/// (<see cref="BotConfiguration.BotCapacityPercent"/>) is handed to the bots: the rest stays reserved
/// for real players, who must never be denied a slot by a bot.
/// </para>
/// <para>
/// The split is computed when the server starts. Adding a game server to a running deployment therefore
/// takes a restart before the population spreads onto it; that is deliberate. Moving the ownership of a
/// bot between two RUNNING servers would mean one server animating an account the other one is still
/// animating - the very cross-context situation which corrupts a character.
/// </para>
/// </summary>
internal sealed class BotServerPartition
{
private BotServerPartition(int firstAccount, int accountCount, bool isGenerator)
{
this.FirstAccount = firstAccount;
this.AccountCount = accountCount;
this.IsGenerator = isGenerator;
}
/// <summary>
/// Gets the one-based index of the first bot account this server animates.
/// </summary>
public int FirstAccount { get; }
/// <summary>
/// Gets the number of bot accounts this server animates.
/// </summary>
public int AccountCount { get; }
/// <summary>
/// Gets a value indicating whether this server generates the bot population - and, likewise, carries
/// out the requested reset and purge. Exactly one server does it (the first of the deployment), so
/// the generation of the accounts - and of their unique character names - never runs twice at the
/// same time. The other servers simply find their accounts once they exist; until then, their spawns
/// are retried by the maintenance pass.
/// </summary>
public bool IsGenerator { get; }
/// <summary>
/// Determines the share of the bot population which the given game server animates.
/// </summary>
/// <param name="gameContext">The context of the game server which asks.</param>
/// <param name="configuration">The bot configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>The share of this server.</returns>
public static async ValueTask<BotServerPartition> CreateAsync(IGameContext gameContext, BotConfiguration configuration, ILogger logger)
{
var requestedAccounts = Math.Max(configuration.NumberOfAccounts, 0);
var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
var capacities = await GetAccountCapacitiesAsync(gameContext, configuration, charactersPerAccount, logger).ConfigureAwait(false);
if (gameContext is not IGameServerContext serverContext || capacities.Count == 0)
{
// A deployment we cannot split (no server definitions readable, or a context which is not a
// game server, e.g. in tests): behave exactly like before - this server animates everything.
return new BotServerPartition(1, requestedAccounts, true);
}
var (partition, assignedAccounts) = Split(capacities, serverContext.Id, requestedAccounts);
if (assignedAccounts < requestedAccounts)
{
logger.LogWarning(
"The bot population does not fit: {Requested} account(s) configured, but only {Fitting} fit into {Percent}% of the game servers' capacity. {Dropped} account(s) stay offline - raise the servers' maximum player count, the bot capacity share, or lower the number of accounts.",
requestedAccounts,
assignedAccounts,
configuration.GetEffectiveBotCapacityPercent(),
requestedAccounts - assignedAccounts);
}
logger.LogInformation(
"This game server ({ServerId}) animates {Count} bot account(s) ({First}..{Last}) of {Requested}.",
serverContext.Id,
partition.AccountCount,
partition.AccountCount == 0 ? 0 : partition.FirstAccount,
partition.AccountCount == 0 ? 0 : partition.FirstAccount + partition.AccountCount - 1,
requestedAccounts);
return partition;
}
/// <summary>
/// Determines whether this server animates the bot account with the given one-based index.
/// </summary>
/// <param name="accountIndex">The one-based bot account index.</param>
public bool Owns(int accountIndex)
=> accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount;
/// <summary>
/// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure
/// decision behind <see cref="CreateAsync"/>. Every server runs it over the same list and gets the
/// same answer, which is what makes the split need no coordination at all.
/// <para>
/// Proportional, not first-come: filling one server to the brim before using the next would leave the
/// added server empty until the first one overflows - and a player on it would meet nobody. The bots
/// are there to populate the world, so they spread over the servers the players can choose from.
/// </para>
/// </summary>
/// <param name="capacities">How many accounts each game server may animate, ordered by server id.</param>
/// <param name="serverId">The id of the server which asks.</param>
/// <param name="requestedAccounts">The configured number of bot accounts.</param>
/// <returns>The share of the asking server, and how many accounts fit into the deployment at all.</returns>
internal static (BotServerPartition Partition, int AssignedAccounts) Split(
IEnumerable<(byte ServerId, int Capacity)> capacities,
byte serverId,
int requestedAccounts)
{
var allServers = capacities.ToList();
var servers = allServers.Where(c => c.Capacity > 0).ToList();
var totalCapacity = servers.Sum(server => (long)server.Capacity);
// Who generates - and purges - the population is decided by the SET of servers alone, never by
// how many accounts are configured: a deployment which currently wants zero bots still needs an
// owner for the destructive operations, otherwise "delete all bots" would silently do nothing on
// every server. The first server (they all walk the list in the same order) takes the role.
var owner = servers.Count > 0 ? servers[0].ServerId : allServers.Select(server => (byte?)server.ServerId).FirstOrDefault();
var isGenerator = owner == serverId;
if (totalCapacity == 0 || requestedAccounts <= 0)
{
return (new BotServerPartition(1, 0, isGenerator), 0);
}
// What does not fit into the servers' share stays offline; those accounts wake up as soon as the
// deployment offers the room (another game server, a higher player limit or bot capacity share).
var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
var partition = new BotServerPartition(1, 0, isGenerator);
long capacitySoFar = 0;
var accountsSoFar = 0;
foreach (var (currentServer, capacity) in servers)
{
capacitySoFar += capacity;
// Walk the cumulative capacity, so the rounding of one server's share is corrected by the
// next one instead of adding up: the shares always sum up to the assigned accounts exactly.
var accountsUpToHere = (int)(assignedAccounts * capacitySoFar / totalCapacity);
var share = accountsUpToHere - accountsSoFar;
if (currentServer == serverId && share > 0)
{
partition = new BotServerPartition(accountsSoFar + 1, share, isGenerator);
}
accountsSoFar = accountsUpToHere;
}
return (partition, assignedAccounts);
}
/// <summary>
/// Reads how many bot ACCOUNTS each configured game server may animate: its maximum player count,
/// reduced to the bots' share of it, divided by the characters an account animates at once. The
/// servers are ordered by their id, so every server walks the same list in the same order.
/// </summary>
private static async ValueTask<List<(byte ServerId, int Capacity)>> GetAccountCapacitiesAsync(
IGameContext gameContext,
BotConfiguration configuration,
int charactersPerAccount,
ILogger logger)
{
try
{
using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext();
var definitions = await context.GetAsync<GameServerDefinition>().ConfigureAwait(false);
var capacityPercent = configuration.GetEffectiveBotCapacityPercent();
return definitions
.OrderBy(definition => definition.ServerID)
.Select(definition => (
definition.ServerID,
Capacity: (definition.ServerConfiguration?.MaximumPlayers ?? 0) * capacityPercent / 100 / charactersPerAccount))
.ToList();
}
catch (Exception ex)
{
logger.LogError(ex, "Could not read the game server definitions; this server animates the whole bot population.");
return [];
}
}
}

View File

@@ -0,0 +1,555 @@
// <copyright file="BotShoppingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions
/// run low, the bot visits a merchant, sells its junk loot for Zen, repairs its gear and buys refills
/// with it - closing the economic loop (materializing supplies out of thin air stays only as an
/// emergency fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular
/// player actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>,
/// <see cref="BuyNpcItemAction"/>, <see cref="ItemRepairAction"/>), and while the dialog is open the
/// player state pauses the combat AI - the bot visibly "shops".
/// </summary>
internal static class BotShoppingHandler
{
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
private const int FreeSlotPressure = 20;
/// <summary>Go restocking once a potion kind holds less than this share of the target stock.</summary>
private const int PotionLowThresholdPercent = 66;
/// <summary>Maximum purchases per potion kind per trip - a safety bound, the stock target is the real limit.</summary>
private const int MaxPurchasesPerKind = 20;
/// <summary>Maximum jewels bought per trip: a player buys one now and then, not a hoard at once.</summary>
private const int MaxJewelPurchasesPerTrip = 3;
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
private const int MinZenReserve = 10000;
/// <summary>
/// Zen below which a trip for potions alone is pointless - the cheapest healing item a stock
/// merchant sells still costs more than this, so the bot would walk there and buy nothing.
/// </summary>
private const int MinPotionMoney = 1000;
/// <summary>
/// Zen a bot keeps back before it spends anything on jewels. Jewels are a luxury next to potions and
/// repairs, which keep the bot alive and fighting, so it only buys them out of real surplus.
/// </summary>
private const int JewelPurchaseReserve = 10_000_000;
private static readonly TalkNpcAction TalkAction = new();
private static readonly SellItemToNpcAction SellAction = new();
private static readonly BuyNpcItemAction BuyAction = new();
private static readonly ItemRepairAction RepairAction = new();
private static readonly CloseNpcDialogAction CloseAction = new();
private static readonly ItemPriceCalculator PriceCalculator = new();
/// <summary>
/// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
/// or a potion stack is running low (and there is Zen to restock with).
/// </summary>
/// <param name="player">The bot player.</param>
public static bool NeedsShopping(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return false;
}
if (IsUnderSlotPressure(inventory) && GetSellableJunk(player, inventory).Count > 0)
{
return true;
}
// A potion trip needs something to pay with: Zen, or loot to turn into Zen once it is there.
// A broke bot buys nothing, so the trigger still stands when it gets back - and it sets off
// again, and again, and never hunts, which is the only way it could have earned the money.
// The emergency refill (see BotNavigator) keeps it alive meanwhile, at a stock deliberately
// below this target - which is exactly what made the loop permanent rather than occasional.
if (GetLowPotionKinds(player).Any()
&& (player.Money >= MinPotionMoney || GetSellableJunk(player, inventory).Count > 0))
{
return true;
}
// Both remaining triggers exist because a merchant trip is the ONLY moment a bot can turn its
// loot into anything: selling and jewel spending both happen there. A filling backpack alone is
// not enough of a trigger - it is exactly what the rest of this class stops happening, so a
// tidy bot would sit on a hoard it can neither sell nor spend, forever.
return BotJewelHandler.HasSurplus(player)
|| BotJewelHandler.HasPendingUpgrade(player);
}
/// <summary>
/// Finds the position of a merchant NPC on the map, preferring the one which sells what the bot
/// needs right now. The search runs over the map's LIVE objects, not the spawn configuration:
/// wandering merchants exist in the configuration but are only spawned now and then (their spawn
/// trigger is not automatic) - a bot walking to a configured but unspawned merchant would wait at an
/// empty spot and give up its trip, forever.
/// </summary>
/// <param name="player">The bot player, whose current needs rank the merchants.</param>
/// <param name="map">The game map.</param>
public static Point? FindMerchantPosition(OfflinePlayer player, GameMap map)
{
// Covers the whole 256x256 map from its center.
var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
.Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
.ToList();
// A map can have a merchant and still be useless for what the bot came for: Crywolf has a
// blacksmith and a wandering merchant, neither of which stocks a single potion. Reporting "none
// here" sends the bot home to a real town instead of walking it to a shop that cannot help it,
// every cooldown, forever.
if (GetLowPotionKinds(player).Any()
&& !merchants.Any(m => SellsPotions(m.Definition.MerchantStore!)))
{
return null;
}
var best = merchants
.OrderByDescending(m => ScoreMerchant(player, m.Definition.MerchantStore!))
.FirstOrDefault();
return best?.Position;
}
/// <summary>
/// Performs the actual trade with the merchant standing near the given position: opens the dialog,
/// sells the junk loot, repairs the gear, buys refills and closes the dialog again.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="map">The game map.</param>
/// <param name="merchantPosition">The position of the merchant.</param>
/// <returns>True, if a merchant was found and the trade ran; false if no merchant is there.</returns>
public static async ValueTask<bool> TryTradeAsync(OfflinePlayer player, GameMap map, Point merchantPosition)
{
var merchant = map.GetNpcsInRange(merchantPosition, 4)
.FirstOrDefault(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 });
if (merchant is null || player.Inventory is not { } inventory)
{
// Not silent: exactly this case hid the wandering-merchant bug (a configured but
// unspawned merchant) for a long time.
player.Logger.LogInformation("Bot '{Name}' found no merchant near {Position} and gives up the trip.", player.Name, merchantPosition);
return false;
}
await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false);
if (player.OpenedNpc is null)
{
player.Logger.LogInformation("Bot '{Name}' could not open the dialog of '{Merchant}'.", player.Name, merchant.Definition.Designation);
return false;
}
try
{
// Repairing first is not cosmetic: it is the one purchase the bot always makes, and for a
// bot sitting at the money limit the Zen it spends is the only headroom its sales will
// have. Selling first meant every sale was refused and the junk was destroyed instead,
// while the repair a moment later freed enough room to have sold a good part of it.
var repaired = await RepairGearAsync(player).ConfigureAwait(false);
var (sold, unsold) = await SellJunkAsync(player, inventory).ConfigureAwait(false);
var store = player.OpenedNpc.Definition.MerchantStore;
var boughtPotions = store is null ? 0 : await BuyPotionsAsync(player, store).ConfigureAwait(false);
var boughtJewels = store is null ? 0 : await BuyJewelsAsync(player, store).ConfigureAwait(false);
// Only now, with every purchase of this visit paid for, is it settled how much room the
// money limit really leaves: each Zen spent above buys back the chance to sell one more
// piece instead of destroying it.
var (soldLate, discarded) = await ClearUnsoldAsync(player, inventory, unsold).ConfigureAwait(false);
sold += soldLate;
// Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to
// do" from a silently failed trip. Every counter reports what REALLY happened - a sale
// which the money limit refused is not a sale.
player.Logger.LogInformation(
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), discarded {Discarded}, repaired {Repaired}, bought {Potions} potion stack(s) and {Jewels} jewel(s), {Money} zen left.",
player.Name,
merchant.Definition.Designation,
sold,
discarded,
repaired,
boughtPotions,
boughtJewels,
player.Money);
}
finally
{
await CloseAction.CloseNpcDialogAsync(player).ConfigureAwait(false);
}
return true;
}
/// <summary>
/// Collects the backpack items the bot has no use for. Not its potions, not the jewels within its
/// working stock, and not a piece it would put on - but everything else goes, treasures included.
/// Unlike a player, a bot cannot trade an excellent piece away, so hoarding one only silts up the
/// backpack until the loot pickup stops entirely.
/// </summary>
private static List<Item> GetSellableJunk(OfflinePlayer player, IStorage inventory)
{
var stockLimit = BotJewelHandler.GetStockLimit(player);
var keptJewels = new Dictionary<ItemIdentifier, int>();
var junk = new List<Item>();
foreach (var item in inventory.Items)
{
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition
|| definition.IsAmmunition)
{
continue; // equipped, or an archer's arrows - selling those would disarm the bow.
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
if (HealingHandler.HealthPotionPriority.Contains(identifier)
|| HealingHandler.ManaPotionPriority.Contains(identifier))
{
continue; // the survival kit the offline AI drinks from.
}
if (BotJewelHandler.UsableJewels.Contains(identifier))
{
// Keep the working stock and sell the surplus, counting per kind while walking the
// backpack: asking "is the stock full?" for each jewel on its own would answer yes for
// every one of fifteen Souls at a limit of ten, and the bot would end up with none.
var kept = keptJewels.GetValueOrDefault(identifier);
if (kept < stockLimit)
{
keptJewels[identifier] = kept + 1;
continue;
}
junk.Add(item);
continue;
}
// Whatever the bot would wear stays: selling a piece it picked up as an upgrade one tick
// before it puts it on is pure loss.
if (!BotEquipmentHandler.IsUpgradeFor(player, item))
{
junk.Add(item);
}
}
return junk;
}
/// <summary>
/// Sells the junk, most valuable piece first, so that a bot which is close to the money limit still
/// captures as much of its loot as fits. What the limit refuses is handed back to the caller: it is
/// offered once more after the purchases of this visit have freed some room.
/// </summary>
private static async ValueTask<(int Sold, List<Item> Unsold)> SellJunkAsync(OfflinePlayer player, IStorage inventory)
{
var junk = GetSellableJunk(player, inventory)
.Select(i => (Item: i, Price: PriceCalculator.CalculateSellingPrice(i, i.Durability())))
.OrderByDescending(x => x.Price)
.ToList();
var sold = 0;
var unsold = new List<Item>();
foreach (var (item, _) in junk)
{
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
{
sold++;
}
else
{
unsold.Add(item);
}
}
return (sold, unsold);
}
/// <summary>
/// Deals with what the money limit refused earlier. The purchases in between have spent Zen, so a
/// second attempt sells whatever now fits. Only what is still refused is destroyed - there is no
/// other way out of the backpack for it (a bot cannot trade, and dropping upgraded or excellent gear
/// is something no player can do either), and a wedged bot is worse.
/// Gear is only destroyed under slot pressure: a full wallet alone is no reason to burn loot, and it
/// may well be sellable again next visit. Jewel surplus is not given that benefit of the doubt - a
/// kind the bot cannot use at all, or the part above its stock limit, has no use to it whatsoever,
/// and waiting for slot pressure just parks it in the backpack for hours.
/// </summary>
private static async ValueTask<(int Sold, int Discarded)> ClearUnsoldAsync(OfflinePlayer player, IStorage inventory, List<Item> unsold)
{
if (unsold.Count == 0)
{
return (0, 0);
}
var maximumMoney = player.GameContext.Configuration.MaximumInventoryMoney;
var underPressure = IsUnderSlotPressure(inventory);
var sold = 0;
var discarded = 0;
foreach (var item in unsold)
{
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
{
sold++;
continue;
}
if (underPressure || IsDeadWeight(item))
{
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
discarded++;
}
}
if (discarded > 0)
{
player.Logger.LogInformation(
"Bot '{Name}' destroyed {Count} item(s) it could not sell: its money is at the maximum of {Maximum}.",
player.Name,
discarded,
maximumMoney);
}
return (sold, discarded);
}
/// <summary>
/// Repairs the equipped gear while the merchant dialog is open, which is what earns the NPC discount
/// (see <see cref="ItemPriceCalculator.CalculateRepairPrice"/>). Repairing in the field without a
/// dialog - what the MU Helper's own auto repair does, and why it stays off for bots - pays the full
/// price instead. The cost scales with the missing durability, so repairing on every visit costs
/// about the same as one big repair later, and never lets an item reach zero, where the price is
/// multiplied by the destroyed-item penalty on top.
/// </summary>
/// <returns>The number of items which were repaired.</returns>
private static async ValueTask<int> RepairGearAsync(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return 0;
}
var damaged = new List<(byte Slot, long Price)>();
for (var slot = InventoryConstants.FirstEquippableItemSlotIndex; slot <= InventoryConstants.LastEquippableItemSlotIndex; slot++)
{
if (slot == InventoryConstants.PetSlot)
{
continue; // pets are repaired by the pet trainer, not here.
}
if (inventory.GetItem(slot) is { } item
&& item.Durability() < item.GetMaximumDurabilityOfOnePiece())
{
damaged.Add((slot, PriceCalculator.CalculateRepairPrice(item, true)));
}
}
// Cheapest first, and never spend down to nothing: repairing everything a bot owns can cost more
// than it has, and `RepairAllItemsAsync` would take the money for the first pieces and stop -
// leaving the rest at zero durability AND the bot too poor to shop again, which is a trap it
// cannot get out of, because the merchant trip is the only place its gear ever gets repaired.
var repaired = 0;
foreach (var (slot, price) in damaged.OrderBy(d => d.Price))
{
if (player.Money - price < MinZenReserve)
{
continue;
}
await RepairAction.RepairItemAsync(player, slot).ConfigureAwait(false);
if (inventory.GetItem(slot) is { } item
&& item.Durability() >= item.GetMaximumDurabilityOfOnePiece())
{
repaired++;
}
}
return repaired;
}
/// <summary>
/// Restocks the potions the offline AI actually drinks, buying the biggest stack the bot can afford
/// of the best kind the merchant offers. Merchants often carry the same potion as a small and a large
/// stack; taking the first match would buy the tiny one twenty times over.
/// </summary>
private static async ValueTask<int> BuyPotionsAsync(OfflinePlayer player, ItemStorage store)
{
var target = GetPotionStockTarget(player);
var bought = 0;
foreach (var priority in GetLowPotionKinds(player))
{
for (var i = 0; i < MaxPurchasesPerKind && GetCharges(player, priority) < target; i++)
{
if (FindBestOffer(player, store, priority) is not { } offer)
{
break; // the merchant has none of this kind, or none the bot can afford.
}
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
bought++;
}
}
return bought;
}
/// <summary>
/// Buys the jewels the bot can spend on its own gear, if the merchant happens to sell any. No stock
/// merchant does, so on a default configuration this simply never fires - it is here for servers
/// which put jewels into their shops, where it turns a bot's Zen into actual progress instead of
/// letting it pile up against the money limit.
/// </summary>
private static async ValueTask<int> BuyJewelsAsync(OfflinePlayer player, ItemStorage store)
{
var bought = 0;
var stockLimit = BotJewelHandler.GetStockLimit(player);
foreach (var identifier in BotJewelHandler.UsableJewels)
{
while (bought < MaxJewelPurchasesPerTrip
&& BotJewelHandler.CountInStock(player, identifier) < stockLimit
&& FindAffordableOffer(player, store, identifier, JewelPurchaseReserve) is { } offer)
{
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
bought++;
}
}
return bought;
}
/// <summary>
/// Gets the potion kinds whose stock is running low, as the priority lists the offline AI drinks by.
/// The charges are counted over the whole list, not per item number: a bot with a full stack of
/// medium healing potions is not out of healing just because it holds no large ones.
/// </summary>
private static IEnumerable<ItemIdentifier[]> GetLowPotionKinds(Player player)
{
var low = GetPotionStockTarget(player) * PotionLowThresholdPercent / 100;
if (GetCharges(player, HealingHandler.HealthPotionPriority) < low)
{
yield return HealingHandler.HealthPotionPriority;
}
if (GetCharges(player, HealingHandler.ManaPotionPriority) < low)
{
yield return HealingHandler.ManaPotionPriority;
}
}
private static int GetPotionStockTarget(Player player)
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectivePotionStockCharges() ?? 60;
private static int GetCharges(Player player, ItemIdentifier[] kinds)
{
return (int)(player.Inventory?.Items
.Where(i => i.Definition is { } definition && kinds.Contains(new ItemIdentifier(definition.Number, definition.Group)))
.Sum(i => i.Durability) ?? 0);
}
/// <summary>
/// Finds the best offer for a potion list: the highest priority kind the merchant has and the bot can
/// afford, and of that kind the biggest stack - one purchase of a 255 charge stack beats twenty
/// purchases of a stack of three, in Zen spent per charge as well as in backpack slots used.
/// </summary>
private static Item? FindBestOffer(Player player, ItemStorage store, ItemIdentifier[] priority)
{
foreach (var identifier in priority)
{
// No reserve for potions on purpose: they are what keeps the bot alive and hunting, so
// spending the last coin on them is right. Holding a reserve back here meant a bot which
// the repair had left just under it walked to the merchant and bought nothing at all.
if (FindAffordableOffer(player, store, identifier, 0) is { } offer)
{
return offer;
}
}
return null;
}
private static Item? FindAffordableOffer(Player player, ItemStorage store, ItemIdentifier identifier, int reserve)
{
return store.Items
.Where(i => Matches(i, identifier))
.Where(i => PriceCalculator.CalculateFinalBuyingPrice(i) + reserve <= player.Money)
.OrderByDescending(i => i.Durability)
.FirstOrDefault();
}
/// <summary>
/// Ranks a merchant by what the bot needs right now: the one which sells its potions wins while they
/// run low, otherwise a jewel seller is worth the walk. Without this a bot with a full potion stock
/// still always walked to the potion girl, past the merchant which had what it actually wanted.
/// </summary>
private static int ScoreMerchant(OfflinePlayer player, ItemStorage store)
{
var needsPotions = GetLowPotionKinds(player).Any();
var sellsPotions = SellsPotions(store);
var sellsJewels = BotJewelHandler.UsableJewels
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
var score = 0;
if (sellsPotions)
{
score += needsPotions ? 2 : 1;
}
if (sellsJewels)
{
score += needsPotions ? 1 : 2;
}
return score;
}
/// <summary>
/// A jewel which reached the junk list: either a kind the bot can never spend, or the part of a
/// usable kind above its stock limit. Unlike a piece of gear it has no second life - the bot cannot
/// wear it, craft with it or trade it - so when the money limit refuses the sale there is nothing
/// left to wait for.
/// </summary>
private static bool IsDeadWeight(Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
return BotJewelHandler.UsableJewels.Contains(identifier)
|| BotJewelHandler.UnusableJewels.Contains(identifier);
}
private static bool SellsPotions(ItemStorage store)
=> HealingHandler.HealthPotionPriority.Concat(HealingHandler.ManaPotionPriority)
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
private static bool Matches(Item item, ItemIdentifier identifier)
=> item.Definition is { } definition && identifier == new ItemIdentifier(definition.Number, definition.Group);
private static bool IsUnderSlotPressure(IStorage inventory)
=> inventory.FreeSlots.Count() < FreeSlotPressure;
}

View File

@@ -0,0 +1,174 @@
// <copyright file="BotSkillProgressionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlayerActions.Character;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Grows a server-side bot like a real player when it levels up during play: the earned stat points are
/// invested according to the bot's class build (see <see cref="BotProgression.GetStatWeights"/>), and any
/// class skill whose learn requirements (total energy, leadership, character level, ...) are now met is
/// learned - attack skills as well as the class's own buffs and heals. Skills are only ever learned for
/// the character's own class (<see cref="Skill.QualifiedCharacters"/>), using the same requirements the
/// game enforces for human players, so a grown bot matches a freshly generated one of the same level.
/// </summary>
[PlugIn]
[Display(Name = "Bot skill progression", Description = "Invests level-up stat points and teaches server-side bots new class- and level-appropriate skills as they level up.")]
[Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")]
public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
{
private static readonly IncreaseStatsAction IncreaseStatsAction = new();
private static readonly BotSkillProgressionPlugIn CatchUp = new();
/// <summary>
/// Applies the progression a bot is owed but has not received, when it enters the world. Both stat
/// points and skills are otherwise only handed out on a level-up, so anything a bot became entitled
/// to while it was not playing waits for its next one - and a bot at the maximum level has no next
/// one. That covers a freshly generated character, one whose level-up handler failed, and a bot which
/// qualifies for a skill it was not allowed to learn when it last levelled.
/// </summary>
/// <param name="player">The bot which entered the world.</param>
public static void CatchUpPendingProgress(Player player)
{
CatchUp.CharacterLeveledUp(player);
}
/// <inheritdoc />
public void CharacterLeveledUp(Player player)
{
if (player.Account?.IsBot != true
|| player.SelectedCharacter?.CharacterClass is not { }
|| player.SkillList is not { })
{
return;
}
// Queue the progression into the bot's AI tick instead of running it right here: this hook fires
// from the experience/level-up path while the combat handler may be enumerating the skill list on
// its own timer - the tick serializes both. No own SaveChanges here - the new stats and skills
// are persisted by the periodic save, avoiding extra concurrency pressure.
if (player is Offline.OfflinePlayer offlinePlayer)
{
offlinePlayer.PendingBotActions.Enqueue(() => new ValueTask(this.ProgressAsync(offlinePlayer)));
}
else
{
_ = this.ProgressAsync(player);
}
}
private async Task ProgressAsync(Player player)
{
try
{
this.EvolveClassIfDue(player);
await this.SpendStatPointsAsync(player).ConfigureAwait(false);
await this.LearnNewSkillsAsync(player).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Failed to progress bot '{Name}' after level-up.", player.Name);
}
}
/// <summary>
/// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it
/// reaches <see cref="BotProgression.ClassEvolutionLevel"/> - the exact assignment the class-change
/// quest performs for a human player (see <c>QuestCompletionAction</c>); simulating the quest run
/// itself would be invisible to observers anyway. Skills and gear of the new class follow
/// automatically, because all bot progression keys off the current class's qualifications.
/// </summary>
private void EvolveClassIfDue(Player player)
{
var character = player.SelectedCharacter!;
if (player.Level < BotProgression.ClassEvolutionLevel
|| BotProgression.GetEvolutionTarget(character.CharacterClass!) is not { } evolvedClass)
{
return;
}
character.CharacterClass = evolvedClass;
player.Logger.LogInformation(
"Bot '{Name}' evolved into {Class} at level {Level}.",
player.Name,
evolvedClass.Name,
player.Level);
}
private async ValueTask SpendStatPointsAsync(Player player)
{
var character = player.SelectedCharacter!;
var characterClass = character.CharacterClass!;
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
// Mirrors the capacity checks of the stat-increase action (a stat's configured maximum on fun
// servers), plus the bot's personal vitality target on reset-meta servers: a full stat drops
// out of the split, so its share flows into the rest of the build instead of getting lost.
long CapacityOf(AttributeDefinition stat)
{
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var current = (long)(player.Attributes?[stat] ?? 0f);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - current;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = current - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
if (amount > 0)
{
await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false);
}
}
}
private async ValueTask LearnNewSkillsAsync(Player player)
{
var characterClass = player.SelectedCharacter!.CharacterClass!;
var skillList = player.SkillList!;
float? GetValue(AttributeDefinition attribute) => player.Attributes?[attribute];
foreach (var skill in player.GameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| skillList.ContainsSkill((ushort)skill.Number)
|| !BotProgression.MeetsRequirements(skill, GetValue))
{
continue;
}
await skillList.AddLearnedSkillAsync(skill).ConfigureAwait(false);
player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, player.Level);
}
}
}

View File

@@ -0,0 +1,263 @@
// <copyright file="BotWingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Grants a bot its wings at the classic level milestones, like a player who saves up for them:
/// at level <see cref="FirstTierLevel"/> the first pair (+0, luck, +12 option), at
/// <see cref="SecondTierLevel"/> the second pair (+9, luck, +16 option) and at
/// <see cref="ThirdTierLevel"/> the third pair (+15, luck, +16 option). Wings don't drop from
/// monsters, so they are created directly and put straight into the wing slot - the planner
/// guarantees the class qualification and level requirement a regular equip would check, and the
/// slot placement mounts the power-ups like one. The outgrown pair is destroyed - never dropped,
/// so no player can grab a made-up wing from the ground.
/// The wing model of the item data does the rest: which classes may wear which pair is defined by
/// <see cref="ItemDefinition.QualifiedCharacters"/>, so e.g. the third-tier wings (master classes
/// only) are simply not offered to a bot which did not evolve yet, and the Dark Lord/Rage Fighter
/// capes - their only pre-master wing - are re-granted as a fresh +9 cape at the second milestone.
/// </summary>
internal static class BotWingHandler
{
/// <summary>The level at which a bot gets its first tier wings (+0, luck, +12 option).</summary>
private const int FirstTierLevel = 180;
/// <summary>The level at which a bot gets its second tier wings (+9, luck, +16 option).</summary>
private const int SecondTierLevel = 280;
/// <summary>The level at which a bot gets its third tier wings (+15, luck, +16 option).</summary>
private const int ThirdTierLevel = 400;
/// <summary>The item level of the second tier grant; also disambiguates the tier of a cape (see <see cref="TierOf"/>).</summary>
private const byte SecondTierItemLevel = 9;
/// <summary>First tier wing numbers (group 12): Wings of Elf, Heaven, Satan and Curse.</summary>
private static readonly (byte Group, short Number)[] FirstTierIds = { (12, 0), (12, 1), (12, 2), (12, 41) };
/// <summary>Second tier wing numbers (group 12): Wings of Spirits, Soul, Dragon, Darkness and Despair.</summary>
private static readonly (byte Group, short Number)[] SecondTierIds = { (12, 3), (12, 4), (12, 5), (12, 6), (12, 42) };
/// <summary>Third tier wing numbers (group 12): Wing of Storm, Eternal, Illusion, Ruin, Dimension and the Capes of Emperor/Overrule.</summary>
private static readonly (byte Group, short Number)[] ThirdTierIds = { (12, 36), (12, 37), (12, 38), (12, 39), (12, 40), (12, 43), (12, 50) };
/// <summary>
/// The Cape of Lord (13, 30, Dark Lord) and Cape of Fighter (12, 49, Rage Fighter): the only
/// pre-master wing of their classes, granted at the first milestone at +0 and again at the
/// second as a fresh +9 cape. Unlike all other wings, the Cape of Lord lives in group 13 -
/// group 12 number 30 is the Packed Jewel of Bless (which the wing-slot filter of
/// <see cref="PlanNextGrant"/> would keep out of the candidates anyway).
/// </summary>
private static readonly (byte Group, short Number)[] CapeIds = { (13, 30), (12, 49) };
/// <summary>
/// Checks the bot's level milestones and puts on the earned wings; called from the bot's regular
/// evaluation cadence, queued into the MuHelper tick because equipping mounts item power-ups.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TryAdvanceWingsAsync(OfflinePlayer player)
{
if (PlanNextGrant(player) is not { } plan || player.Inventory is not { } inventory)
{
return;
}
// The outgrown pair is destroyed, not dropped - a conjured wing must never lie on the
// ground for a player to pick up. Clearing the slot first also keeps the grant independent
// of the backpack, which is usually too crammed with loot for a wing's 5x3 footprint.
if (inventory.GetItem(InventoryConstants.WingsSlot) is { } outgrown)
{
await player.DestroyInventoryItemAsync(outgrown).ConfigureAwait(false);
player.Logger.LogInformation("Bot '{Name}' discarded its outgrown wings '{Wings}'.", player.Name, outgrown);
}
// Directly into the wing slot: the planner already guarantees the class qualification and
// the level requirement, and the slot placement mounts the power-ups and broadcasts the
// changed appearance like a regular equip.
var wings = CreateWings(player, plan);
if (!await inventory.AddItemAsync(InventoryConstants.WingsSlot, wings).ConfigureAwait(false))
{
// Shouldn't happen - the slot was just cleared; don't leak the created item.
await player.PersistenceContext.DeleteAsync(wings).ConfigureAwait(false);
player.Logger.LogWarning("Bot '{Name}' could not equip its new wings '{Wings}'.", player.Name, wings);
return;
}
player.Logger.LogInformation("Bot '{Name}' earned its tier {Tier} wings: '{Wings}'.", player.Name, plan.Tier, wings);
try
{
// Persist right away like after using jewels - a milestone shouldn't be lost (and the
// outgrown pair resurrected) by a crash before the next periodic save.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after granting wings; the periodic save will retry.", player.Name);
}
}
/// <summary>
/// Determines the wings the bot has earned but does not wear yet, or <c>null</c> when it already
/// wears its best earned pair (or none is due). Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
internal static (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier)? PlanNextGrant(Player player)
{
if (player.Inventory is not { } inventory
|| player.SelectedCharacter?.CharacterClass is not { } characterClass
|| player.Attributes is not { } attributes)
{
return null;
}
var level = (int)attributes[Stats.Level];
var earnedTier = level switch
{
>= ThirdTierLevel => 3,
>= SecondTierLevel => 2,
>= FirstTierLevel => 1,
_ => 0,
};
// Walk down from the earned tier to the best one the class currently qualifies for: a bot
// which did not evolve into its master class yet simply isn't qualified for the third tier
// wings and keeps its second pair until the evolution.
for (var tier = earnedTier; tier >= 1; tier--)
{
var candidates = player.GameContext.Configuration.Items
.Where(d => TierIds(tier).Contains((d.Group, d.Number))
&& d.ItemSlot?.ItemSlots.Contains(InventoryConstants.WingsSlot) == true
&& d.QualifiedCharacters.Contains(characterClass))
.ToList();
if (candidates.Count == 0)
{
continue;
}
if (inventory.GetItem(InventoryConstants.WingsSlot) is { } equipped && TierOf(equipped) >= tier)
{
// Already wearing this tier (or a better one, e.g. right after a reset while
// re-levelling through the lower milestones) - never downgrade.
return null;
}
var isCaster = attributes[Stats.TotalEnergy] > attributes[Stats.TotalStrength];
var definition = candidates.MaxBy(d => FindWingOption(d, isCaster).Score)!;
var (itemLevel, optionLevel) = tier switch
{
3 => ((byte)15, 4),
2 => (SecondTierItemLevel, 4),
_ => ((byte)0, 3),
};
return (definition, itemLevel, optionLevel, tier);
}
return null;
}
private static IReadOnlyCollection<(byte Group, short Number)> TierIds(int tier)
{
return tier switch
{
3 => ThirdTierIds,
2 => SecondTierIds.Concat(CapeIds).ToList(),
_ => FirstTierIds.Concat(CapeIds).ToList(),
};
}
/// <summary>
/// The tier a worn wing counts as; the capes are the first-tier grant of their classes but count
/// as the second one once re-granted at +9.
/// </summary>
private static int TierOf(Item wings)
{
if (wings.Definition is not { } definition)
{
return 0;
}
var id = (definition.Group, definition.Number);
if (ThirdTierIds.Contains(id))
{
return 3;
}
if (CapeIds.Contains(id))
{
return wings.Level >= SecondTierItemLevel ? 2 : 1;
}
if (SecondTierIds.Contains(id))
{
return 2;
}
return FirstTierIds.Contains(id) ? 1 : 0;
}
/// <summary>
/// Picks the wing's "additional" option (the +4-per-level one, <see cref="ItemOptionTypes.Option"/>)
/// which fits the bot's fighting style best - wizardry damage for casters, physical damage
/// otherwise - and scores it, so wings offering the matching damage option (the Magic Gladiator
/// may wear both Wings of Heaven and Satan) win the candidate selection.
/// </summary>
private static (IncreasableItemOption? Option, int Score) FindWingOption(ItemDefinition definition, bool isCaster)
{
static int ScoreOf(IncreasableItemOption option, bool isCaster)
{
var target = option.PowerUpDefinition?.TargetAttribute;
if (target == Stats.WizardryBaseDmg || target == Stats.CurseBaseDmg)
{
return isCaster ? 3 : 1;
}
if (target == Stats.PhysicalBaseDmg)
{
return isCaster ? 1 : 3;
}
return 0;
}
return definition.PossibleItemOptions
.SelectMany(o => o.PossibleOptions)
.Where(o => o.OptionType == ItemOptionTypes.Option)
.Select(o => ((IncreasableItemOption?)o, ScoreOf(o, isCaster)))
.OrderByDescending(pair => pair.Item2)
.FirstOrDefault();
}
private static Item CreateWings(OfflinePlayer player, (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier) plan)
{
var item = player.PersistenceContext.CreateNew<Item>();
item.Definition = plan.Definition;
item.Level = plan.ItemLevel;
item.Durability = plan.Definition.Durability;
if (plan.Definition.PossibleItemOptions
.SelectMany(o => o.PossibleOptions)
.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck) is { } luck)
{
var luckLink = player.PersistenceContext.CreateNew<ItemOptionLink>();
luckLink.ItemOption = luck;
item.ItemOptions.Add(luckLink);
}
var isCaster = player.Attributes![Stats.TotalEnergy] > player.Attributes[Stats.TotalStrength];
if (FindWingOption(plan.Definition, isCaster).Option is { } option)
{
var optionLink = player.PersistenceContext.CreateNew<ItemOptionLink>();
optionLink.ItemOption = option;
optionLink.Level = plan.OptionLevel;
item.ItemOptions.Add(optionLink);
}
return item;
}
}

View File

@@ -0,0 +1,13 @@
// <copyright file="PendingPartyInvite.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// A party invitation from a player which a bot accepted, waiting for the human-like delay to pass
/// before the party is actually formed (see <see cref="BotPartyHandler"/>).
/// </summary>
/// <param name="Requester">The player who invited the bot.</param>
/// <param name="AcceptAtUtc">When the bot answers the invitation.</param>
internal sealed record PendingPartyInvite(Player Requester, DateTime AcceptAtUtc);

View File

@@ -4,109 +4,151 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// In-memory Castle Siege phase state machine and battle contention (P3).
/// In-memory Castle Siege state machine and battle contention.
/// Time is injected via method parameters so it can be tested deterministically.
/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne.
/// The throne holder when the siege ends becomes the castle owner.
/// <para>
/// The cycle uses the original Season 6 <see cref="CastleSiegeState"/> values, which are exactly the values
/// the game client expects (see <c>CASTLESIEGE_STATE</c> in the client's <c>WSclient.h</c>). AdaMu drives only
/// a subset of them, because it registers guilds directly and has no Mark of Lord step:
/// </para>
/// <code>
/// Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0)
/// </code>
/// <para>
/// The skipped states (<see cref="CastleSiegeState.Idle2"/>, <see cref="CastleSiegeState.RegisterMark"/>,
/// <see cref="CastleSiegeState.Idle3"/>, <see cref="CastleSiegeState.Notify"/>) keep their numbers so the
/// client stays compatible; the server simply never enters them.
/// </para>
/// <para>
/// Guilds are identified by their persistent <see cref="Guid"/>, not by name. A guild rename (or a delete and
/// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names
/// are carried alongside purely for display and for the packets that send a name to the client.
/// </para>
/// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is
/// operated by clicking it and then staying in its area: the operation needs
/// <see cref="CastleSiegeSettings.SwitchPushSeconds"/> to complete, after which the switch counts as held
/// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its
/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the
/// switches do; whoever holds it when the siege ends becomes the castle owner.
/// </summary>
public class CastleSiegeContext
{
/// <summary>The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.</summary>
public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly List<string> _registeredGuilds = new();
private readonly Dictionary<short, string?> _switchHolders = new() { { 217, null }, { 218, null } };
private DateTime _phaseStartedUtc;
private string? _occupier;
private readonly Dictionary<Guid, string> _registeredGuilds = new();
private readonly Dictionary<short, CastleSiegeSwitchOperation?> _switches = new() { { 217, null }, { 218, null } };
private DateTime _stateStartedUtc;
private Guid? _occupier;
private string? _occupierName;
private int _defensesRemaining;
private bool _dirty;
private string? _crownHoldGuild;
private Guid? _crownHoldGuild;
private DateTime? _crownHoldStartUtc;
private Guid? _crownHoldRequestedBy;
private bool _lastShieldDown;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param>
public CastleSiegeContext(CastleSiegeConfiguration configuration)
public CastleSiegeContext(CastleSiegeSettings configuration)
{
this.Configuration = configuration;
this.Phase = CastleSiegePhase.Ownership;
this.State = CastleSiegeState.Idle1;
}
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
public event Action<CastleSiegePhase>? PhaseChanged;
/// <summary>Raised after the state changes. Argument is the new state.</summary>
public event Action<CastleSiegeState>? StateChanged;
/// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
/// so AdminPanel edits take effect without a restart.</summary>
public CastleSiegeConfiguration Configuration { get; private set; }
public CastleSiegeSettings Configuration { get; private set; }
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
/// <param name="configuration">The live configuration.</param>
public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
/// <summary>Gets the current state.</summary>
public CastleSiegeState State { get; private set; }
/// <summary>Gets the current phase.</summary>
public CastleSiegePhase Phase { get; private set; }
/// <summary>Gets the UTC time the current state started (used for persistence/restore).</summary>
public DateTime StateStartedUtc => this._stateStartedUtc;
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary>
public DateTime PhaseStartedUtc => this._phaseStartedUtc;
/// <summary>Gets the persistent identifier of the owner guild, or <see langword="null"/> if unowned.</summary>
public Guid? OwnerGuildId { get; private set; }
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
/// <summary>Gets the owner guild's name for display and client packets, or null.</summary>
public string? OwnerGuildName { get; private set; }
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
public string? OccupierGuildName => this._occupier;
/// <summary>Gets the guild currently holding the throne during the siege, or null.</summary>
public Guid? OccupierGuildId => this._occupier;
/// <summary>Gets the throne holder's name for display and client packets, or null.</summary>
public string? OccupierGuildName => this._occupierName;
/// <summary>Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.</summary>
public int DefensesRemaining => this._defensesRemaining;
/// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Gets the persistent identifiers of the guilds registered for the current cycle.</summary>
public IReadOnlyCollection<Guid> RegisteredGuildIds => this._registeredGuilds.Keys;
/// <summary>Gets the names of the guilds registered for the current cycle (display only).</summary>
public IReadOnlyCollection<string> RegisteredGuildNames => this._registeredGuilds.Values;
/// <summary>Gets a value indicating whether the siege battle is currently running.</summary>
public bool IsSiegeRunning => this.State == CastleSiegeState.Start;
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
/// <param name="configuration">The live configuration.</param>
public void UpdateConfiguration(CastleSiegeSettings configuration) => this.Configuration = configuration;
/// <summary>Returns whether the given guild is registered for the current cycle.</summary>
/// <param name="guildId">The persistent guild identifier.</param>
public bool IsRegistered(Guid guildId) => this._registeredGuilds.ContainsKey(guildId);
/// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now)
{
switch (this.Phase)
switch (this.State)
{
case CastleSiegePhase.Ownership:
case CastleSiegeState.Idle1:
if (this.Configuration.IsRegistrationOpenTime(now))
{
return this.ForceStartRegistrationAsync(now);
}
break;
case CastleSiegePhase.Registration:
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
case CastleSiegeState.RegisterGuild:
if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
return this.TransitionAsync(CastleSiegeState.Ready, now);
}
break;
case CastleSiegePhase.Preparation:
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
case CastleSiegeState.Ready:
if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration)
{
return this.TransitionAsync(CastleSiegePhase.Siege, now);
return this.TransitionAsync(CastleSiegeState.Start, now);
}
break;
case CastleSiegePhase.Siege:
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
case CastleSiegeState.Start:
if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration)
{
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
return this.TransitionAsync(CastleSiegeState.End, now);
}
break;
case CastleSiegePhase.Settlement:
case CastleSiegeState.End:
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier);
this.SetOwner(occupier, this._occupierName);
}
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
return this.TransitionAsync(CastleSiegeState.EndCycle, now);
case CastleSiegeState.EndCycle:
return this.TransitionAsync(CastleSiegeState.Idle1, now);
default:
break;
}
@@ -114,62 +156,67 @@ public class CastleSiegeContext
return ValueTask.CompletedTask;
}
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
/// <summary>Admin: forces the cycle into guild registration now (from any state).</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Registration, now);
return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
}
/// <summary>Admin: forces a specific phase now.</summary>
/// <param name="phase">The target phase.</param>
/// <summary>Admin: forces a specific state now.</summary>
/// <param name="state">The target state.</param>
/// <param name="now">The current UTC time.</param>
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
=> this.TransitionAsync(phase, now);
public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
=> this.TransitionAsync(state, now);
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
/// <summary>Admin: resets to the idle (resting) state and clears registrations/battle state.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
return this.TransitionAsync(CastleSiegeState.Idle1, now);
}
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
/// <param name="guildName">The guild name.</param>
public void RegisterGuild(string guildName)
/// <summary>Registers a guild for the current cycle. No-op outside the registration state.</summary>
/// <param name="guildId">The persistent guild identifier.</param>
/// <param name="guildName">The guild name, for display.</param>
public void RegisterGuild(Guid guildId, string guildName)
{
if (this.Phase == CastleSiegePhase.Registration
&& !this._registeredGuilds.Contains(guildName))
if (this.State == CastleSiegeState.RegisterGuild
&& this._registeredGuilds.TryAdd(guildId, guildName))
{
this._registeredGuilds.Add(guildName);
this._dirty = true;
}
}
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
/// <param name="guildName">The owner guild name, or null to clear.</param>
public void SetOwner(string? guildName)
/// <summary>Admin: sets (or clears) the current owner guild.</summary>
/// <param name="guildId">The owner guild identifier, or null to clear.</param>
/// <param name="guildName">The owner guild name, or null.</param>
public void SetOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildName = guildName;
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
this._dirty = true;
}
/// <summary>
/// Mirrors the shared castle owner from the configuration. Used on game servers that do NOT host the
/// Mirrors the shared castle owner loaded from the database. Used on game servers that do NOT host the
/// siege, so their hunting-map gate and castle flag still reflect the current owner. Does not mark the
/// state dirty (these servers never persist).
/// </summary>
public void SyncOwnerFromConfig()
/// <param name="guildId">The owner guild identifier, or null.</param>
/// <param name="guildName">The owner guild name, or null.</param>
public void SyncOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
}
/// <summary>
/// Sets the weekly auto-schedule (days of week + UTC time) into the configuration and marks the state
/// Sets the auto-open schedule (days of week + UTC time) into the configuration and marks the state
/// dirty for persistence. Empty days disables auto-start (manual only). The configuration is the single
/// source of truth, so this is equivalent to editing the plugin config in the AdminPanel.
/// </summary>
@@ -192,83 +239,127 @@ public class CastleSiegeContext
/// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
{
if (this.Phase != CastleSiegePhase.Siege)
if (!this.IsSiegeRunning)
{
return TimeSpan.Zero;
}
var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
var remaining = (this._stateStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
/// crown's shield is dropped for them), or null. Only meaningful during the siege.
/// Returns how much time is left in the current state, or <see cref="TimeSpan.Zero"/> when the state has
/// no duration (idle states wait for an admin command or the auto-open time).
/// </summary>
public string? GetShieldEligibleGuild()
/// <param name="nowUtc">The current UTC time.</param>
public TimeSpan GetRemainingStateTime(DateTime nowUtc)
{
if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
var duration = this.State switch
{
CastleSiegeState.RegisterGuild => this.Configuration.RegistrationDuration,
CastleSiegeState.Ready => this.Configuration.PreparationDuration,
CastleSiegeState.Start => this.Configuration.SiegeDuration,
_ => TimeSpan.Zero,
};
if (duration == TimeSpan.Zero)
{
return TimeSpan.Zero;
}
var remaining = (this._stateStartedUtc + duration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>
/// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it,
/// or null. Only meaningful during the siege.
/// </summary>
public Guid? GetShieldEligibleGuild()
{
if (!this.IsSiegeRunning)
{
return null;
}
var holder = this._switchHolders[217];
return holder is not null && holder == this._switchHolders[218] ? holder : null;
var first = this.GetHeldSwitchGuild(217);
return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>.
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
/// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
/// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
/// hold both switches, so a click can never arm a hold the guild isn't entitled to.
/// </summary>
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
/// <param name="guildId">The requesting guild master's guild identifier.</param>
/// <returns><see langword="true"/> if the request was accepted.</returns>
public bool RequestCrownHold(Guid guildId)
{
if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId)
{
return false;
}
this._crownHoldRequestedBy = guildId;
return true;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild holding both switches
/// (shield down) and <paramref name="masterHolding"/> is whether that guild's master stands on the crown.
/// The hold only runs after the master requested it via <see cref="RequestCrownHold"/>; it captures the
/// throne once it ran for <paramref name="holdDuration"/>. Losing a switch or the master leaving the crown
/// resets the hold, and the crown has to be clicked again (contestable until the siege ends).
/// </summary>
/// <param name="eligibleGuild">The guild holding both switches, or null.</param>
/// <param name="eligibleGuildName">That guild's name, for display.</param>
/// <param name="masterHolding">Whether that guild's master is on the crown.</param>
/// <param name="now">The current UTC time.</param>
/// <param name="holdDuration">How long the master must hold to capture.</param>
public CrownTickResult TickCrownHold(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration)
public CrownTickResult TickCrownHold(Guid? eligibleGuild, string? eligibleGuildName, bool masterHolding, DateTime now, TimeSpan holdDuration)
{
var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null;
var shieldDown = this.IsSiegeRunning && eligibleGuild is not null;
var shieldChanged = shieldDown != this._lastShieldDown;
this._lastShieldDown = shieldDown;
if (this.Phase != CastleSiegePhase.Siege)
if (!this.IsSiegeRunning)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null);
this.ResetCrownHold();
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
}
var wasHolding = this._crownHoldGuild is not null;
// The guild that already occupies the throne just holds it no re-registration (avoids a capture loop).
// The guild that already occupies the throne just holds it - no re-registration (avoids a capture loop).
// Only a DIFFERENT guild taking both switches can register/capture (contest).
var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier;
var canCapture = eligibleGuild is not null
&& eligibleGuild != this._occupier
&& this._crownHoldRequestedBy == eligibleGuild;
if (!canCapture || !masterHolding)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
this.ResetCrownHold();
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
}
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
{
this._crownHoldGuild = eligibleGuild;
this._crownHoldStartUtc = now;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild, eligibleGuildName);
}
if (now - this._crownHoldStartUtc.Value >= holdDuration)
{
this._occupier = eligibleGuild;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._occupierName = eligibleGuildName;
this.ResetCrownHold();
this._dirty = true;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
}
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild);
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild, eligibleGuildName);
}
/// <summary>
@@ -283,22 +374,27 @@ public class CastleSiegeContext
}
/// <summary>
/// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without
/// firing <see cref="PhaseChanged"/> or marking the state dirty. Battle state stays cleared.
/// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
/// firing <see cref="StateChanged"/> or marking the state dirty. Battle state stays cleared.
/// </summary>
/// <param name="owner">The persisted owner guild name, or null.</param>
/// <param name="phase">The persisted phase.</param>
/// <param name="phaseStartedUtc">When the persisted phase started (UTC), or null to keep the default.</param>
/// <param name="registeredGuilds">The persisted registered guild names, or null.</param>
public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable<string>? registeredGuilds)
/// <param name="ownerGuildId">The persisted owner guild identifier, or null.</param>
/// <param name="ownerGuildName">The persisted owner guild name, or null.</param>
/// <param name="state">The persisted state.</param>
/// <param name="stateStartedUtc">When the persisted state started (UTC), or null to keep the default.</param>
/// <param name="registeredGuilds">The persisted registrations (id to name), or null.</param>
public void RestoreState(Guid? ownerGuildId, string? ownerGuildName, CastleSiegeState state, DateTime? stateStartedUtc, IEnumerable<KeyValuePair<Guid, string>>? registeredGuilds)
{
this.OwnerGuildName = owner;
this.Phase = phase;
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc;
this.OwnerGuildId = ownerGuildId;
this.OwnerGuildName = ownerGuildId is null ? null : ownerGuildName;
this.State = state;
this._stateStartedUtc = stateStartedUtc ?? this._stateStartedUtc;
this._registeredGuilds.Clear();
if (registeredGuilds is not null)
{
this._registeredGuilds.AddRange(registeredGuilds);
foreach (var registration in registeredGuilds)
{
this._registeredGuilds[registration.Key] = registration.Value;
}
}
this._dirty = false;
@@ -317,79 +413,124 @@ public class CastleSiegeContext
}
}
/// <summary>Returns who is currently operating a Crown Switch, or <see langword="null"/>.</summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber)
=> this._switches.TryGetValue(switchNumber, out var operation) ? operation : null;
/// <summary>
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege.
/// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
/// player at a time: while somebody else is on it, the click is refused and the caller is told who holds
/// it, which is what the client shows as "another siege team is running the crown switch".
/// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The holding guild's name, or null.</param>
public void SetSwitchHolder(short switchNumber, string? guildName)
/// <param name="guildId">The clicking player's guild identifier.</param>
/// <param name="guildName">The clicking player's guild name, for display.</param>
/// <param name="playerId">The clicking player's object identifier on the map.</param>
/// <param name="playerName">The clicking player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="now">The current UTC time.</param>
/// <returns>The outcome, and the current operation when the switch is taken.</returns>
public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation(
short switchNumber,
Guid guildId,
string guildName,
ushort playerId,
string playerName,
ushort switchObjectId,
DateTime now)
{
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber))
{
this._switchHolders[switchNumber] = guildName;
return (CastleSiegeSwitchPush.SiegeNotRunning, null);
}
if (this._switches[switchNumber] is { } current)
{
return current.PlayerId == playerId
? (CastleSiegeSwitchPush.AlreadyYours, current)
: (CastleSiegeSwitchPush.TakenByOther, current);
}
var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now);
this._switches[switchNumber] = operation;
return (CastleSiegeSwitchPush.Started, operation);
}
/// <summary>
/// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC).
/// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the
/// guild is currently holding BOTH Crown Switches (a member standing on each).
/// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
/// area, and completes - which makes the switch count for the guild - once it ran <paramref name="pushDuration"/>.
/// </summary>
/// <param name="guildName">The capturing guild's name.</param>
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns>
public (bool Success, string Reason) TryCaptureThrone(string guildName)
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="operatorPresent">Whether the operating player is still in the switch's area.</param>
/// <param name="now">The current UTC time.</param>
/// <param name="pushDuration">How long operating the switch takes.</param>
/// <returns>What happened to the switch in this tick, and the operation it happened to.</returns>
public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch(
short switchNumber,
bool operatorPresent,
DateTime now,
TimeSpan pushDuration)
{
if (this.Phase != CastleSiegePhase.Siege)
if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null)
{
return (false, "The siege is not running.");
return (CastleSiegeSwitchEvent.None, null);
}
if (this._occupier is not null)
if (!this.IsSiegeRunning || !operatorPresent)
{
return (false, this._occupier == guildName
? "Your guild already holds the throne."
: $"The throne is already held by '{this._occupier}'.");
this._switches[switchNumber] = null;
return (CastleSiegeSwitchEvent.Released, operation);
}
if (this._defensesRemaining > 0)
if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration)
{
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
operation.MarkHeld();
return (CastleSiegeSwitchEvent.Held, operation);
}
if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName)
{
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
}
this._occupier = guildName;
return (true, "throne captured");
return (CastleSiegeSwitchEvent.None, operation);
}
/// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, "
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}";
=> $"CS state={this.State}({(int)this.State}), owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds.Values)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
private Guid? GetHeldSwitchGuild(short switchNumber)
=> this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null;
private string DescribeSwitch(short switchNumber)
=> this._switches[switchNumber] is { } operation
? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}"
: "-";
private void ResetCrownHold()
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._crownHoldRequestedBy = null;
}
private void ClearBattleState()
{
this._switchHolders[217] = null;
this._switchHolders[218] = null;
this._switches[217] = null;
this._switches[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._occupierName = null;
this.ResetCrownHold();
this._lastShieldDown = false;
}
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
private ValueTask TransitionAsync(CastleSiegeState state, DateTime now)
{
this.Phase = phase;
this._phaseStartedUtc = now;
this.State = state;
this._stateStartedUtc = now;
this._dirty = true;
this.PhaseChanged?.Invoke(phase);
this.StateChanged?.Invoke(state);
return ValueTask.CompletedTask;
}
}
@@ -414,5 +555,6 @@ public enum CrownEvent
/// <param name="ShieldDown">Whether the crown shield is currently down (both switches held, defenses cleared).</param>
/// <param name="ShieldChanged">Whether the shield state changed this tick (only then should the client be told).</param>
/// <param name="Event">The event that occurred this tick.</param>
/// <param name="Guild">The guild the event refers to, if any.</param>
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild);
/// <param name="GuildId">The guild the event refers to, if any.</param>
/// <param name="GuildName">That guild's name, for display and client packets.</param>
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, Guid? GuildId, string? GuildName);

View File

@@ -49,7 +49,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
if (context.Phase != CastleSiegePhase.Registration)
if (context.State != CastleSiegeState.RegisterGuild)
{
await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false);
return;
@@ -61,17 +61,15 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
// Registrations are keyed on the guild's persistent id, so a later rename cannot detach them.
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
await ShowAsync(player, "Your guild could not be resolved. Please try again in a moment.").ConfigureAwait(false);
return;
}
if (context.RegisteredGuilds.Contains(guildName))
var guildName = guild.Name;
if (context.IsRegistered(guild.Id))
{
await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
return;
@@ -84,7 +82,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
context.RegisterGuild(guildName);
context.RegisterGuild(guild.Id, guildName);
await ShowAsync(player, fee > 0
? $"Your guild '{guildName}' is registered for the Castle Siege. ({fee} zen paid)"
: $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false);

View File

@@ -1,26 +0,0 @@
// <copyright file="CastleSiegePhase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// The phases of a Castle Siege cycle.
/// </summary>
public enum CastleSiegePhase
{
/// <summary>Resting phase: castle is (un)owned, waiting for the next registration window.</summary>
Ownership,
/// <summary>Guilds can register to attack.</summary>
Registration,
/// <summary>Registration closed; defenders prepare before the siege starts.</summary>
Preparation,
/// <summary>The siege battle is running.</summary>
Siege,
/// <summary>Siege ended; determining the new owner.</summary>
Settlement,
}

View File

@@ -1,4 +1,4 @@
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
// <copyright file="CastleSiegeSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
@@ -7,15 +7,31 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Configuration for the Castle Siege cycle. Rides on the plugin custom-configuration system (no dedicated
/// database table). A cycle runs: Ownership -> Registration -> Preparation -> Siege(war) -> Settlement, and
/// auto-starts when the current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute/second durations) are proxies over the
/// runtime fields, which are hidden from the editor to keep the form clean.
/// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so
/// it is editable in the AdminPanel and needs no dedicated database table.
/// <para>
/// This is deliberately separate from <see cref="DataModel.Configuration.CastleSiegeConfiguration"/>, which is
/// the upstream, database-backed configuration holding the NPC/zone/upgrade definitions and the crown hold
/// time. Keeping AdaMu's operational knobs out of that entity means upstream schema changes apply cleanly and
/// no hand-editing of the generated persistence code is needed.
/// </para>
/// <para>
/// What lives where:
/// <list type="bullet">
/// <item>Castle owner and guild registrations: database (<c>CastleSiegeData</c>, <c>CastleSiegeGuildRegistration</c>).</item>
/// <item>NPC/zone/upgrade definitions and crown hold time: database (<c>GameConfiguration.CastleSiegeConfiguration</c>).</item>
/// <item>Cycle durations, registration fee, designated server and the current state: here.</item>
/// </list>
/// </para>
/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the
/// current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute durations) are proxies over the runtime
/// fields, which are hidden from the editor to keep the form clean.
/// </summary>
public class CastleSiegeConfiguration
public class CastleSiegeSettings
{
/// <summary>
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a
@@ -42,7 +58,7 @@ public class CastleSiegeConfiguration
/// <summary>
/// Gets or sets the times of day (UTC) at which a new cycle opens registration.
/// Empty = no auto-start (admins start cycles manually via the chat command).
/// Empty = no auto-start (admins start cycles manually via the chat command or the AdminPanel).
/// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
@@ -70,17 +86,6 @@ public class CastleSiegeConfiguration
set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
/// <summary>
/// Gets or sets how long the guild master must hold the Crown to capture the throne, in seconds.
/// The client shows a 60-second countdown, so 60 matches the on-screen timer.
/// </summary>
[JsonIgnore]
public int CrownHoldSeconds
{
get => (int)this.CrownHoldDuration.TotalSeconds;
set => this.CrownHoldDuration = TimeSpan.FromSeconds(Math.Max(1, value));
}
/// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.
@@ -112,29 +117,23 @@ public class CastleSiegeConfiguration
[Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Gets or sets how long the guild master must hold the Crown to capture the throne.</summary>
[Browsable(false)]
public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
/// The player has to stay in the switch's area for that long, and keeps it until they leave.
/// </summary>
public int SwitchPushSeconds { get; set; } = 15;
// --- Persisted runtime state (hidden from the AdminPanel) ---
// These ride on the plugin's custom-configuration JSON (stored in PostgreSQL), so the castle owner and the
// current cycle survive server restarts. Written by CastleSiegeEventPlugIn; restored on startup.
// --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
// Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
// owner and the guild registrations live in real database tables, so they are not duplicated here.
/// <summary>Gets or sets the persisted castle owner guild name (null = unowned).</summary>
/// <summary>Gets or sets the persisted current state, so the cycle resumes after a restart.</summary>
[Browsable(false)]
public string? PersistedOwnerGuildName { get; set; }
public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1;
/// <summary>Gets or sets the persisted current phase, so the cycle resumes after a restart.</summary>
/// <summary>Gets or sets when the persisted state started (UTC), or null if never persisted.</summary>
[Browsable(false)]
public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership;
/// <summary>Gets or sets when the persisted phase started (UTC), or null if never persisted.</summary>
[Browsable(false)]
public DateTime? PersistedPhaseStartedUtc { get; set; }
/// <summary>Gets or sets the persisted registered guild names for the current cycle.</summary>
[Browsable(false)]
public IList<string> PersistedRegisteredGuilds { get; set; } = new List<string>();
public DateTime? PersistedStateStartedUtc { get; set; }
/// <summary>
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls

View File

@@ -0,0 +1,20 @@
// <copyright file="CastleSiegeSwitchEvent.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// What happened to a Crown Switch during one tick.
/// </summary>
public enum CastleSiegeSwitchEvent
{
/// <summary>Nothing worth reporting.</summary>
None,
/// <summary>The operation completed, so the switch now counts for the operator's guild.</summary>
Held,
/// <summary>The operator left (or the siege ended), so the switch is free again.</summary>
Released,
}

View File

@@ -0,0 +1,55 @@
// <copyright file="CastleSiegeSwitchOperation.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by
/// staying in its area; the switch counts as held for the guild once the operation has run its time.
/// </summary>
public class CastleSiegeSwitchOperation
{
/// <summary>Initializes a new instance of the <see cref="CastleSiegeSwitchOperation"/> class.</summary>
/// <param name="guildId">The operating player's guild identifier.</param>
/// <param name="guildName">The operating player's guild name, for display.</param>
/// <param name="playerId">The operating player's object identifier on the map.</param>
/// <param name="playerName">The operating player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="startedUtc">When the operation started (UTC).</param>
public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc)
{
this.GuildId = guildId;
this.GuildName = guildName;
this.PlayerId = playerId;
this.PlayerName = playerName;
this.SwitchObjectId = switchObjectId;
this.StartedUtc = startedUtc;
}
/// <summary>Gets the operating player's guild identifier.</summary>
public Guid GuildId { get; }
/// <summary>Gets the operating player's guild name.</summary>
public string GuildName { get; }
/// <summary>Gets the operating player's object identifier on the map.</summary>
public ushort PlayerId { get; }
/// <summary>Gets the operating player's name.</summary>
public string PlayerName { get; }
/// <summary>Gets the switch NPC's object identifier on the map, which the client's packets refer to.</summary>
public ushort SwitchObjectId { get; }
/// <summary>Gets the point in time (UTC) when the operation started.</summary>
public DateTime StartedUtc { get; }
/// <summary>
/// Gets a value indicating whether the operation ran its time, so the switch counts for the guild.
/// </summary>
public bool IsHeld { get; private set; }
/// <summary>Marks the operation as completed, which makes the switch count for the guild.</summary>
internal void MarkHeld() => this.IsHeld = true;
}

View File

@@ -0,0 +1,23 @@
// <copyright file="CastleSiegeSwitchPush.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// The outcome of a player clicking a Crown Switch.
/// </summary>
public enum CastleSiegeSwitchPush
{
/// <summary>The player started operating the switch.</summary>
Started,
/// <summary>The player is already operating this switch.</summary>
AlreadyYours,
/// <summary>Somebody else is operating this switch.</summary>
TakenByOther,
/// <summary>The siege is not running, so the switches do nothing.</summary>
SiegeNotRunning,
}

View File

@@ -0,0 +1,92 @@
// <copyright file="CastleSiegeSwitchTalkPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the
/// switch, which the client shows as a progress box; the switch counts for the guild once the operation
/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can
/// operate a switch at a time - anybody else clicking it is told that another team is on it.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")]
[PlugIn]
[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")]
public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn
{
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number))
{
return;
}
// We drive the switch ourselves, so suppress the default "not implemented" message.
eventArgs.HasBeenHandled = true;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
return;
}
if (!context.IsSiegeRunning)
{
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
return;
}
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild
|| !context.IsRegistered(guild.Id))
{
await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false);
return;
}
var (result, operation) = context.TryStartSwitchOperation(
npc.Definition.Number,
guild.Id,
guild.Name,
player.Id,
player.Name,
npc.Id,
DateTime.UtcNow);
switch (result)
{
case CastleSiegeSwitchPush.Started:
// The info packet goes first: it is what makes every client allocate its switch table, which
// the "switch released" packet later reads without checking that it exists.
await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.TakenByOther when operation is { } other:
// State 2 makes the client name the player who is already on it.
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.AlreadyYours:
break;
default:
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
break;
}
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -43,56 +43,58 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
if (player.GuildStatus is not { } guildStatus)
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
{
await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false);
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
}
if (!context.RegisteredGuilds.Contains(guildName))
if (!context.IsRegistered(guild.Id))
{
await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false);
return;
}
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state.
await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
{
if (context.Phase != CastleSiegePhase.Siege)
// Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
// stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing.
var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster;
if (isGuildMaster && context.RequestCrownHold(guild.Id))
{
return "The siege is not running yet.";
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
return;
}
if (context.DefensesRemaining > 0)
await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
{
if (!context.IsSiegeRunning)
{
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
return "The siege is not running yet.";
}
var eligible = context.GetShieldEligibleGuild();
if (eligible is null)
{
return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
return context.DefensesRemaining > 0
? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)."
: "Hold BOTH Crown Switches with your guild - the Crown's shield will drop.";
}
if (eligible == guildName)
if (eligible != guildId)
{
return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!";
return "Another guild is holding both switches. Take a switch back to raise their shield.";
}
return $"Guild '{eligible}' is holding both switches. Take a switch back to raise their shield.";
if (context.OccupierGuildId == guildId)
{
return "Your guild already holds the throne - keep it until the siege ends.";
}
return isGuildMaster
? "Your guild holds both switches, but the Crown cannot be registered right now."
: "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!";
}
private static ValueTask ShowAsync(Player player, string text)

View File

@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic;
using System.Diagnostics;
using System.Threading;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.Pathfinding;
using Nito.AsyncEx;
@@ -20,6 +19,8 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable
/// </summary>
private readonly AsyncLock _pickupLock;
private readonly IReadOnlyList<MoneyShare> _shares;
private Timer? _removeTimer;
private bool _availableToPick = true;
@@ -30,9 +31,14 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable
/// <param name="amount">The amount.</param>
/// <param name="position">The position where the item was dropped on the map.</param>
/// <param name="map">The map.</param>
public DroppedMoney(uint amount, Point position, GameMap map)
/// <param name="shares">
/// The part of the money which is reserved for each player, matching the experience they gained from the kill.
/// When it's empty - for example for money from an item box - the money is split equally instead.
/// </param>
public DroppedMoney(uint amount, Point position, GameMap map, IReadOnlyList<MoneyShare>? shares = null)
{
this.Amount = amount;
this._shares = shares ?? [];
this._pickupLock = new();
this.Position = position;
this.CurrentMap = map;
@@ -80,51 +86,16 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable
this._availableToPick = false;
}
if (player.Party is { } party)
if (!this.TryGiveMoneyTo(player))
{
var partyMembers = party.PartyList
.OfType<Player>()
.Where(p => p.CurrentMap == player.CurrentMap && !p.IsAtSafezone() && p.Attributes is { })
.ToList();
if (partyMembers.Count > 0)
// Nobody got the money, so the drop is released again. Keeping it claimed would leave it
// lying on the map, unpickable for everyone until it expires - and then lost.
using (await this._pickupLock.LockAsync())
{
var share = (int)(this.Amount / partyMembers.Count);
foreach (var member in partyMembers)
{
member.TryAddMoney((int)(share * member.Attributes![Stats.MoneyAmountRate]));
}
this._availableToPick = true;
}
}
else
{
var clampMoneyOnPickup = player.GameContext?.Configuration?.ClampMoneyOnPickup ?? false;
if (clampMoneyOnPickup)
{
var maxMoney = player.GameContext?.Configuration?.MaximumInventoryMoney ?? int.MaxValue;
var currentMoney = player.Money;
var amountToAdd = (int)Math.Min(this.Amount, (uint)Math.Max(0, maxMoney - currentMoney));
if (amountToAdd <= 0)
{
player.Logger.LogDebug("Player is at maximum money limit, Player {0}, Money {1}", player, this);
return false;
}
if (!player.TryAddMoney(amountToAdd))
{
player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this);
return false;
}
}
else
{
if (!player.TryAddMoney((int)this.Amount))
{
player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this);
return false;
}
}
return false;
}
await this.DisposeAsync().ConfigureAwait(false);
@@ -158,6 +129,43 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable
await base.DisposeAsyncCore().ConfigureAwait(false);
}
/// <summary>
/// Tries to hand the money over to the player, or to its party. Returns <c>false</c> if it could not be
/// given to anyone, e.g. because the receiver is already at the maximum inventory money.
/// </summary>
/// <param name="player">The player which picks the money up.</param>
/// <returns><c>True</c>, if at least one player received money; Otherwise, <c>false</c>.</returns>
private bool TryGiveMoneyTo(Player player)
{
if (player.Party is not { } party)
{
if (!MoneyDistribution.TryPay(player, this.Amount))
{
player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this);
return false;
}
return true;
}
// Money has no owner - it can always be picked up, by strangers too. The recorded shares
// only apply when the party which picks it up is the one which earned it; then the money
// follows the experience. For anyone else there is no experience to follow, so it is split
// equally between the picking party, just like money without any shares (e.g. an item box).
var earnedByThisParty = this._shares.Any(share => party.IsEligibleForMoney(share.Player, player));
var shares = earnedByThisParty
? this._shares
: MoneyDistribution.CreateEqualShares(this.Amount, party.PartyList.OfType<Player>().ToList());
var received = MoneyDistribution.TryPayShares(shares, member => party.IsEligibleForMoney(member, player));
if (!received)
{
player.Logger.LogDebug("No party member could take the money, Player {0}, Money {1}", player, this);
}
return received;
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnTimerTimeout(object? state)
{

View File

@@ -0,0 +1,12 @@
// <copyright file="ExperienceShare.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The experience which a single player gained from a kill.
/// </summary>
/// <param name="Player">The player which gained the experience.</param>
/// <param name="Experience">The gained experience, with all experience rates already applied.</param>
public readonly record struct ExperienceShare(Player Player, int Experience);

View File

@@ -284,6 +284,9 @@ public class GameContext : AsyncDisposable, IGameContext
case MiniGameType.BloodCastle:
miniGameContext = new BloodCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
case MiniGameType.HeykelSavasi:
miniGameContext = new HeykelSavasiContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
default:
miniGameContext = new MiniGameContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
@@ -301,6 +304,23 @@ public class GameContext : AsyncDisposable, IGameContext
return miniGameContext;
}
/// <summary>
/// Gets the currently open <see cref="MiniGames.HeykelSavasiContext"/> instance, if any.
/// </summary>
/// <returns>The open Heykel Savasi context, or <see langword="null" /> if none is currently open.</returns>
/// <remarks>
/// ADAMU-CUSTOM: used by <see cref="PlugIns.HeykelSavasiNpcPlugin"/> to look up the event context
/// when a player talks to NPC 560. Mutations of <see cref="_miniGames"/> (<see cref="GetMiniGameAsync"/>,
/// <see cref="RemoveMiniGameAsync"/>) are synchronized via <see cref="_mapInitializerLock"/>, so this
/// read takes the same lock and snapshots the values before searching.
/// </remarks>
public async ValueTask<MiniGames.HeykelSavasiContext?> GetOpenHeykelSavasiAsync()
{
using var l = await this._mapInitializerLock.LockAsync().ConfigureAwait(false);
return this._miniGames.Values.OfType<MiniGames.HeykelSavasiContext>()
.FirstOrDefault(g => g.State == MiniGameState.Open);
}
/// <inheritdoc />
public async ValueTask RemoveMiniGameAsync(MiniGameContext miniGameContext)
{

View File

@@ -131,6 +131,17 @@ public class GameMap
return this._areaOfInterestManager.GetInRange(point, range).OfType<IAttackable>().ToList();
}
/// <summary>
/// Gets all non-player characters (e.g. merchants) within the specified range of a point.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>The non-player characters in range of the specified coordinate.</returns>
public IList<NonPlayerCharacter> GetNpcsInRange(Point point, int range)
{
return this._areaOfInterestManager.GetInRange(point, range).OfType<NonPlayerCharacter>().ToList();
}
/// <summary>
/// Gets all dropped items and money within the specified range of a point.
/// </summary>

View File

@@ -0,0 +1,16 @@
// <copyright file="IHasIpAddress.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for objects that expose a remote IP address.
/// </summary>
public interface IHasIpAddress
{
/// <summary>
/// Gets the IP address of the remote connection.
/// </summary>
string? IpAddress { get; }
}

View File

@@ -137,6 +137,37 @@ public static class ItemExtensions
return item.Definition?.Group == ShieldItemGroup;
}
/// <summary>
/// Determines whether equipping an item of this definition into the given hand slot would conflict
/// with what the other hand already holds: a two-handed item needs the other hand free (ammunition
/// aside), and nothing but ammunition fits next to an already equipped two-handed item.
/// </summary>
/// <param name="itemDefinition">The definition of the item which would be equipped.</param>
/// <param name="inventory">The inventory to check the other hand in.</param>
/// <param name="toSlot">The hand slot the item would be equipped into.</param>
/// <returns><c>true</c> if the other hand blocks this item; otherwise, <c>false</c>.</returns>
public static bool ConflictsWithEquippedHands(this ItemDefinition itemDefinition, IStorage inventory, byte toSlot)
{
if (itemDefinition.ItemSlot is null)
{
return false;
}
static bool IsOneHandedOrShield(ItemDefinition definition) =>
(definition.ItemSlot!.ItemSlots.Contains(InventoryConstants.RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(InventoryConstants.LeftHandSlot))
|| definition.Group == ShieldItemGroup;
var rightHandItemDefinition = inventory.GetItem(InventoryConstants.RightHandSlot)?.Definition;
return (toSlot == InventoryConstants.LeftHandSlot
&& itemDefinition.Width >= 2
&& rightHandItemDefinition is not null
&& !rightHandItemDefinition.IsAmmunition)
|| (toSlot == InventoryConstants.RightHandSlot
&& IsOneHandedOrShield(itemDefinition)
&& inventory.GetItem(InventoryConstants.LeftHandSlot)?.Definition?.Width >= 2);
}
/// <summary>
/// Determines whether this item is a jewelry (pendant or ring) item.
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
// <copyright file="HeykelSavasiTeam.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
/// <summary>
/// The team of a player participating in the Heykel Savasi event.
/// </summary>
public enum HeykelSavasiTeam
{
/// <summary>
/// The player is not assigned to any team.
/// </summary>
None,
/// <summary>
/// The red team.
/// </summary>
Red,
/// <summary>
/// The blue team.
/// </summary>
Blue,
}

View File

@@ -0,0 +1,223 @@
// <copyright file="MoneyDistribution.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// Splits a money drop between players and hands it over to them.
/// </summary>
/// <remarks>
/// This is the single place where <see cref="Stats.MoneyAmountRate"/> and
/// <see cref="DataModel.Configuration.GameConfiguration.ClampMoneyOnPickup"/> are applied, so that
/// every path - dropped on the ground, added directly, solo or in a party - treats them the same way.
/// </remarks>
internal static class MoneyDistribution
{
/// <summary>
/// Splits the money proportionally to the experience each player gained from the kill.
/// </summary>
/// <param name="amount">The total amount of money to split.</param>
/// <param name="experienceShares">The experience gained per player.</param>
/// <returns>The part of the money which is reserved for each player.</returns>
public static IReadOnlyList<MoneyShare> CreateShares(uint amount, IReadOnlyList<ExperienceShare> experienceShares)
{
if (experienceShares.Count == 0)
{
return [];
}
if (experienceShares.Count == 1)
{
return [new MoneyShare(experienceShares[0].Player, amount)];
}
var weights = new long[experienceShares.Count];
for (int i = 0; i < experienceShares.Count; i++)
{
weights[i] = experienceShares[i].Experience;
}
var parts = SplitByWeight(amount, weights);
var shares = new MoneyShare[experienceShares.Count];
for (int i = 0; i < experienceShares.Count; i++)
{
shares[i] = new MoneyShare(experienceShares[i].Player, parts[i]);
}
return shares;
}
/// <summary>
/// Splits the money equally, which is used when no per player experience is known - for example
/// for the fixed money amount of an item box.
/// </summary>
/// <param name="amount">The total amount of money to split.</param>
/// <param name="players">The players to split it between.</param>
/// <returns>The part of the money which is reserved for each player.</returns>
public static IReadOnlyList<MoneyShare> CreateEqualShares(uint amount, IReadOnlyList<Player> players)
{
if (players.Count == 0)
{
return [];
}
var parts = SplitByWeight(amount, new long[players.Count]);
var shares = new MoneyShare[players.Count];
for (int i = 0; i < players.Count; i++)
{
shares[i] = new MoneyShare(players[i], parts[i]);
}
return shares;
}
/// <summary>
/// Hands the shares over to the players which are still eligible. The shares of players which
/// are not eligible anymore are re-distributed between the remaining ones.
/// </summary>
/// <param name="shares">The shares.</param>
/// <param name="isEligible">Determines whether a player may still receive their share.</param>
/// <returns><c>True</c>, if at least one player received money; Otherwise, <c>false</c>.</returns>
public static bool TryPayShares(IReadOnlyList<MoneyShare> shares, Func<Player, bool> isEligible)
{
var eligible = new List<MoneyShare>(shares.Count);
uint forfeited = 0;
foreach (var share in shares)
{
if (isEligible(share.Player))
{
eligible.Add(share);
}
else
{
forfeited += share.Amount;
}
}
if (eligible.Count == 0)
{
return false;
}
var extra = new uint[eligible.Count];
if (forfeited > 0)
{
var weights = new long[eligible.Count];
for (int i = 0; i < eligible.Count; i++)
{
weights[i] = eligible[i].Amount;
}
extra = SplitByWeight(forfeited, weights);
}
var received = false;
for (int i = 0; i < eligible.Count; i++)
{
received |= TryPay(eligible[i].Player, eligible[i].Amount + extra[i]);
}
return received;
}
/// <summary>
/// Adds the money to the inventory of the player, applying their <see cref="Stats.MoneyAmountRate"/>
/// and the configured pick up clamp.
/// </summary>
/// <param name="player">The player which should receive the money.</param>
/// <param name="amount">The amount, before the money rate of the player is applied.</param>
/// <returns><c>True</c>, if the player received money; Otherwise, <c>false</c>.</returns>
public static bool TryPay(Player player, uint amount)
{
if (amount == 0)
{
return false;
}
// The rate is applied in double precision: a float multiplication would round money amounts
// above the ~16.7M the float mantissa can represent exactly, before the cast to long.
var scaled = (long)(amount * (double)(player.Attributes?[Stats.MoneyAmountRate] ?? 1.0f));
if (scaled <= 0)
{
return false;
}
var amountToAdd = (int)Math.Min(scaled, int.MaxValue);
if (player.GameContext?.Configuration?.ClampMoneyOnPickup ?? false)
{
var maximumMoney = player.GameContext?.Configuration?.MaximumInventoryMoney ?? int.MaxValue;
amountToAdd = (int)Math.Min(amountToAdd, Math.Max(0, maximumMoney - player.Money));
if (amountToAdd <= 0)
{
return false;
}
}
return player.TryAddMoney(amountToAdd);
}
/// <summary>
/// Splits an amount proportionally to the given weights. When all weights are zero, it's split equally.
/// </summary>
private static uint[] SplitByWeight(uint amount, IReadOnlyList<long> weights)
{
var result = new uint[weights.Count];
if (weights.Count == 0 || amount == 0)
{
return result;
}
long totalWeight = 0;
foreach (var weight in weights)
{
totalWeight += Math.Max(0, weight);
}
var remainders = new long[weights.Count];
uint distributed = 0;
for (int i = 0; i < weights.Count; i++)
{
if (totalWeight > 0)
{
var numerator = (long)amount * Math.Max(0, weights[i]);
result[i] = (uint)(numerator / totalWeight);
remainders[i] = numerator % totalWeight;
}
else
{
result[i] = amount / (uint)weights.Count;
remainders[i] = 1;
}
distributed += result[i];
}
// The integer division loses up to one unit per share. Handing all of it to the same
// share would systematically favour one player over many kills, so the units go to the
// shares which were cut the most, one each (largest remainder method).
for (var rest = amount - distributed; rest > 0; rest--)
{
var pick = -1;
for (int i = 0; i < remainders.Length; i++)
{
if (remainders[i] > 0 && (pick < 0 || remainders[i] > remainders[pick]))
{
pick = i;
}
}
if (pick < 0)
{
break;
}
result[pick]++;
remainders[pick] = 0;
}
return result;
}
}

View File

@@ -0,0 +1,12 @@
// <copyright file="MoneyShare.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The part of a money drop which is reserved for a single player.
/// </summary>
/// <param name="Player">The player for which the part is reserved.</param>
/// <param name="Amount">The amount of money, before the <see cref="Attributes.Stats.MoneyAmountRate"/> of the player is applied.</param>
public readonly record struct MoneyShare(Player Player, uint Amount);

View File

@@ -150,6 +150,50 @@ public interface IMuHelperSettings
/// <summary>Gets a value indicating whether to automatically accept requests from guild.</summary>
bool AutoAcceptGuild { get; }
/// <summary>
/// Gets a value indicating whether to automatically accept party requests from anyone, not just
/// friends or guild mates. Defaults to <c>false</c>; used by server-side bots so they group up
/// with players who invite them (see <c>Bots.BotPartyHandler</c> for the applied safeguards).
/// </summary>
bool AutoAcceptAnyone => false;
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
bool FallbackBasicAttack { get; }
/// <summary>
/// Gets a value indicating whether the combat AI should automatically cast the strongest learned
/// attack skill the character can currently afford, instead of relying on the explicitly configured
/// skill IDs. Used by server-side bots (which have no client-side MU Helper config) so they fight
/// with class- and level-appropriate skills; human offline sessions keep their explicit configuration.
/// </summary>
bool AutoSelectBestSkill { get; }
/// <summary>
/// Gets a value indicating whether the buff AI should automatically cast the learned buff skills
/// of the character, instead of relying on the explicitly configured buff slot IDs. Used by
/// server-side bots so each class keeps its own buffs up (e.g. elf Greater Defense/Greater Damage);
/// human offline sessions keep their explicit configuration.
/// </summary>
bool AutoSelectBuffs { get; }
/// <summary>
/// Gets a value indicating whether to drink a mana potion when mana runs low, so casters can keep
/// casting. There is no client-side MU Helper setting for this; it is used by server-side bots.
/// </summary>
bool UseManaPotion { get; }
/// <summary>
/// Gets a value indicating whether the combat AI only engages monsters the character can safely
/// handle (up to half its own level, like the bot navigator's hunting-ground selection). Without
/// this, a bot travelling through hostile territory would pick fights with monsters far above its
/// level and die. Human offline sessions keep the unrestricted behavior - the player chose the spot.
/// </summary>
bool OnlyHuntSafeMonsters { get; }
/// <summary>
/// Gets a value indicating whether to also pick up equippable items which are an upgrade over the
/// character's currently equipped gear (evaluated before pickup), so bots progress their equipment
/// like a real player without hoarding junk.
/// </summary>
bool PickUpgradeItems { get; }
}

View File

@@ -38,6 +38,13 @@ public static class PartyRequestHandler
return true;
}
if (settings.AutoAcceptAnyone && await Bots.BotPartyHandler.TryScheduleAcceptAsync(receiver, requester).ConfigureAwait(false))
{
// The actual accept happens shortly afterwards in the bot's own tick (a human-like delay);
// all bot-specific safeguards live in the handler, so this stays a thin criteria branch.
return true;
}
return false;
}

View File

@@ -109,6 +109,15 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
return null;
}
// ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may
// break it) AND while any of its 3 guard mobs are still alive (guards must be cleared first).
if (attacker is Player heykelStatueAttacker
&& heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame
&& heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker))
{
return null;
}
var hitInfo = await attacker.CalculateDamageAsync(this, skill, isCombo, damageFactor).ConfigureAwait(false);
if (skill?.Skill is not { } attackSkill || attackSkill.DamageType != DamageType.Fenrir)
@@ -133,6 +142,13 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
{
await player.ApplyMaceMasteryStunEffectAsync(this).ConfigureAwait(false);
}
// ADAMU-CUSTOM: TvT Event -> record per-character statue damage for the event scoreboard.
if (this.Definition.ObjectKind == NpcObjectKind.Destructible
&& player.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelDamageGame)
{
heykelDamageGame.RecordStatueDamage(player, this, (uint)hitInfo.HealthDamage);
}
}
if (attacker as IPlayerSurrogate is { } playerSurrogate)
@@ -307,7 +323,9 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
var player = this.GetHitNotificationTarget(attacker);
if (player is { })
{
int exp = await (player.Party?.DistributeExperienceAfterKillAsync(this, player) ?? player.AddExpAfterKillAsync(this)).ConfigureAwait(false);
var experienceShares = player.Party is { } party
? await party.DistributeExperienceAfterKillAsync(this, player).ConfigureAwait(false)
: [new ExperienceShare(player, await player.AddExpAfterKillAsync(this).ConfigureAwait(false))];
if (attacker == player)
{
await player.AfterKilledMonsterAsync().ConfigureAwait(false);
@@ -325,7 +343,7 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level];
}
_ = this.DropItemDelayedAsync(player, exp); // don't wait for completion.
_ = this.DropItemDelayedAsync(player, experienceShares); // don't wait for completion.
}
}
}
@@ -385,46 +403,44 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
}
}
private async ValueTask HandleMoneyDropAsync(uint amount, Player killer)
private async ValueTask HandleMoneyDropAsync(uint amount, Player killer, IReadOnlyList<ExperienceShare> experienceShares)
{
// Each player gets the part of the money which matches the experience they gained from the kill,
// so that money follows the same distribution as the experience it is derived from.
var shares = MoneyDistribution.CreateShares(amount, experienceShares);
// We don't drop money in Devil Square, etc.
var shouldDropMoney = killer.GameContext.Configuration.ShouldDropMoney && killer.CurrentMiniGame is null;
if (!shouldDropMoney)
{
var party = killer.Party;
if (party is null)
if (killer.Party is { } party)
{
killer.TryAddMoney((int)amount);
await party.DistributeMoneyAfterKillAsync(this, killer, shares).ConfigureAwait(false);
}
else
{
await party.DistributeMoneyAfterKillAsync(this, killer, amount).ConfigureAwait(false);
_ = MoneyDistribution.TryPay(killer, amount);
}
return;
}
var droppedMoney = new DroppedMoney((uint)(amount * (killer.Attributes?[Stats.MoneyAmountRate] ?? 1.0f)), this.Position, this.CurrentMap);
var droppedMoney = new DroppedMoney(amount, this.Position, this.CurrentMap, shares);
await this.CurrentMap.AddAsync(droppedMoney).ConfigureAwait(false);
}
private async ValueTask DropItemAsync(int exp, Player killer)
private async ValueTask DropItemAsync(IReadOnlyList<ExperienceShare> experienceShares, Player killer)
{
// When the killer is in a party, DistributeExperienceAfterKillAsync returns a
// total party experience that does NOT include game rate (ExperienceRate) or
// personal experience rate multipliers. Since the money drop amount is
// derived from this experience value, party money drops were dramatically
// lower than solo drops. We recalculate the experience for money purposes
// using the solo formula so money is consistent regardless of party state.
if (killer.Party is not null)
var exp = 0;
foreach (var share in experienceShares)
{
exp = killer.CalculateExpAfterKill(this);
exp += share.Experience;
}
var (generatedItems, droppedMoney) = await this._dropGenerator.GenerateItemDropsAsync(this.Definition, exp, killer).ConfigureAwait(false);
if (droppedMoney > 0)
{
await this.HandleMoneyDropAsync(droppedMoney.Value, killer).ConfigureAwait(false);
await this.HandleMoneyDropAsync(droppedMoney.Value, killer, experienceShares).ConfigureAwait(false);
}
var firstItem = !droppedMoney.HasValue;
@@ -447,12 +463,12 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
}
}
private async ValueTask DropItemDelayedAsync(Player player, int gainedExp)
private async ValueTask DropItemDelayedAsync(Player player, IReadOnlyList<ExperienceShare> experienceShares)
{
try
{
await Task.Delay(1000).ConfigureAwait(false);
await this.DropItemAsync(gainedExp, player).ConfigureAwait(false);
await this.DropItemAsync(experienceShares, player).ConfigureAwait(false);
}
catch (Exception ex)
{

View File

@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.PlugIns;
@@ -25,6 +26,8 @@ public sealed class BuffHandler
private int _nextSlotIndex;
private bool _buffTimerTriggered;
private DateTime? _nextPeriodicBuffTime;
private IList<int>? _cachedAutoBuffIds;
private int _cachedAutoBuffSkillCount = -1;
/// <summary>
/// Initializes a new instance of the <see cref="BuffHandler"/> class.
@@ -38,7 +41,9 @@ public sealed class BuffHandler
}
/// <summary>
/// Gets the configured buff skill IDs from the settings.
/// Gets the configured buff skill IDs from the settings. With <see cref="IMuHelperSettings.AutoSelectBuffs"/>
/// enabled (server-side bots), the character's learned buff skills are used instead of the explicitly
/// configured slots, so each class keeps its own buffs up without any per-character configuration.
/// </summary>
public IList<int> ConfiguredBuffIds
{
@@ -49,6 +54,34 @@ public sealed class BuffHandler
return [];
}
if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList)
{
// The learned buffs only change when a new skill is learned, so the list is cached and
// only rebuilt when the skill count changes - building it fresh with LINQ on every
// 500ms tick of hundreds of bots was measurable CPU for no benefit.
var skillCount = skillList.Skills.Count();
if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount)
{
var learnedBuffs = skillList.Skills
.Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null })
.Select(s => (int)s.Skill!.Number)
.OrderBy(n => n)
.Take(BuffSlotCount)
.ToList();
// Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty".
while (learnedBuffs.Count < BuffSlotCount)
{
learnedBuffs.Add(0);
}
this._cachedAutoBuffIds = learnedBuffs;
this._cachedAutoBuffSkillCount = skillCount;
}
return this._cachedAutoBuffIds;
}
return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
}
}
@@ -82,7 +115,8 @@ public sealed class BuffHandler
}
var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId);
if (skillEntry?.Skill?.MagicEffectDef is null)
if (skillEntry?.Skill?.MagicEffectDef is null
|| !this.CanCast(skillEntry.Skill))
{
continue;
}
@@ -100,6 +134,22 @@ public sealed class BuffHandler
return true;
}
/// <summary>
/// Whether the character currently meets the skill's own requirements, and can therefore cast it
/// at all. A character keeps its skills across a reset but not the level which unlocked them, so a
/// veteran back at level 12 still owns Swell Life, which asks for level 120.
/// <para>
/// Skipping it here is what keeps the whole helper running. A buff which targets the party goes
/// through the skill plugin, which refuses an unmet requirement deep inside and silently - and this
/// handler reports it as applied regardless. The buff step then ends every tick believing it had
/// just buffed, so the steps behind it, picking up loot and attacking, never ran at all: the
/// character stood in the world doing nothing, for good.
/// </para>
/// </summary>
/// <param name="skill">The skill to cast.</param>
private bool CanCast(Skill skill)
=> BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute]);
/// <summary>
/// Attempts to apply the buff to self and, if applicable, to party members.
/// </summary>
@@ -246,8 +296,21 @@ public sealed class BuffHandler
return false;
}
return target.MagicEffectList.ActiveEffects.Values
.Any(e => e.Definition == effectDef);
try
{
// Eager snapshot, like the other readers of ActiveEffects (see MagicEffectsList): the
// list is mutated by the effect expiry timers, and a lazy enumeration from this
// (unsynchronized) helper tick raced them regularly at scale. A list shrinking in the
// middle of the copy can still leave null holes in the snapshot (hence the tolerant
// predicate) or throw out of the copy itself (hence the catch-all around this pure
// read) - any torn read simply counts as "active", and the next tick retries.
var activeEffects = target.MagicEffectList.ActiveEffects.Values.ToArray();
return activeEffects.Any(e => e?.Definition == effectDef);
}
catch (Exception)
{
return true;
}
}
private void UpdatePeriodicBuffTimer()

View File

@@ -4,7 +4,10 @@
namespace MUnique.OpenMU.GameLogic.Offline;
using System.Collections.Concurrent;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
@@ -19,26 +22,107 @@ public sealed class CombatHandler
{
private const byte DefaultRange = 1;
private const byte BowRange = 6;
/// <summary>Item group of the Horn of Fenrir, the pet behind <see cref="DamageType.Fenrir"/>.</summary>
private const byte FenrirItemGroup = 13;
/// <summary>Item number of the Horn of Fenrir within <see cref="FenrirItemGroup"/>.</summary>
private const short FenrirItemNumber = 37;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the largest share of the bot's maximum health a single average
/// monster hit may take for the monster to count as safe. Sized so the bot survives several hits
/// even when a few monsters aggro at once, with the healing handler (potions at 60%) keeping up.
/// Tightened from 0.20 with the player-meta stat builds: their small health pools mean melee bots
/// (which stand inside the monster pack) need a bigger margin per hit to survive a swarm.
/// </summary>
private const float SafeHitHealthShare = 0.15f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the bot's attack power must exceed the monster's defense by this
/// factor. Without it a bot picks fights it can barely scratch - e.g. the Vulcanus tank monsters
/// (defense ~340, health ~100k) shrug off a modestly geared bot's hits, the "fight" lasts minutes,
/// and the accumulated damage kills the bot even though every single hit it takes looks survivable.
/// </summary>
private const float MinAttackAdvantage = 1.2f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the monster must die within this many net hits of the bot.
/// The per-hit checks alone let a fighter "safely" besiege a 100k-health tank monster for ten
/// minutes, until its potions ran dry and it died anyway - the fight length itself is the risk.
/// At the offline AI's attack pace this bounds a kill to roughly a minute or two.
/// </summary>
private const int MaxHitsToKill = 100;
/// <summary>
/// See <see cref="IsSafeTarget"/>: how much longer a mastered bot may take to kill a monster which
/// pays master experience. Master experience is only granted for monsters of at least
/// <c>GameConfiguration.MinimumMonsterLevelForMasterExperience</c>, and those hold 40.000+ health -
/// out of reach of the regular hit budget for a bot in the gear it collects from drops, which left
/// mastered bots hunting monsters that pay them nothing at all. Since a character at the maximum
/// level earns nothing else either, a long fight it survives beats a quick one worth zero: the
/// budget is stretched for those monsters only, while the survivability check below is NOT - a bot
/// still refuses a monster whose hits it cannot take.
/// </summary>
private const int MasterHitBudgetFactor = 3;
private const int ComboFinisherDelayTicks = 3;
private const int InterSkillDelayTicks = 1;
private const int MinComboSkillCount = 3;
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
private const int MaxApproachFailures = 3;
/// <summary>
/// A skill scoring at least this share of the best score counts as equally good, and reach decides
/// between them. Deliberately narrow: it is meant to level out the flat bonus of comparable spells,
/// not to trade away a skill which is genuinely stronger.
/// </summary>
private const float EquivalentSkillScoreShare = 0.9f;
/// <summary>
/// How many monsters an area skill is credited with at most. A pack does make one worth more than a
/// single-target skill, but not without bound - the extra targets are usually spread over the area,
/// and not all of them are actually caught.
/// </summary>
private const int MaxScoredAreaTargets = 5;
/// <summary>The distance around the current target within which monsters count as one pack.</summary>
private const int AreaSkillClusterRange = 3;
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
/// <summary>How long an unreachable target is ignored before it may be considered again.</summary>
private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10);
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
/// <summary>
/// Cache of the combat-relevant stats of monster definitions (config data, immutable at runtime),
/// so the safety checks of hundreds of bots don't re-scan the attribute lists every tick. Keyed by
/// the monster number rather than the definition instance, so a configuration reload (which builds
/// new <see cref="MonsterDefinition"/> instances) reuses the entries instead of orphaning them.
/// </summary>
private static readonly ConcurrentDictionary<short, (int Level, float AverageDamage, float Defense, float Health, float AttackRate)> MonsterStatsCache = new();
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
private readonly Point _originPosition;
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
private IAttackable? _currentTarget;
private int _nearbyMonsterCount;
private int _targetsAroundCurrent = 1;
private int _currentComboStep;
private int _skillCooldownTicks;
private int _approachFailures;
private ushort _unreachableTargetId;
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
private SkillEntry? _tickBestSkill;
private bool _tickBestSkillComputed;
private DateTime _engageAtUtc = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="CombatHandler"/> class.
@@ -46,13 +130,11 @@ public sealed class CombatHandler
/// <param name="player">The offline player.</param>
/// <param name="config">The MU helper settings.</param>
/// <param name="movementHandler">The movement handler.</param>
/// <param name="originPosition">The original position to hunt around.</param>
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler)
{
this._player = player;
this._config = config;
this._movementHandler = movementHandler;
this._originPosition = originPosition;
this._conditionalSkillSlots = config is null ? [] :
[
new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
@@ -70,6 +152,19 @@ public sealed class CombatHandler
/// </summary>
public byte HuntingRange => CalculateHuntingRange(this._config);
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// Gets a value indicating whether this session animates a server-side bot rather than the offline
/// session of a real player. Bots trade a bit of hunting efficiency for looking human (reaction
/// delay, target spread); a player's offline session must behave exactly as it did before the bots
/// moved into this handler.
/// </summary>
private bool IsBot => this._player.Account?.IsBot == true;
/// <summary>
/// Calculates the hunting range in tiles from the specified configuration.
/// </summary>
@@ -85,6 +180,72 @@ public sealed class CombatHandler
return (byte)Math.Max(DefaultRange, config.HuntingRange);
}
/// <summary>
/// Determines whether the monster is one the bot can fight without dying, judged by the monster's
/// REAL combat stats instead of its nominal level: the average hit it lands (its base damage minus
/// the bot's PvM defense, the same subtraction the damage formula applies) must not exceed
/// <see cref="SafeHitHealthShare"/> of the bot's maximum health. A monster's level says nothing
/// about its punch - the high-end maps (Swamp of Calmness, LaCleon, the event fortresses) field
/// "level ~120" monsters which hit for 1000-2300 base damage, several times what regular maps'
/// monsters of the same level deal - so a level cap sent high-level bots in modest gear straight
/// into a death loop there. Judging by damage also scales naturally with equipment: better armor
/// raises the bot's defense and unlocks tougher maps, exactly like it does for a real player.
/// The bot's own offense must in turn exceed the monster's defense (<see cref="MinAttackAdvantage"/>),
/// so it never besieges a tank monster it can barely scratch, and the monster's level must not
/// exceed the bot's own (on reset servers: its reset-aware effective level, see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>).
/// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages)
/// a monster it should not fight.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="monster">The monster definition.</param>
public static bool IsSafeTarget(Player player, MonsterDefinition monster)
{
var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster);
if (monsterLevel <= 0 || player.Attributes is not { } attributes)
{
return false;
}
// Reset-aware: on servers with the reset feature a freshly reset character is nominally a
// low level again but keeps the strength of its resets - the effective level keeps it from
// being locked out of the maps it just hunted on.
if (monsterLevel > BotResetHandler.GetEffectiveLevel(player))
{
return false;
}
var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate);
if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth]))
{
return false;
}
var attackPower = GetAttackPower(player);
if (attackPower <= monsterDefense * MinAttackAdvantage)
{
return false;
}
return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth;
}
/// <summary>
/// The number of net hits the bot may take to kill the monster (see <see cref="MaxHitsToKill"/>),
/// stretched by <see cref="MasterHitBudgetFactor"/> for a mastered bot fighting a monster which
/// actually pays it master experience (see <see cref="MasterHitBudgetFactor"/>).
/// </summary>
private static int GetHitBudget(Player player, int monsterLevel)
{
var configuration = player.GameContext.Configuration;
var isMastered = player.SelectedCharacter?.CharacterClass?.IsMasterClass == true
&& (player.Attributes?[Stats.Level] ?? 0) >= configuration.MaximumLevel;
return isMastered && monsterLevel >= configuration.MinimumMonsterLevelForMasterExperience
? MaxHitsToKill * MasterHitBudgetFactor
: MaxHitsToKill;
}
/// <summary>
/// Decrements the skill cooldown counter by one tick.
/// </summary>
@@ -101,6 +262,7 @@ public sealed class CombatHandler
/// </summary>
public async ValueTask PerformAttackAsync()
{
var previousTarget = this._currentTarget;
this.RefreshTarget();
if (this._currentTarget is null)
@@ -108,13 +270,47 @@ public sealed class CombatHandler
return;
}
// A human doesn't strike the very same instant a new target appears: give each fresh target a
// small randomized reaction delay (the bot faces it, then engages) - the perfectly metronomic
// instant-strike cadence is one of the clearest bot giveaways. Only bots pay for the disguise:
// a player's own offline session must hunt exactly as fast as it always did.
if (this.IsBot)
{
if (!ReferenceEquals(previousTarget, this._currentTarget))
{
this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900));
}
if (DateTime.UtcNow < this._engageAtUtc)
{
this._player.Rotation = this._player.GetDirectionTo(this._currentTarget);
return;
}
}
byte attackRange = this.GetEffectiveAttackRange();
if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
{
await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
if (await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false))
{
this._approachFailures = 0;
}
else if (++this._approachFailures >= MaxApproachFailures)
{
// The target is in (Euclidean) range but no walkable path leads to it - e.g. a monster
// across a wall or river. Blacklist it briefly and drop it, so the bot picks another
// target (or moves on) instead of standing in front of the obstacle forever.
this._unreachableTargetId = this._currentTarget.Id;
this._unreachableTargetUntilUtc = DateTime.UtcNow + UnreachableTargetBlacklistDuration;
this._currentTarget = null;
this._approachFailures = 0;
}
return;
}
this._approachFailures = 0;
if (this._config?.UseCombo == true)
{
await this.ExecuteComboAttackAsync().ConfigureAwait(false);
@@ -157,6 +353,90 @@ public sealed class CombatHandler
}
}
private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster)
{
return MonsterStatsCache.GetOrAdd(
monster.Number,
static (_, m) =>
{
float GetValue(AttributeDefinition attribute)
=> m.Attributes.FirstOrDefault(a => a.AttributeDefinition == attribute)?.Value ?? 0f;
var level = (int)GetValue(Stats.Level);
var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f;
return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm));
},
monster);
}
/// <summary>
/// How much of the monster's average hit actually lands on the bot, over time: the engine rolls
/// every monster swing against the bot's defense rate (see the hit chance in
/// <see cref="AttackableExtensions"/>), so an agility-based character tanks by DODGING, not by
/// soaking. Judging its safety by the raw hit alone declared every such build too squishy for
/// anything past the starter maps - and left the whole caster population stuck on Lorencia.
/// The dodge credit is capped (a bot must not bet its life on a lucky evade streak).
/// </summary>
private static float GetExpectedHitShare(Player player, float monsterAttackRate)
{
const float minimumAssumedHitChance = 0.25f;
if (monsterAttackRate <= 0f || player.Attributes is not { } attributes)
{
return 1f;
}
var defenseRate = attributes[Stats.DefenseRatePvm];
var hitChance = defenseRate < monsterAttackRate ? 1f - (defenseRate / monsterAttackRate) : 0.03f;
return Math.Clamp(hitChance, minimumAssumedHitChance, 1f);
}
/// <summary>
/// A rough estimate of the bot's punch: its best base damage kind (physical for fighters, wizardry
/// for casters, curse for summoners) plus the strongest attack skill it has learned - enough to tell
/// apart "kills this monster at a reasonable pace" from "barely scratches it".
/// </summary>
/// <summary>
/// Counts the monsters standing close enough to the target to be caught by an area skill aimed at it.
/// This is what decides whether the bot swings around itself or picks its single-target skill - the
/// hunting range is far too wide an area to answer that: it holds every monster of the ground.
/// </summary>
private static int CountPackAround(IAttackable? target, List<IAttackable> targets)
{
if (target is null)
{
return 1;
}
return targets.Count(m => m.GetDistanceTo(target) <= AreaSkillClusterRange);
}
private static float GetAttackPower(Player player)
{
if (player.Attributes is not { } attributes)
{
return 0f;
}
var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f;
// The Min/Max wizardry damage is what a caster actually hits with (energy feeds it, see the
// class attribute relations); Stats.WizardryBaseDmg is only the bonus channel of the staff's
// rise - reading it made every caster look like it had no offense at all, so it never passed
// the checks below for anything but the starter maps and stayed there forever.
var wizardry = (attributes[Stats.MinimumWizBaseDmg] + attributes[Stats.MaximumWizBaseDmg]) / 2f;
var curse = (attributes[Stats.MinimumCurseBaseDmg] + attributes[Stats.MaximumCurseBaseDmg]) / 2f;
var skillDamage = 0;
foreach (var entry in player.SkillList?.Skills ?? [])
{
if (entry.Skill is { AttackDamage: > 0 } skill && skill.AttackDamage > skillDamage)
{
skillDamage = skill.AttackDamage;
}
}
return Math.Max(physical, Math.Max(wizardry, curse)) + skillDamage;
}
private async ValueTask ExecuteAttackAsync(IAttackable target)
{
var skill = this.SelectAttackSkill();
@@ -170,7 +450,15 @@ public sealed class CombatHandler
private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
{
// Last line of defense for the "bot must never become an outlaw" invariant: no strike ever
// leaves this handler against a player who isn't a legal PvP target right now.
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
{
return;
}
this._player.Rotation = this._player.GetDirectionTo(target);
this._player.LastAttackUtc = DateTime.UtcNow;
if (skillEntry?.Skill is not { } skill)
{
@@ -190,6 +478,26 @@ public sealed class CombatHandler
private void RefreshTarget()
{
// The best-skill choice is cached for the duration of one tick (it is needed for both the range
// check and the actual attack); a new tick starts with a fresh choice.
this._tickBestSkill = null;
this._tickBestSkillComputed = false;
// Self-defense has priority over farming: a player who recently attacked this bot becomes the
// target, as long as they are still viable and anywhere near. Without this the bot placidly
// keeps hitting monsters while a player kills it. The aggressor memory only sets the PRIORITY,
// though - whether the bot may actually strike is decided by BotPvpRules per attack: the grudge
// outlives the game's self-defense window, and striking outside of it would turn the bot into
// an outlaw (see BotPvpRules.IsLegalPvpTarget).
if (this._config?.UseSelfDefense == true
&& this._player.RecentAggressor is { } aggressor
&& aggressor.IsInRange(this._player.Position, this.HuntingRange * 2)
&& BotPvpRules.IsLegalPvpTarget(this._player, aggressor))
{
this._currentTarget = aggressor;
return;
}
if (this._currentTarget is { } t && !this.IsTargetStillValid(t))
{
this._currentTarget = null;
@@ -197,13 +505,27 @@ public sealed class CombatHandler
if (this._currentTarget is null)
{
var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
this._nearbyMonsterCount = monsters.Count;
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
var candidates = targets
.Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc)
.OrderBy(m => m.GetDistanceTo(this._player))
.Take(this.IsBot ? 2 : 1)
.ToList();
// A bot chooses randomly among the two nearest candidates instead of strictly the nearest
// one: with many bots on one ground, deterministic nearest-first makes them all dogpile the
// same monster and roam as a pack, which looks distinctly bot-like and wastes damage on
// overkill. A player's own offline session keeps hitting the nearest monster - it is his
// character's hunting efficiency, not a crowd to camouflage.
this._currentTarget = candidates.SelectRandom();
this._nearbyMonsterCount = targets.Count;
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
}
else
{
this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
this._nearbyMonsterCount = targets.Count;
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
}
}
@@ -214,11 +536,42 @@ public sealed class CombatHandler
return [];
}
return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.OfType<Monster>()
.Where(this.IsMonsterAttackable);
}
/// <summary>
/// The regular target pool are the attackable monsters; inside a mini game which allows player
/// killing (Chaos Castle) the other participants join it - there everyone is opposition, and a
/// bot which placidly farms monsters while being cut down would be the obvious odd one out.
/// </summary>
private IEnumerable<IAttackable> GetAttackableTargetsInHuntingRange()
{
if (this._player.CurrentMap is not { } map)
{
return [];
}
var freeForAll = this._player.CurrentMiniGame is { AllowPlayerKilling: true };
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.Where(attackable => attackable switch
{
Monster monster => this.IsMonsterAttackable(monster),
Player player => freeForAll && this.IsEventRivalAttackable(player),
_ => false,
});
}
private bool IsEventRivalAttackable(Player target)
{
return !ReferenceEquals(target, this._player)
&& target.IsAlive
&& !target.IsAtSafezone()
&& !target.IsTeleporting
&& BotPvpRules.IsLegalPvpTarget(this._player, target);
}
private bool IsTargetInAttackRange(IAttackable target, byte range)
{
return target.IsInRange(this._player.Position, range);
@@ -226,17 +579,51 @@ public sealed class CombatHandler
private bool IsTargetStillValid(IAttackable target)
{
// A player target must stay legal for the whole fight: the self-defense window can expire
// mid-fight (the player stopped hitting back and ran), and every further strike past that
// point would be an unprovoked attack that escalates the bot's own hero state.
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
{
return false;
}
return target.IsAlive
&& !target.IsAtSafezone()
&& !target.IsTeleporting
&& target.IsInRange(this._originPosition, this.HuntingRange);
&& target.IsInRange(this.OriginPosition, this.HuntingRange);
}
private bool IsMonsterAttackable(Monster monster)
{
return monster.IsAlive
&& !monster.IsAtSafezone()
&& monster.Definition.ObjectKind == NpcObjectKind.Monster;
&& monster.Definition.ObjectKind == NpcObjectKind.Monster
&& this.IsWithinSafeHuntLevel(monster);
}
/// <summary>
/// With <see cref="IMuHelperSettings.OnlyHuntSafeMonsters"/> (server-side bots), the combat AI only
/// engages monsters which pass the same <see cref="IsSafeTarget"/> check the bot navigator hunts by.
/// Without this, a bot travelling through hostile territory picks a fight with any monster that
/// comes within range - including ones far too strong - and dies. Human offline sessions keep the
/// unrestricted behavior, since the player chose their hunting spot deliberately.
/// </summary>
private bool IsWithinSafeHuntLevel(Monster monster)
{
if (this._config?.OnlyHuntSafeMonsters != true)
{
return true;
}
if (this._player.CurrentMiniGame is not null)
{
// Inside a mini game event the opposition is not the bot's choice - it fights what
// the event throws at it, like every other participant. Refusing "unsafe" waves
// would leave the bot idling in the middle of a Blood Castle.
return true;
}
return IsSafeTarget(this._player, monster.Definition);
}
private async ValueTask ExecutePhysicalAttackAsync(IAttackable target)
@@ -274,6 +661,36 @@ public sealed class CombatHandler
{
await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
}
// A player target is hit by the area skill as well. Outside of free-for-all events ONLY the
// target itself (the self-defense aggressor): any bystanding player in the blast radius is
// deliberately spared - a bot's self-defense must never splash uninvolved players, no
// matter what it casts. Inside a mini game with free player killing (Chaos Castle) there
// are no uninvolved players, so the skill splashes the other participants like any area
// skill would. The legality re-check right at the strike closes the last race: the target
// was legal when it was picked, but the situation may have changed in the meantime.
IEnumerable<Player> playerTargets;
if (this._player.CurrentMiniGame is { AllowPlayerKilling: true })
{
playerTargets = this._player.CurrentMap?
.GetAttackablesInRange(target.Position, skill.Range)
.OfType<Player>()
.Where(p => !ReferenceEquals(p, this._player))
?? [];
}
else
{
playerTargets = target is Player playerTarget ? [playerTarget] : [];
}
foreach (var player in playerTargets)
{
if (player.IsAlive && !player.IsAtSafezone()
&& BotPvpRules.IsLegalPvpTarget(this._player, player))
{
await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
}
}
}
private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill)
@@ -290,10 +707,12 @@ public sealed class CombatHandler
return null;
}
// If no skills are configured at all, don't attack.
// If no skills are configured at all, don't attack - unless the AI is allowed to pick a skill
// on its own (bots), in which case we fall through to the automatic selection below.
if (this._config.BasicSkillId == 0
&& this._config.ActivationSkill1Id == 0
&& this._config.ActivationSkill2Id == 0)
&& this._config.ActivationSkill2Id == 0
&& !this._config.AutoSelectBestSkill)
{
return null;
}
@@ -316,9 +735,122 @@ public sealed class CombatHandler
}
}
// No explicitly configured skill fired: let the AI pick the strongest affordable learned attack
// skill. This scales with the character's level and mana pool, so higher-level bots naturally cast
// stronger spells, and drop back to a basic attack (via FallbackBasicAttack) only when out of mana.
if (this._config.AutoSelectBestSkill)
{
return this.SelectBestAffordableSkill();
}
return null;
}
/// <summary>
/// Picks the strongest attack skill the character has learned and can currently afford (enough mana
/// and ability). Only attack skills (direct hit or area damage) are considered; learned skills are
/// always class-qualified, so this can never cast a skill the class is not entitled to.
/// </summary>
private SkillEntry? SelectBestAffordableSkill()
{
if (this._tickBestSkillComputed)
{
// Computed once per tick: both the attack-range check and the attack itself need it.
return this._tickBestSkill;
}
this._tickBestSkillComputed = true;
if (this._player.SkillList is not { } skillList)
{
return null;
}
var ridesFenrir = this.RidesFenrir();
var candidates = new List<(SkillEntry Entry, float Score)>();
foreach (var entry in skillList.Skills)
{
if (entry.Skill is not { } skill
|| !BotProgression.IsAttackSkill(skill)
|| BotProgression.IsCastleSiegeOnly(skill)
|| (BotProgression.RequiresPet(skill) && !ridesFenrir)
|| skill.Range == 0
// Same trap as the buffs: a character keeps its skills across a reset but not the level
// which unlocked them, and the cast is refused deep inside, silently. A single-target
// skill picked here would simply not go off - the basic attack only steps in when NO
// skill was selected, not when the selected one fails.
|| !BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute])
|| !this.HasEnoughResources(entry))
{
continue;
}
candidates.Add((entry, this.ScoreSkill(skill)));
}
if (candidates.Count == 0)
{
this._tickBestSkill = null;
return null;
}
// Among skills which are worth about the same, reach decides. The flat bonus of a skill is added
// to the character's own damage, so at a few thousand base damage the gap between the strongest
// spell and the second strongest is a rounding error - while three tiles of range are three tiles
// whether the character is level 20 or 400. This is what stopped every wizard from fighting at
// arm's length with Hellfire.
var bestScore = candidates.Max(c => c.Score);
return this._tickBestSkill = candidates
.Where(c => c.Score >= bestScore * EquivalentSkillScoreShare)
.OrderByDescending(c => c.Entry.Skill!.Range)
.ThenByDescending(c => c.Entry.Skill!.MasterDefinition is not null)
.ThenByDescending(c => c.Score)
.First()
.Entry;
}
/// <summary>
/// Estimates what one cast of the skill is worth right now: the damage of a single hit, times the
/// number of hits the skill performs, times the number of monsters an area skill would catch.
/// <see cref="Skill.AttackDamage"/> alone does not say it - it is a flat bonus added to the
/// character's base damage, so a skill can carry none at all and still be the strongest thing the
/// class owns, which is exactly the case for a Rage Fighter's four-hit skills.
/// </summary>
private float ScoreSkill(Skill skill)
{
var attributes = this._player.Attributes;
var baseDamage = skill.DamageType switch
{
DamageType.Wizardry => attributes?[Stats.MaximumWizBaseDmg] ?? 0,
DamageType.Curse => attributes?[Stats.MaximumCurseBaseDmg] ?? 0,
// Only reached when the character actually rides a Fenrir - the skill is filtered out
// otherwise, because this attribute is derived from the character's own stats and says
// nothing about whether the pet is there (see RidesFenrir).
DamageType.Fenrir => attributes?[Stats.FenrirBaseDmg] ?? 0,
_ => attributes?[Stats.MaximumPhysBaseDmg] ?? 0,
};
var perHit = baseDamage + skill.AttackDamage;
var hits = Math.Max((int)skill.NumberOfHitsPerAttack, 1);
var targets = BotProgression.IsAreaSkill(skill)
? Math.Clamp(this._targetsAroundCurrent, 1, MaxScoredAreaTargets)
: 1;
return perHit * hits * targets;
}
/// <summary>
/// Determines whether the character actually rides a Fenrir, which the skills reported by
/// <see cref="BotProgression.RequiresPet"/> need in order to be worth anything.
/// </summary>
private bool RidesFenrir()
=> this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is
{
Durability: > 0.0,
Definition: { Group: FenrirItemGroup, Number: FenrirItemNumber },
};
/// <summary>
/// Evaluates whether the skill in the given slot should fire this tick.
/// </summary>
@@ -514,6 +1046,15 @@ public sealed class CombatHandler
}
}
// Bots have no configured skill IDs but auto-select their attack skill; use the range of the skill
// they would actually cast now, so ranged casters attack from a distance instead of closing to melee.
if (this._config.AutoSelectBestSkill
&& this.SelectBestAffordableSkill()?.Skill?.Range is { } autoRange
&& autoRange > 0)
{
return (byte)autoRange;
}
if (this._player.Attributes is { } attributes
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
{

View File

@@ -18,9 +18,12 @@ using MUnique.OpenMU.Interfaces;
/// </summary>
public sealed class HealingHandler
{
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly ItemIdentifier[] HealthPotionPriority =
/// <summary>
/// The health potions the offline player drinks, best first. Also the shopping list a bot restocks
/// from (see <c>BotShoppingHandler</c>): buying what it does not drink, or not buying what it does,
/// is how a bot ends up starving next to a full merchant.
/// </summary>
internal static readonly ItemIdentifier[] HealthPotionPriority =
[
ItemConstants.LargeHealingPotion,
ItemConstants.MediumHealingPotion,
@@ -28,6 +31,21 @@ public sealed class HealingHandler
ItemConstants.Apple,
];
/// <summary>
/// The mana potions the offline player drinks, best first. <see cref="HealthPotionPriority"/>.
/// </summary>
internal static readonly ItemIdentifier[] ManaPotionPriority =
[
ItemConstants.LargeManaPotion,
ItemConstants.MediumManaPotion,
ItemConstants.SmallManaPotion,
];
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
private const int ManaThresholdPercent = 30;
private static readonly ItemConsumeAction ConsumeAction = new();
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
@@ -75,7 +93,25 @@ public sealed class HealingHandler
if (this._config!.UseHealPotion && this.IsHealthBelowThreshold(this._player, this._config.PotionThresholdPercent))
{
await this.UseHealthPotionAsync().ConfigureAwait(false);
return;
}
if (this._config.UseManaPotion && this.IsManaBelowThreshold())
{
await this.UsePotionAsync(ManaPotionPriority).ConfigureAwait(false);
}
}
private bool IsManaBelowThreshold()
{
if (this._player.Attributes is not { } attributes)
{
return false;
}
double mana = attributes[Stats.CurrentMana];
double maxMana = attributes[Stats.MaximumMana];
return maxMana > 0 && (mana * 100.0 / maxMana) <= ManaThresholdPercent;
}
private async ValueTask PerformPartyHealingAsync()
@@ -126,14 +162,16 @@ public sealed class HealingHandler
return maxHp > 0 && (hp * 100.0 / maxHp) <= thresholdPercent;
}
private async ValueTask UseHealthPotionAsync()
private ValueTask UseHealthPotionAsync() => this.UsePotionAsync(HealthPotionPriority);
private async ValueTask UsePotionAsync(ItemIdentifier[] priority)
{
if (this._player.Inventory is null)
{
return;
}
foreach (var identifier in HealthPotionPriority)
foreach (var identifier in priority)
{
var potion = this._player.Inventory.Items
.FirstOrDefault(i => i.Definition?.Group == identifier.Group

View File

@@ -117,16 +117,29 @@ public sealed class ItemPickupHandler
if (this._config.PickJewel && IsJewel(item))
{
return true;
// A human's helper takes every jewel - its owner trades, crafts or hoards them later. A bot
// has no later: it can only spend Bless, Soul and Life on its own gear, so a Jewel of Chaos
// or a stock-exceeding Soul is a backpack slot it never gets back.
return this._player.Account?.IsBot != true
|| Bots.BotJewelHandler.WantsMoreOf(this._player, item);
}
if (this._config.PickAncient && item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
var isExcellent = item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
if ((this._config.PickAncient && isAncient) || (this._config.PickExcellent && isExcellent))
{
return true;
// A human's helper hoards every excellent/ancient piece - its owner sorts the treasure
// out later. A bot has no later: it cannot trade, so it only takes what it can actually
// wear as an upgrade; everything else would silt up its backpack until the loot pickup
// stops.
return this._player.Account?.IsBot != true
|| Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item);
}
if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
if (this._config.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item))
{
// The item is class-qualified gear which beats what the bot currently wears - worth picking
// up; the BotEquipmentHandler will equip it on one of its next passes.
return true;
}

View File

@@ -16,7 +16,6 @@ public sealed class MovementHandler
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly Point _originPosition;
private DateTime? _outOfRangeSince;
@@ -25,14 +24,17 @@ public sealed class MovementHandler
/// </summary>
/// <param name="player">The offline player.</param>
/// <param name="config">The MU Helper configuration.</param>
/// <param name="originPosition">The original spawn position.</param>
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config)
{
this._player = player;
this._config = config;
this._originPosition = originPosition;
}
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// Gets the hunting range in tiles.
/// </summary>
@@ -51,7 +53,7 @@ public sealed class MovementHandler
if (this.ShouldRegroup(out var distance))
{
await this.WalkToAsync(this._originPosition).ConfigureAwait(false);
await this.WalkToAsync(this.OriginPosition).ConfigureAwait(false);
this._outOfRangeSince = null;
return false;
}
@@ -69,13 +71,43 @@ public sealed class MovementHandler
/// </summary>
/// <param name="target">The target to move closer to.</param>
/// <param name="range">The range to stop within.</param>
public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
/// <returns>True, if a walk towards the target was started; false, if no path exists or walking is not possible.</returns>
public async ValueTask<bool> MoveCloserToTargetAsync(IAttackable target, byte range)
{
if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange))
if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange))
{
var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range);
await this.WalkToAsync(walkTarget).ConfigureAwait(false);
var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range);
return await this.WalkToAsync(walkTarget).ConfigureAwait(false);
}
return false;
}
/// <summary>
/// Picks the point to walk to when closing in on a target: straight along the line towards it,
/// stopping at attack range. The previous behavior re-randomized a point around the target every
/// tick, which made the character zig-zag visibly towards its prey and re-path constantly.
/// Falls back to a random point near the target when the straight-line point is not walkable.
/// </summary>
private static Point GetApproachPoint(GameMap map, Point from, Point to, byte stopRange)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
if (distance <= stopRange)
{
return from;
}
var factor = (double)(distance - stopRange) / distance;
var x = (byte)Math.Clamp(from.X + (int)Math.Round(dx * factor), 0, 255);
var y = (byte)Math.Clamp(from.Y + (int)Math.Round(dy * factor), 0, 255);
if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y])
{
return new Point(x, y);
}
return map.Terrain.GetRandomCoordinate(to, stopRange);
}
/// <summary>
@@ -120,7 +152,7 @@ public sealed class MovementHandler
private bool ShouldRegroup(out double distance)
{
distance = this._player.GetDistanceTo(this._originPosition);
distance = this._player.GetDistanceTo(this.OriginPosition);
if (distance <= RegroupDistanceThreshold)
{
return false;

View File

@@ -7,16 +7,72 @@ namespace MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// An offline player that continues leveling after the real client disconnects.
/// </summary>
public sealed class OfflinePlayer : Player
public class OfflinePlayer : Player
{
/// <summary>
/// A player who killed this bot this many times gets no (further) revenge: walking back a third
/// time into the same lost fight would just be a death loop feeding the killer free kills.
/// </summary>
private const int RepeatedKillThreshold = 2;
/// <summary>
/// How long an attack by a player stays "hot" as a self-defense target, counted from the LAST hit
/// (every attack refreshes it). Long enough to hold a grudge: an attacker who breaks off and comes
/// back within this window stays the bot's priority target instead of being forgiven after
/// seconds - whether it may actually be struck is decided per attack by <see cref="Bots.BotPvpRules"/>.
/// </summary>
private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5);
/// <summary>
/// How long a revenge stays armed after the respawn. One attempt only: if the bot has not reached
/// its death site within this time (long routes, fights on the way), it gives up and hunts normally.
/// </summary>
private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3);
/// <summary>
/// How long the bot keeps away from hunting grounds near its death site after the same player
/// killed it repeatedly (see <see cref="RepeatedKillThreshold"/>).
/// </summary>
private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// How long a death counts toward <see cref="RepeatedKillThreshold"/>. A kill by a player the bot
/// has not seen for this long counts as a fresh grudge again, not as a repeated one.
/// </summary>
private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30);
/// <summary>
/// How often each (human) player killed this bot recently, keyed by character name. Written by the
/// death plugin and read by the AI ticks, hence concurrent.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DeathRecord> _deathsByKiller = new();
private OfflinePlayerMuHelper? _intelligence;
private Task? _intelligenceDisposeTask;
/// <summary>
/// The player who most recently attacked this bot, with the time of that attack. Written from the
/// attack path and read from the AI tick; immutable and written atomically (a single reference
/// store), so the two can access it without a lock and without a torn <see cref="DateTime"/> read.
/// </summary>
private volatile Aggression? _aggression;
/// <summary>
/// The pending (not yet armed, <see cref="RevengeState.ExpiresAtUtc"/> is null) or armed revenge.
/// The state object is immutable and the field is written atomically, so the death plugin and the
/// AI ticks can access it without a lock.
/// </summary>
private volatile RevengeState? _revenge;
/// <summary>See <see cref="TryGetDeathSiteToAvoid"/>; immutable and written atomically, like <see cref="_revenge"/>.</summary>
private volatile DeathSite? _deathSiteToAvoid;
/// <summary>
/// Initializes a new instance of the <see cref="OfflinePlayer"/> class.
/// </summary>
@@ -36,6 +92,80 @@ public sealed class OfflinePlayer : Player
/// </summary>
public DateTime StartTimestamp { get; internal set; }
/// <summary>
/// Gets or sets the position the intelligence hunts around. For a plain offline player this is
/// the spawn position and never changes. Bots update it to roam between hunting grounds.
/// </summary>
public Point HuntingOrigin { get; set; }
/// <summary>
/// Gets a value indicating whether the player should keep playing after dying and respawning.
/// A normal offline session ends on death; bots override this to keep running forever.
/// </summary>
public virtual bool RespawnAndContinue => false;
/// <summary>
/// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the
/// <see cref="OfflinePlayerMuHelper"/> drains at the start of each tick. This serializes such
/// mutations with the combat handler, so e.g. the skill list is never modified while combat is
/// enumerating it.
/// </summary>
internal System.Collections.Concurrent.ConcurrentQueue<Func<ValueTask>> PendingBotActions { get; } = new();
/// <summary>
/// Gets the player who most recently attacked this bot (self-defense target), if the aggression
/// is recent enough and the aggressor is still a viable target.
/// </summary>
internal Player? RecentAggressor
{
get
{
if (this._aggression is { } aggression
&& DateTime.UtcNow - aggression.AtUtc <= AggressionMemory
&& aggression.Aggressor.IsAlive
&& !aggression.Aggressor.IsAtSafezone())
{
return aggression.Aggressor;
}
return null;
}
}
/// <summary>
/// Gets or sets the pending party invitation from a player, scheduled by
/// <see cref="Bots.BotPartyHandler"/> and executed with a human-like delay in the bot's tick.
/// </summary>
internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; }
/// <summary>
/// Gets or sets the time at which the bot gets bored of its current party with a human player
/// and politely leaves it (managed by <see cref="Bots.BotPartyHandler"/>).
/// </summary>
internal DateTime? PartyBoredomAtUtc { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to
/// or trading with a merchant), maintained by <see cref="Bots.BotNavigator"/>. While on an errand
/// the bot declines party invitations, like a busy player would.
/// </summary>
internal bool IsOnShoppingTrip { get; set; }
/// <summary>
/// Gets a value indicating whether a revenge against a player killer is pending or armed - the
/// bot has unfinished business and is in no mood to group up.
/// </summary>
internal bool HasRevengeIntent => this._revenge is not null;
/// <summary>
/// Gets or sets the time the character last struck something. It is the only honest answer to
/// "is this map paying off": whatever keeps a bot from fighting - monsters it may not engage,
/// grounds another bot empties first, a map its level opened but its body cannot handle - the
/// symptom is the same, and so is the remedy (see <see cref="Bots.BotNavigator"/>: move to easier
/// ground rather than walk between hunting grounds forever).
/// </summary>
internal DateTime LastAttackUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// Initializes the offline player by loading the account fresh from the database.
/// </summary>
@@ -70,6 +200,8 @@ public sealed class OfflinePlayer : Player
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
this.HuntingOrigin = this.Position;
this.StartIntelligence();
this.Logger.LogDebug(
@@ -90,11 +222,188 @@ public sealed class OfflinePlayer : Player
/// <summary>
/// Stops the offline player and removes it from the world.
/// </summary>
public async ValueTask StopAsync()
public virtual async ValueTask StopAsync()
{
await this.DisconnectAsync().ConfigureAwait(false);
}
/// <summary>
/// Registers a player who attacked this bot, so the combat AI can defend itself.
/// </summary>
/// <param name="aggressor">The player who attacked this bot.</param>
internal void RegisterAggressor(Player aggressor)
{
this._aggression = new Aggression(aggressor, DateTime.UtcNow);
}
/// <summary>
/// Registers that a (human) player killed this bot. The first kill makes a revenge pending: after
/// respawning on the same map, the bot marches back to the place of its death (driven by the
/// <see cref="Bots.BotNavigator"/>) with re-armed aggressor memory, so it attacks the killer on
/// sight. A repeated kill by the same player (see <see cref="RepeatedKillThreshold"/>) cancels
/// revenge instead and makes the bot avoid hunting grounds near the death site for a while.
/// </summary>
/// <param name="killer">The player who killed this bot.</param>
internal void RegisterDeathByPlayer(Player killer)
{
if (this.CurrentMap?.Definition is not { } deathMap)
{
return;
}
var now = DateTime.UtcNow;
var deathPosition = this.Position;
var record = this._deathsByKiller.AddOrUpdate(
killer.Name,
_ => new DeathRecord(1, now),
(_, existing) => now - existing.LastDeathUtc > DeathCountMemory
? new DeathRecord(1, now)
: new DeathRecord(existing.Count + 1, now));
if (record.Count >= RepeatedKillThreshold)
{
this._revenge = null;
this._deathSiteToAvoid = new DeathSite(deathPosition, deathMap, now + DeathSiteAvoidanceDuration);
this.Logger.LogInformation(
"Bot '{Name}' was killed by '{Killer}' again; giving up on revenge and avoiding the area around {Position} for a while.",
this.Name,
killer.Name,
deathPosition);
return;
}
this._revenge = new RevengeState(killer, deathPosition, deathMap, null);
}
/// <summary>
/// Arms a pending revenge once the bot respawned, called by the <see cref="OfflinePlayerMuHelper"/>
/// when a bot resumes after death. Only a respawn on the map the bot died on qualifies (from any
/// other map the march back would be meaningless); the aggressor memory is re-armed, so the combat
/// AI keeps the killer prioritized (struck only when legal, see <see cref="Bots.BotPvpRules"/>),
/// and the revenge gets its time-to-live.
/// </summary>
internal void ArmRevengeAfterRespawn()
{
if (this._revenge is not { ExpiresAtUtc: null } revenge)
{
return;
}
if (!object.Equals(this.CurrentMap?.Definition, revenge.DeathMap))
{
this._revenge = null;
return;
}
this._revenge = revenge with { ExpiresAtUtc = DateTime.UtcNow + RevengeDuration };
this.RegisterAggressor(revenge.Killer);
this.Logger.LogInformation("Bot '{Name}' returns to avenge its death against '{Killer}'.", this.Name, revenge.Killer.Name);
}
/// <summary>
/// Gets the destination of an armed, still running revenge. Expires the revenge when its
/// time-to-live ran out or the bot is no longer on the map it died on (e.g. it warped away).
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the bot's death to march back to.</param>
/// <returns><c>true</c> if a revenge is active and <paramref name="deathSite"/> was set.</returns>
internal bool TryGetRevengeDestination(GameMapDefinition currentMap, out Point deathSite)
{
deathSite = default;
if (this._revenge is not { ExpiresAtUtc: { } expiresAt } revenge)
{
return false;
}
if (DateTime.UtcNow > expiresAt)
{
this.ExpireRevenge("it timed out before the bot reached the death site");
return false;
}
if (!object.Equals(currentMap, revenge.DeathMap))
{
this.ExpireRevenge("the bot left the map it died on");
return false;
}
deathSite = revenge.DeathPosition;
return true;
}
/// <summary>
/// Ends an active revenge - the single attempt is spent, the bot returns to its normal routine.
/// The aggressor memory is deliberately left armed: if the killer is still around, the combat AI
/// engages it, and if it strikes again, self-defense re-arms the memory anyway.
/// </summary>
/// <param name="reason">Why the revenge ended, for the log.</param>
internal void ExpireRevenge(string reason)
{
if (this._revenge is { } revenge)
{
this._revenge = null;
this.Logger.LogInformation("Bot '{Name}' revenge against '{Killer}' ended: {Reason}.", this.Name, revenge.Killer.Name, reason);
}
}
/// <summary>
/// Gets the death site the bot should keep away from when picking a hunting ground - set after the
/// same player killed it repeatedly, so it stops walking back into the same lost fight.
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the repeated deaths.</param>
/// <returns><c>true</c> if an avoidance is active on the given map and <paramref name="deathSite"/> was set.</returns>
internal bool TryGetDeathSiteToAvoid(GameMapDefinition currentMap, out Point deathSite)
{
deathSite = default;
if (this._deathSiteToAvoid is not { } site
|| DateTime.UtcNow > site.AvoidUntilUtc
|| !object.Equals(currentMap, site.Map))
{
return false;
}
deathSite = site.Position;
return true;
}
/// <summary>
/// Executes and removes all queued <see cref="PendingBotActions"/>.
/// </summary>
internal async ValueTask DrainPendingBotActionsAsync()
{
while (this.PendingBotActions.TryDequeue(out var action))
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Queued bot action failed for {Account}.", this.AccountLoginName);
}
}
}
/// <summary>
/// Called when an AI tick of this player finished without an exception. Does nothing here - a bot
/// uses it to forget earlier failures (see <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickSucceeded()
{
// Nothing to do for a plain offline player.
}
/// <summary>
/// Called when an AI tick of this player threw. Does nothing here, so the human offline mode keeps
/// behaving exactly as before; a bot counts the failures and asks for a restart when they don't stop
/// (see <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickFailed()
{
// Nothing to do for a plain offline player.
}
/// <inheritdoc />
protected override async ValueTask InternalDisconnectAsync()
{
@@ -132,6 +441,15 @@ public sealed class OfflinePlayer : Player
protected override ICustomPlugInContainer<IViewPlugIn> CreateViewPlugInContainer()
=> new OfflineViewPlugInContainer(this);
/// <summary>
/// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation.
/// </summary>
protected virtual void StartIntelligence()
{
this._intelligence = new OfflinePlayerMuHelper(this);
this._intelligence.Start();
}
private async ValueTask AdvanceToCharacterSelectionStateAsync()
{
// Advance state to allow the intelligence to perform actions.
@@ -146,9 +464,24 @@ public sealed class OfflinePlayer : Player
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
}
private void StartIntelligence()
{
this._intelligence = new OfflinePlayerMuHelper(this);
this._intelligence.Start();
}
}
/// <summary>
/// How often (and how recently) a specific player killed this bot.
/// </summary>
private sealed record DeathRecord(int Count, DateTime LastDeathUtc);
/// <summary>
/// A revenge for a death by a player's hand: pending while <see cref="ExpiresAtUtc"/> is null
/// (the bot has not respawned yet), armed and running once it is set.
/// </summary>
private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc);
/// <summary>
/// A death site the bot avoids when picking hunting grounds, after repeated deaths there.
/// </summary>
private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc);
/// <summary>
/// The most recent aggression against this bot: who attacked and when.
/// </summary>
private sealed record Aggression(Player Aggressor, DateTime AtUtc);
}

View File

@@ -46,14 +46,13 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
public OfflinePlayerMuHelper(OfflinePlayer player)
{
this._player = player;
var originalPosition = player.Position;
var config = player.MuHelperSettings;
this._buffHandler = new BuffHandler(player, config);
this._healingHandler = new HealingHandler(player, config);
this._itemPickupHandler = new ItemPickupHandler(player, config);
this._movementHandler = new MovementHandler(player, config, originalPosition);
this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
this._movementHandler = new MovementHandler(player, config);
this._combatHandler = new CombatHandler(player, config, this._movementHandler);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
this._petHandler = new PetHandler(player, config);
@@ -120,10 +119,17 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
this._isDead = true;
// Do not cancel the loop here: a bot needs to keep ticking so it can resume after respawning.
// For a normal offline session the tick stops the session on respawn, which disposes (and cancels) this helper.
}
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
// Randomize the loop phase, so hundreds of concurrently started players don't all tick on the
// same 500ms boundary - smoother server load and less robotic synchrony between them.
await Task.Delay(Rand.NextInt(0, 500), cancellationToken).ConfigureAwait(false);
while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -135,7 +141,12 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
// Run the whole tick under the player's persistence lock so its structural mutations
// (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap
// this bot's periodic progress save, which runs on a separate timer. The tick has no
// internal delays, so the lock is held only for its brief duration.
await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)
{
@@ -144,11 +155,16 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
catch (Exception ex)
{
this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName);
this._player.OnAiTickFailed();
}
}
private async ValueTask TickAsync(CancellationToken cancellationToken)
{
// Actions queued from outside the tick (e.g. skill learning on level-up) run here, serialized
// with the combat handler - so nothing mutates the skill list while combat is enumerating it.
await this._player.DrainPendingBotActionsAsync().ConfigureAwait(false);
if (await this.HandleDeathAsync().ConfigureAwait(false))
{
return;
@@ -215,6 +231,19 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
return true;
}
if (this._player.RespawnAndContinue)
{
// Bots keep playing: reset the death state and re-anchor the hunting origin to the respawn
// position so the navigator picks a fresh hunting ground from where the bot came back to life.
// A death by a player's hand may have left a pending revenge - arm it now (the navigator
// then marches the bot back to its death site instead of picking a hunting ground).
this._isDead = false;
this._player.HuntingOrigin = this._player.Position;
this._player.ArmRevengeAfterRespawn();
this._player.Logger.LogInformation("Bot '{Name}' respawned; resuming.", this._player.Name);
return false;
}
if (this._player.Account?.LoginName is { } loginName)
{
this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName);

View File

@@ -22,6 +22,8 @@ internal sealed class RepairHandler
private readonly IMuHelperSettings? _config;
private readonly ItemRepairAction _repairAction = new();
private bool _loggedDisabled;
/// <summary>
/// Initializes a new instance of the <see cref="RepairHandler"/> class.
/// </summary>
@@ -41,7 +43,13 @@ internal sealed class RepairHandler
{
if (this._config is not { RepairItem: true })
{
this._player.Logger.LogDebug("Auto-repair is disabled by MU Helper configuration for character {CharacterName}.", this._player.Name);
// Once per session: this states a configuration, not an event, and the tick runs twice a second.
if (!this._loggedDisabled)
{
this._loggedDisabled = true;
this._player.Logger.LogDebug("Auto-repair is disabled by MU Helper configuration for character {CharacterName}.", this._player.Name);
}
return;
}

View File

@@ -34,6 +34,14 @@ internal sealed class ZenConsumptionHandler
/// <returns><c>true</c> if the player can continue; <c>false</c> if insufficient Zen.</returns>
public async ValueTask<bool> DeductZenAsync()
{
// Bots are exempt from the PC-Cafe fee: they don't accumulate Zen fast enough
// to cover it and would otherwise go bankrupt and stop. Human offline-leveling
// players (IsBot == false) keep paying as before.
if (this._player.Account?.IsBot == true)
{
return true;
}
if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval)
{
return true;

View File

@@ -201,8 +201,8 @@ public sealed class Party : AsyncDisposable
/// </summary>
/// <param name="killedObject">The object that was killed.</param>
/// <param name="killer">The killer who is a party member.</param>
/// <returns>The total experience distributed.</returns>
public async ValueTask<int> DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
/// <returns>The experience which each party member gained, with all experience rates applied.</returns>
public async ValueTask<IReadOnlyList<ExperienceShare>> DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
{
using var l = await this._distributionLock.LockAsync();
try
@@ -220,34 +220,29 @@ public sealed class Party : AsyncDisposable
/// </summary>
/// <param name="killed">The object that was killed.</param>
/// <param name="killer">The killer who is a party member.</param>
/// <param name="amount">The amount of money to distribute.</param>
public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, uint amount)
/// <param name="shares">The part of the money which is reserved for each party member.</param>
public ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, IReadOnlyList<MoneyShare> shares)
{
using var l = await this._distributionLock.LockAsync();
try
{
this._logger.LogDebug("Distributing money after killing {name}", killed.GetName());
this._distributionList.AddRange(
this._partyMembers.OfType<Player>()
.Where(p => p.CurrentMap == killer.CurrentMap
&& !p.IsAtSafezone()
&& p.Attributes is { }));
// No lock is taken here: unlike the experience distribution this no longer touches the shared
// _distributionList, and paying out the pre-computed shares is consistent with the lock-free
// pick up path in DroppedMoney.
this._logger.LogDebug("Distributing money after killing {name}", killed.GetName());
_ = MoneyDistribution.TryPayShares(shares, player => this.IsEligibleForMoney(player, killer));
return ValueTask.CompletedTask;
}
if (this._distributionList.Count == 0)
{
return;
}
var moneyPart = amount / this._distributionList.Count;
foreach (var player in this._distributionList)
{
player.TryAddMoney((int)(moneyPart * player.Attributes![Stats.MoneyAmountRate]));
}
}
finally
{
this._distributionList.Clear();
}
/// <summary>
/// Determines whether the player may receive a part of a money drop of the party.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="killer">The killer who is a party member.</param>
/// <returns><c>True</c>, if the player may receive money; Otherwise, <c>false</c>.</returns>
internal bool IsEligibleForMoney(Player player, IPartyMember killer)
{
return this._partyMembers.Contains(player)
&& player.CurrentMap == killer.CurrentMap
&& !player.IsAtSafezone()
&& player.Attributes is { };
}
/// <summary>
@@ -340,7 +335,7 @@ public sealed class Party : AsyncDisposable
base.Dispose(disposing);
}
private static (int Total, float PerLevel) CalculatePartyExperience(List<Player> recipients, IAttackable killed)
private static float CalculatePartyExperiencePerLevel(List<Player> recipients, IAttackable killed)
{
var memberCount = recipients.Count;
var totalLevel = recipients.Sum(p => (int)p.Attributes![Stats.TotalLevel]);
@@ -355,9 +350,8 @@ public sealed class Party : AsyncDisposable
var randomMinMultiplier = attributes[Stats.RandomExperienceMinMultiplier];
var randomMaxMultiplier = attributes[Stats.RandomExperienceMaxMultiplier];
var totalExperience = CalculateTotalExperience(totalBaseExperience, randomMinMultiplier, randomMaxMultiplier);
var perLevel = (float)totalExperience / totalLevel;
return (totalExperience, perLevel);
return (float)totalExperience / totalLevel;
}
private static int CalculateTotalExperience(double totalBaseExperience, float randomMinMultiplier, float randomMaxMultiplier)
@@ -377,7 +371,7 @@ public sealed class Party : AsyncDisposable
return (int)totalBaseExperience;
}
private static async ValueTask AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
private static async ValueTask<int> AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
{
var attributes = player.Attributes!;
var isAtMaxLevel = (short)attributes[Stats.Level] == player.GameContext.Configuration.MaximumLevel;
@@ -391,20 +385,23 @@ public sealed class Party : AsyncDisposable
* (attributes[Stats.MasterExperienceRate] + attributes[Stats.BonusExperienceRate]));
await player.AddMasterExperienceAsync(exp, killed).ConfigureAwait(false);
return exp;
}
else if (!isAtMaxLevel)
{
var exp = (int)(perLevel
* attributes[Stats.Level]
* player.GameContext.ExperienceRate
* (attributes[Stats.ExperienceRate] + attributes[Stats.BonusExperienceRate]));
await player.AddExperienceAsync(exp, killed).ConfigureAwait(false);
}
else
var normalExperience = (int)(perLevel
* attributes[Stats.Level]
* player.GameContext.ExperienceRate
* (attributes[Stats.ExperienceRate] + attributes[Stats.BonusExperienceRate]));
if (!isAtMaxLevel)
{
// Player is at max level but has not completed master quest. No experience awarded.
await player.AddExperienceAsync(normalExperience, killed).ConfigureAwait(false);
}
// At the maximum level without the master quest no experience is awarded, but the amount is
// still returned: the money drop is derived from it, and a solo kill returns it as well
// (see Player.AddExpAfterKillAsync), so such a member must not end up without any money.
return normalExperience;
}
private async ValueTask ExitPartyAsync(IPartyMember member, byte index)
@@ -423,6 +420,12 @@ public sealed class Party : AsyncDisposable
if (!shouldDispose)
{
this._partyMembers = this._partyMembers.Where(m => m != member).ToArray();
// If the party master is leaving, assign the new master to the first remaining member.
if (this.PartyMaster == member && this._partyMembers.Length > 0)
{
this.PartyMaster = this._partyMembers[0];
}
}
}
@@ -460,11 +463,11 @@ public sealed class Party : AsyncDisposable
}
}
private async ValueTask<int> InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
private async ValueTask<IReadOnlyList<ExperienceShare>> InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
{
if (killedObject.IsSummonedMonster)
{
return 0;
return [];
}
using (await killer.ObserverLock.ReaderLockAsync().ConfigureAwait(false))
@@ -477,17 +480,20 @@ public sealed class Party : AsyncDisposable
if (this._distributionList.Count == 0)
{
return 0;
return [];
}
var (total, perLevel) = CalculatePartyExperience(this._distributionList, killedObject);
var perLevel = CalculatePartyExperiencePerLevel(this._distributionList, killedObject);
// The shares are copied into their own list, because _distributionList is reused and cleared by the caller.
var shares = new List<ExperienceShare>(this._distributionList.Count);
foreach (var player in this._distributionList)
{
await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false);
var experience = await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false);
shares.Add(new ExperienceShare(player, experience));
}
return total;
return shares;
}
private async ValueTask UpdateNearbyCountAsync()

View File

@@ -1,4 +1,4 @@
// <copyright file="Player.cs" company="MUnique">
// <copyright file="Player.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
@@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private readonly AsyncLock _moveLock = new();
private readonly AsyncLock _experienceLock = new();
/// <summary>
/// Serializes context mutations done by this player's action handlers against the periodic and
/// disconnect progress saves, which run on an independent timer flow. See
/// <see cref="RunPersistenceExclusiveAsync{T}"/>.
/// </summary>
private readonly AsyncLock _persistenceLock = new();
/// <summary>
/// Tracks, per asynchronous flow, whether <see cref="_persistenceLock"/> is already held, so the
/// lock can be re-entered (Nito's <see cref="AsyncLock"/> is not reentrant). It is an instance
/// field on purpose: reentrancy must be tracked per player, so a flow holding player A's lock
/// still acquires player B's lock (e.g. during a trade) instead of wrongly skipping it.
/// </summary>
private readonly AsyncLocal<bool> _persistenceLockHeld = new();
private readonly Walker _walker;
private readonly AppearanceDataAdapter _appearanceData;
@@ -203,6 +218,11 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <inheritdoc/>
public ushort Id { get; set; }
/// <summary>
/// Gets or sets a custom login result to override the default when login fails.
/// </summary>
public Views.Login.LoginResult? LoginResultOverride { get; set; }
/// <inheritdoc cref="IPartyMember" />
public string Name => this.SelectedCharacter?.Name ?? string.Empty;
@@ -712,7 +732,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
// ADAMU-CUSTOM: Castle Siege PvP gate — true while the siege phase runs on Valley of Loren (map 30).
private bool IsCastleSiegeBattleActive()
=> this.CurrentMap?.Definition.Number == 30
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.Phase == CastleSiege.CastleSiegePhase.Siege;
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.IsSiegeRunning == true;
/// <inheritdoc/>
public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
@@ -736,6 +756,25 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
return null;
}
// ADAMU-CUSTOM: Heykel Savasi PvP rules.
if (this.CurrentMiniGame is HeykelSavasiContext heykelSavasi
&& attacker is Player heykelAttacker)
{
// No player-vs-player damage before the battle actually starts (registration / 30s preparation).
if (heykelSavasi.State != MiniGameState.Playing)
{
return null;
}
// Never friendly-fire within the same team.
var heykelVictimTeam = heykelSavasi.GetTeam(this);
if (heykelVictimTeam != HeykelSavasiTeam.None
&& heykelVictimTeam == heykelSavasi.GetTeam(heykelAttacker))
{
return null;
}
}
var hitInfo = await attacker.CalculateDamageAsync(this, skill, isCombo, damageFactor).ConfigureAwait(false);
if (skill?.Skill is not { } attackSkill || attackSkill.DamageType != DamageType.Fenrir)
@@ -1142,7 +1181,13 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
{
// Older clients use a separate packet for the respawn, while newer don't.
// It requires a slightly different logic.
this.CurrentMap = await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found.");
// ADAMU-CUSTOM: when respawning inside a mini game (e.g. the TvT Event), stay on that game's OWN
// map instance - not the shared world map of the same number - otherwise the respawned player lands
// on an empty copy and can't see the statues or the other participants. Mirrors the newer-client
// path in ClientReadyAfterMapChangeAsync.
this.CurrentMap = this.CurrentMiniGame is { } respawnMiniGame
? respawnMiniGame.Map
: (await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."));
await respawnPlugIn.RespawnAsync().ConfigureAwait(false);
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
this.IsAlive = true;
@@ -1195,6 +1240,13 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
await this.CurrentMap.AddAsync(summon).ConfigureAwait(false);
summon.OnSpawn();
}
// ADAMU-CUSTOM: Heykel Savasi -> reapply the team's earned buffs after (re)spawning during battle.
if (this.CurrentMiniGame is HeykelSavasiContext heykelSavasiRebuff
&& heykelSavasiRebuff.State == MiniGameState.Playing)
{
await heykelSavasiRebuff.OnPlayerRespawnedAsync(this).ConfigureAwait(false);
}
}
/// <summary>
@@ -1609,25 +1661,26 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
var durationExtended = false;
foreach (var powerUpDef in powerUps)
{
IElement powerUp;
IElement powerUp = this.Attributes!.CreateElement(powerUpDef);
if (skillEntry.Level > 0)
{
powerUp = this.Attributes!.CreateElement(powerUpDef);
foreach (var masterSkillEntry in GetMasterSkillEntries(skillEntry))
{
var extendsDuration = masterSkillEntry.Skill?.MasterDefinition?.ExtendsDuration ?? false;
if (extendsDuration && !durationExtended)
{
durationElement = new CombinedElement(durationElement, new ConstantElement(masterSkillEntry.CalculateValue()));
var value = masterSkillEntry.CalculateValue();
if (value < 1)
{
value *= 100;
}
durationElement = new CombinedElement(durationElement, new ConstantElement(value));
durationElementPvp = new CombinedElement(durationElementPvp, new ConstantElement(value));
}
else if (extendsDuration)
if (masterSkillEntry.Skill?.MasterDefinition?.TargetAttribute is not null)
{
continue;
}
else
{
// Apply either for all, or just for the specified TargetAttribute of the master skill
powerUp = AppedMasterSkillPowerUp(masterSkillEntry, powerUpDef, powerUp);
}
}
@@ -1635,10 +1688,6 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
// After the first iteration all possible duration extensions have been applied
durationExtended = true;
}
else
{
powerUp = this.Attributes!.CreateElement(powerUpDef);
}
result[i] = (powerUpDef.TargetAttribute!, powerUp);
i++;
@@ -1657,15 +1706,12 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
IElement AppedMasterSkillPowerUp(SkillEntry masterSkillEntry, PowerUpDefinition powerUpDef, IElement powerUp)
{
if (masterSkillEntry.Skill?.MasterDefinition is not { } masterSkillDefinition)
{
return powerUp;
}
var masterSkillDefinition = masterSkillEntry.Skill!.MasterDefinition!;
if (masterSkillDefinition.TargetAttribute is { } masterSkillTargetAttribute
&& masterSkillTargetAttribute == powerUpDef.TargetAttribute)
if (masterSkillDefinition.TargetAttribute == powerUpDef.TargetAttribute
&& masterSkillDefinition.Aggregation == powerUp.AggregateType)
{
var additionalValue = new SimpleElement(masterSkillEntry.CalculateValue(), masterSkillEntry.Skill.MasterDefinition?.Aggregation ?? powerUp.AggregateType);
var additionalValue = new SimpleElement(masterSkillEntry.CalculateValue(), masterSkillDefinition.Aggregation);
powerUp = new CombinedElement(powerUp, additionalValue);
}
@@ -1733,7 +1779,6 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
area.MaximumHealthOverride = (int)monster.Attributes[Stats.MaximumHealth];
area.MaximumHealthOverride += (int)(monster.Attributes[Stats.MaximumHealth] * this.Attributes?[Stats.SummonedMonsterHealthIncrease] ?? 0);
// todo: Stats.SummonedMonsterDefenseIncrease
this.Summon = (monster, intelligence);
monster.Initialize();
await gameMap.AddAsync(monster).ConfigureAwait(false);
@@ -1849,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <returns>Success of the save operation.</returns>
public async ValueTask<bool> SaveProgressAsync(CancellationToken cancellationToken = default)
{
if (!this.IsTemplatePlayer)
if (this.IsTemplatePlayer)
{
return await this.PersistenceContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
return true;
return await this.RunPersistenceExclusiveAsync(
() => this.PersistenceContext.SaveChangesAsync(cancellationToken),
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock, so that context
/// mutations and progress saves for the player never run concurrently.
/// </summary>
/// <remarks>
/// The periodic progress save (<see cref="PlugIns.PeriodicSaveProgressPlugIn"/>) runs on an
/// independent timer flow. Action handlers mutate tracked entities with plain field/collection
/// writes (e.g. crafting toggling <c>item.ItemOptions</c>) which bypass the persistence context's
/// own lock; if such a mutation runs while <see cref="IContext.SaveChangesAsync"/> enumerates the
/// change tracker, the save throws (collection-modified / DbUpdateConcurrency) and every following
/// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel
/// and the save against each other closes that window. The lock is re-entrant per asynchronous
/// flow, so an inline save inside an already-serialized handler does not deadlock.
/// <para>
/// Invariant: never acquire another player's persistence lock (via their
/// <see cref="SaveProgressAsync"/> or <see cref="RunPersistenceExclusiveAsync{T}"/>) from inside a
/// packet handler, which already holds this player's lock, unless a global lock order is enforced.
/// Today only the trade accept does a cross-player save, and it cannot form a cycle because a trade
/// has a single accepting side (so the A-then-B acquisition order has no concurrent B-then-A
/// counterpart). A second cross-player caller with the opposite order could deadlock.
/// </para>
/// </remarks>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The result of the operation.</returns>
public async ValueTask<T> RunPersistenceExclusiveAsync<T>(Func<ValueTask<T>> operation, CancellationToken cancellationToken = default)
{
if (this._persistenceLockHeld.Value)
{
return await operation().ConfigureAwait(false);
}
using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
this._persistenceLockHeld.Value = true;
try
{
return await operation().ConfigureAwait(false);
}
finally
{
this._persistenceLockHeld.Value = false;
}
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock.
/// See <see cref="RunPersistenceExclusiveAsync{T}"/> for the rationale.
/// </summary>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A value task which completes when the operation completed.</returns>
public async ValueTask RunPersistenceExclusiveAsync(Func<ValueTask> operation, CancellationToken cancellationToken = default)
{
if (this._persistenceLockHeld.Value)
{
await operation().ConfigureAwait(false);
return;
}
using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
this._persistenceLockHeld.Value = true;
try
{
await operation().ConfigureAwait(false);
}
finally
{
this._persistenceLockHeld.Value = false;
}
}
/// <summary>
@@ -2316,6 +2435,14 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
};
}
// ADAMU-CUSTOM: Heykel Savasi -> respawn at the player's own team base while the battle is running.
if (this.CurrentMiniGame is HeykelSavasiContext heykelSavasiRespawn
&& heykelSavasiRespawn.State == MiniGameState.Playing
&& heykelSavasiRespawn.GetTeam(this) != HeykelSavasiTeam.None)
{
return heykelSavasiRespawn.GetTeamSpawnGate(heykelSavasiRespawn.GetTeam(this));
}
var spawnTargetMapDefinition = this.CurrentMap.Definition.SafezoneMap ?? this.CurrentMap.Definition;
var targetMap = await this.GameContext.GetMapAsync((ushort)spawnTargetMapDefinition.Number, false).ConfigureAwait(false);
return targetMap?.SafeZoneSpawnGate
@@ -2425,6 +2552,23 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
this._respawnAfterDeathCts = new CancellationTokenSource();
await this.ForEachWorldObserverAsync<IObjectGotKilledPlugIn>(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false);
// ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the scoreboard. Done here
// (not in AfterKilledPlayerAsync) because that PK-penalty path is intentionally skipped for mini-game
// PvP (AllowPlayerKilling == true), which would otherwise mean event kills never count.
if (killer is Player heykelKiller
&& heykelKiller.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame
&& ReferenceEquals(this.CurrentMiniGame, heykelKiller.CurrentMiniGame))
{
var killerTeam = heykelKillGame.GetTeam(heykelKiller);
var victimTeam = heykelKillGame.GetTeam(this);
if (killerTeam != victimTeam
&& killerTeam != MiniGames.HeykelSavasiTeam.None
&& victimTeam != MiniGames.HeykelSavasiTeam.None)
{
heykelKillGame.RecordKill(heykelKiller);
}
}
if (killer is Player killerAfterKilled
&& !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score)
&& this.CurrentMiniGame?.AllowPlayerKilling is not true)

View File

@@ -52,6 +52,7 @@ public class ItemStackAction
foreach (var jewel in jewels)
{
await player.Inventory.RemoveItemAsync(jewel).ConfigureAwait(false);
await player.PersistenceContext.DeleteAsync(jewel).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemRemovedPlugIn>(p => p.RemoveItemAsync(jewel.ItemSlot)).ConfigureAwait(false);
}
@@ -118,6 +119,7 @@ public class ItemStackAction
}
await player.Inventory.RemoveItemAsync(stacked).ConfigureAwait(false);
await player.PersistenceContext.DeleteAsync(stacked).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemRemovedPlugIn>(p => p.RemoveItemAsync(slot)).ConfigureAwait(false);
foreach (var freeSlot in freeSlots)
{

View File

@@ -263,18 +263,7 @@ public class MoveItemAction
if (itemDefinition.ItemSlot.ItemSlots.Contains(toSlot) &&
player.CompliesRequirements(item))
{
static bool IsOneHandedOrShield(ItemDefinition definition) =>
(definition.ItemSlot!.ItemSlots.Contains(RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(LeftHandSlot)) || definition.Group == 6;
var rightHandItemDefinition = storage.GetItem(RightHandSlot)?.Definition!;
if ((toSlot == LeftHandSlot
&& itemDefinition.Width >= 2
&& rightHandItemDefinition != null
&& !rightHandItemDefinition.IsAmmunition)
|| (toSlot == RightHandSlot
&& IsOneHandedOrShield(itemDefinition)
&& storage.GetItem(LeftHandSlot)?.Definition!.Width >= 2))
if (itemDefinition.ConflictsWithEquippedHands(storage, toSlot))
{
// Attempting to equip a two-handed item to the left hand slot when a shield is in the right hand slot,
// or trying to equip a one-handed weapon or shield to the right hand slot when a two-handed item is in the left hand slot.

View File

@@ -28,7 +28,8 @@ public class SellItemToNpcAction
/// </summary>
/// <param name="player">The player.</param>
/// <param name="slot">The slot.</param>
public async ValueTask SellItemAsync(Player player, byte slot)
/// <returns><c>True</c>, if the item was sold; otherwise, <c>false</c>.</returns>
public async ValueTask<bool> SellItemAsync(Player player, byte slot)
{
using var loggerScope = player.Logger.BeginScope(this.GetType());
var item = player.Inventory?.GetItem(slot);
@@ -36,36 +37,45 @@ public class SellItemToNpcAction
{
player.Logger.LogWarning("Player {0} requested to sell item at slot {1}, but item wasn't found.", player, slot);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
if (player.OpenedNpc?.Definition.MerchantStore is null)
{
player.Logger.LogWarning("Player {0} requested to sell item at slot {1} to an npc, but no npc merchant store is currently opened.", player, slot);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
if (item.Definition is null || (item.Definition.IsBoundToCharacter && (item.Definition.Durability == 0 || item.Durability > 0)))
{
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
await this.SellItemAsync(player, item).ConfigureAwait(false);
return await this.SellItemAsync(player, item).ConfigureAwait(false);
}
private async ValueTask SellItemAsync(Player player, Item item)
private async ValueTask<bool> SellItemAsync(Player player, Item item)
{
var sellingPrice = (int)this._itemPriceCalculator.CalculateSellingPrice(item, item.Durability());
player.Logger.LogDebug("Calculated selling price {0} for item {1}", sellingPrice, item);
if (player.TryAddMoney(sellingPrice))
if (!player.TryAddMoney(sellingPrice))
{
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
// The money doesn't fit into the inventory anymore. Without the answer the request would
// stay unanswered - the client keeps waiting, and the player gets no hint why nothing
// happened. All other refusals above already report back this way.
player.Logger.LogDebug("Item {0} not sold, the money of player {1} is at its maximum.", item, player);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return false;
}
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
return true;
}
}

View File

@@ -189,7 +189,9 @@ public class LoginAction
private async ValueTask HandleAlreadyConnectedAsync(Player player, string username)
{
await player.InvokeViewPlugInAsync<IShowLoginResultPlugIn>(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false);
var result = player.LoginResultOverride ?? LoginResult.AccountAlreadyConnected;
player.LoginResultOverride = null;
await player.InvokeViewPlugInAsync<IShowLoginResultPlugIn>(p => p.ShowLoginResultAsync(result)).ConfigureAwait(false);
if (player.GameContext is IGameServerContext gameServerContext)
{
await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false);

View File

@@ -99,6 +99,10 @@ public class EnterMiniGameAction
var entrance = miniGameDefinition.Entrance ?? throw new InvalidOperationException("mini game entrance not defined");
var miniGame = await player.GameContext.GetMiniGameAsync(miniGameDefinition, player).ConfigureAwait(false);
// Snapshot before entering: an event which disallows parties (Chaos Castle) kicks the
// entering player out of its party below, losing the knowledge of who was going to follow.
var partyBots = Bots.BotMiniGameHandler.SnapshotPartyBots(player);
var enterResult = await miniGame.TryEnterAsync(player).ConfigureAwait(false);
if (enterResult == EnterResult.Success)
{
@@ -125,6 +129,10 @@ public class EnterMiniGameAction
await player.RemoveSummonAsync().ConfigureAwait(false);
await player.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
await player.WarpToAsync(entrance).ConfigureAwait(false);
// The bots of the entering party leader follow them in (each checked against the same
// entry restrictions, no ticket needed - the leader's own ticket legitimizes the visit).
Bots.BotMiniGameHandler.BringPartyBotsAlong(player, partyBots, miniGameDefinition, miniGame);
}
else
{

View File

@@ -0,0 +1,35 @@
// <copyright file="HeykelSavasiJoinAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Player action which implements joining a team of the Heykel Savasi (statue war) event.
/// </summary>
public class HeykelSavasiJoinAction
{
/// <summary>
/// Tries to join the given player to the given team of the Heykel Savasi event, and warps the
/// player to the team's base spawn gate on success.
/// </summary>
/// <param name="player">The player who wants to join.</param>
/// <param name="team">The team which the player wants to join.</param>
/// <param name="context">The currently open Heykel Savasi context.</param>
public async ValueTask JoinAsync(Player player, HeykelSavasiTeam team, HeykelSavasiContext context)
{
if (await context.TryJoinTeamAsync(player, team).ConfigureAwait(false))
{
await player.WarpToAsync(context.GetTeamSpawnGate(team)).ConfigureAwait(false);
}
else
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p =>
p.ShowMessageAsync("You can't join a team right now (team balance/status).", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
}

View File

@@ -28,7 +28,12 @@ public class PartyRequestAction
if (toRequest.Party != null || toRequest.LastPartyRequester != null)
{
if (toRequest.Party != null && Equals(toRequest.Party.PartyMaster, toRequest))
// A server-side bot is asked as well when it is a plain member of its (bot) party: a living
// player takes precedence over the bot's own company, so it leaves that party and joins the
// inviter (see BotPartyHandler). Everyone else keeps the original rule - only the master of a
// party can answer an invitation.
var isBot = toRequest.Account?.IsBot == true;
if (toRequest.Party != null && (isBot || Equals(toRequest.Party.PartyMaster, toRequest)))
{
if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false))
{

View File

@@ -1,64 +0,0 @@
// <copyright file="ElfSoldierBuffRequestAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Quests;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Action of requesting the elf soldier buff.
/// </summary>
/// <remarks>
/// Instead of hard-coding all this stuff, we could define something like a 'RequestableBuff' in the MonsterDefinition.
/// </remarks>
public class ElfSoldierBuffRequestAction
{
private static readonly short ElfSoldierNumber = 257;
private static readonly MagicEffectDefinition BuffEffect = new SoldierBuffMagicEffectDefinition
{
InformObservers = true,
Name = "Elf Soldier Buff",
Number = 3,
StopByDeath = true,
};
/// <summary>
/// Requests the buff and adds it to the <see cref="Player.MagicEffectList"/> when the player is allowed to get it.
/// </summary>
/// <param name="player">The player.</param>
public async ValueTask RequestBuffAsync(Player player)
{
if (player.OpenedNpc is null
|| player.OpenedNpc.Definition.NpcWindow != NpcWindow.NpcDialog
|| player.OpenedNpc.Definition.Number != ElfSoldierNumber)
{
return;
}
if (player.Level > 220)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.ElfSoldierStrongEnoughMessage)).ConfigureAwait(false);
return;
}
await player.MagicEffectList.AddEffectAsync(new MagicEffect(
TimeSpan.FromMinutes(60),
BuffEffect,
new MagicEffect.ElementWithTarget(new ConstantElement(50 + (player.Level / 5), AggregateType.AddFinal), Stats.DefenseFinal),
new MagicEffect.ElementWithTarget(new ConstantElement(45 + (player.Level / 3)), Stats.GreaterDamageBonus))).ConfigureAwait(false);
}
private sealed class SoldierBuffMagicEffectDefinition : MagicEffectDefinition
{
public SoldierBuffMagicEffectDefinition()
{
this.PowerUpDefinitions = new List<PowerUpDefinition>(2);
}
}
}

View File

@@ -0,0 +1,90 @@
// <copyright file="NpcBuffRequestAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Quests;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Action which applies the <see cref="Buff"/>s of the currently opened NPC.
/// </summary>
public class NpcBuffRequestAction
{
/// <summary>
/// Requests the buffs from the opened NPC and adds them to the <see cref="Player.MagicEffectList"/>.
/// </summary>
/// <param name="player">The player.</param>
public async ValueTask RequestBuffAsync(Player player)
{
if (player.OpenedNpc?.Definition is not { NpcWindow: NpcWindow.NpcDialog, Buffs: { } buffs } || !buffs.Any())
{
return;
}
var anyApplied = false;
var anyTooLow = false;
var anyTooStrong = false;
var anyValidEffect = false;
foreach (var buff in buffs)
{
if (buff.MagicEffectDefinition is not { } effectDef)
{
continue;
}
anyValidEffect = true;
if (buff.MinimumLevel.HasValue && player.Level < buff.MinimumLevel.Value)
{
anyTooLow = true;
continue;
}
if (buff.MaximumLevel.HasValue && player.Level > buff.MaximumLevel.Value)
{
anyTooStrong = true;
continue;
}
var duration = TimeSpan.FromSeconds(effectDef.Duration?.ConstantValue?.Value ?? 0);
if (duration.TotalSeconds == 0)
{
continue;
}
var boosts = effectDef.PowerUpDefinitions
.Where(def => def.Boost is not null && def.TargetAttribute is not null)
.Select(def => new MagicEffect.ElementWithTarget(player.Attributes!.CreateElement(def), def.TargetAttribute!))
.ToArray();
if (boosts.Length == 0)
{
continue;
}
var effect = new MagicEffect(duration, effectDef, boosts);
await player.MagicEffectList.AddEffectAsync(effect).ConfigureAwait(false);
anyApplied = true;
}
if (anyApplied)
{
return;
}
if (anyValidEffect && anyTooLow)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CharacterNotStrongEnoughMessage)).ConfigureAwait(false);
return;
}
if (anyValidEffect && anyTooStrong)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CharacterTooStrongMessage)).ConfigureAwait(false);
}
}
}

Some files were not shown because too many files have changed in this diff Show More