app

package
v0.1.0-beta.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: GPL-3.0 Imports: 21 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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).

View Source
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.

View Source
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

type AddItemResult struct {
	Index uint16
	Zeny  int32
	Item  invdomain.Item
}

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

func (c *CombatService) Attack(attackerID, defenderID domain.EntityID) (int32, bool, error)

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

type Dice interface {
	Intn(n int) int
}

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.

func (*EquipService) Unequip

func (s *EquipService) Unequip(ctx context.Context, accountID, charID uint32, invIndex int) error

Unequip removes the item at the 1-based inventory index from its slot.

type Option

type Option func(*CombatService)

Option configures a CombatService's modifier tables and RNG source.

func WithAttributeFix

func WithAttributeFix(t *attrfix.RateTable) Option

WithAttributeFix injects the attr_fix element-rate table the kernel multiplies post-DEF damage by. Omit for the identity (100%) baseline.

func WithDice

func WithDice(d Dice) Option

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

func WithSizeFix(t *sizefix.SizeTable) Option

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

func (s *TradeService) Ack(_ context.Context, charID uint32, accept bool) error

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

func (s *TradeService) OK(ctx context.Context, charID uint32) (bool, error)

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

func (s *TradeService) Partner(_ context.Context, charID uint32) (uint32, bool)

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

func (w *WorldService) EnterMap(ctx context.Context, charID uint32) (domain.Entity, error)

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) Get

Get returns a copy of the entity by ID.

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

func (w *WorldService) MoveEntity(id domain.EntityID, pos domain.Position) error

MoveEntity updates an entity's position and moves it in the AOI grid.

func (*WorldService) PlayersNear

func (w *WorldService) PlayersNear(mapName string, pos domain.Position) []domain.EntityID

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) Stop

func (w *WorldService) Stop()

Stop signals the tick loop to drain.

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL