Compare commits
117 Commits
a61890ef6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df338578d1 | ||
|
|
d60aad7d2f | ||
|
|
233ad41f6b | ||
|
|
72874567c6 | ||
|
|
23bc36fd65 | ||
|
|
79922204c5 | ||
|
|
f5b4292af1 | ||
|
|
074f91bd47 | ||
|
|
f050e56e91 | ||
|
|
f7f6526c16 | ||
|
|
31a5e64f40 | ||
|
|
790a101f23 | ||
|
|
52950362ff | ||
|
|
d3dd57f620 | ||
|
|
7671fe2d94 | ||
|
|
ebf6958068 | ||
|
|
0ff0707212 | ||
|
|
ede52f4176 | ||
|
|
797e17b666 | ||
|
|
d249fca935 | ||
|
|
f8e856c7c6 | ||
|
|
af46499279 | ||
|
|
6f7e58ff35 | ||
|
|
3aa9815b10 | ||
|
|
0fdb455cec | ||
|
|
452f0bb139 | ||
|
|
38fd435ff6 | ||
|
|
30b0fd1bc9 | ||
|
|
4f63b5f4f3 | ||
|
|
cb810e8b0c | ||
|
|
0aedeb9ce8 | ||
|
|
7a7e63b40e | ||
|
|
066894c4f6 | ||
|
|
4f83b72de8 | ||
|
|
960664c1c3 | ||
|
|
6d473a4cf6 | ||
|
|
5e33f16cdd | ||
|
|
e3c2012ddb | ||
|
|
ddc9482809 | ||
|
|
00be181d5c | ||
|
|
b884474fa6 | ||
|
|
7166513681 | ||
|
|
7325e1a1d6 | ||
|
|
d7c589fc45 | ||
|
|
6307ecfc24 | ||
|
|
9e0bcede2f | ||
|
|
d03518deac | ||
|
|
67cc57cf31 | ||
|
|
e910845383 | ||
|
|
eb4c05eda2 | ||
|
|
b854565586 | ||
|
|
0e2c94b84b | ||
|
|
215f48bfc9 | ||
|
|
0159849335 | ||
|
|
93050d5276 | ||
|
|
dee0e014b1 | ||
|
|
54bdf901b8 | ||
|
|
d04cbecae3 | ||
|
|
8baf329654 | ||
|
|
30feae5a26 | ||
|
|
d22ec20a04 | ||
|
|
ed32ebdfd3 | ||
|
|
77df5d1145 | ||
|
|
e9aec9540f | ||
|
|
a76647265c | ||
|
|
107513aeb0 | ||
|
|
49ffa5bd89 | ||
|
|
538189ea7c | ||
|
|
586590e06f | ||
|
|
a2119b272e | ||
|
|
bd5cda12dd | ||
|
|
0f9db37a92 | ||
|
|
a76708c899 | ||
|
|
ae68e15f77 | ||
|
|
d41d46f24f | ||
|
|
7312b0e20d | ||
|
|
e45ad4f8d7 | ||
|
|
fcf2e80570 | ||
|
|
1e8478fe70 | ||
|
|
7aae9dff0c | ||
|
|
5750ab6511 | ||
|
|
a174c1a29f | ||
|
|
583df657fb | ||
|
|
917831e198 | ||
|
|
790d944757 | ||
|
|
e6e93450b7 | ||
|
|
e0fafe7e03 | ||
|
|
b674e5a2cb | ||
|
|
5f03a011db | ||
|
|
83cb9b9fe4 | ||
|
|
89aef08bed | ||
|
|
ceacc11446 | ||
|
|
712773c332 | ||
|
|
32e05e2bc1 | ||
|
|
372929a8d1 | ||
|
|
cd55e726f1 | ||
|
|
7e954b610f | ||
|
|
2efe1ef84d | ||
|
|
f5e1f46444 | ||
|
|
cc0b7abb44 | ||
|
|
5bc5f461e5 | ||
|
|
8295c30b71 | ||
|
|
968eb0c155 | ||
|
|
5d0f475472 | ||
|
|
b90013c4bb | ||
|
|
732196f623 | ||
|
|
5a354fb419 | ||
|
|
3d5d40b753 | ||
|
|
1cadaa08ab | ||
|
|
3e8cd8fdae | ||
|
|
affa4bd73b | ||
|
|
fc9ba0e594 | ||
|
|
d84b29eac3 | ||
|
|
80ec466ab1 | ||
|
|
e3d37467d5 | ||
|
|
7e21fc1fb3 | ||
|
|
4628902f11 |
@@ -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
327
docs/Bots.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
18
docs/Packets/C1-F5-00-ChatCommandListRequest_by-client.md
Normal file
18
docs/Packets/C1-F5-00-ChatCommandListRequest_by-client.md
Normal 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 |
|
||||
19
docs/Packets/C1-FA-HeykelSavasiOpenTeamPanel_by-server.md
Normal file
19
docs/Packets/C1-FA-HeykelSavasiOpenTeamPanel_by-server.md
Normal 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 |
|
||||
18
docs/Packets/C1-FA-HeykelSavasiTeamSelect_by-client.md
Normal file
18
docs/Packets/C1-FA-HeykelSavasiTeamSelect_by-client.md
Normal 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. |
|
||||
24
docs/Packets/C1-FB-HeykelSavasiHudState_by-server.md
Normal file
24
docs/Packets/C1-FB-HeykelSavasiHudState_by-server.md
Normal 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. |
|
||||
30
docs/Packets/C1-FC-HeykelSavasiTeamRoster_by-server.md
Normal file
30
docs/Packets/C1-FC-HeykelSavasiTeamRoster_by-server.md
Normal 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. |
|
||||
32
docs/Packets/C1-FD-HeykelSavasiScoreboard_by-server.md
Normal file
32
docs/Packets/C1-FD-HeykelSavasiScoreboard_by-server.md
Normal 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. |
|
||||
60
docs/Packets/C2-F5-01-AvailableChatCommand_by-server.md
Normal file
60
docs/Packets/C2-F5-01-AvailableChatCommand_by-server.md
Normal 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. |
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
34
docs/superpowers/specs/2026-07-19-poison-wings-design.md
Normal file
34
docs/superpowers/specs/2026-07-19-poison-wings-design.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Add Poison Wing & Poison Cape — Design
|
||||
|
||||
**Date:** 2026-07-19
|
||||
**Status:** Approved
|
||||
**Scope:** Add TWO wing items (basic: render + equippable + base defense). Source pack: `C:\Users\efpa\Downloads\asa devilzmu\asa devilzmu\6` (Wing241, Wing242).
|
||||
|
||||
## Items
|
||||
|
||||
| Item | Slot (group/number) | Model | Name | Classes |
|
||||
|------|---------------------|-------|------|---------|
|
||||
| Wing241 | 12 / 240 | MODEL_WING+240 | Poison Wing | Dark Knight |
|
||||
| Wing242 | 12 / 241 | MODEL_WING+241 | Poison Cape | Dark Lord + Rage Fighter |
|
||||
|
||||
Wings are item group 12; `MODEL_WING = MODEL_ITEM + ITEM_WING (12*512)`, so item (12, N) auto-maps to model `MODEL_WING + N`. Numbers 240/241 are high/free in standard S6. Model files `Wing241.bmd`/`Wing242.bmd` load at `MODEL_WING+240`/`+241` (filename `Wing{N}` where N=number+1).
|
||||
|
||||
## Client (vendor/MuMain → D:\AdaMu\AdaMu)
|
||||
|
||||
1. `ZzzOpenData.cpp OpenItems()`: `AccessModel(MODEL_WING+240, L"Data\\Item\\", L"Wing", 241)` and `+241 → "Wing242"`. `OpenItemTextures()`: `OpenTexture(MODEL_WING+240/241, L"Item\\")`.
|
||||
2. Copy `Wing241.bmd` + its texture, `Wing242.bmd` + its texture → `Data\Item\`. Extract the BMD's referenced texture names (decrypt) and copy the matching files (the pack ships `.ozt`/`.tga`; the engine loads the encrypted `.OZT`/`.OZJ`, so provide those; missing effect textures now log instead of blocking).
|
||||
3. `Item_eng.bmd`: add name/stat records at slots 12/240 ("Poison Wing", DK) and 12/241 ("Poison Cape", DL+RF), via the 84-byte-record binary patcher (see `android-add-new-weapon-item`). Slot field = wing slot.
|
||||
4. Rebuild `main.exe` + deploy.
|
||||
|
||||
## Server (AdamuSw)
|
||||
|
||||
5. `VersionSeasonSix/Items/Wings.cs`: new method `CreatePoisonWings()` with two `CreateWing` calls — `CreateWing(240, W, H, "Poison Wing", ...DK flag...)` and `CreateWing(241, W, H, "Poison Cape", ...DL+RF flags...)`, basic defense + level table, minimal options. W/H from the item icon size (default 12x12 or per pack).
|
||||
6. Register in `Wings.Initialize()` (fresh seed) + update-plugin `AddPoisonWingsUpdateSeason6.cs` (UpdateVersion=98, idempotency guard on 12/240).
|
||||
7. `docker build` + apply via AdminPanel → Updates (existing DB) / auto on fresh seed.
|
||||
|
||||
## Verify
|
||||
|
||||
Give the wings to a character (AdminPanel/inventory edit), equip in the wing slot, confirm: correct name/stats in tooltip (from Item_eng.bmd), 3D model renders on the character's back, class restriction enforced.
|
||||
|
||||
## Out of scope
|
||||
Wing options (excellent), flight, per-level ancient/socket bonuses, balance. Just render + equip + base defense.
|
||||
@@ -1,12 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<!--
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
<BlazoredToasts />
|
||||
-->
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<BreadcrumbNavigation />
|
||||
|
||||
@@ -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
|
||||
|
||||
30
src/DataModel/Configuration/Buff.cs
Normal file
30
src/DataModel/Configuration/Buff.cs
Normal 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; }
|
||||
}
|
||||
152
src/DataModel/Configuration/CastleSiegeConfiguration.cs
Normal file
152
src/DataModel/Configuration/CastleSiegeConfiguration.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
36
src/DataModel/Configuration/CastleSiegeJoinSide.cs
Normal file
36
src/DataModel/Configuration/CastleSiegeJoinSide.cs
Normal 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,
|
||||
}
|
||||
55
src/DataModel/Configuration/CastleSiegeNpcDefinition.cs
Normal file
55
src/DataModel/Configuration/CastleSiegeNpcDefinition.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
61
src/DataModel/Configuration/CastleSiegeState.cs
Normal file
61
src/DataModel/Configuration/CastleSiegeState.cs
Normal 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,
|
||||
}
|
||||
40
src/DataModel/Configuration/CastleSiegeStateScheduleEntry.cs
Normal file
40
src/DataModel/Configuration/CastleSiegeStateScheduleEntry.cs
Normal 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 (0–23) at which this state transition occurs.
|
||||
/// </summary>
|
||||
public byte Hour { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minute (0–59) 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}";
|
||||
}
|
||||
}
|
||||
40
src/DataModel/Configuration/CastleSiegeUpgradeDefinition.cs
Normal file
40
src/DataModel/Configuration/CastleSiegeUpgradeDefinition.cs
Normal 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 (0–3), 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}";
|
||||
}
|
||||
}
|
||||
31
src/DataModel/Configuration/CastleSiegeUpgradeType.cs
Normal file
31
src/DataModel/Configuration/CastleSiegeUpgradeType.cs
Normal 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,
|
||||
}
|
||||
40
src/DataModel/Configuration/CastleSiegeZoneDefinition.cs
Normal file
40
src/DataModel/Configuration/CastleSiegeZoneDefinition.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -38,4 +38,9 @@ public enum MiniGameType
|
||||
/// The doppelganger event.
|
||||
/// </summary>
|
||||
Doppelganger,
|
||||
|
||||
/// <summary>
|
||||
/// The heykel savasi event.
|
||||
/// </summary>
|
||||
HeykelSavasi,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
67
src/DataModel/Entities/CastleSiegeData.cs
Normal file
67
src/DataModel/Entities/CastleSiegeData.cs
Normal 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 (0–3).
|
||||
/// </summary>
|
||||
public byte TaxChaos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the personal store tax rate applied by the castle owner (0–3).
|
||||
/// </summary>
|
||||
public byte TaxStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry fee (in Zen) for the castle owner's hunt zone (0–300000).
|
||||
/// </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";
|
||||
}
|
||||
}
|
||||
44
src/DataModel/Entities/CastleSiegeGuildRegistration.cs
Normal file
44
src/DataModel/Entities/CastleSiegeGuildRegistration.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
52
src/DataModel/Entities/CastleSiegeNpcState.cs
Normal file
52
src/DataModel/Entities/CastleSiegeNpcState.cs
Normal 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 (0–3).
|
||||
/// </summary>
|
||||
public byte DefenseLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current HP regeneration upgrade level (0–3).
|
||||
/// </summary>
|
||||
public byte RegenLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current maximum HP upgrade level (0–3).
|
||||
/// </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})";
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
167
src/GameLogic/Bots/BotConfiguration.cs
Normal file
167
src/GameLogic/Bots/BotConfiguration.cs
Normal 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();
|
||||
}
|
||||
283
src/GameLogic/Bots/BotEquipmentHandler.cs
Normal file
283
src/GameLogic/Bots/BotEquipmentHandler.cs
Normal 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);
|
||||
}
|
||||
709
src/GameLogic/Bots/BotFeaturePlugIn.cs
Normal file
709
src/GameLogic/Bots/BotFeaturePlugIn.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
629
src/GameLogic/Bots/BotGenerator.cs
Normal file
629
src/GameLogic/Bots/BotGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
383
src/GameLogic/Bots/BotJewelHandler.cs
Normal file
383
src/GameLogic/Bots/BotJewelHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
316
src/GameLogic/Bots/BotManager.cs
Normal file
316
src/GameLogic/Bots/BotManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
351
src/GameLogic/Bots/BotMasterHandler.cs
Normal file
351
src/GameLogic/Bots/BotMasterHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
177
src/GameLogic/Bots/BotMiniGameHandler.cs
Normal file
177
src/GameLogic/Bots/BotMiniGameHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
212
src/GameLogic/Bots/BotMuHelperSettings.cs
Normal file
212
src/GameLogic/Bots/BotMuHelperSettings.cs
Normal 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;
|
||||
}
|
||||
92
src/GameLogic/Bots/BotNameGenerator.cs
Normal file
92
src/GameLogic/Bots/BotNameGenerator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
1841
src/GameLogic/Bots/BotNavigator.cs
Normal file
1841
src/GameLogic/Bots/BotNavigator.cs
Normal file
File diff suppressed because it is too large
Load Diff
207
src/GameLogic/Bots/BotPartyHandler.cs
Normal file
207
src/GameLogic/Bots/BotPartyHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/GameLogic/Bots/BotPlayer.cs
Normal file
115
src/GameLogic/Bots/BotPlayer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
496
src/GameLogic/Bots/BotProgression.cs
Normal file
496
src/GameLogic/Bots/BotProgression.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/GameLogic/Bots/BotPvpRules.cs
Normal file
69
src/GameLogic/Bots/BotPvpRules.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
218
src/GameLogic/Bots/BotResetHandler.cs
Normal file
218
src/GameLogic/Bots/BotResetHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/GameLogic/Bots/BotRevengePlugIn.cs
Normal file
40
src/GameLogic/Bots/BotRevengePlugIn.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/GameLogic/Bots/BotSelfDefensePlugIn.cs
Normal file
36
src/GameLogic/Bots/BotSelfDefensePlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/GameLogic/Bots/BotServerPartition.cs
Normal file
195
src/GameLogic/Bots/BotServerPartition.cs
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
555
src/GameLogic/Bots/BotShoppingHandler.cs
Normal file
555
src/GameLogic/Bots/BotShoppingHandler.cs
Normal 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;
|
||||
}
|
||||
174
src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
Normal file
174
src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
263
src/GameLogic/Bots/BotWingHandler.cs
Normal file
263
src/GameLogic/Bots/BotWingHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/GameLogic/Bots/PendingPartyInvite.cs
Normal file
13
src/GameLogic/Bots/PendingPartyInvite.cs
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
20
src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
Normal file
20
src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
Normal 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,
|
||||
}
|
||||
55
src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
Normal file
55
src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
Normal 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;
|
||||
}
|
||||
23
src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
Normal file
23
src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
Normal 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,
|
||||
}
|
||||
92
src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
Normal file
92
src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
// 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))
|
||||
{
|
||||
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
|
||||
await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
|
||||
{
|
||||
if (context.Phase != CastleSiegePhase.Siege)
|
||||
if (!context.IsSiegeRunning)
|
||||
{
|
||||
return "The siege is not running yet.";
|
||||
}
|
||||
|
||||
if (context.DefensesRemaining > 0)
|
||||
{
|
||||
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,52 +86,17 @@ 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
this._availableToPick = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
12
src/GameLogic/ExperienceShare.cs
Normal file
12
src/GameLogic/ExperienceShare.cs
Normal 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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
src/GameLogic/IHasIpAddress.cs
Normal file
16
src/GameLogic/IHasIpAddress.cs
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
1426
src/GameLogic/MiniGames/HeykelSavasiContext.cs
Normal file
1426
src/GameLogic/MiniGames/HeykelSavasiContext.cs
Normal file
File diff suppressed because it is too large
Load Diff
26
src/GameLogic/MiniGames/HeykelSavasiTeam.cs
Normal file
26
src/GameLogic/MiniGames/HeykelSavasiTeam.cs
Normal 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,
|
||||
}
|
||||
223
src/GameLogic/MoneyDistribution.cs
Normal file
223
src/GameLogic/MoneyDistribution.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/GameLogic/MoneyShare.cs
Normal file
12
src/GameLogic/MoneyShare.cs
Normal 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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
using var l = await this._distributionLock.LockAsync();
|
||||
try
|
||||
/// <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)
|
||||
{
|
||||
// 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());
|
||||
this._distributionList.AddRange(
|
||||
this._partyMembers.OfType<Player>()
|
||||
.Where(p => p.CurrentMap == killer.CurrentMap
|
||||
&& !p.IsAtSafezone()
|
||||
&& p.Attributes is { }));
|
||||
|
||||
if (this._distributionList.Count == 0)
|
||||
{
|
||||
return;
|
||||
_ = MoneyDistribution.TryPayShares(shares, player => this.IsEligibleForMoney(player, killer));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
var moneyPart = amount / this._distributionList.Count;
|
||||
foreach (var player in this._distributionList)
|
||||
/// <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)
|
||||
{
|
||||
player.TryAddMoney((int)(moneyPart * player.Attributes![Stats.MoneyAmountRate]));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._distributionList.Clear();
|
||||
}
|
||||
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
|
||||
|
||||
var normalExperience = (int)(perLevel
|
||||
* attributes[Stats.Level]
|
||||
* player.GameContext.ExperienceRate
|
||||
* (attributes[Stats.ExperienceRate] + attributes[Stats.BonusExperienceRate]));
|
||||
|
||||
await player.AddExperienceAsync(exp, killed).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
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()
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
else if (extendsDuration)
|
||||
var value = masterSkillEntry.CalculateValue();
|
||||
if (value < 1)
|
||||
{
|
||||
continue;
|
||||
value *= 100;
|
||||
}
|
||||
else
|
||||
|
||||
durationElement = new CombinedElement(durationElement, new ConstantElement(value));
|
||||
durationElementPvp = new CombinedElement(durationElementPvp, new ConstantElement(value));
|
||||
}
|
||||
|
||||
if (masterSkillEntry.Skill?.MasterDefinition?.TargetAttribute is not null)
|
||||
{
|
||||
// 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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user