package game import ( "fmt" "math/rand" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) resolveCraftVar(sess *net.Session, varSpec string, craft *item.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { if !strings.Contains(varSpec, "%") { return varSpec } outDef, _ := g.ItemStore.Load(outputID) outputName := outputID if outDef != nil { outputName = g.itemColorize(sess, outDef, outDef.Name) } varSpec = strings.ReplaceAll(varSpec, "%n", outputName) for i, e := range craft.Ingredients { key := fmt.Sprintf("%%i%d", i+1) if !strings.Contains(varSpec, key) { continue } var matchedID string for _, id := range e.Items { if hasItem(id) { matchedID = id break } } if matchedID == "" && len(e.Items) > 0 { matchedID = e.Items[0] } if def, err := g.ItemStore.Load(matchedID); err == nil { varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) } else { varSpec = strings.ReplaceAll(varSpec, key, matchedID) } } for i, bpID := range byproducts { key := fmt.Sprintf("%%b%d", i+1) if !strings.Contains(varSpec, key) { continue } if def, err := g.ItemStore.Load(bpID); err == nil { varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) } else { varSpec = strings.ReplaceAll(varSpec, key, bpID) } } return g.expandTags(sess, varSpec) } func (g *Game) resolveCraftMessage(sess *net.Session, itemMsg, defaultMsg string, craft *item.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { msg := itemMsg if msg == "" { msg = defaultMsg } if msg == "" { return "" } return g.resolveCraftVar(sess, msg, craft, outputID, byproducts, hasItem) } func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, actionType behavior.ActionType, displayVerb, startMsg, endMsg string, count int, craftIndex int) { g.cancelAction(p) g.cancelBgAction(p) skill := craft.EffectiveSkill() if skill != "" { skillLevel := p.Level(player.SkillName(skill)) if skillLevel < craft.Level { sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill)) g.reprompt(sess) return } } if !craftHasAllItemsQty(craft, p.CountItem) { sess.WriteLine("You don't have the required materials.") g.reprompt(sess) return } outDef, _ := g.ItemStore.Load(item.ID) wait := craft.TicksPerCycle if wait <= 0 { wait = 4 } outputName := item.ID if outDef != nil { outputName = outDef.Name } p.Action = &behavior.Action{ Type: actionType, TargetID: item.ID, TargetName: outputName, Data: &behavior.ProductionData{ ItemID: item.ID, CraftIndex: craftIndex, Phase: 0, TicksPerCycle: wait, StartMessage: startMsg, EndMessage: endMsg, Remaining: count, }, WaitLeft: engine.ToTicks(1), } g.broadcastAction(sess, "%s starts %s.", p.Name, displayVerb) } // usableCraftIndex returns the index of the first recipe of item the player // satisfies (level and ingredients), or -1. Availability checks scan every // recipe, so execution must select the same one instead of always Craft[0]. func (g *Game) usableCraftIndex(p *player.Player, item *item.ItemDef) int { for i := range item.Craft { c := &item.Craft[i] skill := c.EffectiveSkill() if skill != "" && p.Level(player.SkillName(skill)) < c.Level { continue } if !craftHasAllItemsQty(c, p.CountItem) { continue } return i } return -1 } // usableCraft is usableCraftIndex with a first-recipe fallback for callers // that only need a display recipe. func (g *Game) usableCraft(p *player.Player, item *item.ItemDef) *item.CraftDef { if idx := g.usableCraftIndex(p, item); idx >= 0 { return &item.Craft[idx] } return item.FirstCraft() } func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item *item.ItemDef, count int) { idx := g.usableCraftIndex(p, item) if idx < 0 { idx = 0 } if idx >= len(item.Craft) { return } craft := &item.Craft[idx] // Normalize the empty craft type to "combine" here as well as in the // craft index: an omitted `type:` is combine, but the index's normalized // key was never written back to the recipe, so the literal comparison // below would fall through to the production path with an empty key. craftType := craft.Type if craftType == "" { craftType = "combine" } if craftType == "combine" { g.startCombine(sess, p, item, craft, count, idx) return } _, stationName := g.findStation(p.RoomID, craft.Station) if stationName == "" { stationName = "inventory" } cKey := craft.Subtype if cKey == "" { cKey = craftType } info, ok := productionTypes[cKey] if !ok { info = productionTypeInfo{ // Use a known action type so the action actually advances; the // raw subtype is not in productionActionTypes and used to strand // the player in a non-advancing action with no output. ActionType: behavior.TypeCraft, DisplayVerb: cKey, StartMessage: "You start " + cKey + " %i1.", SuccessMessage: "You produce %n.", EndMessage: "You've finished " + cKey + ".", } } startMsg := g.resolveCraftMessage(sess, craft.StartMessage, info.StartMessage, craft, item.ID, nil, p.HasItem) if stationName != "inventory" { startMsg += " on the " + stationName + "." } endMsg := g.resolveCraftMessage(sess, craft.EndMessage, info.EndMessage, craft, item.ID, nil, p.HasItem) g.startProduction(sess, p, item, craft, info.ActionType, info.DisplayVerb, startMsg, endMsg, count, idx) } func (g *Game) loadCraftItem(itemID string) *item.ItemDef { item, err := g.ItemStore.Load(itemID) if err != nil || len(item.Craft) == 0 { return nil } return item } func (g *Game) advanceProduction(sess *net.Session, p *player.Player) { d, ok := p.Action.Data.(*behavior.ProductionData) if !ok { g.cancelAction(p) return } itemID := d.ItemID phase := d.Phase wait := d.TicksPerCycle startMsg := d.StartMessage endMsg := d.EndMessage remaining := d.Remaining item := g.loadCraftItem(itemID) if item == nil { g.cancelAction(p) return } idx := d.CraftIndex if idx < 0 || idx >= len(item.Craft) { idx = 0 } craft := &item.Craft[idx] if phase == 0 { if startMsg != "" { sess.WriteLine(startMsg) } d.Phase = 1 p.Action.WaitLeft = engine.ToTicks(wait) return } skill := craft.EffectiveSkill() skillLevel := p.Level(player.SkillName(skill)) chance := 1.0 if craft.Success != nil { chance = behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), skillLevel, craft.Level) } cKey := craft.Subtype if cKey == "" { cKey = craft.Type } info, _ := productionTypes[cKey] // A full inventory must never destroy materials: verify BOTH possible // outcomes can place their item (the output on success, the fail // replacement on failure) BEFORE the roll and any consumption. failDef, _ := g.ItemStore.Load(craft.Fail) if !craftOutputFits(p, itemID, item) || (failDef != nil && !craftOutputFits(p, craft.Fail, failDef)) { sess.WriteLine("Your inventory is too full!") g.cancelAction(p) return } // Resolve %i 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 { outputQty = 1 } // Byproducts must be collected BEFORE consumption: the resolver // maps each ingredient entry to the alternative actually being // consumed, which requires the items still present. byproducts := g.collectByproducts(p, craft) if !consumeIngredients(craft, p.CountItem, p.RemoveItem) { // Cannot happen after the availability gate above, but never // produce output from a failed consumption. g.cancelAction(p) 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 } for _, bp := range byproducts { if slot := p.FirstFreeSlot(); slot >= 0 { p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) } else { // Never silently delete a byproduct: drop the remainder on // the ground where the player can pick it up. g.World.AddGroundItem(p.RoomID, bp, 1) } } if craft.XP > 0 { if newLevel := p.AddSkillXP(player.SkillName(skill), craft.XP); newLevel > 0 { sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) } } g.saveCharacter(p) var defaultSuccess string if info.SuccessMessage != "" { defaultSuccess = info.SuccessMessage } else { defaultSuccess = "You produce %n." } 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) } sess.WriteLine(msg) } else { if !consumeIngredients(craft, p.CountItem, p.RemoveItem) { g.cancelAction(p) return } if craft.Fail != "" { g.placeCraftFail(p, craft) } g.saveCharacter(p) msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, hasItem) if msg != "" { sess.WriteLine(g.colorize(sess, "damage_taken", msg)) } } if remaining > 0 { remaining-- d.Remaining = remaining if remaining <= 0 { if endMsg != "" { sess.WriteLine(endMsg) } g.cancelAction(p) return } } if g.canContinueProduction(p, item, craft) { p.Action.WaitLeft = engine.ToTicks(wait) return } if endMsg != "" { sess.WriteLine(endMsg) } g.cancelAction(p) } func (g *Game) collectByproducts(p *player.Player, craft *item.CraftDef) []string { if craft == nil { return nil } // The byproduct must match the alternative that will actually be // consumed, resolved with the SAME aggregate plan consumeIngredients // uses (shared stock reserved across entries), not the first // alternative merely present: with a bucket and a jug of water both // in the inventory, consuming the bucket must return the empty // bucket — and when entries share stock, the plan decides who gets it. plan := planIngredients(craft, p.CountItem) if plan == nil { return nil } var byproducts []string for i := range craft.Ingredients { e := &craft.Ingredients[i] if len(e.Byproducts) == 0 { continue } chosen := plan[i].id for j, alt := range e.Items { if alt == chosen && j < len(e.Byproducts) && e.Byproducts[j] != "" { byproducts = append(byproducts, e.Byproducts[j]) break } } } 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 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, craft *item.CraftDef) bool { if craft == nil { return false } if !craftHasAllItemsQty(craft, p.CountItem) { return false } outDef, _ := g.ItemStore.Load(item.ID) if outDef != nil && outDef.Stackable { for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == item.ID { return true } } } return p.FirstFreeSlot() >= 0 }