aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-14 22:38:23 -0400
committerhistoria <[not public]>2026-06-14 22:38:23 -0400
commita6f0ad79e2e3a5b7c11eef1ffc3233f3a2766f77 (patch)
tree89601a502bbfcb50302cf03f09b6febc6040eeb0 /internal
parent1aba2bdd23aebed4032333e74aa553c40a31fcbd (diff)
downloadthehouseoficarus-a6f0ad79e2e3a5b7c11eef1ffc3233f3a2766f77.tar.gz
feat: fractional ticks and server game_speed implemented. potentially insanely janky.
Diffstat (limited to 'internal')
-rw-r--r--internal/action/behavior.go8
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/engine/tick.go25
-rw-r--r--internal/game/action.go3
-rw-r--r--internal/game/action_burn.go20
-rw-r--r--internal/game/action_gather.go140
-rw-r--r--internal/game/action_search.go122
-rw-r--r--internal/game/action_state.go61
-rw-r--r--internal/game/action_talk.go2
-rw-r--r--internal/game/action_toggle.go2
-rw-r--r--internal/game/action_use.go8
-rw-r--r--internal/game/cmd_attack.go14
-rw-r--r--internal/game/cmd_drop.go3
-rw-r--r--internal/game/cmd_get.go6
-rw-r--r--internal/game/cmd_look.go40
-rw-r--r--internal/game/cmd_move.go1
-rw-r--r--internal/game/cmd_queued.go66
-rw-r--r--internal/game/cmd_quit.go3
-rw-r--r--internal/game/cmd_search.go68
-rw-r--r--internal/game/doc.go5
-rw-r--r--internal/game/game.go215
-rw-r--r--internal/game/map_test.go6
-rw-r--r--internal/game/tick.go34
-rw-r--r--internal/game/utils.go23
-rw-r--r--internal/object/item.go10
-rw-r--r--internal/player/player.go5
-rw-r--r--internal/world/mob.go10
-rw-r--r--internal/world/room.go6
-rw-r--r--internal/world/world.go26
29 files changed, 708 insertions, 230 deletions
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
index e0b8cdf..c7ed0c4 100644
--- a/internal/action/behavior.go
+++ b/internal/action/behavior.go
@@ -4,7 +4,7 @@ type GatherConfig struct {
Skill string `yaml:"skill"`
Level int `yaml:"level"`
XP int `yaml:"xp"`
- BaseWait int `yaml:"base_wait"`
+ BaseWait float64 `yaml:"base_wait"`
Tools []string `yaml:"tools"`
Bait string `yaml:"bait"`
Success SuccessFormula `yaml:"success"`
@@ -13,10 +13,10 @@ type GatherConfig struct {
ExhaustedMessage string `yaml:"exhausted_message"`
FailMsg string `yaml:"fail_message"`
Drops []DropEntry `yaml:"drops"`
- RespawnTimer int `yaml:"respawn_timer"`
+ RespawnTimer float64 `yaml:"respawn_timer"`
RespawnMsg string `yaml:"respawn_message"`
RespawnBroadcast string `yaml:"respawn_broadcast"`
- DepleteTimer int `yaml:"deplete_timer"`
+ DepleteTimer float64 `yaml:"deplete_timer"`
NestChance int `yaml:"nest_chance"`
}
@@ -64,7 +64,7 @@ type Condition struct {
type UseConfig struct {
Message string `yaml:"message"`
- Wait int `yaml:"wait"`
+ Wait float64 `yaml:"wait"`
Consume map[string]int `yaml:"consume"`
Reward DropEntry `yaml:"reward"`
FailMsg string `yaml:"fail_message"`
diff --git a/internal/config/config.go b/internal/config/config.go
index 1682a01..d397fae 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,7 +14,8 @@ type Config struct {
}
type GameConfig struct {
- MaxWidth int `yaml:"maxWidth"`
+ TickLength int `yaml:"tick_length"`
+ GameSpeed float64 `yaml:"game_speed"`
}
type TelnetConfig struct {
@@ -37,7 +38,8 @@ type HTTPSConfig struct {
func Default() *Config {
return &Config{
Game: GameConfig{
- MaxWidth: 70,
+ TickLength: 600,
+ GameSpeed: 1.0,
},
Telnet: TelnetConfig{
Enabled: true,
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index d6d470a..419867d 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -1,12 +1,11 @@
package engine
import (
+ "math/rand"
"sync"
"time"
)
-const TickDuration = 600 * time.Millisecond
-
type Callback func() bool
type subscriber struct {
@@ -49,14 +48,17 @@ func (e *Engine) Unsubscribe(id uint64) {
delete(e.subscribers, id)
}
-func (e *Engine) Start() {
+func (e *Engine) Start(tickLengthMs int) {
e.mu.Lock()
defer e.mu.Unlock()
if e.running {
return
}
e.running = true
- e.ticker = time.NewTicker(TickDuration)
+ if tickLengthMs < 50 {
+ tickLengthMs = 50
+ }
+ e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond)
e.stopCh = make(chan struct{})
go func() {
@@ -103,3 +105,18 @@ func (e *Engine) processTick() {
}
}
}
+
+func FractionalTicks(base, speed float64) int {
+ if speed <= 0 {
+ speed = 1
+ }
+ value := base / speed
+ if value < 1 {
+ value = 1
+ }
+ floor := int(value)
+ if rand.Float64() < value-float64(floor) {
+ return floor + 1
+ }
+ return floor
+}
diff --git a/internal/game/action.go b/internal/game/action.go
index 7d2549f..ce29559 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -171,6 +171,7 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) {
func (g *Game) CancelAction(p *player.Player) {
p.Action = nil
+ p.ActionState = nil
}
func (g *Game) AdvanceActions() {
@@ -194,6 +195,8 @@ func (g *Game) AdvanceActions() {
g.advanceBurn(sess, p)
case "stoke":
g.advanceStoke(sess, p)
+ case "search":
+ g.advanceSearch(sess, p)
}
}
}
diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go
index d57307a..917a6fe 100644
--- a/internal/game/action_burn.go
+++ b/internal/game/action_burn.go
@@ -102,6 +102,8 @@ func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fro
phase = 0
}
+ p.ActionState = &ActionState{Type: ActionBurning}
+
p.Action = &action.Action{
Type: "burn",
TargetID: itemID,
@@ -138,11 +140,13 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) {
sess.WriteLine("You begin to tend to the fire.")
+ p.ActionState = &ActionState{Type: ActionStoking}
+
p.Action = &action.Action{
Type: "stoke",
TargetID: itemID,
TargetName: logDef.Name,
- WaitLeft: 10,
+ WaitLeft: g.computeTicks(10),
Data: map[string]any{
"item_id": itemID,
"fire_key": fireKey,
@@ -155,7 +159,7 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) {
func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
data := p.Action.Data
itemID := data["item_id"].(string)
- toolSpeed := data["tool_speed"].(int)
+ toolSpeed := data["tool_speed"].(float64)
phase := data["phase"].(int)
logDef, err := g.ItemStore.Load(itemID)
@@ -178,7 +182,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
g.broadcastDrop(p, logDef.Name)
data["phase"] = 1
- p.Action.WaitLeft = toolSpeed
+ p.Action.WaitLeft = g.computeTicks(toolSpeed)
return
}
@@ -192,7 +196,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
if phase == 1 {
sess.WriteLine(fmt.Sprintf("You try burning the %s on the ground...", logDef.Name))
data["phase"] = 2
- p.Action.WaitLeft = toolSpeed
+ p.Action.WaitLeft = g.computeTicks(toolSpeed)
return
}
@@ -236,7 +240,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
sess.WriteLine("You can't seem to get a fire going.")
data["phase"] = 1
- p.Action.WaitLeft = toolSpeed
+ p.Action.WaitLeft = g.computeTicks(toolSpeed)
}
}
@@ -244,7 +248,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) {
data := p.Action.Data
itemID := data["item_id"].(string)
fireKey := data["fire_key"].(string)
- burnTicks := data["burn_ticks"].(int)
+ burnTicks := data["burn_ticks"].(float64)
xp := data["xp"].(int)
st := g.World.GetObjStateByKey(fireKey)
@@ -289,10 +293,10 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) {
return
}
- p.Action.WaitLeft = 10
+ p.Action.WaitLeft = g.computeTicks(10.0)
}
-func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed int, ok bool) {
+func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed float64, ok bool) {
toolSpeed = -1
var toolItemID string
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 201a619..512be81 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -29,13 +29,13 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
if cfg.DepletedMessage != "" {
msg := cfg.DepletedMessage
if p.OptionBool("depletion") {
- msg += fmt.Sprintf(" (%d ticks to respawn)", st.DepleteTimer)
+ msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer))
}
sess.WriteLine(msg)
} else if cfg.DepleteTimer > 0 {
- sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer))
+ sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer)))
} else {
- sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer))
+ sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer)))
}
return
}
@@ -43,7 +43,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
wait := cfg.BaseWait
if len(cfg.Tools) > 0 {
- toolSpeed := -1
+ var toolSpeed float64 = -1
if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
def, err := g.ItemStore.Load(itemID)
if err == nil {
@@ -107,6 +107,39 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
return
}
+ toolName := ""
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ for _, t := range cfg.Tools {
+ if def.ToolType == t {
+ toolName = def.Name
+ break
+ }
+ }
+ }
+ }
+ if toolName == "" {
+ for _, slot := range p.Inventory {
+ if slot == nil || slot.Quantity <= 0 {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil {
+ continue
+ }
+ for _, t := range cfg.Tools {
+ if def.ToolType == t {
+ toolName = def.Name
+ break
+ }
+ }
+ if toolName != "" {
+ break
+ }
+ }
+ }
+ p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName}
+
data := map[string]any{
"behavior_id": obj.BehaviorID,
"instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
@@ -135,7 +168,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
step := p.Action.Data["step"].(int)
- wait := p.Action.Data["effective_wait"].(int)
+ wait := p.Action.Data["effective_wait"].(float64)
instanceKey := p.Action.Data["instance_key"].(string)
_, shared := p.Action.Data["deplete_timer"]
@@ -153,21 +186,21 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg))
p.Action.Data["step"] = 1
- p.Action.WaitLeft = wait
+ p.Action.WaitLeft = g.computeTicks(wait)
return
}
if step == 2 {
st := g.World.GetObjStateByKey(instanceKey)
if st != nil && st.Depleted {
- p.Action.WaitLeft = 3
+ p.Action.WaitLeft = g.computeTicks(3)
return
}
if cfg.RespawnMsg != "" {
sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg))
}
p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
+ p.Action.WaitLeft = g.computeTicks(wait)
return
}
@@ -231,23 +264,30 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
g.checkBirdNest(sess, p, cfg, freeSlot)
- if shared {
- st := g.World.GetObjStateByKey(instanceKey)
- if st != nil && st.SharedTimer <= 0 {
- g.depleteSharedTree(sess, p, st, cfg, instanceKey)
- return
- }
+ if shared {
+ st := g.World.GetObjStateByKey(instanceKey)
+ if st != nil && st.SharedTimer <= 0 {
+ g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{
+ instanceKey: instanceKey,
+ behaviorID: behaviorID,
+ targetName: p.Action.TargetName,
+ playerNames: []string{p.Name},
+ })
+ p.Action.Data["step"] = 2
+ p.Action.WaitLeft = g.computeTicks(1)
+ return
}
+ }
if !shared && drop.Depletes {
- delay := cfg.RespawnTimer
+ delay := g.computeTicks(cfg.RespawnTimer)
if delay <= 0 {
delay = 10
}
st := g.World.GetObjStateByKey(instanceKey)
if st != nil {
st.Depleted = true
- st.DepleteTimer = delay
+ st.DepleteTimer = float64(delay)
}
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
@@ -265,7 +305,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
p.Action.Data["step"] = 2
- p.Action.WaitLeft = 1
+ p.Action.WaitLeft = g.computeTicks(1)
return
}
@@ -275,7 +315,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
return
}
p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
+ p.Action.WaitLeft = g.computeTicks(wait)
return
}
}
@@ -287,7 +327,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
return
}
p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
+ p.Action.WaitLeft = g.computeTicks(wait)
}
func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry {
@@ -303,34 +343,68 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry
return eligible
}
-func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, cfg *action.GatherConfig, instanceKey string) {
+func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, targetName string, playerNames []string) {
st.Depleted = true
- st.DepleteTimer = cfg.RespawnTimer
+ st.DepleteTimer = float64(g.computeTicks(cfg.RespawnTimer))
st.SharedTimer = 0
msg := cfg.ExhaustedMessage
if msg == "" {
- msg = fmt.Sprintf("The %s falls to the ground!", p.Action.TargetName)
+ msg = fmt.Sprintf("The %s falls to the ground!", targetName)
}
- sess.WriteLine(fmt.Sprintf("\n%s", msg))
-
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other == sess {
+ for _, sess := range g.Hub.PlayersInRoom(st.RoomID) {
+ op, ok := sess.Player.(*player.Player)
+ if !ok || op.Action == nil || op.Action.Type != "gather" {
continue
}
- op, ok := other.Player.(*player.Player)
- if !ok || op.Action == nil || op.Action.Type != "gather" {
+ if op.Action.Data["instance_key"] != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) {
+ continue
+ }
+ isDepleter := false
+ for _, name := range playerNames {
+ if op.Name == name {
+ isDepleter = true
+ break
+ }
+ }
+ if isDepleter {
+ sess.WriteLine(fmt.Sprintf("\n%s", msg))
continue
}
- if op.Action.Data["instance_key"] == instanceKey {
- other.WriteLine(fmt.Sprintf("\n%s", msg))
- g.CancelAction(op)
+ sess.WriteLine(fmt.Sprintf("\n%s", msg))
+ g.CancelAction(op)
+ }
+}
+
+func (g *Game) flushPendingDepletions() {
+ if len(g.pendingDepletions) == 0 {
+ return
+ }
+
+ merged := make(map[string]*pendingDepletion)
+ for _, pd := range g.pendingDepletions {
+ if existing, ok := merged[pd.instanceKey]; ok {
+ existing.playerNames = append(existing.playerNames, pd.playerNames...)
+ } else {
+ copy := pd
+ merged[pd.instanceKey] = &copy
+ }
+ }
+
+ for _, pd := range merged {
+ st := g.World.GetObjStateByKey(pd.instanceKey)
+ if st == nil || st.Depleted {
+ continue
+ }
+ cfg, err := g.BehaviorStore.LoadGather(pd.behaviorID)
+ if err != nil {
+ continue
}
+ g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames)
}
- p.Action.Data["step"] = 2
- p.Action.WaitLeft = 1
+ g.pendingDepletions = nil
}
func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.GatherConfig, currentDropSlot int) {
diff --git a/internal/game/action_search.go b/internal/game/action_search.go
new file mode 100644
index 0000000..07bae44
--- /dev/null
+++ b/internal/game/action_search.go
@@ -0,0 +1,122 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, slotIdx int) {
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil || def.SearchTable == "" {
+ sess.WriteLine("You can't search that.")
+ return
+ }
+
+ ticks := def.SearchTicks
+ if ticks <= 0 {
+ ticks = 3
+ }
+
+ p.Action = &action.Action{
+ Type: "search",
+ TargetID: itemID,
+ TargetName: def.Name,
+ WaitLeft: g.computeTicks(ticks),
+ Data: map[string]any{
+ "item_id": itemID,
+ "slot_idx": slotIdx,
+ "started": false,
+ },
+ }
+
+ p.ActionState = &ActionState{Type: ActionSearching, TargetName: def.Name}
+}
+
+func (g *Game) advanceSearch(sess *net.Session, p *player.Player) {
+ data := p.Action.Data
+ itemID := data["item_id"].(string)
+ slotIdx := data["slot_idx"].(int)
+ started := data["started"].(bool)
+
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ sess.WriteLine("Something went wrong.")
+ g.CancelAction(p)
+ return
+ }
+
+ if !started {
+ msg := def.SearchMessage
+ if msg == "" {
+ msg = fmt.Sprintf("digging through the %s", def.Name)
+ }
+ sess.WriteLine(fmt.Sprintf("You begin %s.", msg))
+ data["started"] = true
+ return
+ }
+
+ slot := p.InvSlot(slotIdx)
+ if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
+ sess.WriteLine("The item is gone.")
+ g.CancelAction(p)
+ return
+ }
+
+ if slot.Quantity > 1 {
+ slot.Quantity--
+ } else {
+ p.SetInvSlot(slotIdx, nil)
+ }
+
+ dt, err := g.BehaviorStore.LoadDropTable(def.SearchTable)
+ if err == nil && len(dt.Drops) > 0 {
+ drop := g.BehaviorStore.ResolveDrop(dt.Drops)
+ if drop != nil && drop.ItemID != "" {
+ g.giveSearchLoot(sess, p, drop)
+ }
+ }
+
+ if def.SearchMiscTable != "" {
+ dt2, err := g.BehaviorStore.LoadDropTable(def.SearchMiscTable)
+ if err == nil && len(dt2.Drops) > 0 {
+ drop := g.BehaviorStore.ResolveDrop(dt2.Drops)
+ if drop != nil && drop.ItemID != "" {
+ g.giveSearchLoot(sess, p, drop)
+ }
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+ g.CancelAction(p)
+}
+
+func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) {
+ qty := drop.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ name := drop.ItemID
+ if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
+ name = def.Name
+ }
+
+ if drop.ItemID == "credits" {
+ p.Credits += qty
+ sess.WriteLine(fmt.Sprintf("You find %d credits.", qty))
+ return
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
+ sess.WriteLine(fmt.Sprintf("You find %s. It falls to the ground.", name))
+ return
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ sess.WriteLine(fmt.Sprintf("You find %s.", name))
+}
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
new file mode 100644
index 0000000..fba5319
--- /dev/null
+++ b/internal/game/action_state.go
@@ -0,0 +1,61 @@
+package game
+
+import "fmt"
+
+type ActionType string
+
+const (
+ ActionIdle ActionType = ""
+ ActionGathering ActionType = "gathering"
+ ActionCombating ActionType = "combating"
+ ActionMoving ActionType = "moving"
+ ActionUsing ActionType = "using"
+ ActionTalking ActionType = "talking"
+ ActionToggling ActionType = "toggling"
+ ActionBurning ActionType = "burning"
+ ActionStoking ActionType = "stoking"
+ ActionPickingUp ActionType = "picking_up"
+ ActionDropping ActionType = "dropping"
+ ActionSearching ActionType = "searching"
+ ActionResting ActionType = "resting"
+)
+
+type ActionState struct {
+ Type ActionType
+ TargetName string
+ ToolName string
+ Direction string
+}
+
+func (a *ActionState) Description() string {
+ if a == nil || a.Type == ActionIdle {
+ return ""
+ }
+ switch a.Type {
+ case ActionGathering:
+ return "mining a " + a.TargetName
+ case ActionCombating:
+ return "fighting a " + a.TargetName
+ case ActionMoving:
+ return "walking in from the " + a.Direction
+ case ActionUsing:
+ return "using a " + a.TargetName
+ case ActionTalking:
+ return "talking to " + a.TargetName
+ case ActionToggling:
+ return fmt.Sprintf("pulling a %s", a.TargetName)
+ case ActionBurning:
+ return "trying to start a fire"
+ case ActionStoking:
+ return "tending to a fire"
+ case ActionPickingUp:
+ return "picking up " + a.TargetName
+ case ActionDropping:
+ return "dropping " + a.TargetName
+ case ActionSearching:
+ return "digging through a " + a.TargetName
+ case ActionResting:
+ return "resting"
+ }
+ return ""
+}
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
index 4980c5a..8aa95be 100644
--- a/internal/game/action_talk.go
+++ b/internal/game/action_talk.go
@@ -19,6 +19,7 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI
}
if node, ok := cfg.Nodes["start"]; ok {
+ p.ActionState = &ActionState{Type: ActionTalking, TargetName: mob.Name}
p.Action = &action.Action{
Type: "talk",
TargetID: mob.DefID,
@@ -39,6 +40,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
if node, ok := cfg.Nodes["start"]; ok {
+ p.ActionState = &ActionState{Type: ActionTalking, TargetName: obj.Name}
p.Action = &action.Action{
Type: "talk",
TargetID: obj.ID,
diff --git a/internal/game/action_toggle.go b/internal/game/action_toggle.go
index 3818d0d..f20d03c 100644
--- a/internal/game/action_toggle.go
+++ b/internal/game/action_toggle.go
@@ -34,6 +34,8 @@ func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.Obje
g.WorldFlags[flag] = val
}
+ p.ActionState = &ActionState{Type: ActionToggling, TargetName: obj.Name}
+
sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message))
if g.Hub != nil {
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
index c47492f..8987ee0 100644
--- a/internal/game/action_use.go
+++ b/internal/game/action_use.go
@@ -33,11 +33,13 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD
return
}
+ p.ActionState = &ActionState{Type: ActionUsing, TargetName: obj.Name}
+
p.Action = &action.Action{
Type: "use",
TargetID: obj.ID,
TargetName: obj.Name,
- WaitLeft: 1,
+ WaitLeft: g.computeTicks(1),
Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID},
}
@@ -76,7 +78,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
if cfg.FailMsg != "" {
sess.WriteLine(cfg.FailMsg)
}
- p.Action.WaitLeft = cfg.Wait
+ p.Action.WaitLeft = g.computeTicks(cfg.Wait)
return
}
}
@@ -117,5 +119,5 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
return
}
- p.Action.WaitLeft = cfg.Wait
+ p.Action.WaitLeft = g.computeTicks(cfg.Wait)
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 2f79c16..ef80189 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -131,8 +131,9 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
}
sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr))
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
- g.Ticks.Subscribe(playerSpeed, func() bool {
+ g.Ticks.Subscribe(g.computeTicks(playerSpeed), func() bool {
cs := combat.GetCombat(p.Name)
if cs == nil || !cs.Active {
return false
@@ -150,7 +151,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
return true
})
- g.Ticks.Subscribe(mob.Speed, func() bool {
+ g.Ticks.Subscribe(g.computeTicks(mob.Speed), func() bool {
cs := combat.GetCombat(p.Name)
if cs == nil || !cs.Active {
return false
@@ -277,6 +278,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst
func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
g.combatPadWidth = 0
combat.LeaveCombat(p.Name)
+ p.ActionState = nil
if p.HP <= 0 {
sess.WriteLine("\nOh dear, you are dead!")
@@ -340,9 +342,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
}
- respawnTicks := mob.RespawnTicks
+ respawnTicks := g.computeTicks(mob.RespawnTicks)
if respawnTicks <= 0 {
- respawnTicks = 30
+ respawnTicks = g.computeTicks(30)
}
instanceID := mob.InstanceID
g.Ticks.Subscribe(respawnTicks, func() bool {
@@ -467,12 +469,12 @@ func (g *Game) respawnMob(instanceID string) {
}
}
-func (g *Game) playerWeaponSpeed(p *player.Player) int {
+func (g *Game) playerWeaponSpeed(p *player.Player) float64 {
if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
def, err := g.ItemStore.Load(itemID)
if err == nil && def.Speed > 0 {
return def.Speed
}
}
- return 5
+ return 5.0
}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 149b86b..dc506f0 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -11,6 +11,7 @@ import (
func (g *Game) doDropAll(sess *net.Session) {
p := sess.Player.(*player.Player)
g.CancelAction(p)
+ p.ActionState = &ActionState{Type: ActionDropping, TargetName: "inventory"}
count := 0
for _, slot := range p.Inventory {
@@ -52,6 +53,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
if def != nil {
name = def.Name
}
+ p.ActionState = &ActionState{Type: ActionDropping, TargetName: name}
if def != nil && def.Stackable {
slot := p.InvSlot(matches[0].Slot)
@@ -113,6 +115,7 @@ func (g *Game) doDrop(sess *net.Session, input string) {
if def != nil {
name = def.Name
}
+ p.ActionState = &ActionState{Type: ActionDropping, TargetName: name}
if qty == 0 {
if def != nil && def.Stackable {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 78a9621..7ec183f 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -39,10 +39,16 @@ func (g *Game) doGet(sess *net.Session, input string) {
if itemID == "credits" {
g.pickupCredits(sess, p, itemID)
+ p.ActionState = &ActionState{Type: ActionPickingUp, TargetName: "credits"}
return
}
def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ p.ActionState = &ActionState{Type: ActionPickingUp, TargetName: name}
available := 0
if gqty, ok := ground[itemID]; ok {
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index a840579..3b81f80 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -25,8 +25,12 @@ func (g *Game) doLook(sess *net.Session) {
room.Name,
)
- if p.OptionBool("tinymap") && g.MapWidth > 0 {
- descLines := wrapText(room.Description, g.MapWidth)
+ mapWidth := p.OptionInt("max_room_description_width")
+ if mapWidth <= 0 {
+ mapWidth = 70
+ }
+ if p.OptionBool("tinymap") && mapWidth > 0 {
+ descLines := wrapText(room.Description, mapWidth)
mapLines := buildTinyMap(g, p.RoomID)
if len(mapLines) == 7 {
g.writeLookSideBySide(sess, p, descLines, mapLines)
@@ -107,10 +111,10 @@ func (g *Game) doLook(sess *net.Session) {
instances = append(instances, instInfo{
idx: i + 1,
depleted: st.Depleted,
- sharedMax: st.SharedMax,
+ sharedMax: int(st.SharedMax),
sharedCur: st.SharedTimer,
- respawnIn: st.DepleteTimer,
- quality: st.Quality,
+ respawnIn: int(st.DepleteTimer),
+ quality: int(st.Quality),
})
}
multi := len(instances) > 1
@@ -302,8 +306,10 @@ func (g *Game) doLook(sess *net.Session) {
}
line += fmt.Sprintf(" (fighting %s)", name)
}
- } else if desc := playerActionDescription(op); desc != "" {
- line += ", " + desc
+ } else if as, ok := op.ActionState.(*ActionState); ok && as != nil {
+ if desc := as.Description(); desc != "" {
+ line += ", " + desc
+ }
}
sess.WriteLine(line + ".")
}
@@ -380,15 +386,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
for _, ist := range instances {
if ist.Depleted {
if len(instances) > 1 {
- sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, ist.DepleteTimer))
+ sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer)))
} else {
- sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", ist.DepleteTimer))
+ sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", int(ist.DepleteTimer)))
}
- } else if ist.SharedMax > 0 && ist.SharedTimer < ist.SharedMax {
+ } else if ist.SharedMax > 0 && float64(ist.SharedTimer) < ist.SharedMax {
if len(instances) > 1 {
- sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%d.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax))
+ sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax))
} else {
- sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%d ticks.", ist.SharedTimer, ist.SharedMax))
+ sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax))
}
}
}
@@ -396,7 +402,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
for _, ist := range instances {
if ist.Quality > 0 {
if qdesc, ok := def.Props["quality_description"].(string); ok && qdesc != "" {
- qdesc = strings.ReplaceAll(qdesc, "{quality}", fmt.Sprintf("%d", ist.Quality))
+ qdesc = strings.ReplaceAll(qdesc, "{quality}", fmt.Sprintf("%.0f", ist.Quality))
sess.WriteLine(fmt.Sprintf(" %s", qdesc))
}
}
@@ -568,7 +574,11 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine
if len(mapLines) > total {
total = len(mapLines)
}
- leftMap := p.OptionBool("left-tinymap")
+ leftMap := p.OptionBool("left_tinymap")
+ mapWidth := p.OptionInt("max_room_description_width")
+ if mapWidth <= 0 {
+ mapWidth = 70
+ }
for i := 0; i < total; i++ {
desc := ""
if i < len(descLines) {
@@ -581,7 +591,7 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine
if leftMap {
sess.WriteLine(fmt.Sprintf("%s %s", mapLine, desc))
} else {
- sess.WriteLine(fmt.Sprintf("%-*s %s", g.MapWidth, desc, mapLine))
+ sess.WriteLine(fmt.Sprintf("%-*s %s", mapWidth, desc, mapLine))
}
}
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 9ef7a00..0b6571e 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -79,6 +79,7 @@ func (g *Game) doMove(sess *net.Session, dir string) {
}
sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir))
+ p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)}
if p.OptionBool("description") {
g.doLook(sess)
} else {
diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go
new file mode 100644
index 0000000..040ff31
--- /dev/null
+++ b/internal/game/cmd_queued.go
@@ -0,0 +1,66 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doQueued(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+
+ freeCmds := g.freeQueue[p.Name]
+ activeCmd := g.activeQueue[p.Name]
+
+ if len(freeCmds) == 0 && activeCmd == nil {
+ sess.WriteLine("\nNo actions queued.")
+ return
+ }
+
+ sess.WriteLine("")
+
+ if len(freeCmds) > 0 {
+ sess.WriteLine(" Queued free actions (will execute in order):")
+ for i, qc := range freeCmds {
+ cmd := qc.Command
+ if qc.Args != "" {
+ cmd += " " + qc.Args
+ }
+ sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, cmd))
+ }
+ }
+
+ if activeCmd != nil {
+ if len(freeCmds) > 0 {
+ sess.WriteLine("")
+ }
+ cmd := activeCmd.Command
+ if activeCmd.Args != "" {
+ cmd += " " + activeCmd.Args
+ }
+ sess.WriteLine(fmt.Sprintf(" Queued active action (executes after free actions):"))
+ sess.WriteLine(fmt.Sprintf(" %s", cmd))
+ }
+
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf(" All queued actions take effect on the next game tick (600ms)."))
+ sess.WriteLine(fmt.Sprintf(" Free actions stack. Only the most recent active action survives."))
+
+ var names []string
+ for name := range g.activeQueue {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ pos := 0
+ for i, name := range names {
+ if name == p.Name {
+ pos = i + 1
+ break
+ }
+ }
+ if pos > 0 && len(names) > 1 {
+ sess.WriteLine(fmt.Sprintf("\n Your active action will resolve as queue position #%d of %d.", pos, len(names)))
+ }
+}
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 51e1fee..90e7019 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -15,10 +15,11 @@ func (g *Game) doQuit(sess *net.Session) {
}
g.CancelAction(p)
+ p.ActionState = &ActionState{Type: ActionResting}
g.cancelRest(p.Name)
ticksLeft := 10
- id := g.Ticks.Subscribe(1, func() bool {
+ id := g.Ticks.Subscribe(g.computeTicks(1), func() bool {
switch ticksLeft {
case 10:
sess.WriteLine("You sit down to rest...")
diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go
index 341685e..d9a5b1e 100644
--- a/internal/game/cmd_search.go
+++ b/internal/game/cmd_search.go
@@ -10,6 +10,7 @@ import (
func (g *Game) doSearch(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
+ g.CancelAction(p)
lower := strings.ToLower(strings.TrimSpace(input))
if lower == "" {
@@ -23,65 +24,16 @@ func (g *Game) doSearch(sess *net.Session, input string) {
return
}
- itemID := matches[0].ID
- slotIdx := matches[0].Slot
-
- if itemID != "birds_nest" {
- sess.WriteLine(fmt.Sprintf("You can't search %s.", matches[0].Name))
- return
- }
-
- slot := p.InvSlot(slotIdx)
- if slot == nil || slot.ItemID != "birds_nest" {
- return
- }
-
- if slot.Quantity > 1 {
- slot.Quantity--
- } else {
- p.SetInvSlot(slotIdx, nil)
- }
-
- dt, err := g.BehaviorStore.LoadDropTable("birds_nest_drop")
- if err != nil || len(dt.Drops) == 0 {
- sess.WriteLine("You search the bird's nest but find nothing.")
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- drop := g.BehaviorStore.ResolveDrop(dt.Drops)
- if drop == nil {
- sess.WriteLine("You search the bird's nest but find nothing.")
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- qty := drop.Quantity
- if qty <= 0 {
- qty = 1
- }
-
- if drop.ItemID == "credits" {
- p.Credits += qty
- sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %d credits.", qty))
- } else {
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
- name := drop.ItemID
- if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s. It falls to the ground.", name))
- } else {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
- name := drop.ItemID
- if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s.", name))
+ unique := uniqueItemNames(matches)
+ if len(unique) > 1 {
+ sess.WriteLine("Which one?")
+ for _, name := range unique {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
}
+ return
}
- g.AccountStore.SaveCharacter(p)
+ itemID := matches[0].ID
+ slotIdx := matches[0].Slot
+ g.startSearch(sess, p, itemID, slotIdx)
}
diff --git a/internal/game/doc.go b/internal/game/doc.go
index 97814e9..e276994 100644
--- a/internal/game/doc.go
+++ b/internal/game/doc.go
@@ -15,8 +15,9 @@
// Actions that take time (gather, use, burn, stoke) tick via
// AdvanceActions, called each 600ms tick.
//
-// Tick handlers: DisconnectTick, RegenTick, WanderTick, WoodcuttingTick,
-// and FireTick run each game tick to advance world simulation.
+// Tick handlers: ProcessQueuedCommands, DisconnectTick, RegenTick,
+// WanderTick, SharedDepletionTick, and FireTick run each game tick to
+// advance world simulation.
//
// File organization:
// cmd_*.go — player command handlers (doLook, doMove, doGet, etc.)
diff --git a/internal/game/game.go b/internal/game/game.go
index cecde2a..37a1f3f 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -2,8 +2,10 @@ package game
import (
"fmt"
+ "sort"
"strings"
"sync"
+ "time"
"thirdcollapse/internal/action"
"thirdcollapse/internal/engine"
@@ -13,6 +15,21 @@ import (
"thirdcollapse/internal/world"
)
+type CommandClass int
+
+const (
+ ClassInstant CommandClass = iota
+ ClassFree
+ ClassActive
+)
+
+type QueuedCommand struct {
+ Session *net.Session
+ Command string
+ Args string
+ Timestamp time.Time
+}
+
type Game struct {
World *world.World
ObjectStore *object.ObjectStore
@@ -28,22 +45,28 @@ type Game struct {
charsMu sync.Mutex
loggedInChars map[string]*net.Session
combatPadWidth int
- MapWidth int
+ GameSpeed float64
+ freeQueue map[string][]QueuedCommand
+ activeQueue map[string]*QueuedCommand
+ pendingDepletions []pendingDepletion
}
func New(dataDir string) *Game {
return &Game{
- World: world.New(dataDir),
- ObjectStore: object.NewObjectStore(dataDir),
- ItemStore: object.NewItemStore(dataDir),
- AccountStore: player.NewAccountStore(dataDir),
- MobStore: world.NewMobStore(dataDir),
- BehaviorStore: action.NewStore(dataDir),
- Ticks: engine.New(),
- WorldFlags: make(map[string]any),
- dataDir: dataDir,
- restTimers: make(map[string]uint64),
- loggedInChars: make(map[string]*net.Session),
+ World: world.New(dataDir),
+ ObjectStore: object.NewObjectStore(dataDir),
+ ItemStore: object.NewItemStore(dataDir),
+ AccountStore: player.NewAccountStore(dataDir),
+ MobStore: world.NewMobStore(dataDir),
+ BehaviorStore: action.NewStore(dataDir),
+ Ticks: engine.New(),
+ WorldFlags: make(map[string]any),
+ dataDir: dataDir,
+ restTimers: make(map[string]uint64),
+ loggedInChars: make(map[string]*net.Session),
+ freeQueue: make(map[string][]QueuedCommand),
+ activeQueue: make(map[string]*QueuedCommand),
+ pendingDepletions: nil,
}
}
@@ -94,17 +117,26 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
}
}
+func classifyCommand(cmd string) CommandClass {
+ switch cmd {
+ case "say", "score", "sc", "inventory", "i", "inv",
+ "equipment", "eq", "look", "l", "exits", "help",
+ "map", "option", "options", "alias", "unalias",
+ "description", "desc", "queued":
+ return ClassInstant
+ case "wear", "wield", "remove", "unwear", "unwield", "style":
+ return ClassFree
+ default:
+ return ClassActive
+ }
+}
+
func (g *Game) handleGameCommand(sess *net.Session, input string) {
if input == "" {
sess.Write("> ")
return
}
- p, _ := sess.Player.(*player.Player)
- if p != nil {
- g.cancelRest(p.Name)
- }
-
if sess.Account != nil && sess.Account.Aliases != nil {
firstSpace := strings.Index(input, " ")
var firstWord, rest string
@@ -125,7 +157,48 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
parts := strings.Fields(strings.ToLower(input))
cmd := parts[0]
- args := parts[1:]
+ class := classifyCommand(cmd)
+
+ if class == ClassInstant {
+ g.executeCommand(sess, cmd, parts[1:], input)
+ sess.Write("\r\n> ")
+ return
+ }
+
+ p, _ := sess.Player.(*player.Player)
+ if p == nil {
+ return
+ }
+
+ if class == ClassFree {
+ g.freeQueue[p.Name] = append(g.freeQueue[p.Name], QueuedCommand{
+ Session: sess,
+ Command: cmd,
+ Args: strings.Join(parts[1:], " "),
+ Timestamp: time.Now(),
+ })
+ if !p.OptionBool("queue_actions_silently") {
+ sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd))
+ }
+ sess.Write("\r\n> ")
+ return
+ }
+
+ g.activeQueue[p.Name] = &QueuedCommand{
+ Session: sess,
+ Command: cmd,
+ Args: strings.Join(parts[1:], " "),
+ Timestamp: time.Now(),
+ }
+ g.cancelRest(p.Name)
+ if !p.OptionBool("queue_actions_silently") {
+ sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd))
+ }
+ sess.Write("\r\n> ")
+}
+
+func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) {
+ p, _ := sess.Player.(*player.Player)
switch cmd {
case "get", "take", "grab", "pick":
@@ -153,6 +226,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.doDrop(sess, strings.Join(args, " "))
}
case "attack", "kill":
+ if p == nil {
+ return
+ }
if len(args) == 0 {
target := g.resolveDefaultMob(p.RoomID)
if target == "" {
@@ -183,9 +259,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
if len(args) == 0 {
sess.WriteLine("Say what?")
} else {
- msgStart := strings.Index(strings.ToLower(input), "say ") + 4
- if msgStart >= 4 && msgStart < len(input) {
- g.doSay(sess, input[msgStart:])
+ msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4
+ if msgStart >= 4 && msgStart < len(rawInput) {
+ g.doSay(sess, rawInput[msgStart:])
} else {
g.doSay(sess, strings.Join(args, " "))
}
@@ -207,7 +283,8 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.doExits(sess)
case "map":
g.doMap(sess)
-
+ case "queued":
+ g.doQueued(sess)
case "help":
if len(args) == 0 {
g.doHelp(sess, "")
@@ -215,6 +292,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.doHelp(sess, strings.Join(args, " "))
}
case "mine", "chop", "fish", "cut", "use", "pull", "push":
+ if p == nil {
+ return
+ }
+ g.CancelAction(p)
if len(args) == 0 {
target := g.resolveDefaultTarget(p.RoomID, cmd)
if target == "" {
@@ -228,6 +309,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
return
}
case "talk", "speak", "ask":
+ if p == nil {
+ return
+ }
+ g.CancelAction(p)
if len(args) == 0 {
sess.WriteLine("Talk to whom?")
} else {
@@ -235,9 +320,17 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
return
}
case "burn":
+ if p == nil {
+ return
+ }
+ g.CancelAction(p)
g.doBurn(sess, p, strings.Join(args, " "))
return
case "stoke":
+ if p == nil {
+ return
+ }
+ g.CancelAction(p)
g.doStoke(sess, p, strings.Join(args, " "))
return
case "alias":
@@ -247,6 +340,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.doUnalias(sess, args)
return
case "search":
+ if p == nil {
+ return
+ }
+ g.CancelAction(p)
if len(args) == 0 {
sess.WriteLine("Search what?")
} else {
@@ -254,14 +351,86 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
}
return
case "wear", "wield":
+ if p == nil {
+ return
+ }
g.doWear(sess, strings.Join(args, " "))
return
case "remove", "unwear", "unwield":
+ if p == nil {
+ return
+ }
g.doRemove(sess, strings.Join(args, " "))
return
default:
sess.WriteLine("Unknown command.")
}
+}
- sess.Write("\r\n> ")
+func (g *Game) ProcessQueuedCommands() {
+ if g.Hub == nil {
+ return
+ }
+
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil || p.ActionState == nil {
+ continue
+ }
+ as, ok := p.ActionState.(*ActionState)
+ if !ok {
+ continue
+ }
+ switch as.Type {
+ case ActionGathering, ActionCombating, ActionUsing, ActionTalking,
+ ActionToggling, ActionBurning, ActionStoking, ActionResting:
+ default:
+ p.ActionState = nil
+ }
+ }
+
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil {
+ continue
+ }
+ cmds := g.freeQueue[p.Name]
+ for _, qc := range cmds {
+ g.cancelRest(p.Name)
+ parts := strings.Fields(qc.Command + " " + qc.Args)
+ if len(parts) > 0 {
+ g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args)
+ }
+ }
+ delete(g.freeQueue, p.Name)
+ }
+
+ var actives []QueuedCommand
+ for _, qc := range g.activeQueue {
+ actives = append(actives, *qc)
+ }
+ sort.Slice(actives, func(i, j int) bool {
+ return actives[i].Timestamp.Before(actives[j].Timestamp)
+ })
+
+ for _, qc := range actives {
+ parts := strings.Fields(qc.Command + " " + qc.Args)
+ if len(parts) > 0 {
+ g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args)
+ }
+ }
+ g.activeQueue = make(map[string]*QueuedCommand)
+
+ g.flushPendingDepletions()
+}
+
+type pendingDepletion struct {
+ instanceKey string
+ behaviorID string
+ targetName string
+ playerNames []string
+}
+
+func (g *Game) computeTicks(base float64) int {
+ return engine.FractionalTicks(base, g.GameSpeed)
}
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index adba595..3453117 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -9,8 +9,7 @@ import (
func TestBuildTinyMap(t *testing.T) {
g := &Game{
- World: world.New("../../data"),
- MapWidth: 70,
+ World: world.New("../../data"),
}
tests := []struct {
@@ -95,8 +94,7 @@ func TestWrapText(t *testing.T) {
func TestBuildFullMap(t *testing.T) {
g := &Game{
- World: world.New("../../data"),
- MapWidth: 70,
+ World: world.New("../../data"),
}
tests := []struct {
diff --git a/internal/game/tick.go b/internal/game/tick.go
index 3c3f1d3..4d037ec 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -82,7 +82,7 @@ func (g *Game) WanderTick() {
continue
}
inst.WanderTickCounter++
- if inst.WanderTickCounter < inst.WanderInterval {
+ if float64(inst.WanderTickCounter) < inst.WanderInterval {
continue
}
inst.WanderTickCounter = 0
@@ -144,40 +144,36 @@ func (g *Game) WanderTick() {
}
}
-func (g *Game) WoodcuttingTick() {
+func (g *Game) SharedDepletionTick() {
+ activeKeys := make(map[string]bool)
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil || p.Action == nil || p.Action.Type != "gather" {
+ continue
+ }
+ if key, ok := p.Action.Data["instance_key"].(string); ok {
+ activeKeys[key] = true
+ }
+ }
+
all := g.World.AllSharedObjStates()
for _, st := range all {
if st.Depleted {
continue
}
key := g.World.ObjStateKey(st.RoomID, st.DefID, st.Index)
- choppers := g.countChoppers(st.RoomID, key)
- if choppers > 0 {
+ if activeKeys[key] {
if st.SharedTimer > 0 {
st.SharedTimer--
}
} else {
- if st.SharedTimer < st.SharedMax {
+ if st.SharedTimer < int(st.SharedMax) {
st.SharedTimer++
}
}
}
}
-func (g *Game) countChoppers(roomID int, instanceKey string) int {
- count := 0
- for _, sess := range g.Hub.PlayersInRoom(roomID) {
- p, ok := sess.Player.(*player.Player)
- if !ok || p.Action == nil || p.Action.Type != "gather" {
- continue
- }
- if key, ok := p.Action.Data["instance_key"].(string); ok && key == instanceKey {
- count++
- }
- }
- return count
-}
-
func (g *Game) legalMobExits(inst *world.MobInstance) []int {
room, err := g.World.LoadRoom(inst.RoomID)
if err != nil || len(room.Exits) == 0 {
diff --git a/internal/game/utils.go b/internal/game/utils.go
index e80de24..729ea97 100644
--- a/internal/game/utils.go
+++ b/internal/game/utils.go
@@ -79,29 +79,6 @@ func formatPickupList(sess *net.Session, picked []string) {
}
}
-func playerActionDescription(p *player.Player) string {
- if p.Action == nil {
- return ""
- }
- target := p.Action.TargetName
- if idx, ok := p.Action.Data["instance_idx"]; ok {
- target += fmt.Sprintf(" [%d]", idx)
- }
- switch p.Action.Type {
- case "gather":
- return "mining a " + target
- case "talk":
- return "talking to " + target
- case "use":
- return "using a " + target
- case "burn":
- return "trying to start a fire"
- case "stoke":
- return "tending to a fire"
- }
- return ""
-}
-
func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot {
slot := &player.InventorySlot{ItemID: itemID, Quantity: qty}
if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 {
diff --git a/internal/object/item.go b/internal/object/item.go
index 18fd034..f4e2f8b 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -40,14 +40,18 @@ type ItemDef struct {
EquipSlot EquipSlot `yaml:"equip_slot"`
WeaponType WeaponType `yaml:"weapon_type"`
Stats ItemStats `yaml:"stats"`
- Speed int `yaml:"speed"`
+ Speed float64 `yaml:"speed"`
ToolType string `yaml:"tool_type"`
- ToolSpeed int `yaml:"tool_speed"`
- BurnTicks int `yaml:"burn_ticks"`
+ ToolSpeed float64 `yaml:"tool_speed"`
+ BurnTicks float64 `yaml:"burn_ticks"`
FireLevel int `yaml:"fire_level"`
FireXP int `yaml:"fire_xp"`
Quality int `yaml:"quality"`
MaxQuality int `yaml:"max_quality"`
+ SearchTable string `yaml:"search_table"`
+ SearchMiscTable string `yaml:"search_misc_table"`
+ SearchTicks float64 `yaml:"search_ticks"`
+ SearchMessage string `yaml:"search_message"`
}
type ItemStats struct {
diff --git a/internal/player/player.go b/internal/player/player.go
index 332656c..15521a3 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -107,7 +107,7 @@ type OptionDef struct {
var OptionDefs = []OptionDef{
{"description", OptBool, true, nil, "Long room descriptions when moving"},
{"tinymap", OptBool, true, nil, "Mini-map display"},
- {"left-tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"},
+ {"left_tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"},
{"xpdrops", OptBool, true, nil, "XP drop messages"},
{"exits", OptBool, true, nil, "Long exit display in look"},
{"mobenter", OptBool, true, nil, "Messages when mobs enter the room"},
@@ -121,6 +121,8 @@ var OptionDefs = []OptionDef{
{"mapheight", OptInt, 20, nil, "Map height for the map command"},
{"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"},
{"automap", OptBool, false, nil, "Show map automatically after moving"},
+ {"queue_actions_silently", OptBool, true, nil, "Suppress 'You prepare to...' messages for queued actions"},
+ {"max_room_description_width", OptInt, 70, nil, "Maximum width for room descriptions"},
}
var optionByName map[string]*OptionDef
@@ -151,6 +153,7 @@ type Player struct {
Flags map[string]any `yaml:"flags"`
RegenerateTick int
Action *action.Action `yaml:"-"`
+ ActionState any `yaml:"-"`
}
func (p *Player) OptionBool(name string) bool {
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 59c8a20..c161e10 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -28,11 +28,11 @@ type MobDef struct {
Strength int `yaml:"strength"`
Defense int `yaml:"defense"`
HP int `yaml:"hp"`
- Speed int `yaml:"speed"`
+ Speed float64 `yaml:"speed"`
Aggressive bool `yaml:"aggressive"`
Protected bool `yaml:"protected"`
Unique bool `yaml:"unique"`
- RespawnTicks int `yaml:"respawn_ticks"`
+ RespawnTicks float64 `yaml:"respawn_ticks"`
Drops DropTable `yaml:"drops"`
}
@@ -46,17 +46,17 @@ type MobInstance struct {
Attack int
Strength int
Defense int
- Speed int
+ Speed float64
Aggressive bool
Protected bool
Unique bool
- RespawnTicks int
+ RespawnTicks float64
RoomID int
HomeRoomID int
Drops DropTable
IdleDescription string
WanderRooms []int
- WanderInterval int
+ WanderInterval float64
WanderTickCounter int
regenerateTick int
}
diff --git a/internal/world/room.go b/internal/world/room.go
index 46fb876..16fd3fd 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -43,7 +43,7 @@ var ExitOrder = []ExitDir{
type SpawnDef struct {
ItemID string `yaml:"item_id"`
Quantity int `yaml:"quantity"`
- RespawnTicks int `yaml:"respawn_ticks"`
+ RespawnTicks float64 `yaml:"respawn_ticks"`
}
type ExitDef struct {
@@ -80,7 +80,7 @@ type Room struct {
type RoomMob struct {
ID string `yaml:"id"`
WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval int `yaml:"wander_interval"`
+ WanderInterval float64 `yaml:"wander_interval"`
}
func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
@@ -104,5 +104,5 @@ type EnterStep struct {
type RoomObject struct {
ID string `yaml:"id"`
WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval int `yaml:"wander_interval"`
+ WanderInterval float64 `yaml:"wander_interval"`
}
diff --git a/internal/world/world.go b/internal/world/world.go
index ac5345e..a554432 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -54,18 +54,18 @@ type ObjMove struct {
type ObjState struct {
Depleted bool
- DepleteTimer int
+ DepleteTimer float64
DefID string
Name string
Index int
RoomID int
JustRespawned bool
WanderRooms []int
- WanderInterval int
+ WanderInterval float64
WanderCounter int
- SharedMax int
+ SharedMax float64
SharedTimer int
- Quality int
+ Quality float64
}
func (w *World) ObjStateKey(roomID int, defID string, index int) string {
@@ -102,7 +102,7 @@ func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) {
}
}
-func (w *World) AddObjInstance(roomID int, defID string, quality int) {
+func (w *World) AddObjInstance(roomID int, defID string, quality float64) {
w.mu.Lock()
defer w.mu.Unlock()
if w.objStates == nil {
@@ -250,7 +250,7 @@ func (w *World) SetObjName(roomID int, defID string, name string) {
}
}
-func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int) {
+func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) {
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
@@ -261,13 +261,13 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int
}
}
-func (w *World) SetObjDepleteTimer(roomID int, defID string, max int) {
+func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) {
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 {
st.SharedMax = max
- st.SharedTimer = max
+ st.SharedTimer = int(max)
}
}
}
@@ -284,7 +284,7 @@ func (w *World) AllSharedObjStates() []*ObjState {
return out
}
-func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) {
+func (w *World) SetObjDepleted(roomID int, defID string, index int, delay float64) {
w.mu.Lock()
defer w.mu.Unlock()
key := w.objStateKey(roomID, defID, index)
@@ -486,7 +486,7 @@ func (w *World) SeedGroundItems(roomID int) {
e.quantity += s.Quantity
e.respawnQty += s.Quantity
e.isSpawn = true
- e.respawnDelay = s.RespawnTicks
+ e.respawnDelay = int(s.RespawnTicks)
merged = true
break
}
@@ -496,7 +496,7 @@ func (w *World) SeedGroundItems(roomID int) {
itemID: s.ItemID,
quantity: s.Quantity,
isSpawn: true,
- respawnDelay: s.RespawnTicks,
+ respawnDelay: int(s.RespawnTicks),
respawnQty: s.Quantity,
}
w.groundItems[roomID] = append(w.groundItems[roomID], e)
@@ -545,7 +545,7 @@ func (w *World) Tick() {
st.Depleted = false
st.JustRespawned = true
if st.SharedMax > 0 {
- st.SharedTimer = st.SharedMax
+ st.SharedTimer = int(st.SharedMax)
}
}
}
@@ -577,7 +577,7 @@ func (w *World) TickObjWander() {
continue
}
st.WanderCounter++
- if st.WanderCounter >= st.WanderInterval {
+ if float64(st.WanderCounter) >= st.WanderInterval {
st.WanderCounter = 0
toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))]
if toRoom == st.RoomID {