Documentation
¶
Overview ¶
Package app implements the world bounded context use cases: entity lifecycle (add/remove/move), AOI visibility, and the 50 Hz tick loop. The WorldService owns the in-memory entity registry and the per-map AOI grids; it is the authority for on-map state.
Index ¶
- Variables
- type AddItemResult
- type CombatService
- type Dice
- type EquipService
- func (s *EquipService) Equip(ctx context.Context, accountID, charID uint32, invIndex int, position uint32) error
- func (s *EquipService) EquipmentProfile(ctx context.Context, accountID, charID uint32) (statcalc.Equipment, error)
- func (s *EquipService) Unequip(ctx context.Context, accountID, charID uint32, invIndex int) error
- type Option
- type SkillService
- type SpawnService
- func (s *SpawnService) DropItem(nameID, amount uint32, mapName string, pos domain.Position, ...) domain.FloorItem
- func (s *SpawnService) FloorItems(mapName string) []domain.FloorItem
- func (s *SpawnService) OnMobDeath(mobClass int32, mapName string, pos domain.Position, mobID domain.EntityID) []domain.FloorItem
- func (s *SpawnService) PickupFloorItem(groundID uint32) (domain.FloorItem, error)
- func (s *SpawnService) SpawnMob(mobID domain.EntityID, mobClass int32, mapName string, pos domain.Position, ...) error
- func (s *SpawnService) Stop()
- type TradeEconPort
- type TradeInventoryPort
- type TradeService
- func (s *TradeService) Ack(_ context.Context, charID uint32, accept bool) error
- func (s *TradeService) AddItem(ctx context.Context, charID uint32, invIndex, amount int) (AddItemResult, error)
- func (s *TradeService) Cancel(_ context.Context, charID uint32)
- func (s *TradeService) OK(ctx context.Context, charID uint32) (bool, error)
- func (s *TradeService) Partner(_ context.Context, charID uint32) (uint32, bool)
- func (s *TradeService) Request(_ context.Context, requesterID, targetID uint32) error
- type WorldService
- func (w *WorldService) AddEntity(e domain.Entity) error
- func (w *WorldService) EnterMap(ctx context.Context, charID uint32) (domain.Entity, error)
- func (w *WorldService) Get(id domain.EntityID) (domain.Entity, error)
- func (w *WorldService) HealPlayer(charID uint32, hpPct, spPct int) (hp, sp int32, err error)
- func (w *WorldService) LeaveMap(ctx context.Context, charID uint32) error
- func (w *WorldService) MoveEntity(id domain.EntityID, pos domain.Position) error
- func (w *WorldService) PlayersNear(mapName string, pos domain.Position) []domain.EntityID
- func (w *WorldService) PlayersOnMap(mapName string) []domain.EntityID
- func (w *WorldService) QueryVisible(mapName string, x, y int) []domain.EntityID
- func (w *WorldService) RegenTick(dt time.Duration)
- func (w *WorldService) RemoveEntity(id domain.EntityID) error
- func (w *WorldService) SetPosition(ctx context.Context, charID uint32, mapName string, pos domain.Position) error
- func (w *WorldService) StartTick(ctx context.Context, update func(ctx context.Context, dt time.Duration))
- func (w *WorldService) Stop()
- func (w *WorldService) WarpPlayer(charID uint32, mapName string, x, y int16) error
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotEquippable means the item has no equipment locations at all (Etc/etc.). ErrNotEquippable = errors.New("equip: item is not equippable") // ErrWrongSlot means the item is equippable but not into the requested position. ErrWrongSlot = errors.New("equip: item cannot go in that slot") // ErrItemNotFound means the 1-based inventory index is out of range. ErrItemNotFound = errors.New("equip: inventory index out of range") )
Equip errors are distinct sentinels so the gateway maps each to a specific S→C ack result without parsing strings (no branch on error strings).
var ( // ErrUnknownSkill is returned when the cast skill ID is not in the registry. ErrUnknownSkill = errors.New("skill not found") // ErrInvalidLevel is returned when the requested level is outside [1, MaxLevel]. ErrInvalidLevel = errors.New("invalid skill level") // ErrSkillOutOfRange is returned when the target is beyond the skill's range. ErrSkillOutOfRange = errors.New("target out of skill range") // ErrInsufficientSP is returned when the caster lacks the SP cost. ErrInsufficientSP = errors.New("insufficient SP") )
Skill cast validation errors. They are distinct sentinels so callers can branch on cause (e.g. gateway -> different deny packets) without parsing strings, per the no-branch-on-error-strings rule.
var ( ErrTradeSelf = errors.New("trade: requester is the target") ErrTradeAlreadyTrading = errors.New("trade: a party is already trading") ErrTradeTargetOffline = errors.New("trade: target is not an online player") ErrTradeDifferentMap = errors.New("trade: parties are on different maps") ErrTradeNotActive = errors.New("trade: no active trade session") ErrTradeLocked = errors.New("trade: side already locked, cannot change offer") ErrTradeItemOutOfRange = errors.New("trade: inventory index out of range") ErrTradeItemInsufficient = errors.New("trade: insufficient item or zeny") ErrTradeItemEquipped = errors.New("trade: cannot trade an equipped item") ErrTradeConcludeFailed = errors.New("trade: conclude failed, rolled back and cancelled") )
Trade state-machine errors are distinct sentinels so the gateway maps each to a specific S→C result byte without parsing strings (no branch on error strings).
Functions ¶
This section is empty.
Types ¶
type AddItemResult ¶
AddItemResult carries what the gateway emits after a successful stage: the self-ack index, the staged zeny (Index==0), or the resolved item (Index>0) for the partner's ZC_ADD_EXCHANGE_ITEM. Exactly one of Zeny/Item is meaningful per the wire convention (Index==0 is zeny).
type CombatService ¶
type CombatService struct {
// contains filtered or unexported fields
}
CombatService resolves melee attacks against entities. It builds the combat.Attacker/Defender profiles from the WorldService registry — mob defenders resolve their DEF/stats/element/size from mob_db, PC attackers fold their equipped WeaponATK and weapon element/subtype into the damage base — computes damage via the kernel's pre-renewal formula, rolls the hit/crit axes, and applies it to the defender.
func NewCombatService ¶
func NewCombatService(world *WorldService, mobs *mobdb.Registry, equip equipmentProfiler, opts ...Option) *CombatService
NewCombatService builds a combat service backed by the world registry, the mob_db registry, and (optionally) an equipment profiler. The formula set and mode are fixed at construction (pre-renewal for Thai Classic). mobs may be nil (mob defenders then resolve 0 DEF); equip may be nil (PC attackers then fight with the naked, zero-equipment baseline). The element/size modifier tables and RNG are optional: omit them for the identity-modifier, always-hit baseline (the pre-accuracy contract); the app wiring provisions all four.
func (*CombatService) Attack ¶
Attack resolves a melee hit from attackerID to defenderID and applies the damage. It returns the resolved damage (≥0) and died=true when this hit reduced the defender's HP to 0 (the orchestrator despawns/drops on death). A miss returns 0 damage and applies nothing.
The accuracy/critical axes are RNG-driven but kept OUT of the deterministic kernel (combat.NormalMelee): this service computes the hit/crit totals, rolls them against the injected Dice, and passes the outcome in as a combat.Roll. With no Dice injected (nil) the roll resolves a connecting, non-critical hit — the legacy pre-accuracy baseline — so callers that do not inject randomness stay fully deterministic.
Roll formulas (pre-renewal, mirroring rAthena battle.cpp's hit/crit calc):
- hitPct = clamp(80 + attackerHit − defenderFlee, 5, 95), where attackerHit is fs.Hit (Level+Dex) and defenderFlee is the mob's flee (Level+Agi). The hit connects when Dice.Intn(100) < hitPct.
- critRate = fs.Critical, the stored per-mille critical total (10 + Luk·10/3; the client divides by 10 for display). A critical lands when Dice.Intn(1000) < critRate. A critical bypasses flee (pre-renewal), so it always connects regardless of the hit roll (the kernel's isMiss is !Hit && !Crit).
type Dice ¶
Dice is the RNG surface the miss/critical rolls draw from: a half-open [0,n) integer. *math/rand.Rand satisfies it. A nil Dice (the legacy default) skips the rolls and resolves a connecting, non-critical hit — preserving the deterministic pre-accuracy baseline for tests that do not inject randomness.
func NewGlobalDice ¶
func NewGlobalDice() Dice
NewGlobalDice returns the production combat RNG. Combat resolves on the single 50Hz world tick; the global source is concurrency-safe regardless.
type EquipService ¶
type EquipService struct {
// contains filtered or unexported fields
}
EquipService runs the equipment use case: wear/remove gear into its EQP_* slot and build the statcalc.Equipment profile the kernel's damage/status math reads. Equipped state lives on the inventory rows (Item.Equip bitmask); the item_db Attack/Defense columns supply the weapon-ATK / armor-DEF contributions.
func NewEquipService ¶
func NewEquipService(inv equipInventory, items *itemdb.Registry) *EquipService
NewEquipService builds an EquipService backed by the inventory port and the loaded item_db registry. items may be nil (best-effort degradation: every item resolves as unknown and contributes no stats), matching the boot-time empty-registry fallback in world/di.go.
func (*EquipService) Equip ¶
func (s *EquipService) Equip(ctx context.Context, accountID, charID uint32, invIndex int, position uint32) error
Equip wears the item at the 1-based inventory index invIndex into position, the EQP_* bitmask the client requested.
Validation: an item with no equip locations (EquipLocations == 0) yields ErrNotEquippable; an equippable item whose allowed locations do not overlap position yields ErrWrongSlot. On a slot conflict — another equipped item already occupying any bit of position — the conflicting item is unequipped first, then the requested item is worn.
func (*EquipService) EquipmentProfile ¶
func (s *EquipService) EquipmentProfile(ctx context.Context, accountID, charID uint32) (statcalc.Equipment, error)
EquipmentProfile sums the equipped contributions the kernel's statcalc reads:
- WeaponATK is the Attack of every item worn in a hand slot (equip.Arms, the right/left-hand composite). Shields sit in the left hand but carry Attack 0, so they correctly add nothing.
- ItemDEF is the Defense summed over every equipped item — rAthena sums def across all equipped gear (weapon included), so a weapon's Defense (usually 0) folds in naturally.
- ItemMDEF is left at 0: item_db has no MDEF column, so equipment MDEF (which in rAthena comes from item scripts) is out of scope for this milestone.
type Option ¶
type Option func(*CombatService)
Option configures a CombatService's modifier tables and RNG source.
func WithAttributeFix ¶
WithAttributeFix injects the attr_fix element-rate table the kernel multiplies post-DEF damage by. Omit for the identity (100%) baseline.
func WithDice ¶
WithDice injects the RNG source for the miss/crit rolls. Omit (nil) for the deterministic legacy default: every hit connects and none crits.
func WithSizeFix ¶
WithSizeFix injects the size_fix weapon-type × mob-size table. Omit for the identity (100%) baseline.
type SkillService ¶
type SkillService struct {
// contains filtered or unexported fields
}
SkillService resolves a skill cast against a target entity. It validates the cast (skill known, level in range, target reachable, SP affordable), spends the SP, and for an enemy-targeted (offensive) skill resolves a melee-equivalent hit through the existing CombatService. Full skill-damage modeling — element, size, crit, and per-skill multipliers — is deliberately out of scope here and left to future work; this phase delivers a castable, visible, damage-dealing skill by reusing the proven pre-renewal melee path.
func NewSkillService ¶
func NewSkillService(world *WorldService, combat *CombatService, skills *skilldb.Registry) *SkillService
NewSkillService builds a skill service backed by the world registry, the combat service (for offensive-skill hits), and the skill_db registry. skills may be nil (every cast resolves unknown), but the app wiring provisions a non-nil registry.
func (*SkillService) UseSkillOnTarget ¶
func (s *SkillService) UseSkillOnTarget( casterID domain.EntityID, skillID int32, level int16, targetGID domain.EntityID, ) (dmg int32, died bool, err error)
UseSkillOnTarget resolves a single-target skill cast from casterID onto targetGID at the given level. It returns the resolved damage (>=0) and died=true when this cast reduced the target's HP to 0, mirroring CombatService.Attack's contract. SP is spent only on a cast that passes every gate; a failed validation or an out-of-range/insufficient-SP target spends nothing. Non-offensive skills (buffs/heals/passives) spend SP but apply no damage yet — their stat effects are TODO.
type SpawnService ¶
type SpawnService struct {
// contains filtered or unexported fields
}
SpawnService owns floor items (drops on the map) and mob spawning. Mob spawn points + mob death→drop live here; the pickup path resolves a floor item and hands it to the inventory port (injected by the gateway handler).
func NewSpawnService ¶
func NewSpawnService(world *WorldService, mobs *mobdb.Registry, items *itemdb.Registry) *SpawnService
NewSpawnService builds a SpawnService. mobs/items may be nil (no drops in tests); a nil item_db means mob drops resolve no items. The service owns a cancellable context for respawn timers; Stop drains them.
func (*SpawnService) DropItem ¶
func (s *SpawnService) DropItem(nameID, amount uint32, mapName string, pos domain.Position, dropper domain.EntityID) domain.FloorItem
DropItem places one floor item and returns it. The GroundID is unique.
func (*SpawnService) FloorItems ¶
func (s *SpawnService) FloorItems(mapName string) []domain.FloorItem
FloorItems returns a snapshot of floor items on a map (for the AOI broadcast of ZC_ITEM_ENTRY on map-enter / spawn).
func (*SpawnService) OnMobDeath ¶
func (s *SpawnService) OnMobDeath(mobClass int32, mapName string, pos domain.Position, mobID domain.EntityID) []domain.FloorItem
OnMobDeath generates floor-item drops from the mob's drop table, despawns the mob (removes it from the world registry + AOI grid), and arms a respawn timer if the mob has a registered spawn point. It returns the floor items it placed so the orchestrator (gateway) can broadcast ZC_ITEM_ENTRY / ZC_NOTIFY_VANISH. A nil mob_db or item_db yields no drops; an AegisName with no item_db match is skipped (the drop does not land).
func (*SpawnService) PickupFloorItem ¶
func (s *SpawnService) PickupFloorItem(groundID uint32) (domain.FloorItem, error)
PickupFloorItem removes a floor item by GroundID and returns it, or domain.ErrEntityNotFound if absent/already taken.
func (*SpawnService) SpawnMob ¶
func (s *SpawnService) SpawnMob(mobID domain.EntityID, mobClass int32, mapName string, pos domain.Position, name string, hp, maxHp int32, respawnDelay time.Duration) error
SpawnMob registers a mob entity in the world at the given position. mobClass is the mob_db id the combat service resolves the mob's DEF/stats by. A non-zero respawnDelay registers a respawn template so the mob re-spawns at this position after death.
func (*SpawnService) Stop ¶
func (s *SpawnService) Stop()
Stop cancels pending respawn timers. Call on world shutdown so respawn goroutines do not outlive the world.
type TradeEconPort ¶
type TradeEconPort interface {
GetZeny(ctx context.Context, charID uint32) (int32, error)
DeductZeny(ctx context.Context, charID uint32, amount int32) error
CreditZeny(ctx context.Context, charID uint32, amount int32) error
}
TradeEconPort is the narrow economy surface trade needs: read a balance (to validate staged zeny) and move zeny at conclude. economy.EconomyService satisfies it directly.
type TradeInventoryPort ¶
type TradeInventoryPort interface {
LoadByChar(ctx context.Context, accountID, charID uint32) ([]invdomain.Item, error)
Add(ctx context.Context, charID, nameID uint32, amount int) (invdomain.Item, error)
Remove(ctx context.Context, id invdomain.ItemID, amount int) error
}
TradeInventoryPort is the narrow inventory surface trade needs: load a char's bag (to resolve add-item slots and re-verify staged items at conclude), remove a staged row, and grant an item by nameID. Defining it locally keeps world/app off inventory/app; inventory.InventoryService satisfies it directly.
type TradeService ¶
type TradeService struct {
// contains filtered or unexported fields
}
TradeService runs the per-map player-to-player trade state machine: a request/ack handshake opens a session, each side stages items and zeny without touching their inventories, and pressing Ok on both sides concludes with an atomic item+zeny swap. A cancel (or any conclude failure) tears both sessions down and never duplicates or deletes items. It is safe for concurrent use: a single mutex serializes every state transition.
Isolation note: the mutex serializes trade calls, but the inventory/economy ports are independent. A drop/use on another path can still mutate a bag between this service's verify and remove at conclude — a TOCTOU window the conclude verify narrows but cannot close. The proper fix is a transactional inventory op, which does not exist yet; until then a verified-but-raced remove fails the swap and rolls back.
func NewTradeService ¶
func NewTradeService(world *WorldService, inv TradeInventoryPort, econ TradeEconPort) *TradeService
NewTradeService builds a TradeService over the world registry and the inventory/economy ports.
func (*TradeService) Ack ¶
Ack resolves the target's response to a pending request. On accept both sides move to active; on reject both sessions are torn down. The gateway already knows accept/reject (the CZ_TRADE_ACK type it parsed) and emits the matching ZC_ACK_EXCHANGE_ITEM / ZC_CANCEL_EXCHANGE_ITEM.
func (*TradeService) AddItem ¶
func (s *TradeService) AddItem(ctx context.Context, charID uint32, invIndex, amount int) (AddItemResult, error)
AddItem stages an item (invIndex>0) or zeny (invIndex==0) on charID's side of an active, unlocked trade. The inventory is not mutated; staging only records the intent. It validates the slot is in range, the item is not equipped, the staged amount (across prior stages of the same row) does not exceed the stack, and for zeny that the balance covers it. On success the gateway emits ZC_ACK_ADD_EXCHANGE_ITEM to the adder and ZC_ADD_EXCHANGE_ITEM to the partner.
func (*TradeService) Cancel ¶
func (s *TradeService) Cancel(_ context.Context, charID uint32)
Cancel tears down charID's session and its partner's. The gateway emits ZC_CANCEL_EXCHANGE_ITEM to both. A no-op when charID is not trading.
func (*TradeService) OK ¶
OK locks charID's side. When both sides are locked it runs the atomic conclude swap. Returns concluded=false while waiting for the partner (gateway emits ZC_CONCLUDE_EXCHANGE_ITEM with Who=0 to the locker, Who=1 to the partner), and concluded=true once the swap succeeds (sessions are torn down either way).
func (*TradeService) Partner ¶
Partner returns the charID the given char is currently trading with (pending or active), and whether such a session exists. The gateway resolves the partner's connection through this so trade packets reach both sides; resolve it BEFORE calling Ack/OK/Cancel, which tear the session down.
func (*TradeService) Request ¶
func (s *TradeService) Request(_ context.Context, requesterID, targetID uint32) error
Request opens a pending trade between requesterID and targetID. Both must be online PCs on the same map and neither may already be trading. On success the gateway emits ZC_REQ_EXCHANGE_ITEM to the target (carrying the requester's name/AID/level, which it resolves from the world registry).
type WorldService ¶
type WorldService struct {
// OnStatChange, when set, is invoked after RegenTick changes a player's HP/SP
// so the gateway can emit ZC_PAR_CHANGE to that player's client. charID is the
// entity's GID (char_id). nil = regen is silent (server state still advances;
// useful for tests and headless operation). Invoked off the world mutex.
OnStatChange func(charID uint32, hp, sp int32)
// contains filtered or unexported fields
}
WorldService owns the in-memory entity registry and per-map AOI grids. It is safe for concurrent use: a mutex protects the registry; the AOI grid manages its own internal synchronization. The periodic tick loop fires a caller-supplied update callback; respawn and combat advance on their own event paths, not this loop.
func NewWorldService ¶
func NewWorldService(repo domain.WorldRepository, log *slog.Logger, tickRateHz int) *WorldService
NewWorldService builds a WorldService. The tick rate is derived from tickRateHz (50 = 20 ms). The tick loop starts on StartTick and stops on Stop.
func (*WorldService) AddEntity ¶
func (w *WorldService) AddEntity(e domain.Entity) error
AddEntity registers an entity in the registry and inserts it into its map's AOI grid. Returns ErrEntityAlreadyExists if the ID is already present.
func (*WorldService) EnterMap ¶
EnterMap loads a character's enter state from the repo and registers it as a PC entity. Returns the populated entity for the map-enter response path.
func (*WorldService) HealPlayer ¶
func (w *WorldService) HealPlayer(charID uint32, hpPct, spPct int) (hp, sp int32, err error)
HealPlayer restores a player's HP and SP by hpPct/spPct percent of their maximums, clamped to [0, max], and returns the resulting values so the caller can emit ZC_PAR_CHANGE. It is the script `percentheal` builtin's world capability and satisfies the content domain's ScriptWorld port.
func (*WorldService) LeaveMap ¶
func (w *WorldService) LeaveMap(ctx context.Context, charID uint32) error
LeaveMap removes a character from the world and marks it offline.
func (*WorldService) MoveEntity ¶
MoveEntity updates an entity's position and moves it in the AOI grid.
func (*WorldService) PlayersNear ¶
PlayersNear returns the IDs of player-character entities within the AOI view range (15 cells) of pos on mapName. These are the recipients a localized map event (a move, a drop, a mob death) must reach so OTHER players see it. Mobs and NPCs are excluded because they own no client connection and never receive packets. The query reuses the AOI grid's broadcast range so a neighbor who could see an event is exactly a neighbor who is told about it.
The world mutex is held across the grid query and the type filter so the two reads form one consistent snapshot; the AOI grid's own per-tower locks are acquired underneath (world→tower order, matching AddEntity/MoveEntity).
func (*WorldService) PlayersOnMap ¶
func (w *WorldService) PlayersOnMap(mapName string) []domain.EntityID
PlayersOnMap returns the IDs of every player-character entity currently on mapName. It is the map-wide variant of PlayersNear, used when an event has no single cell anchor. PlayersNear is the preferred tight query for ordinary localized events.
func (*WorldService) QueryVisible ¶
func (w *WorldService) QueryVisible(mapName string, x, y int) []domain.EntityID
QueryVisible returns the entity IDs visible from a position on a map (within the AOI view range). This is the input to the spawn/visibility refresh path.
func (*WorldService) RegenTick ¶
func (w *WorldService) RegenTick(dt time.Duration)
RegenTick advances natural HP/SP regen by dt. It accumulates elapsed time and, when the standing interval elapses (6 s HP, 8 s SP), advances every living PC's vitals by the pre-renewal status_natural_heal amount, clamped to the max. Mobs/ NPCs and dead PCs (HP <= 0) are skipped. When OnStatChange is set it is invoked per changed PC, off the world mutex. Entity mutation takes the world mutex (combat's applyDamage uses the same lock), so regen is race-free with damage.
func (*WorldService) RemoveEntity ¶
func (w *WorldService) RemoveEntity(id domain.EntityID) error
RemoveEntity removes an entity from the registry and AOI grid.
func (*WorldService) SetPosition ¶
func (w *WorldService) SetPosition(ctx context.Context, charID uint32, mapName string, pos domain.Position) error
SetPosition persists a char's destination map + position (warp/transit). The caller sends MapMoveResponse so the client reconnects and EnterMap loads this.
func (*WorldService) StartTick ¶
func (w *WorldService) StartTick(ctx context.Context, update func(ctx context.Context, dt time.Duration))
StartTick runs the periodic game loop. Each tick fires update and blocks until ctx is cancelled or Stop is called. The composition root passes a regen callback (RegenTick); spawn runs on SpawnService's own timers and combat is event-driven. When update is nil (tests only) StartTick logs a single boot warning and returns instead of spinning the ticker for nothing.
func (*WorldService) WarpPlayer ¶
func (w *WorldService) WarpPlayer(charID uint32, mapName string, x, y int16) error
WarpPlayer persists a player's warp destination (map + tile). The caller emits ZC_NPCACK_MAPMOVE; the client reconnects and EnterMap reloads this position. It is the script `warp` builtin's world capability and satisfies the content domain's ScriptWorld port (structural — world stays free of content imports).