package game import ( "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, count int, craftIndex int) { g.cancelAction(p) g.cancelBgAction(p) if !craftHasAllItemsQty(craft, p.CountItem) { sess.WriteLine("You don't have the required materials.") g.reprompt(sess) return } // See startProduction: combine recipes can start outside the crafting // menus, so the station/tool gate must live at the execution site. if len(craft.Station) > 0 { if stID, _ := g.findStation(p.RoomID, craft.Station); stID == "" { sess.WriteLine("You are not near the required station.") g.reprompt(sess) return } } if craft.Tool != "" && !g.hasToolType(p, craft.Tool) { sess.WriteLine("You do not have the required tool.") g.reprompt(sess) return } outDef, _ := g.ItemStore.Load(item.ID) outputName := item.ID if outDef != nil { outputName = outDef.Name } startMsg := g.resolveCraftMessage(sess, craft.StartMessage, "You start combining %i1.", craft, item.ID, nil, p.HasItem) endMsg := g.resolveCraftMessage(sess, craft.EndMessage, "You've finished combining.", craft, item.ID, nil, p.HasItem) p.Action = &behavior.Action{ Type: behavior.TypeCombine, TargetID: item.ID, TargetName: outputName, Data: &behavior.CombineData{ ItemID: item.ID, CraftIndex: craftIndex, Phase: 0, StepIndex: 0, Remaining: count, StartMessage: startMsg, EndMessage: endMsg, TicksPerCycle: craft.TicksPerCycle, }, WaitLeft: engine.ToTicks(1), } g.broadcastAction(sess, "%s starts combining.", p.Name) } // recipeSkill resolves the skill a recipe trains and rolls against. A recipe // type is an action name, not necessarily a skill: player.SkillName of e.g. // "combine" or "cleaning" is a literal action, so p.Level would read a map // miss as level 1 and AddSkillXP would persist a phantom skill key. A recipe // that declares a real skill name is honored; anything else trains Crafting. func recipeSkill(craft *item.CraftDef) player.SkillName { skill := player.SkillName(craft.EffectiveSkill()) if player.ValidSkill(skill) { return skill } return player.Crafting } // combineSkill resolves the skill a combine recipe trains. Combine recipes // declare type "combine", which is an action, not a skill: the shared // recipeSkill fallback keeps success rolls and XP on Crafting. func combineSkill(craft *item.CraftDef) player.SkillName { return recipeSkill(craft) } func (g *Game) advanceCombine(sess *net.Session, p *player.Player) { d, ok := p.Action.Data.(*behavior.CombineData) if !ok { g.cancelAction(p) return } item := g.loadCraftItem(d.ItemID) if item == nil { g.cancelAction(p) return } idx := d.CraftIndex if idx < 0 || idx >= len(item.Craft) { idx = 0 } craft := &item.Craft[idx] switch d.Phase { case 0: if d.StartMessage != "" { sess.WriteLine(d.StartMessage) } d.Phase = 1 if len(craft.Steps) > 0 { p.Action.WaitLeft = engine.ToTicks(craft.Steps[0].Delay) } else { firstWait := craft.TicksPerCycle if firstWait <= 0 { firstWait = 4 } p.Action.WaitLeft = engine.ToTicks(firstWait) } return case 1: if len(craft.Steps) > 0 && d.StepIndex < len(craft.Steps) { step := craft.Steps[d.StepIndex] sess.WriteLine(g.resolveCraftVar(sess, step.Message, craft, item.ID, nil, p.HasItem)) d.StepIndex++ if d.StepIndex < len(craft.Steps) { p.Action.WaitLeft = engine.ToTicks(craft.Steps[d.StepIndex].Delay) return } } d.Phase = 2 p.Action.WaitLeft = engine.ToTicks(1) return case 2: if !craftHasAllItemsQty(craft, p.CountItem) { if d.EndMessage != "" { sess.WriteLine(d.EndMessage) } g.cancelAction(p) return } // A full inventory must never destroy materials: verify BOTH the // output and any fail replacement can be placed BEFORE collecting // byproducts and consuming. outDef, _ := g.ItemStore.Load(item.ID) if !craftOutputFits(p, item.ID, outDef) || !g.craftFailFits(p, craft) { sess.WriteLine("Your inventory is too full!") g.cancelAction(p) return } // Byproducts must be collected BEFORE consumption: the resolver maps // each ingredient entry to the alternative actually being consumed // (bucket vs jug of water), which requires the items still present. byproducts := g.collectByproducts(p, craft) hasItem := craftConsumedHasItem(craft, p.CountItem, p.HasItem) if !consumeIngredients(craft, p.CountItem, p.RemoveItem) { g.cancelAction(p) return } skill := combineSkill(craft) if craftRollSuccess(craft, p.Level(skill)) { outputQty := craft.OutputQty if outputQty <= 0 { outputQty = 1 } if !placeCraftOutput(p, item.ID, outDef, outputQty) { // Defensive: the fits check above should have guaranteed a // stack or slot. sess.WriteLine("Your inventory is too full!") g.cancelAction(p) return } // Return the empty containers (and any other byproducts) alongside // the output; without this, combine crafts silently discarded them. g.placeCraftByproducts(p, byproducts) if craft.XP > 0 { g.awardSkillXP(sess, p, skill, craft.XP) } } else { g.placeCraftFail(p, craft) msg := g.resolveCraftMessage(sess, craft.FailMessage, "", craft, item.ID, nil, hasItem) if msg != "" { sess.WriteLine(g.colorize(sess, "damage_taken", msg)) } } g.saveCharacter(p) if d.Remaining > 0 { d.Remaining-- if d.Remaining <= 0 { if d.EndMessage != "" { sess.WriteLine(d.EndMessage) } g.cancelAction(p) return } } if !craftHasAllItemsQty(craft, p.CountItem) { if d.EndMessage != "" { sess.WriteLine(d.EndMessage) } g.cancelAction(p) return } d.Phase = 0 d.StepIndex = 0 p.Action.WaitLeft = engine.ToTicks(1) } }