package game import ( "sync" "thehouseoficarus/internal/item" "thehouseoficarus/internal/player" ) type CraftIndex struct { mu sync.RWMutex byType map[string][]*item.ItemDef bySubtype map[string][]*item.ItemDef byInput map[string][]*item.ItemDef byStation map[string][]*item.ItemDef } func NewCraftIndex() *CraftIndex { return &CraftIndex{ byType: make(map[string][]*item.ItemDef), bySubtype: make(map[string][]*item.ItemDef), byInput: make(map[string][]*item.ItemDef), byStation: make(map[string][]*item.ItemDef), } } func (idx *CraftIndex) Build(items []*item.ItemDef) { idx.mu.Lock() defer idx.mu.Unlock() idx.byType = make(map[string][]*item.ItemDef) idx.bySubtype = make(map[string][]*item.ItemDef) idx.byInput = make(map[string][]*item.ItemDef) idx.byStation = make(map[string][]*item.ItemDef) for _, def := range items { if len(def.Craft) == 0 { continue } for ci := range def.Craft { craft := &def.Craft[ci] t := craft.Type if t == "" { t = "combine" } idx.byType[t] = appendUnique(idx.byType[t], def) if craft.Subtype != "" { idx.bySubtype[craft.Subtype] = appendUnique(idx.bySubtype[craft.Subtype], def) } for _, e := range craft.Ingredients { for _, id := range e.Items { idx.byInput[id] = appendUnique(idx.byInput[id], def) } } for _, sid := range craft.Station { idx.byStation[sid] = appendUnique(idx.byStation[sid], def) } } } } func appendUnique(items []*item.ItemDef, item *item.ItemDef) []*item.ItemDef { for _, existing := range items { if existing == item { return items } } return append(items, item) } func (idx *CraftIndex) ByType(craftType string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byType[craftType] out := make([]*item.ItemDef, len(items)) copy(out, items) return out } func (idx *CraftIndex) BySubtype(subtype string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.bySubtype[subtype] out := make([]*item.ItemDef, len(items)) copy(out, items) return out } func (idx *CraftIndex) ByStation(stationDefID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byStation[stationDefID] out := make([]*item.ItemDef, len(items)) copy(out, items) return out } func (idx *CraftIndex) ByInput(itemID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byInput[itemID] out := make([]*item.ItemDef, len(items)) copy(out, items) return out } func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() candidatesA := idx.byInput[itemA] if len(candidatesA) == 0 { return nil } candidateSetB := make(map[string]bool) for _, def := range idx.byInput[itemB] { candidateSetB[def.ID] = true } var results []*item.ItemDef for _, def := range candidatesA { if !candidateSetB[def.ID] { continue } for _, craft := range def.Craft { entryA := craftEntry(craft, itemA) entryB := craftEntry(craft, itemB) if entryA >= 0 && entryB >= 0 && entryA != entryB { results = append(results, def) break } } } return results } func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() var results []*item.ItemDef for _, def := range idx.byStation[stationDefID] { for _, craft := range def.Craft { if craftMatchesEntry(&craft, itemID) { results = append(results, def) break } } } return results } func craftEntry(craft item.CraftDef, itemID string) int { for ei, e := range craft.Ingredients { for _, id := range e.Items { if id == itemID { return ei } } } return -1 } func craftMatchesEntry(c *item.CraftDef, itemID string) bool { if c == nil { return false } for _, e := range c.Ingredients { for _, id := range e.Items { if id == itemID { return true } } } return false } // craftHasAllItems reports whether every ingredient entry can be satisfied: // one listed alternative covering the entry's full quantity (not merely one // of each item being present — a recipe needing 15 feathers is not offered // on 1). Resolves through craftIngredientSource so availability checks can // never disagree with consumption. func craftHasAllItems(c *item.CraftDef, countItem func(string) int) bool { return craftHasAllItemsQty(c, countItem) } // craftIngredientSource resolves one ingredient entry: the FIRST listed // alternative the player holds in the entry's full (normalized) quantity, // plus that quantity. Availability checks and consumption both resolve // through this helper so they always pick the same alternative — an entry // listing several alternatives (e.g. bucket or jug of water) consumes the // one the check verified, never a partially-stocked other one. // Returns "" when no alternative covers the quantity. func craftIngredientSource(e *item.IngredientEntry, countItem func(string) int) (string, int) { qty := e.Quantity if qty <= 0 { qty = 1 } for _, id := range e.Items { if countItem(id) >= qty { return id, qty } } return "", qty } func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool { return planIngredients(c, countItem) != nil } // consumption is one planned ingredient removal: the alternative chosen // for an entry and the quantity to remove of it. type consumption struct { id string qty int } // planIngredients resolves every ingredient entry against the player's // stock, reserving quantities ACROSS entries: two entries listing the same // item (e.g. two one-ore entries) cannot both consume the same single ore. // Availability checks and consumption both plan through this helper so // they always agree; returns nil when any entry cannot be satisfied from // the stock remaining after earlier entries' reservations. func planIngredients(c *item.CraftDef, countItem func(string) int) []consumption { if c == nil { return nil } // A craft with no ingredient entries is not "always satisfied": it would // consume nothing and loop forever producing output. Treat it as // unsatisfiable; the validator also rejects it. if len(c.Ingredients) == 0 { return nil } reserved := make(map[string]int, len(c.Ingredients)) plan := make([]consumption, 0, len(c.Ingredients)) for i := range c.Ingredients { e := &c.Ingredients[i] qty := e.Quantity if qty <= 0 { qty = 1 } chosen := "" for _, id := range e.Items { if countItem(id)-reserved[id] >= qty { chosen = id break } } if chosen == "" { return nil } reserved[chosen] += qty plan = append(plan, consumption{id: chosen, qty: qty}) } return plan } // consumeIngredients removes one batch of every ingredient entry. Each // entry consumes the same alternative the availability checks verify, and // consumption is all-or-nothing: nothing is removed unless every entry can // be satisfied from the aggregate stock, so a craft can never partially // consume an ingredient or double-count shared stock. Callers that already // ran a craftHasAllItems* check cannot see a false return; it guards // callers whose own pre-check is weaker. Callers that check the result // must abort the craft without producing output. func consumeIngredients(c *item.CraftDef, countItem func(string) int, removeItem func(string, int) bool) bool { plan := planIngredients(c, countItem) if plan == nil { return false } for _, s := range plan { removeItem(s.id, s.qty) } return true } // stackProps returns the Quality and MaxQuality a NEW inventory stack of def // receives, mirroring newInventorySlot (core_utils.go): a quality-bearing // definition is stamped at its full quality. Every stack-creation and // stack-merge site must agree on these values, or a stack produced by one // path (craft, harvest, ammo removal, shop) stops merging with the same item // picked up from the ground while the inventory is full. func stackProps(def *item.ItemDef) (quality, maxQuality int) { if def != nil && def.MaxQuality > 0 { return def.MaxQuality, def.MaxQuality } return 0, 0 } // craftOutputFits reports whether outputQty of an item can be placed into // the inventory right now: merged into an existing stack for stackable // definitions, otherwise a free slot. Crafters must check BEFORE consuming // ingredients so a full inventory can never destroy materials without // producing the output. func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool { if outDef != nil && outDef.Stackable { quality, maxQuality := stackProps(outDef) for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { return true } } } return p.FirstFreeSlot() != -1 } // placeCraftOutput puts outputQty of an item into the inventory, merging // into an existing stack for stackable definitions. Returns false only // when nothing fits — callers should have verified craftOutputFits first // (consumption cannot free fewer slots than the check saw on the owner // goroutine, so a false return is defensive). func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool { quality, maxQuality := stackProps(outDef) if outDef != nil && outDef.Stackable { for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { slot.Quantity += outputQty return true } } } freeSlot := p.FirstFreeSlot() if freeSlot == -1 { return false } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: outputQty, Quality: quality, MaxQuality: maxQuality}) return true }