32 Commits

Author SHA1 Message Date
nolt
7671fe2d94 Serialize player context mutations against the periodic save
A player's progress is persisted from two unrelated flows: action
handlers triggered by incoming packets (sequential per connection) and
the periodic save, which runs on an independent timer. Action handlers
mutate tracked entities with plain field and collection writes - for
example crafting toggles item.ItemOptions, and item stacking and NPC
selling delete item rows - which bypass the persistence context's own
lock. When such a mutation runs while SaveChangesAsync enumerates the
change tracker, the save throws (collection-modified, or a
DbUpdateConcurrency "affected 0 rows" since the context has no
concurrency tokens). SaveChanges is atomic, so every following save
fails too and the whole session never persists: on relog the player
rolls back, losing progress and items.

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

Add regression tests covering mutual exclusion and re-entrancy of the
lock.
2026-08-13 08:45:38 +03:00
nolt
ebf6958068 bots: accept party invitations regardless of level gap
The reset-aware level gate in BotPartyHandler rejected nearly every party
invitation on servers with resets, because it folded reset count into the level
scale (one reset ~= 400 points against a 500 cap). A veteran player inviting a
freshly generated bot was always over the limit, so the invitation was silently
declined. Remove the gate so a bot accepts any inviter who is alive and in the
world, matching OpenMU's own party action; the situational safeguards (shopping,
revenge, mini game, pending invite, human companion) stay in place.
2026-08-13 08:45:29 +03:00
Acentech Dev
f8e856c7c6 feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled
The switches used to be held by simply standing near them, and the crown captured
by standing near it - clicking a switch only produced the client's "not implemented
yet" message. This drives both from the original interaction instead:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:44:12 +03:00
Claude
0aedeb9ce8 Rename the message so it doesn't collide with the description class
The message and the class which describes a chat command in the game
logic were both named ChatCommandInfo. A view plugin needs both, so the
name was ambiguous there and its method didn't match the interface any
more.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSpK6jkyF8ZS5nYXyGwYxA
(cherry picked from commit f3a9cb132c295863942bb0f06f40cde231e57907)
2026-07-27 01:14:05 +03:00
nolt
7325e1a1d6 Let strangers pick up party money and split it equally
Money has no owner - unlike a dropped item it can be picked up by anyone
standing on it. Deriving the drop's shares from the killer's party made a
stranger's pick up pay nothing and leave the money on the ground, destroying
zen that was previously taken and split by the picking party.

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

Covered by DroppedMoneyTest.StrangerPartySplitsPickedUpMoneyEquallyAsync and
EarningPartyReceivesMoneyByExperienceShareAsync.

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

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

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

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

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

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

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

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

(cherry picked from commit 53f25aca08e690255571f2bb6c796bb7c10934a2)
2026-07-27 00:54:16 +03:00
sven-n
e910845383 Merge pull request #841 from nolt/bot-behaviour
Make the server-side bots hold up as a population

(cherry picked from commit 88535b63a958fee9803d5fc3a4bdac5a3318d221)
2026-07-23 11:38:09 +03:00
sven-n
0e2c94b84b Merge pull request #836 from nolt/speedhack-exempt-offline-players
Don't check offline players for speedhacking

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

