Documentation
¶
Overview ¶
Package app seeding: the WorldSeeder turns a compiled script corpus into live world entities — dialog NPCs, shop NPCs, and mob spawns — so a production boot has a populated world instead of an empty grid.
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
- func NewScriptInventoryAdapter(inv *invapp.InventoryService, equip scriptInvEquip, ...) contentdomain.ScriptInventory
- type AddItemResult
- type CombatService
- type Dice
- type EquipService
- func (s *EquipService) Equip(ctx context.Context, accountID, charID uint32, serverRow 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, serverRow int) error
- type ItemUseAck
- type ItemUseService
- type LevelingService
- type MobAIService
- type Option
- type SkillService
- func (s *SkillService) LearnSkill(ctx context.Context, charID uint32, skillID int32) (newLevel int16, spCost int32, rng int16, upgradable bool, err error)
- func (s *SkillService) SetTree(tree *skilltree.Registry)
- func (s *SkillService) UseSkillOnTarget(casterID domain.EntityID, skillID int32, level int16, ...) (dmg int32, died bool, err error)
- 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) MobExp(mobClass int32) (base, job uint64)
- 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) PortalAt(mapName string, x, y int) (script.WarpDef, bool)
- func (s *SpawnService) RegisterPortals(defs []script.WarpDef)
- func (s *SpawnService) SpawnMob(mobID domain.EntityID, mobClass int32, mapName string, pos domain.Position, ...) error
- func (s *SpawnService) Stop()
- type StorageInventoryPort
- type StorageMoveResult
- type StoragePort
- type StorageService
- func (s *StorageService) LoadWarehouse(ctx context.Context, accountID uint32) ([]storagedomain.StorageItem, error)
- func (s *StorageService) MoveFromStorage(ctx context.Context, accountID, charID, wireIndex uint32, amount int) (StorageMoveResult, error)
- func (s *StorageService) MoveToStorage(ctx context.Context, accountID, charID, wireIndex uint32, amount int) (StorageMoveResult, error)
- 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, serverRow, clientIndex, amount int) (AddItemResult, error)
- func (s *TradeService) AddZeny(ctx context.Context, charID uint32, 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 WorldSeeder
- type WorldService
- func (w *WorldService) AddEntity(e domain.Entity) error
- func (w *WorldService) AddVitals(charID uint32, hp, sp int32) (hpAfter, spAfter int32, err error)
- func (w *WorldService) AllocateStat(charID uint32, stat string) (newVal uint32, remaining uint32, cost uint32, err error)
- func (w *WorldService) ArmRespawn(charID uint32, delay time.Duration)
- func (w *WorldService) CancelRespawn(charID uint32)
- func (w *WorldService) Checkpoint(ctx context.Context)
- 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) GrantExp(ctx context.Context, charID uint32, baseExp, jobExp uint64) (newBase, newJob uint64, err error)
- func (w *WorldService) HealPlayer(charID uint32, hpPct, spPct int) (hp, sp int32, err error)
- func (w *WorldService) LearnSkill(charID uint32, skillID int32, level int16) 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) PlayerByAccount(accountID uint32) (domain.Entity, bool)
- func (w *WorldService) PlayerByName(name string) (domain.Entity, bool)
- 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) RelocatePlayer(charID uint32, mapName string, x, y int16) error
- func (w *WorldService) RemoveEntity(id domain.EntityID) error
- func (w *WorldService) RespawnPlayer(charID uint32) error
- func (w *WorldService) SaveAll(ctx context.Context)
- func (w *WorldService) SetFacing(charID uint32, dir uint8, head uint16) error
- func (w *WorldService) SetLeveling(svc *LevelingService)
- func (w *WorldService) SetPosition(ctx context.Context, charID uint32, mapName string, pos domain.Position) error
- func (w *WorldService) SetSitting(charID uint32, sitting bool) error
- func (w *WorldService) StartCheckpoint(ctx context.Context, interval time.Duration)
- 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 inventory row is out of range. ErrItemNotFound = errors.New("equip: inventory row 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 ( // ErrItemUseNotFound means the inventory row is out of range. ErrItemUseNotFound = errors.New("itemuse: inventory row out of range") // ErrNotUsable means the item is not a usable/healing type (the client // should not send CZ_USE_ITEM for it, but this rejects it defensively). ErrNotUsable = errors.New("itemuse: item is not usable") )
Item-use 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 ( // ErrNoSkillPoints is returned when the character has no skill points left. ErrNoSkillPoints = errors.New("no skill points") // ErrSkillNotInTree is returned when the skill is not available for the // character's job (not in skill_tree.yml for that job). ErrSkillNotInTree = errors.New("skill not in job tree") // ErrSkillMaxLevel is returned when the skill is already at its effective // maximum level (tree MaxLevel or character job level cap). ErrSkillMaxLevel = errors.New("skill at max level") // ErrSkillLocked is returned when the tree entry's BaseLevel/JobLevel // thresholds or prerequisite skills are not yet met (rAthena's // pc_calc_skilltree unlock gate). ErrSkillLocked = errors.New("skill locked by tree requirements") )
Learn-skill gate errors. These are separate from cast errors because the gateway layer drops failures silently (no reply packet).
var ( ErrStorageIndexOutOfRange = errors.New("storage: index out of range") ErrStorageInsufficient = errors.New("storage: insufficient item amount") ErrStorageEquipped = errors.New("storage: cannot store an equipped item") ErrStorageRowMissing = errors.New("storage: row missing between load and remove") ErrStorageFull = errors.New("storage: warehouse is full") ErrStorageNotOpen = errors.New("storage: warehouse not open") )
Storage errors. Distinct sentinels so the gateway maps each to a ZC_STOREITEMLISTRESULT result byte (0=success, 1=failure) without parsing strings.
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 ¶
func NewScriptInventoryAdapter ¶
func NewScriptInventoryAdapter(inv *invapp.InventoryService, equip scriptInvEquip, chars scriptInvAccountResolver) contentdomain.ScriptInventory
NewScriptInventoryAdapter builds a contentdomain.ScriptInventory over the inventory service, the equip port, and the character repository (for accountID resolution).
Types ¶
type AddItemResult ¶
type AddItemResult struct {
// Index is the WIRE index to echo in the ack (the client index the adder
// sent), NOT a server row: the gateway echoes it verbatim so the client can
// match the ack to its own row. A zeny stage leaves it at the sent value.
Index uint16
Zeny int32
Item invdomain.Item
// IsZeny distinguishes a zeny stage from an item stage. It replaces the old
// `Index == 0` test, which became ambiguous once inventory row 0 became
// addressable (Phase 42) and which never matched rAthena anyway: ZC_ADD_
// EXCHANGE_ITEM carries no index, and rAthena signals zeny by sending an
// all-zero item (clif_tradeadditem `if(index){...}else{ p = {}; }`,
// clif.cpp:4773-4798) with the amount set.
IsZeny bool
}
AddItemResult carries what the gateway emits after a successful stage: the wire index to echo in the self-ack, the staged zeny, or the resolved item for the partner's ZC_ADD_EXCHANGE_ITEM. IsZeny selects which of Zeny/Item is meaningful.
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, serverRow int, position uint32) error
Equip wears the item at server row serverRow (0-based, an index into the LoadByChar list) 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 ItemUseAck ¶
ItemUseAck reports a use-item outcome for the S→C ack: the resolved item_db id, the stack count remaining after consuming one unit (0 if the row was deleted), and the HP/SP actually restored (0 for non-healing items). ItemID/Remaining feed ZC_USE_ITEM_ACK2; the healed amounts drive the separate ZC_PAR_CHANGE.
type ItemUseService ¶
type ItemUseService struct {
// contains filtered or unexported fields
}
ItemUseService runs the usable-item (potion) use case: consume one unit of an item at server row serverRow (0-based, an index into the LoadByChar list) and apply its effects. Healing items (itemheal script) restore flat HP/SP through the vitals port; non-healing usable items are consumed but apply no vitals here (buffs/status are deferred). Item state lives on the inventory rows; the item_db Heal() ranges supply the restore amounts.
func NewItemUseService ¶
func NewItemUseService(inv itemUseInventory, items *itemdb.Registry, world vitals) *ItemUseService
NewItemUseService builds an ItemUseService backed by the inventory port, the loaded item_db registry, and the world vitals port. items may be nil (best-effort degradation: every item resolves as unknown), matching the boot-time empty-registry fallback in world/di.go.
func (*ItemUseService) Use ¶
func (s *ItemUseService) Use(ctx context.Context, accountID, charID uint32, serverRow int) (ItemUseAck, error)
Use consumes one unit of the item at server row serverRow (0-based) and applies its effects.
Healing items (itemheal script) restore a rolled [hpMin,hpMax]/[spMin,spMax] amount through the vitals port — deterministic for fixed-range potions where min==max. Non-healing usable items are consumed but apply no vitals here.
Consume-before-heal order: the item is removed first (inv.Remove), then the vitals are applied. A zero/missing stack surfaces ErrInsufficientAmount / ErrItemNotFound before any heal; an AddVitals failure after the remove loses the unit — acceptable since heal failures are rare and the alternative (heal-then-remove) can duplicate the item on a mid-way crash.
type LevelingService ¶
type LevelingService struct {
// contains filtered or unexported fields
}
LevelingService handles the conversion of accumulated EXP into base levels.
func NewLevelingService ¶
func NewLevelingService(world *WorldService, curve *jobexp.Registry, stats *jobbasepoints.Registry, log *slog.Logger) *LevelingService
NewLevelingService builds the EXP→level converter. A nil curve or stats registry (the loader fallback when job_exp.yml/job_basepoints.yml are unreadable) leaves leveling disabled: EXP still accrues, thresholds are never consumed.
func (*LevelingService) CheckJobLevelUp ¶
CheckJobLevelUp is called after job EXP accrual to determine if the entity job-levels up. It consumes job EXP and increments the character's SkillPoint counter (+1 per job level, pre-re Novice convention). Returns the number of job levels gained.
func (*LevelingService) CheckLevelUp ¶
CheckLevelUp is called after EXP accrual to determine if the entity levels up. It consumes EXP and updates vitals. Returns the number of levels gained.
type MobAIService ¶
type MobAIService struct {
// OnMobAttack, when set, is invoked after a mob swings at a player (the attack
// resolved without error), carrying the dealt damage and whether the hit
// killed the target. It is the notify sink that mirrors world.OnStatChange:
// the gateway sets it to emit ZC_NOTIFY_ACT so the player (and AOI neighbors)
// see the mob land a hit and the player's HP bar drops. nil = silent/headless
// (tests that only assert HP deltas leave it unset). Called off the world lock,
// after Attack has applied its damage, on the same goroutine as MonsterTick.
OnMobAttack func(mobID, targetID domain.EntityID, dmg int32, died bool)
// OnMobMove, when set, is invoked after a mob takes one chase step toward a
// target (MoveEntity reseated it in the AOI grid), carrying the from/to cells
// and the mob_db WalkSpeed (ms per cell; the gateway converts to kRO units
// when it builds the UnitWalkingResponse broadcast). It mirrors OnMobAttack:
// the gateway sets it to emit ZC_UNIT_WALKING to the mob's AOI neighbors so
// players see the mob walk. nil = silent/headless (tests that only assert
// cell deltas leave it unset). Called off the world lock, after MoveEntity, on
// the same goroutine as MonsterTick.
OnMobMove func(mobID domain.EntityID, from, to domain.Position, speed int16)
// contains filtered or unexported fields
}
MobAIService drives mob behavior on the world tick. It mirrors the CombatService/SpawnService pattern: constructed against the world registry, the mob_db registry, and the combat service, with MonsterTick invoked from the 50 Hz loop alongside RegenTick.
An aggressive mob (mob_db Ai>=aggressiveMobAi) acquires the nearest player within its ChaseRange (Chebyshev distance, matching RO's cell/grid adjacency), keeps it while the player stays within ChaseRange and alive, and swings every mobAttackInterval when the player is within the mob's AttackRange. When the target is within ChaseRange but outside AttackRange, the mob pursues: one greedy step toward the target every WalkSpeed ms (mob_db, ms per cell) until it closes to AttackRange, then swings. The chase step is a bounded greedy step-toward, not A* pathfinding: rAthena's real pathing is far richer (walkable-cell maps, obstacle avoidance), but the current world model is an open field, so a greedy step is correct and obstacle-aware pathing is deferred. Passive mobs idle.
func NewMobAIService ¶
func NewMobAIService(world *WorldService, mobs *mobdb.Registry, combat *CombatService, log *slog.Logger) *MobAIService
NewMobAIService builds a MobAIService. mobs/combat mirror the CombatService construction (mobs may be nil in tests; mobs then resolve no stats and deal no damage).
func (*MobAIService) MonsterTick ¶
func (a *MobAIService) MonsterTick(ctx context.Context, dt time.Duration)
MonsterTick advances every mob's aggro/attack/chase state by dt. It must not hold world.mu across CombatService.Attack (Attack locks world.mu in applyDamage) or MoveEntity (which locks world.mu), so it collects mob+target snapshots under a read-lock, releases it, accumulates cadence, then fires attacks and chase steps off-lock.
The distance metric is Chebyshev: max(|dx|,|dy|). RO's map is a cell grid and its movement/attack adjacency is the 8-neighbor (king-move) metric, so a player is "in range" when their Chebyshev distance to the mob is <= the range.
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.
LearnSkill spends a skill point to raise one learned skill level, gated by the job's skill tree (skill_tree.yml) and the character's job level.
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) LearnSkill ¶
func (s *SkillService) LearnSkill(ctx context.Context, charID uint32, skillID int32) (newLevel int16, spCost int32, rng int16, upgradable bool, err error)
LearnSkill spends one skill point to raise skillID's learned level by one, gated by the Novice skill tree (unlock thresholds + prerequisites, then the tree MaxLevel cap). On success it persists the new learned level and point balance and returns the ZC_SKILLINFO_UPDATE payload: new learned level, SP cost at that level, range at that level, and whether another raise remains. Failure returns a typed sentinel; nothing is spent or persisted.
func (*SkillService) SetTree ¶
func (s *SkillService) SetTree(tree *skilltree.Registry)
SetTree injects the skill tree registry (skill_tree.yml). Call this once during app wiring before any LearnSkill calls.
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 {
// OnMobSpawn, when set, is invoked after a mob (re)enters the world — the
// initial SpawnMob placement and every respawn-timer AddEntity — so the
// gateway can broadcast the appear frame to the mob's AOI neighbors.
OnMobSpawn func(mobID domain.EntityID)
// 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) MobExp ¶
func (s *SpawnService) MobExp(mobClass int32) (base, job uint64)
MobExp returns the mob_db BaseExp/JobExp awarded for killing a mob of the given class — the EXP source WorldService.GrantExp accrues to the killer on death. A nil mob_db or an unknown class yields (0, 0): best-effort, the killer simply earns no EXP (no error). mob_db stores EXP as int32 reward counts, clamped to ≥0 before widening to uint64. Party EXP split is deferred — the full reward goes to the single killer.
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) PortalAt ¶
PortalAt returns the warp portal anchored on (mapName, x, y), if any. The gateway consults it after a successful move: landing on a trigger tile teleports the player to the portal's destination.
func (*SpawnService) RegisterPortals ¶
func (s *SpawnService) RegisterPortals(defs []script.WarpDef)
RegisterPortals loads warp portals from the compiled corpus (idempotent: re-registering the same tile replaces its destination).
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 StorageInventoryPort ¶
type StorageInventoryPort 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
}
StorageInventoryPort is the narrow inventory surface storage needs: load a char's bag (to resolve a wire index to a server row), remove a row, and grant an item by nameID. Defining it locally keeps world/app off inventory/app; inventory.InventoryService satisfies it directly.
type StorageMoveResult ¶
type StorageMoveResult struct {
Index uint16 // server row + 2 (client index convention)
StoredID storagedomain.StorageItemID
NameID uint32
Amount uint32
IsFromBag bool // true = bag→warehouse; false = warehouse→bag
}
StorageMoveResult carries what the gateway emits after a successful move: the warehouse-row index to ack, the nameID that landed in the warehouse, and the new amount on that row (the merge-or-insert result).
type StoragePort ¶
type StoragePort interface {
LoadByAccount(ctx context.Context, accountID uint32) ([]storagedomain.StorageItem, error)
Add(ctx context.Context, accountID, nameID uint32, amount int) (storagedomain.StorageItem, error)
Remove(ctx context.Context, id storagedomain.StorageItemID, amount int) error
}
StoragePort is the narrow storage surface world needs: load an account's warehouse, add a row (with stack-merge), remove a row. commerce/storage's StorageService satisfies it directly.
type StorageService ¶
type StorageService struct {
// contains filtered or unexported fields
}
StorageService is the world-side orchestrator for the bag↔warehouse verb. Storage itself is a separate bounded context (commerce/storage) with its own table and repo; the world layer is responsible for cross-context orchestration — a "move to warehouse" is a world verb that calls inventory.Remove + storage.Add atomically.
The atomicity contract: if either side fails, the whole move aborts and neither the bag nor the warehouse has changed. Today this is enforced by a "remove first, then add" sequence with the bag removal error short-circuit; a future DB-level transaction will harden this (TODO).
func NewStorageService ¶
func NewStorageService(inv StorageInventoryPort, storage StoragePort) *StorageService
NewStorageService builds the orchestrator with the inventory and storage services wired in. Both must be non-nil — the bag↔warehouse verb requires both contexts to be reachable. Composition root + DI both call this.
func (*StorageService) LoadWarehouse ¶
func (s *StorageService) LoadWarehouse(ctx context.Context, accountID uint32) ([]storagedomain.StorageItem, error)
LoadWarehouse returns the account's warehouse rows for the ZC_STORE_* init burst on CZ_REQ_OPENSTORE2. Index preservation is load-bearing: the warehouse wire index is server-row + 2, so the order returned here is the order the client will see in its grid.
func (*StorageService) MoveFromStorage ¶
func (s *StorageService) MoveFromStorage(ctx context.Context, accountID, charID, wireIndex uint32, amount int) (StorageMoveResult, error)
MoveFromStorage moves amount units of the warehouse row at wireIndex (warehouse index = server row + 2) from the account's warehouse into the player's bag. Stackable items merge into an existing same-NameID bag row; equipment always inserts a new bag row.
Failure semantics mirror MoveToStorage: a failed bag insert leaves the warehouse row removed-but-not-delivered (the future DB transaction closes that seam). Out-of-range / insufficient / not-found errors are mapped to distinct sentinels so the gateway can echo a useful ack.
func (*StorageService) MoveToStorage ¶
func (s *StorageService) MoveToStorage(ctx context.Context, accountID, charID, wireIndex uint32, amount int) (StorageMoveResult, error)
MoveToStorage moves amount units of the bag row at wireIndex (client index = server row + 2) from the player's bag into the account's warehouse. Stackable items merge into an existing same-NameID warehouse row; equipment always inserts a new row.
Failure semantics:
- Index out of range or item is equipped → ErrStorageIndexOutOfRange / ErrStorageEquipped, NOTHING is mutated.
- bag row vanishes between load and remove → ErrStorageRowMissing, NOTHING is mutated.
- remove / add fails (e.g. warehouse full) → the failed step leaves the other side unchanged; the error is wrapped and returned. The contract is "all or nothing"; a future DB transaction will harden this.
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
DeductZenyWithPeer(ctx context.Context, charID uint32, amount int32, peer uint32) error
CreditZenyWithPeer(ctx context.Context, charID uint32, amount int32, peer uint32) 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.
Trade legs are tagged with ReasonTrade and the partner's charID, so the ledger shows the "who paid whom" pair on both sides. The peer parameter on the *WithPeer variants lets the trade service stamp each leg without coupling to the economy domain type; the DI adapter translates it into a LedgerEntry.
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, serverRow, clientIndex, amount int) (AddItemResult, error)
AddItem stages an ITEM (server row, 0-based) on charID's side of an active, unlocked trade. Zeny goes through AddZeny instead — see that method for why the zeny branch cannot be expressed as a server row. The inventory is not mutated; staging only records the intent. It validates the row is in range, the item is not equipped, and the staged amount (across prior stages of the same row) does not exceed the stack. On success the gateway emits ZC_ACK_ADD_EXCHANGE_ITEM to the adder and ZC_ADD_EXCHANGE_ITEM to the partner.
func (*TradeService) AddZeny ¶
func (s *TradeService) AddZeny(ctx context.Context, charID uint32, amount int) (AddItemResult, error)
AddZeny stages an amount of zeny on charID's side of the trade. It is the separate entry point for rAthena's zeny branch: clif_parse_AddExchangeItem tests the WIRE index for 0 before any conversion and calls trade_tradeaddzeny (clif.cpp:12565). That raw-wire test cannot be reproduced by a server row, because wire index 0 is below the client offset and converts to a wrapped row — so the gateway dispatches on the wire value and calls this instead.
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 WorldSeeder ¶
type WorldSeeder struct {
// contains filtered or unexported fields
}
WorldSeeder spawns script-corpus entities into the world. The content module's NPC/shop stores are fed GID→name mappings through the register callbacks supplied by the caller, keeping world free of content imports (structural, mirroring the ScriptWorld port).
func NewWorldSeeder ¶
func NewWorldSeeder(world *WorldService, spawn *SpawnService, mobs *mobdb.Registry, log *slog.Logger) *WorldSeeder
NewWorldSeeder builds a seeder. mobs may be nil (corpus mobs then spawn at the nominal pool).
func (*WorldSeeder) Seed ¶
func (s *WorldSeeder) Seed(set *script.CompiledScriptSet, registerNPC func(gid uint32, name string), registerShop func(gid uint32, shopName string), ) (npcs, mobs int)
Seed walks the compiled corpus: dialog NPCs get entities plus registerNPC(gid, scriptName); shops get entities plus registerShop(gid, shopName); mob spawns go to the spawn service. It returns (npcCount, mobCount) for boot logging. Either register callback may be nil (placement still happens; clicks just resolve no script).
func (*WorldSeeder) SeedMobs ¶
func (s *WorldSeeder) SeedMobs(defs []script.SpawnDef) int
SeedMobs spawns corpus mobs: for each SpawnDef, Amount mobs at cells inside the xs×ys box centered on (X,Y). HP comes from mob_db when the class resolves. The respawn delay is the corpus pair's bound (rAthena randomizes inside the window; the seeder uses the bound for determinism).
func (*WorldSeeder) SpawnNPC ¶
func (s *WorldSeeder) SpawnNPC(name, mapName string, x, y, facing, sprite int) (uint32, error)
SpawnNPC places one NPC entity and returns its GID. The sprite rides the Entity.Class field (the mob-class field; the combat service never resolves an EntityTypeNPC Class through mob_db, so the reuse is by design — the client renders ObjectType=6 + Class as the sprite).
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)
// OnExpChange, when set, is invoked after GrantExp changes a player's EXP
// so the gateway can emit ZC_EXP_CHANGE to that player's client. charID is the
// entity's GID (char_id). nil = grant is silent. Invoked off the world mutex.
OnExpChange func(charID uint32, baseExp, jobExp uint64)
// OnRespawn, when set, is invoked after RespawnPlayer revives a PC at its save
// point so the gateway can broadcast the save-point appear (ZC_SPAWN_UNIT to
// neighbors) and relocate the player's own client (ZC_ACCEPT_ENTER). charID is
// the entity's GID (char_id). nil = respawn is silent (server state still
// advances; useful for tests and headless operation). Invoked off the world
// mutex, after OnStatChange, so the HP/SP bar settles before the appear burst.
OnRespawn func(charID uint32)
// OnLevelUp, when set, is invoked after a PC levels up.
// It provides the new level, recalculated MaxHP/MaxSP, and remaining status points.
// nil = leveling is silent. Invoked off the world mutex.
OnLevelUp func(charID uint32, newLevel int16, maxHP, maxSP int32, statusPoint uint32)
// 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) AddVitals ¶
func (w *WorldService) AddVitals(charID uint32, hp, sp int32) (hpAfter, spAfter int32, err error)
AddVitals applies an absolute HP/SP delta to a player, clamped to [0, Max], and returns the resulting values. It is the usable-item (potion) use case's world capability: a healing item's flat restore (Red Potion +45 HP) flows through here, unlike HealPlayer's percentage restore. HP/SP is transient runtime state and is not persisted (mirroring HealPlayer). Like RegenTick it fires OnStatChange off the world mutex so the gateway can emit ZC_PAR_CHANGE; delta may be negative (the [0, Max] clamp keeps it symmetric for future drain paths).
func (*WorldService) AllocateStat ¶
func (w *WorldService) AllocateStat(charID uint32, stat string) (newVal uint32, remaining uint32, cost uint32, err error)
AllocateStat raises one base stat by one step, spending status points at the kernel's StatusPointCost rate (the packet package's pc_need_status_point table — 1 + (cur+9)/10 pre-renewal; keeping it in one place avoids drift). Stats cap at 99 (documented pre-re convention). Returns the new stat value, remaining points, and the cost consumed. Typed errors: ErrEntityNotFound, ErrUnknownStat, ErrStatCapped, ErrNoStatusPoints.
func (*WorldService) ArmRespawn ¶
func (w *WorldService) ArmRespawn(charID uint32, delay time.Duration)
ArmRespawn arms a per-char cancellable timer that fires RespawnPlayer(charID) after delay. Each death arms one timer (a re-arm cancels any prior pending one for that char); Stop cancels in-flight timers via the service context so a pending respawn never fires after shutdown. The gateway calls this when a mob kills a PC (after broadcasting the death-cell vanish); the timer callback performs the respawn + save-point appear (the gateway STEP wires the appear broadcast). CancelRespawn lets the player-driven respawn button (CZ_RESTART type=0) cancel a still-pending timer so the button and the timer never both fire RespawnPlayer.
func (*WorldService) CancelRespawn ¶
func (w *WorldService) CancelRespawn(charID uint32)
CancelRespawn cancels a pending auto-respawn timer for charID (a no-op if none is armed). The gateway calls this on a player-driven respawn (CZ_RESTART type=0) so the death timer and the respawn button do not both fire RespawnPlayer; RespawnPlayer is idempotent regardless, but cancelling avoids a redundant revive + appear burst.
func (*WorldService) Checkpoint ¶
func (w *WorldService) Checkpoint(ctx context.Context)
Checkpoint persists every online PC's vitals + EXP + position WITHOUT marking it offline, so a hard crash between disconnects (SIGKILL/panic/power loss) loses at most one checkpoint interval of in-session HP/SP/EXP change instead of the whole session. It is the periodic durability snapshot driven by StartCheckpoint; SaveAll is the shutdown/logout flush that additionally flips the online flag to false — reusing SaveAll for a periodic checkpoint would mass-disconnect every connected char, so Checkpoint is a separate method. Best-effort and off-lock, mirroring SaveAll: the snapshot is taken under w.mu (RLock) and released before the DB writes, so a slow write does not block the 50 Hz tick loop. Each char's failure is logged and skipped so one bad row never aborts the checkpoint.
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) GrantExp ¶
func (w *WorldService) GrantExp(ctx context.Context, charID uint32, baseExp, jobExp uint64) (newBase, newJob uint64, err error)
GrantExp accrues a mob-kill EXP reward to a player: it adds baseExp/jobExp to the entity's runtime EXP totals (clamped at math.MaxUint64 so the uint64 add can never overflow or panic) and returns the new totals. It is the EXP-on-kill use case's world capability: a dead mob's mob_db BaseExp/JobExp flows through here. EXP is runtime state mirrored to the char table by LeaveMap/SaveAll (via SaveState), so disconnect/shutdown/warp persist it from the same path as HP/SP. Leveling is deliberately deferred — GrantExp only accrues EXP; the level-up track (job_stats EXP curve, HP/SP recalc, status-point accrual) is a separate subsystem not modeled here. Thread-safe: mutated under w.mu; the OnExpChange notify fires off the lock (mirrors AddVitals/OnStatChange).
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) LearnSkill ¶
func (w *WorldService) LearnSkill(charID uint32, skillID int32, level int16) error
LearnSkill records skillID as learned at level for the given char. It raises the learned level in-place if level exceeds the current record. A zero or negative level is a no-op. This is the test seam and the future CZ_SKILLUP handler's underlying primitive.
func (*WorldService) LeaveMap ¶
func (w *WorldService) LeaveMap(ctx context.Context, charID uint32) error
LeaveMap removes a character from the world and marks it offline. Both the warp path (transit.Warp) and the disconnect path (gateway OnClose) funnel through here so position + offline flag + vitals are persisted from a single primitive. e is a value snapshot taken under w.mu by Get, so e.HP/e.SP are a consistent point-in-time read (combat/regen mutate HP concurrently on the tick/attack goroutines) — the DB writes run after the snapshot, off the lock.
func (*WorldService) MoveEntity ¶
MoveEntity updates an entity's position and moves it in the AOI grid.
func (*WorldService) PlayerByAccount ¶
func (w *WorldService) PlayerByAccount(accountID uint32) (domain.Entity, bool)
PlayerByAccount resolves an online player-character's entity by its account id. CZ_REQ_JOIN_GROUP carries the invite target's account id (rAthena clif_parse_PartyInvite resolves it with map_id2sd), and an account owns several chars, so this reports the online one. Same RLock scan as PlayerByName; an offline target resolves false, which the caller reports as PARTY_REPLY_OFFLINE.
func (*WorldService) PlayerByName ¶
func (w *WorldService) PlayerByName(name string) (domain.Entity, bool)
PlayerByName resolves an online player-character's entity by exact name. Names are unique among live PCs (the char table enforces it); the scan walks the registry under RLock. Whisper routing uses this — a target that is not online resolves false, which the caller reports as target-offline.
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 for every living PC. Each entity accumulates elapsed time in regenAcc; when its interval elapses (6 s HP / 8 s SP standing, halved to 3 s / 4 s while Entity.Sitting) it advances 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. regenAcc is mutated under w.mu (same lock as the entity registry), so regen stays race-free with damage and with SetSitting. Moving/attacking pausing regen (rAthena status_natural_heal) is not modeled.
func (*WorldService) RelocatePlayer ¶
func (w *WorldService) RelocatePlayer(charID uint32, mapName string, x, y int16) error
RelocatePlayer moves a live PC to a new map+cell in one step: entity map+pos, AOI grid (off the old map's grid, onto the destination map's grid), and the persisted position. It is the portal path's world capability — unlike WarpPlayer (persist-only, for the script builtin whose client reconnects), the live entity is reseated immediately so destination-map neighbors and the re-enter back-fill resolve the player at the arrival cell.
func (*WorldService) RemoveEntity ¶
func (w *WorldService) RemoveEntity(id domain.EntityID) error
RemoveEntity removes an entity from the registry and AOI grid.
func (*WorldService) RespawnPlayer ¶
func (w *WorldService) RespawnPlayer(charID uint32) error
RespawnPlayer moves a dead PC to its save point and revives its vitals, so a mob-killed player reappears at its save point instead of sitting dead forever. It is the bounded goAthena death model: no ghost/tomb/respawn-button — a killed PC vanishes (broadcast by the caller, gateway STEP), is out of the visible set for the respawn delay, then reappears here with HP/SP restored. The player's connection stays alive across the delay.
The save point is the runtime-cached Entity.SaveMap/SavePos; a char without a seeded save point respawns on its current map in place (bounded default — every production char loads one from the char table). Vitals revive to respawnRevivePct of max. All mutations run under w.mu and the AOI reseat happens off-lock (the grid synchronizes itself), mirroring MoveEntity; OnStatChange is collected under the lock and dispatched off it so the gateway's HP/SP-bar emit never runs on the world mutex. Thread-safe against the tick loop: MonsterTick drops dead targets on death, so moving a revived PC mid-tick is race-free.
func (*WorldService) SaveAll ¶
func (w *WorldService) SaveAll(ctx context.Context)
SaveAll persists every online PC's vitals + EXP and marks it offline, so a graceful shutdown/restart does not lose in-session HP/SP and EXP-from-kill changes (combat/regen/heal/respawn) nor leave stale-online rows. Best-effort: each char's failure is logged and skipped so one bad row never aborts the save-all. State+position are snapshotted under w.mu (the normal shutdown path has already stopped the tick loop, so regen is not mutating concurrently); the DB writes run after the lock is released so the snapshot does not block the tick loop on other paths.
func (*WorldService) SetFacing ¶
func (w *WorldService) SetFacing(charID uint32, dir uint8, head uint16) error
SetFacing commits a PC entity's body direction and head facing (CZ_CHANGE_DIR). The broadcast to neighbors reads it off the cached entity, so a client that walks or re-enters AOI afterwards sees the updated facing. Absent entity is a late packet after leave — callers treat it like SetSitting's sentinel.
func (*WorldService) SetLeveling ¶
func (w *WorldService) SetLeveling(svc *LevelingService)
SetLeveling attaches the EXP→level converter after construction so the DI root (which loads job_exp.yml/job_basepoints.yml) can wire it without churning NewWorldService's many call sites. nil (the zero value) keeps leveling disabled — EXP accrues but thresholds are never consumed.
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) SetSitting ¶
func (w *WorldService) SetSitting(charID uint32, sitting bool) error
SetSitting sets a PC entity's seated state. The gateway calls it on CZ_ACTION_REQUEST sit/stand (DMGSitDown/DMGStandUp); Sitting halves the natural-regen interval in RegenTick. Returns ErrEntityNotFound if the entity is absent — a sit/stand for an entity not on this map is harmless and the caller ignores that sentinel. Thread-safe; the action request normally runs on the reactor goroutine, but the lock is taken for consistency with the other entity mutators (AddVitals, MoveEntity).
func (*WorldService) StartCheckpoint ¶
func (w *WorldService) StartCheckpoint(ctx context.Context, interval time.Duration)
StartCheckpoint spawns a slow ticker that periodically calls Checkpoint so a hard crash (SIGKILL/panic/power loss) between disconnects loses at most one interval of in-session HP/SP/EXP change. It is separate from the 50 Hz tick loop (StartTick): checkpointing is an infrequent durability snapshot (default 5 m), not per-frame work. The goroutine drains on ctx cancellation (graceful shutdown) or Stop, mirroring StartTick's lifecycle. Each checkpoint runs under its own timeout derived from ctx so a slow DB on one tick does not block the next; on shutdown the already-cancelled ctx makes the racing checkpoint no-op fast, leaving the final flush to SaveAll.
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 drains the world's background work: the respawn timers (so a dead PC's pending auto-respawn never fires into a torn-down world) and the tick/checkpoint loops' stop channel. Idempotent — closing stopCh twice must not panic.
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).