aboutsummaryrefslogtreecommitdiff
path: root/internal/game/production_engine.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/production_engine.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/production_engine.go')
-rw-r--r--internal/game/production_engine.go102
1 files changed, 85 insertions, 17 deletions
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 035b7e2..ddada6b 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -172,11 +172,11 @@ func (g *Game) loadCraftItem(itemID string) *item.ItemDef {
return item
}
-func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
+func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
d, ok := p.Action.Data.(*behavior.ProductionData)
if !ok {
g.cancelAction(p)
- return false
+ return
}
itemID := d.ItemID
phase := d.Phase
@@ -188,7 +188,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
item := g.loadCraftItem(itemID)
if item == nil {
g.cancelAction(p)
- return false
+ return
}
craft := item.FirstCraft()
@@ -198,7 +198,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
}
d.Phase = 1
p.Action.WaitLeft = engine.ToTicks(wait)
- return true
+ return
}
skill := craft.EffectiveSkill()
@@ -222,9 +222,14 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
if !craftOutputFits(p, itemID, item) || (failDef != nil && !craftOutputFits(p, craft.Fail, failDef)) {
sess.WriteLine("Your inventory is too full!")
g.cancelAction(p)
- return false
+ return
}
+ // Resolve %i<n> names from the alternative that will actually be
+ // consumed: consumption empties the inventory, so p.HasItem can no
+ // longer identify it when the message is rendered below.
+ hasItem := craftConsumedHasItem(craft, p.CountItem, p.HasItem)
+
if rand.Float64() < chance {
outputQty := craft.OutputQty
if outputQty <= 0 {
@@ -240,14 +245,14 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
// Cannot happen after the availability gate above, but never
// produce output from a failed consumption.
g.cancelAction(p)
- return false
+ return
}
if !placeCraftOutput(p, itemID, item, outputQty) {
// Defensive: the fits check above should have guaranteed a
// stack or slot.
sess.WriteLine("Your inventory is too full!")
g.cancelAction(p)
- return false
+ return
}
for _, bp := range byproducts {
@@ -273,7 +278,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
} else {
defaultSuccess = "You produce %n."
}
- msg := g.resolveCraftMessage(sess, craft.SuccessMessage, defaultSuccess, craft, item.ID, byproducts, p.HasItem)
+ msg := g.resolveCraftMessage(sess, craft.SuccessMessage, defaultSuccess, craft, item.ID, byproducts, hasItem)
if p.OptionBool("xp_drops") && craft.XP > 0 {
msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP)
}
@@ -281,18 +286,15 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
} else {
if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
g.cancelAction(p)
- return false
+ return
}
if craft.Fail != "" {
- freeSlot := p.FirstFreeSlot()
- if freeSlot >= 0 {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: craft.Fail, Quantity: 1})
- }
+ g.placeCraftFail(p, craft)
}
g.saveCharacter(p)
- msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, p.HasItem)
+ msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, hasItem)
if msg != "" {
sess.WriteLine(g.colorize(sess, "damage_taken", msg))
}
@@ -306,20 +308,19 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
sess.WriteLine(endMsg)
}
g.cancelAction(p)
- return false
+ return
}
}
if g.canContinueProduction(p, item) {
p.Action.WaitLeft = engine.ToTicks(wait)
- return true
+ return
}
if endMsg != "" {
sess.WriteLine(endMsg)
}
g.cancelAction(p)
- return false
}
func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string {
@@ -354,6 +355,73 @@ func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string
return byproducts
}
+// craftConsumedHasItem returns a hasItem predicate that reports the
+// ingredient alternatives consumeIngredients will remove right now — the
+// same aggregate plan availability and consumption use. A %i<n> message
+// resolved AFTER consumption therefore still names the item the player
+// actually used (p.HasItem is false for everything consumed). Falls back to
+// the supplied predicate when no plan can be built.
+func craftConsumedHasItem(craft *item.CraftDef, countItem func(string) int, fallback func(string) bool) func(string) bool {
+ plan := planIngredients(craft, countItem)
+ if plan == nil {
+ return fallback
+ }
+ consumed := make(map[string]bool, len(plan))
+ for _, c := range plan {
+ consumed[c.id] = true
+ }
+ return func(id string) bool { return consumed[id] }
+}
+
+// craftRollSuccess rolls one craft cycle's success: true when the recipe
+// defines no success formula or the roll succeeds.
+func craftRollSuccess(craft *item.CraftDef, level int) bool {
+ if craft == nil || craft.Success == nil {
+ return true
+ }
+ chance := behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), level, craft.Level)
+ return rand.Float64() < chance
+}
+
+// craftFailFits reports whether the recipe's fail replacement (if any) can
+// be placed right now. Like the output fit check, it must run BEFORE
+// consumption so a full inventory never destroys materials.
+func (g *Game) craftFailFits(p *player.Player, craft *item.CraftDef) bool {
+ if craft == nil || craft.Fail == "" {
+ return true
+ }
+ def, _ := g.ItemStore.Load(craft.Fail)
+ if def == nil {
+ return true
+ }
+ return craftOutputFits(p, craft.Fail, def)
+}
+
+// placeCraftFail delivers a failed craft's fail replacement into the
+// inventory, merging into a matching stack when the fail item is stackable
+// just like the success output does. FirstFreeSlot alone is not enough: the
+// pre-roll gate counts a stack merge (craftFailFits), so a full inventory
+// with a matching stack must not silently destroy the fail replacement.
+func (g *Game) placeCraftFail(p *player.Player, craft *item.CraftDef) {
+ if craft == nil || craft.Fail == "" {
+ return
+ }
+ def, _ := g.ItemStore.Load(craft.Fail)
+ placeCraftOutput(p, craft.Fail, def, 1)
+}
+
+// placeCraftByproducts delivers byproducts into free slots, dropping any
+// remainder on the ground rather than silently deleting it.
+func (g *Game) placeCraftByproducts(p *player.Player, byproducts []string) {
+ for _, bp := range byproducts {
+ if slot := p.FirstFreeSlot(); slot >= 0 {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1})
+ } else {
+ g.World.AddGroundItem(p.RoomID, bp, 1)
+ }
+ }
+}
+
func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool {
craft := item.FirstCraft()
if craft == nil {