(cherry picked from commit 83e942e4c778a14eeade8433632a6180ef286e3f)
2026-07-23 11:38:09 +03:00
sven-n
d04cbecae3 Merge pull request #820 from nolt/feature-bots
(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
2026-07-23 11:38:08 +03:00
sven-n
d22ec20a04 Merge pull request #825 from eduardosmaniotto/feature/configurable-npc-buffs
feature: configurable npc buffs
(cherry picked from commit 6a061fcad756e644dadf3721e84e3cce2a25bceb)
2026-07-23 11:37:22 +03:00
sven-n
ed32ebdfd3 Merge pull request #827 from Rhefew/feature/ip-connection-limit
feat(plugins): implement customizable maximum concurrent connections

(cherry picked from commit 0e30df9ae22a265c3486dea1eba259567631eb87)
2026-07-23 11:35:43 +03:00
Acentech Dev
ceacc11446 feat(hs): apply mapped class buffs to team on statue break 2026-07-21 00:15:52 +03:00
Acentech Dev
712773c332 feat(hs): sequential statue spawn + guard mobs + win detection
Adds HeykelSavasiContext runtime logic for Task 4.2: per-team statue
break progress/winner tracking (extracted into a pure, testable
StatueProgressState), OnDestructibleDied wiring against the base
MiniGameContext's existing Destructible->Died auto-subscription, and
SpawnStatueAsync which spawns the next statue (561) + 4 guards (580)
via the map initializer using placeholder line-of-statues coordinates.
OnGameStartAsync now spawns each team's first statue after the warp.
2026-07-21 00:07:00 +03:00
Acentech Dev
968eb0c155 feat(hs): HeykelSavasiContext team tracking + balance rule
Adds the HeykelSavasiTeam enum (None/Red/Blue) and the HeykelSavasiContext
skeleton (subclass of MiniGameContext) with team registration
(AssignTeam/GetTeam/PlayersOf/TeamCount) and a join-balance rule
(|Red-Blue| <= 2 after the prospective join), exposed as a pure static
IsJoinAllowed for fast unit testing without constructing the full context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:59:15 +03:00
Acentech Dev
1de06d83ab fix(reset): warp the player home instead of moving him silently
Some checks failed
.NET Core / build (push) Has been cancelled
MoveHomeAsync wrote PositionX/PositionY/CurrentMap/Rotation on the character
record directly, which is a partial copy of Player.PlaceAtGateAsync: it placed
the player but skipped removing him from the map and telling the client.

Player.Position is backed by those very fields, so the coordinates jumped on the
server while the client never got a map change. The client then interpolated a
walk to the new spot and the character visibly slid across the map after a reset.

WarpToAsync does the same placement plus the map removal and the map change
notification, and it handles respawning on the same map. It is the path every
other caller uses (duel room, gate NPCs, mini games, castle siege portal).

The existing tests all ran with MoveHome = false, which is why this path was
never covered. The new test pins the notification: it fails on the old code
because MapChangeAsync is never invoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:26:04 +03:00
Acentech Dev
f135e90899 fix(CS): stop crown re-capture loop + show seal panel only to the master
Some checks failed
.NET Core / build (push) Has been cancelled
- The occupier can no longer re-register its own throne (canCapture = eligible != occupier),
  which caused a repeating 60s panel loop; only a DIFFERENT guild can contest.
- The 60s registration panel (0xB2/0x15) is now sent ONLY to the master on the crown,
  not broadcast to switch-holders/other players. Shield(0x16) + capture(0x18) still broadcast.
+1 test (15 total).
2026-07-15 16:22:13 +03:00
Acentech Dev
3dd4881406 feat(CS): authentic Crown-hold throne capture (S6 protocol) replacing Sinior-talk
Some checks failed
.NET Core / build (push) Has been cancelled
Break all gates + hold both switches (defenses down) -> the Crown shield drops
(C1 B2 16=0). The guild master then stands on the Crown (176,212) and holds for
CrownHoldDuration (default 60s, client shows a 60s countdown via C1 B2 15) to
capture; capture broadcasts C1 B2 18 + golden text and sets the occupier. Losing a
switch or leaving the crown resets the hold (contestable until the siege timer ends).
Sinior (223) is now informational guidance. +2 tests (14 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:04:10 +03:00
Acentech Dev
4651ef232c refactor(CS): schedule + periods are config-owned, AdminPanel-editable
Some checks failed
.NET Core / build (push) Has been cancelled
Move the auto-schedule from context-owned state to the plugin config (single source
of truth): RegistrationOpenDays + RegistrationOpenTimes + Registration/Preparation/
SiegeDuration are all editable in the AdminPanel plugin config and take effect live
(context refreshes its config reference each tick via UpdateConfiguration). /csschedule
now writes the config. Removed the duplicate Persisted schedule fields. 12 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:52:07 +03:00
Acentech Dev
7cf790c2a8 feat(CS): on-map siege countdown timer (S6 client protocol C1 B2 17/1E)
Some checks failed
.NET Core / build (push) Has been cancelled
Client already renders the siege-map countdown but never received the packets.
New ICastleSiegeStatusViewPlugIn + RemoteView impl send the raw S6 packets:
C1 B2 17 (battle start/stop flag, arms the countdown) and C1 B2 1E (remaining
hour/minute). CastleSiegeEventPlugIn broadcasts them to players on the battle map
every 10s during Siege and sends stop at Settlement. No client changes. +1 test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:45:01 +03:00
Acentech Dev
ac6700a356 feat(CS): weekly auto-schedule (day-of-week + UTC time), persisted, /csschedule GM cmd
Some checks failed
.NET Core / build (push) Has been cancelled
The siege can now auto-open registration on scheduled days/time instead of only
manual /csphase. Schedule is context-owned state (like owner), persisted across
restarts via the same config-JSON path. New GM command /csschedule sets/views/clears
it (e.g. /csschedule Sunday 20:00, UTC). +2 unit tests (11 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:47:29 +03:00
Acentech Dev
9bdd57703f feat(CS): persist owner/phase/registrations across restarts via plugin config JSON
Some checks failed
.NET Core / build (push) Has been cancelled
Castle Siege state was in-memory, so the castle owner (and thus the P4 hunting-map
reward) reset on every server restart/redeploy. Now the context marks itself dirty
on any persistable change (phase transition, registration, owner set); the plugin's
periodic tick writes a snapshot into its own PlugInConfiguration CustomConfiguration
(a JSON blob already stored in PostgreSQL - no schema migration) and restores it on
startup. Battle state (defenses/switches/occupier) stays transient. +2 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:17:34 +03:00
Acentech Dev
72ebc96e28 feat(CS-P3): throne registration via Sinior/Crown talk (223/216) after defenses down + both switches held; reachable defense positions
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 02:49:27 +03:00
Acentech Dev
db8bdd394f feat(CS-P3): PROPER siege - position-based Crown Switch hold (stand on them) + gates & statues destructibles + auto throne capture; remove non-working talk handlers
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 02:28:14 +03:00
Acentech Dev
c50af5e5c2 feat(CS-P3): full siege objective chain - guardian statues (Destructible spawn) + dual Crown Switch hold + throne capture gating 2026-07-15 01:52:19 +03:00
Acentech Dev
9f2078d724 feat(CS-P3): throne capture + settlement winner in state machine + tests
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 00:48:09 +03:00
Acentech Dev
73de807bef feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests 2026-07-14 22:40:24 +03:00
Acentech Dev
2ced76b303 test(ADAMU-CUSTOM): regression test for GameMap.GetNpcByNumber (remote-NPC) 2026-07-14 21:36:34 +03:00
Acentech Dev
36fc125d5c baseline: OpenMU upstream b5a0961 (fresh source) 2026-07-14 19:00:35 +03:00